Jetpack Compose - Adaptive app with dynamic navigation

2024. 2. 14. 18:04IT/Android

728x90

<미리 보기>

스마트 폰
태블릿 PC

 

 

 

<소스 코드>

https://github.com/SeongHyunJeon/android-kotlin-practice/tree/8ece555aff853c252bde179edd64dc6cea18ec93/Reply

 

 

<정리>

BackHandler() - 시스템 뒤로 가기를 정의하는 컴포저블.

@Composable
fun ReplyDetailsScreen(
    replyUiState: ReplyUiState,
    onBackPressed: () -> Unit,
    modifier: Modifier = Modifier
) {
    BackHandler {
        onBackPressed()//해당 화면에서 시스템 뒤로 가기를 눌렀을 때, 실행되는 함수.
    }

    ...
}

WindowSizeClass - 화면 크기를 자동으로 계산하는 함수가 포함된 라이브러리로 너비와 높이에 대해 소형(Compact), 중형(Medium), 대형(Expanded) 세 가지 카테고리로 나눠지는데, 화면 크기에 따라 레이아웃 변경이 이루어지는 지점을 중단점(breakpoint)이라고 한다.

class MainActivity : ComponentActivity() {

    @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            ReplyTheme {
                Surface {
                    val windowSize = calculateWindowSizeClass(this) //화면 크기를 계산한 WindowSizeClass를 반환.
                    ReplyApp(windowSize.widthSizeClass) //너비 정보를 컴포지션 ReplyApp에 전달.
                }
            }
        }
    }
}

fun ReplyApp(
    windowSize: WindowWidthSizeClass,
    modifier: Modifier = Modifier,
) {
    ...

    when (windowSize) {
        WindowWidthSizeClass.Compact -> { //소형인 경우.
            ...
        }
        WindowWidthSizeClass.Medium -> { //중형인 경우.
            ...
        }
        WindowWidthSizeClass.Expanded -> { //대형인 경우.
            ...
        }
        else -> {
            ...
        }
    }
    
    ...
}

 

*스마트 폰의 기본 방향은 세로 모드이지만, 태블릿의 기본 방향은 가로 모드다.

 

WindowSizeClass의 높이 중단점
WindowSizeClass의 너비 중단점
각 화면에 권고되는 탐색 유형


화면 사이즈 별 미리보기 설정 방법.

@Preview(showBackground = true, widthDp = 700) //중형
@Composable
fun ReplyAppMediumPreview() {
    ReplyTheme {
        Surface {
            ReplyApp(windowSize = WindowWidthSizeClass.Medium)
        }
    }
}

@Preview(showBackground = true, widthDp = 1000) //대형
@Composable
fun ReplyAppExpandedPreview() {
    ReplyTheme {
        Surface {
            ReplyApp(windowSize = WindowWidthSizeClass.Expanded)
        }
    }
}

표준 레이아웃(canonical layout) - 일반적인 사용 사례에 근거한 대형 화면 컴포지션으로 Feed, List-detail, Supporting Pain 세 가지카테고리로 나눠진다.

 

https://m3.material.io/foundations/layout/canonical-layouts/overview


PermanentNavigationDrawer - 영구 탐색 컴포저블.

PermanentDrawerSheet - 영구 탐색 영역을 정의하고 실질적인 구현을 파라미터로 받는다.

PermanentNavigationDrawer( //영구 탐색 레이아웃을 설정하는 컴포저블.
    drawerContent = {
        PermanentDrawerSheet(Modifier.width(dimensionResource(R.dimen.drawer_width))) {
            NavigationDrawerContent( //영구 탐색 메뉴의 실질적인 구현 컴포저블.
                selectedDestination = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = Modifier
                    .wrapContentWidth()
                    .fillMaxHeight()
                    .background(MaterialTheme.colorScheme.inverseOnSurface)
                    .padding(dimensionResource(R.dimen.drawer_padding_content))
            )
        }
    },
    modifier = Modifier.testTag(navigationDrawerContentDescription)
) {
    ReplyAppContent( //영구 탐색 메뉴를 제외한 실질적인 컨텐츠 화면 구현 컴포저블. 
        navigationType = navigationType,
        contentType = contentType,
        replyUiState = replyUiState,
        onTabPressed = onTabPressed,
        onEmailCardPressed = onEmailCardPressed,
        navigationItemContentList = navigationItemContentList,
        modifier = modifier
    )
}

AnimatedVisibility - 특정 조건에 따라 컴포저블의 출력 유무를 결정할 수 있는 컴포저블.

Row(modifier = modifier.fillMaxSize()) {
    AnimatedVisibility(visible = navigationType == ReplyNavigationType.NAVIGATION_RAIL) { //중형 화면인 경우, 레일 탐색 출력.
        ReplyNavigationRail(
            ...
        )
    }
    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(MaterialTheme.colorScheme.inverseOnSurface)
    ) {
        ...
    }
}

 

StateRestorationTester - 계측 테스트에서 구성 변경으로 인한 UI상태를 테스트하기 위한 클래스.

class ReplyAppStateRestorationTest {
    @get: Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {
        val stateRestorationTester = StateRestorationTester(composeTestRule) //구성 변경으로 인한 UI상태를 테스트하기 위한 클래스.
        stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Expanded) }

        composeTestRule.onNodeWithText(
            composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
        ).assertIsDisplayed() //세 번째 이메일이 출력되는지 확인.

        composeTestRule.onNodeWithText(
            composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].subject)
        ).performClick() //세 번째 이메일을 클릭.

        /**
         * onChildren() - 해당 노드의 직계 자식 노드들을 의미한다.
         * assertAny() - 해당 노드를 기준으로 주어진 조건을 만족하는 하위 노드가 존재하는지 확인한다.
         * hasAnyDescendant() - 해당 노드를 기준으로 재귀적으로 모든 하위 노드를 순회하여 조건을 만족하는 노드가 존재하는지 확인한다.
         * hasText() - 직계 자식 노드 중 해당 텍스트를 포함하고 있는 노드를 찾는다.
        
         = 특정 범위를 두고 어떤 조건을 만족하는 노드가 존재하는지 확인할 수 있는 테스트 코드.
         */
        composeTestRule.onNodeWithTagForStringId(R.string.details_screen).onChildren()
            .assertAny(hasAnyDescendant(hasText(composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body))))

        stateRestorationTester.emulateSavedInstanceStateRestore() //구성 변경 실행.

        composeTestRule.onNodeWithTagForStringId(R.string.details_screen).onChildren()
            .assertAny(hasAnyDescendant(hasText(composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body))))
    }
}
728x90