[Android] JetPack Compose Codelab 4 : ThemingCodelab (Reply)

이석규·2023년 7월 25일
0
post-thumbnail

오늘도 지난번(BasicStateCodelab)에 이어서 ThemingCodelab의 내용을 둘러보고자 한다.

네비게이션과 함께 여러 프로필 리스트가 있는 앱이다.
하나 특이한 것은 이 앱은 compose-samples와 compose-codelab 두 곳에 다 있는 앱이라는 것이다.음.. 과연 어떤 부분이 특이한지 혹은 중요한지 한번 확인해보도록 하자.


  1. onCreate > setContent > ReplyApp
class MainActivity : ComponentActivity() {

    private val viewModel: ReplyHomeViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            val uiState by viewModel.uiState.collectAsState()
            ReplyApp(
                replyHomeUIState = uiState,
                closeDetailScreen = {
                    viewModel.closeDetailScreen()
                },
                navigateToDetail = { emailId ->
                    viewModel.setSelectedEmail(emailId)
                }
            )
        }
    }
}

viewmodel(ReplyHomeViewModel)에서 리스트에서 취급할 데이터들을 관리하고 있다. viewmodel에서는 다양한 상태를 관리하고 있는데, 자세한 것은 아래에서 확인하도록하자.

우선 MainActivity에서는 이러한 상태를 바탕으로 상태(ReplyHomeUIState), 닫힘 이벤트, 상세 화면으로 이동하는 이벤트를 설정해줬다.

  1. ReplyHomeViewModel
class ReplyHomeViewModel : ViewModel() {

    // UI state exposed to the UI
    private val _uiState = MutableStateFlow(ReplyHomeUIState(loading = true))
    val uiState: StateFlow<ReplyHomeUIState> = _uiState

    init {
        initEmailList()
    }

    private fun initEmailList() {
        val emails = LocalEmailsDataProvider.allEmails
        _uiState.value = ReplyHomeUIState(
            emails = emails,
            selectedEmail = emails.first()
        )
    }

    fun setSelectedEmail(emailId: Long) {
        /**
         * We only set isDetailOnlyOpen to true when it's only single pane layout
         */
        val email = uiState.value.emails.find { it.id == emailId }
        _uiState.value = _uiState.value.copy(
            selectedEmail = email,
            isDetailOnlyOpen = true
        )
    }

    fun closeDetailScreen() {
        _uiState.value = _uiState
            .value.copy(
                isDetailOnlyOpen = false,
                selectedEmail = _uiState.value.emails.first()
            )
    }
}

data class ReplyHomeUIState(
    val emails: List<Email> = emptyList(),
    val selectedEmail: Email? = null,
    val isDetailOnlyOpen: Boolean = false,
    val loading: Boolean = false,
    val error: String? = null
)

ReplyHomeUIState를 데이터 클래스로써 화면의 상태를 정의한다.
이메일들, 선택된 이메일, 상세화면일지 여부, 로딩 여부, 에러(여기서는 안쓴다)

구현된 메서드는 초기 설정, 리스트 아이템이 선택됐을 때 설정, 디테일에서 닫혔을 때의 설정을 정의한다.

하나 흥미로운 점은 메서드에서 리스트 아이템이 선택됐을 때만이 아닌, 모든 메서드에서 selectedEmail를 null이게 놔두지 않는다는 것이다. 실제로는 지우고 실행해도 무방하다.(아닐수도!)

  1. ReplyApp > ReplyAppContent > ReplyInboxScreen, EmptyComingSoon, NavigationBar
@Composable
fun ReplyApp(
    replyHomeUIState: ReplyHomeUIState,
    closeDetailScreen: () -> Unit = {},
    navigateToDetail: (Long) -> Unit = {}
) {
    ReplyAppContent(
        replyHomeUIState = replyHomeUIState,
        closeDetailScreen = closeDetailScreen,
        navigateToDetail = navigateToDetail
    )
}

@Composable
fun ReplyAppContent(
    modifier: Modifier = Modifier,
    replyHomeUIState: ReplyHomeUIState,
    closeDetailScreen: () -> Unit,
    navigateToDetail: (Long) -> Unit,
) {

    val selectedDestination = remember { mutableStateOf(ReplyRoute.INBOX) }

    Column(
        modifier = modifier
            .fillMaxSize()
    ) {

        if (selectedDestination.value == ReplyRoute.INBOX) {
            ReplyInboxScreen(
                replyHomeUIState = replyHomeUIState,
                closeDetailScreen = closeDetailScreen,
                navigateToDetail = navigateToDetail,
                modifier = Modifier.weight(1f)
            )
        } else {
            EmptyComingSoon(modifier = Modifier.weight(1f))
        }

        NavigationBar(modifier = Modifier.fillMaxWidth()) {
            TOP_LEVEL_DESTINATIONS.forEach { replyDestination ->
                NavigationBarItem(
                    selected = selectedDestination.value == replyDestination.route,
                    onClick = { selectedDestination.value = replyDestination.route },
                    icon = {
                        Icon(
                            imageVector = replyDestination.selectedIcon,
                            contentDescription = stringResource(id = replyDestination.iconTextId)
                        )
                    }
                )
            }
        }
    }
}

화면 전반의 요소들을 다루는 ReplyAppContent를 ReplyApp로 랩핑하여 제공한다. ReplyAppContent에는 내비게이션바 위의 영역(ReplyInboxScreen, EmptyComingSoon)과 내비게이션 바가 위치한다.

작동 영상에서 확인할 수 있듯, 현재는 첫번째 목적지를 제외하고는 EmptyComingSoon로 빈 화면 처리하고 있다.
또한 내비게이션 바에서는 foreach를 활용하여 TOP_LEVEL_DESTINATIONS로 리스트로 정의해둔 목적지 리스트를 간결하게 설정해주고 있다.

  1. ReplyInboxScreen > ReplyEmailListContent, LargeFloatingActionButton
@Composable
fun ReplyInboxScreen(
    replyHomeUIState: ReplyHomeUIState,
    closeDetailScreen: () -> Unit,
    navigateToDetail: (Long) -> Unit,
    modifier: Modifier = Modifier
) {

    val emailLazyListState = rememberLazyListState()

    Box(modifier = modifier.fillMaxSize()) {
        ReplyEmailListContent(
            replyHomeUIState = replyHomeUIState,
            emailLazyListState = emailLazyListState,
            modifier = Modifier.fillMaxSize(),
            closeDetailScreen = closeDetailScreen,
            navigateToDetail = navigateToDetail
        )

        LargeFloatingActionButton(
            onClick = { /*Click Implementation*/ },
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(16.dp),
        ) {
            Icon(
                imageVector = Icons.Default.Edit,
                contentDescription = stringResource(id = R.string.edit),
                modifier = Modifier.size(28.dp)
            )
        }

    }
}

첫 화면의 구성 요소들이 포함된 Composable이다. 간단하게 리스트와 플로팅 버튼으로 구성되어있는데, 주목할만한 부분은 따로 있다.

compose를 접하며 가장 알쏭달쏭하고 가장 편리한 부분은 바로 remember 관련인 것 같다.

val emailLazyListState = rememberLazyListState()

처음에는 이것이 왜 설정되어 있는 것인지 매우 의문이었다. 하지만 지우고 나서 실행해보니 그 위화감은 실로 놀라운 반전이었다.

그것은 바로 이것을 설정해줌으로써 상세 화면에 들어갔다 나오면 마지막 리스트의 포커스 상태를 기억한다는 것이다.

아주, 아주 신기하다. 간단해서

  1. ReplyEmailListContent > ReplyEmailDetail, ReplyEmailList
@Composable
fun ReplyEmailListContent(
    replyHomeUIState: ReplyHomeUIState,
    emailLazyListState: LazyListState,
    modifier: Modifier = Modifier,
    closeDetailScreen: () -> Unit,
    navigateToDetail: (Long) -> Unit
) {
    if (replyHomeUIState.selectedEmail != null && replyHomeUIState.isDetailOnlyOpen) {
        BackHandler {
            closeDetailScreen()
        }
        ReplyEmailDetail(email = replyHomeUIState.selectedEmail) {
            closeDetailScreen()
        }
    } else {
        ReplyEmailList(
            emails = replyHomeUIState.emails,
            emailLazyListState = emailLazyListState,
            modifier = modifier,
            navigateToDetail = navigateToDetail
        )
    }
}

코드를 자세히 들여다보면 알 수 있다.
replyHomeUIState와 closeDetailScreen를 구질구질하게 자꾸 들고 왔던 모습을.

바로 여기서 사용하기 위함이다.

replyHomeUIState의 isDetailOnlyOpen를 통해 현재 Composable에서 보여줘야하는 화면이 상세 화면인지 리스트 화면인지 분기한다.
만일 상세 화면이라면, replyHomeUIState 에서 selectedEmail를 파라미터로넘겨준다. 리스트 화면이라면 replyHomeUIState 에서 emails를 파라미터로 넘겨준다.

그리고 closeDetailScreen를 통해 상세화면에서 리스트 화면으로 돌아오려는 로직을 설정하는데, 여기서도 재밌는 부분이 하나 있다.

BackHandler {
    closeDetailScreen()
}

바로 BackHandler 이것은 사용자와의 인터렉션에서 사용자가 뒤로 가기 버튼을 눌렀을 때를 컨트롤 하겠다는 핸들러이다.
미. 쳤. 다. 고 볼 수 있다. (xml에서는 onBackPressedd를 어쩌고 저쩌고,,)

  1. ReplyEmailList > ReplySearchBar, ReplyEmailListItem
    ReplyEmailDetail > EmailDetailAppBar, ReplyEmailThreadItem
@Composable
fun ReplyEmailList(
    emails: List<Email>,
    emailLazyListState: LazyListState,
    selectedEmail: Email? = null,
    modifier: Modifier = Modifier,
    navigateToDetail: (Long) -> Unit
) {
    LazyColumn(modifier = modifier, state = emailLazyListState) {
        item {
            ReplySearchBar(modifier = Modifier.fillMaxWidth())
        }
        items(items = emails, key = { it.id }) { email ->
            ReplyEmailListItem(
                email = email,
                isSelected = email.id == selectedEmail?.id
            ) { emailId ->
                navigateToDetail(emailId)
            }
        }
    }
}

상단바와, 그 아래에는 리스트가 위치해 있다.
상단바도 아이템으로 추가가 됐기 때문에 스크롤이 내려가면 밀려나는 것을 볼 수 있다.
item과 items의 차이는 간단하게 반복성의 차이인데, 요기에 정리해뒀다.

ReplyEmailList(리스트 화면)와 ReplyEmailDetail(상세 화면)이 같은 양상으로 구성되어 있기 때문에 중복되는 부분은 제외하도록 하겠다.
(아이템 뷰를 Column으로 했냐 Card로 했냐 같은 미세한 차이)


이렇게 오늘은 compose-samples와 compose-codelab 둘 다 포함되어 있는 프로젝트에 대해서 알아봤다. 확실히 리스트를 본격적으로 사용하기 위해 살펴보는 용도로 부족함이 없었던 것 같다.
(은근 꿀팁이 있었달까)

다음 포스팅은 원래 MigrationCodelab이었지만 이 프로젝트는 compose가 xml과 함께 쓰일 수 있다는 것을 보여주기 위한 프로젝트이므로 넘어가고, AnimationCodelab을 진행하겠다.


출처 :

  1. https://developer.android.com/codelabs/jetpack-compose-theming?hl=ko#8
profile
안드안드안드

0개의 댓글