동적 탐색으로 적응형 앱 빌드

1. 소개

Android 플랫폼에서 앱을 개발할 때의 가장 큰 이점 중 하나는 웨어러블 기기, 폴더블, 태블릿, 데스크톱, TV 등 다양한 종류의 폼 팩터로 사용자에게 도달할 수 있는 기회가 있다는 것입니다. 사용자는 앱을 사용할 때 동일한 앱을 대형 화면 기기에서 사용하여 보다 큰 공간을 활용하길 원할 수 있습니다. 점점 더 많은 Android 사용자들이 다양한 화면 크기를 갖는 여러 기기에서 앱을 사용하고 있으며 모든 기기에서 고품질 사용자 경험을 기대합니다.

지금까지는 주로 모바일 기기용 앱을 만드는 방법을 배웠습니다. 이 Codelab에서는 다른 화면 크기에 맞게 앱이 조정되도록 앱을 변환하는 방법을 알아봅니다. 폴더블, 태블릿, 데스크톱과 같은 모바일 및 대형 화면 기기에서 유려하고 사용성 높은 적응형 탐색 레이아웃 패턴을 사용합니다.

기본 요건

  • 클래스, 함수, 조건문 등 Kotlin 프로그래밍에 관한 지식
  • ViewModel 클래스에 관한 지식
  • Composable 함수 작성에 관한 능력
  • Jetpack Compose로 레이아웃 빌드 경험
  • 기기 또는 에뮬레이터에서 앱 실행 경험

학습할 내용

  • 간단한 앱에서 탐색 그래프 없이 화면 간 탐색을 만드는 방법
  • Jetpack Compose를 사용하여 적응형 탐색 레이아웃을 만드는 방법
  • 맞춤 뒤로 핸들러를 만드는 방법

빌드할 항목

  • 기존 Reply 앱의 레이아웃이 모든 화면 크기에 맞게 조정되도록 동적 탐색을 구현합니다.

완성된 결과물은 다음 이미지와 같습니다.

56cfa13ef31d0b59.png

​​

필요한 항목

  • 인터넷 액세스가 가능하고 웹브라우저, Android 스튜디오가 있는 컴퓨터
  • GitHub 액세스

2. 앱 개요

Reply 앱 소개

Reply 앱은 이메일 클라이언트와 비슷한 멀티스크린 앱입니다.

a1af0f9193718abf.png

Reply 앱은 받은편지함, 보낸편지함, 임시보관함, 스팸이라는 4가지 탭으로 카테고리를 표시합니다.

시작 코드 다운로드

Android 스튜디오에서 basic-android-kotlin-compose-training-reply-app 폴더를 엽니다.

3. 시작 코드 둘러보기

Reply 앱의 중요 디렉터리

펼쳐져 있는 하위 디렉터리 두 개가 표시된 Reply 앱 파일 디렉터리

Reply 앱 프로젝트의 데이터 레이어와 UI 레이어는 서로 다른 디렉터리로 분리되어 있습니다. ReplyViewModelReplyUiState를 비롯한 컴포저블은 ui 디렉터리에 있습니다. 데이터 레이어를 정의하는 dataenum 클래스와 데이터 제공자 클래스는 data 디렉터리에 있습니다.

Reply 앱의 데이터 초기화

Reply 앱은 init 함수에서 실행되는 ReplyViewModelinitializeUIState() 메서드를 통해 데이터로 초기화됩니다.

ReplyViewModel.kt

...
    init {
        initializeUIState()
    }
 

    private fun initializeUIState() {
        var mailboxes: Map<MailboxType, List<Email>> =
            LocalEmailsDataProvider.allEmails.groupBy { it.mailbox }
        _uiState.value = ReplyUiState(
            mailboxes = mailboxes,
            currentSelectedEmail = mailboxes[MailboxType.Inbox]?.get(0)
                ?: LocalEmailsDataProvider.defaultEmail
        )
    }
...

화면 수준 컴포저블

Reply 앱은 다른 앱과 마찬가지로 viewModeluiState가 선언된 기본 컴포저블로 ReplyApp 컴포저블을 사용합니다. 다양한 viewModel() 함수도 ReplyHomeScreen 컴포저블의 람다 인수로 전달됩니다.

ReplyApp.kt

...
@Composable
fun ReplyApp(modifier: Modifier = Modifier) {
    val viewModel: ReplyViewModel = viewModel()
    val replyUiState = viewModel.uiState.collectAsState().value

    ReplyHomeScreen(
        replyUiState = replyUiState,
        onTabPressed = { mailboxType: MailboxType ->
            viewModel.updateCurrentMailbox(mailboxType = mailboxType)
            viewModel.resetHomeScreenStates()
        },
        onEmailCardPressed = { email: Email ->
            viewModel.updateDetailsScreenStates(
                email = email
            )
        },
        onDetailScreenBackPressed = {
            viewModel.resetHomeScreenStates()
        },
        modifier = modifier
    )
}

기타 컴포저블

  • ReplyHomeScreen.kt에는 탐색 요소를 포함하여 홈 화면의 화면 컴포저블이 있습니다.
  • ReplyHomeContent.kt에는 홈 화면의 더 자세한 컴포저블을 정의하는 컴포저블이 있습니다.
  • ReplyDetailsScreen.kt에는 화면 컴포저블과 세부정보 화면을 위한 작은 컴포저블이 있습니다.

Codelab의 다음 섹션으로 넘어가기 전에 각 파일을 자세히 살펴보고 컴포저블에 관해 더 자세히 알아보세요.

4. 탐색 그래프 없이 화면 변경

이전 개발자 과정에서는 NavHostController 클래스를 사용하여 한 화면에서 다른 화면으로 이동하는 방법을 알아보았습니다. Compose를 사용하면 런타임 변경 가능 상태를 사용하여 간단한 조건문으로 화면을 변경하는 것도 가능합니다. 이 방법은 Reply 앱과 같이 두 화면 간에만 전환하려는 작은 애플리케이션에서 특히 유용합니다.

상태 변경을 사용하여 화면 변경하기

Compose에서는 상태 변경이 발생하면 화면이 재구성됩니다. 간단한 조건문으로 화면을 변경하여 상태 변경에 응답할 수 있습니다.

조건문을 사용하여 사용자가 홈 화면에 있을 때는 홈 화면의 콘텐츠를 표시하고 사용자가 홈 화면에 있지 않을 때는 세부정보 화면의 콘텐츠를 표시해 보겠습니다.

상태가 변경되면 화면이 변경되도록 다음 단계에 따라 Reply 앱을 수정합니다.

  1. Android 스튜디오에서 시작 코드를 엽니다.
  2. ReplyHomeScreen.ktReplyHomeScreen 컴포저블에서 replyUiState 객체의 isShowingHomepage 속성이 true인 경우를 위해 if 문으로 ReplyAppContent 컴포저블을 래핑합니다.

ReplyHomeScreen.kt

@Composable
fun ReplyHomeScreen(
    replyUiState: ReplyUiState,
    onTabPressed: (MailboxType) -> Unit,
    onEmailCardPressed: (Int) -> Unit,
    onDetailScreenBackPressed: () -> Unit,
    modifier: Modifier = Modifier
) {

...
    if (replyUiState.isShowingHomepage) {
        ReplyAppContent(
            replyUiState = replyUiState,
            onTabPressed = onTabPressed,
            onEmailCardPressed = onEmailCardPressed,
            navigationItemContentList = navigationItemContentList,
            modifier = modifier

        )
    }
}

이제 세부정보 화면을 표시하여 사용자가 홈 화면에 있지 않은 시나리오를 고려해야 합니다.

  1. 본문에 ReplyDetailsScreen 컴포저블이 있는 else 브랜치를 추가합니다. replyUIState, onDetailScreenBackPressed, modifierReplyDetailsScreen 컴포저블의 인수로 추가합니다.

ReplyHomeScreen.kt

@Composable
fun ReplyHomeScreen(
    replyUiState: ReplyUiState,
    onTabPressed: (MailboxType) -> Unit,
    onEmailCardPressed: (Int) -> Unit,
    onDetailScreenBackPressed: () -> Unit,
    modifier: Modifier = Modifier
) {

...

    if (replyUiState.isShowingHomepage) {
        ReplyAppContent(
            replyUiState = replyUiState,
            onTabPressed = onTabPressed,
            onEmailCardPressed = onEmailCardPressed,
            navigationItemContentList = navigationItemContentList,
            modifier = modifier

        )
    } else {
        ReplyDetailsScreen(
            replyUiState = replyUiState,
            onBackPressed = onDetailScreenBackPressed,
            modifier = modifier
        )
    }
}

replyUiState 객체는 상태 객체입니다. 따라서 replyUiState 객체의 isShowingHomepage 속성이 변경되면 ReplyHomeScreen 컴포저블이 재구성되고 if/else 문이 런타임에 다시 평가됩니다. 이 접근 방식은 NavHostController 클래스를 사용하지 않고도 여러 화면 간 탐색을 지원합니다.

8443a3ef1a239f6e.gif

맞춤 뒤로 핸들러 만들기

NavHost 컴포저블을 사용하여 화면 간에 전환하는 것의 한 가지 이점은 이전 화면의 방향이 백 스택에 저장된다는 것입니다. 저장된 화면을 사용하면 호출 시 시스템 뒤로 버튼이 이전 화면으로 쉽게 이동할 수 있습니다. Reply 앱은 NavHost를 사용하지 않으므로 뒤로 버튼을 처리하는 코드를 수동으로 추가해야 합니다. 다음으로 이 작업을 진행합니다.

아래 단계에 따라 Reply 앱에서 맞춤 뒤로 핸들러를 만듭니다.

  1. ReplyDetailsScreen 컴포저블의 첫 번째 줄에 BackHandler 컴포저블을 추가합니다.
  2. BackHandler 컴포저블의 본문에서 onBackPressed() 함수를 호출합니다.

ReplyDetailsScreen.kt

...
import androidx.activity.compose.BackHandler
...
@Composable
fun ReplyDetailsScreen(
    replyUiState: ReplyUiState,
    onBackPressed: () -> Unit,
    modifier: Modifier = Modifier
) {
    BackHandler {
        onBackPressed()
    }
... 

5. 대형 화면 기기에서 앱 실행

크기 조절 가능한 에뮬레이터로 앱 확인

사용성이 뛰어난 앱을 만들려면 개발자는 다양한 폼 팩터에서 사용자 경험을 이해해야 합니다. 따라서 개발 프로세스 초기 단계부터 다양한 폼 팩터에서 앱을 테스트해야 합니다.

다양한 화면 크기의 여러 에뮬레이터를 사용하여 앱을 테스트할 수 있습니다. 그러나 이렇게 하는 것은 번거로울 수 있으며, 특히 한 번에 여러 화면 크기를 빌드할 때는 더욱 번거로워집니다. 실행 중인 앱이 방향 변경, 데스크톱의 창 크기 변경, 폴더블의 접힌 상태 변경과 같은 화면 크기 변경에 어떻게 반응하는지 테스트해야 할 수도 있습니다.

Android 스튜디오에는 이러한 시나리오를 테스트할 수 있도록 크기 조절 가능한 에뮬레이터가 도입되었습니다.

크기 조절 가능한 에뮬레이터를 설정하려면 다음 단계를 따르세요.

  1. Android 스튜디오에서 Tools > Device Manager를 선택합니다.

Tools 메뉴에 옵션 목록이 표시되어 있음. 목록의 중간 쯤에 표시된 Device Manager가 선택되어 있음

  1. Device Manager에서 가상 기기를 만들려면 + 아이콘을 클릭합니다.

가상 기기 만들기를 비롯하여 두 가지 메뉴 옵션이 표시된 Device Manager 툴바

  1. Phone 카테고리와 Resizable (Experimental) 기기를 선택합니다.
  2. Next를 클릭합니다.

Device Manager 창에 기기 정의를 선택하라는 메시지가 표시되어 있음. 옵션 목록과 그 위에 검색창이 표시되어 있음 카테고리

  1. API 수준 34 이상을 선택합니다.
  2. Next를 클릭합니다.

Virtual Device Configuration 창에 시스템 이미지를 선택하라는 메시지가 표시되어 있음 API 수준 34가 선택됨

  1. 새 Android Virtual Device의 이름을 지정합니다.
  2. Finish를 클릭합니다.

Android Virtural Device(AVD)의 가상 구성 화면이 표시되어 있음. 구성 화면에는 AVD 이름을 입력할 수 있는 텍스트 필드가 있음 이름 필드 아래에는 기기 정의(Resizable Experimental), 시스템 이미지(Tiramisu), 방향(기본적으로 Portrait 방향이 선택되어 있음)을 포함하는 기기 옵션 목록이 있음 읽기 버튼

대형 화면 에뮬레이터에서 앱 실행

크기 조절 가능한 에뮬레이터를 설정했으니 이제 대형 화면에서 앱이 어떻게 표시되는지 살펴보겠습니다.

  1. 크기 조절 가능한 에뮬레이터에서 앱을 실행합니다.
  2. 디스플레이 모드로 Tablet을 선택합니다.

bfacf9c20a30b06b.png

  1. 태블릿 모드의 앱을 가로 모드로 살펴봅니다.

bb0fa5e954f6ca4b.png

태블릿 화면 디스플레이는 가로로 긴 것을 볼 수 있습니다. 이 방향은 작동하기는 하지만 대형 화면 공간은 최대한으로 사용하고 있다고 할 수는 없습니다. 지금부터 이 문제를 해결하겠습니다.

대형 화면용 설계

태블릿에서 이 앱을 봤을 때 처음으로 든 생각은 디자인이 좋지 않고 별로라는 것이었을 겁니다. 맞습니다. 이 레이아웃은 대형 화면용으로 설계되지 않았습니다.

태블릿이나 폴더블과 같은 대형 화면용으로 설계할 때는 사용자의 인체공학과 사용자의 손가락이 화면에 근접한 정도를 고려해야 합니다. 모바일 기기에서는 사용자의 손가락이 화면의 대부분에 쉽게 닿을 수 있으므로, 버튼이나 탐색 요소와 같은 상호작용 요소의 위치가 그다지 중요하지 않습니다. 그러나 대형 화면에서는 중요한 상호작용 요소를 화면 중앙에 배치하면 손가락으로 닿기가 어려울 수 있습니다.

Reply 앱에서 알 수 있듯이 대형 화면용으로 디자인하는 것은 단순히 UI 요소를 화면에 맞게 늘리거나 확대하는 것을 의미하지 않습니다. 대형 화면용으로 디자인하는 것은 늘어난 공간을 활용하여 사용자에게 색다른 경험을 선사할 기회가 됩니다. 예를 들어, 다른 화면으로 이동할 필요를 없애거나 멀티태스킹을 지원하기 위해 동일한 화면에 레이아웃을 추가할 수 있습니다.

f50e77a4ffd923a.png

이 디자인은 사용자 생산성을 높이고 더 많은 참여를 독려할 수 있습니다. 하지만 이 디자인을 배포하려면 먼저 다양한 화면 크기에 맞는 서로 다른 레이아웃을 만드는 방법을 알아야 합니다.

6. 다양한 화면 크기에 맞게 레이아웃 조정

중단점이란?

어떻게 하면 하나의 앱의 여러 레이아웃을 표시할 수 있는지 궁금하실 겁니다. 간단한 답변은 이 Codelab의 시작 부분에서 했던 것처럼 여러 상태에 대응되는 조건문을 사용하는 것입니다.

적응형 앱을 만들려면 화면 크기에 따라 레이아웃이 변경되도록 해야 합니다. 레이아웃 변경이 이루어지는 측정 지점을 중단점이라고 합니다. Material Design은 대부분의 Android 화면을 처리하는 체계적인 중단점 범위를 만들었습니다.

여러 기기 유형 및 설정의 중단점 범위가 dp 단위로 정리된 표. 0~599dp는 세로 모드의 핸드셋, 가로 모드의 휴대전화, 소형 창 크기, 열 4개, 최소 여백 8용임. 600~839dp는 세로 또는 가로 모드의 소형 폴더블 태블릿, 중형 창 크기 클래스, 열 12개, 최소 여백 12용임. 840dp 이상은 세로 또는 가로 모드의 대형 태블릿, 확장된 창 크기 클래스, 열 12개, 최소 여백 32용임. 표 메모에는 여백과 거터는 유연하며 크기가 같을 필요가 없고 가로 모드의 휴대전화는 0~599dp 중단점 범위에 속하는 예외로 간주된다는 내용이 있음

이 중단점 범위 표에서는 앱이 화면 크기가 600dp 미만인 기기에서 실행 중인 경우 모바일 레이아웃을 표시해야 함을 보여줍니다.

창 크기 클래스 사용하기

Compose에 도입된 WindowSizeClass API를 사용하면 Material Design 중단점 구현이 간단해집니다.

창 크기 클래스는 너비와 높이에 대해 소형, 중형, 대형의 세 가지 크기 카테고리를 도입합니다.

너비 기반 창 크기 클래스를 나타내는 다이어그램. 높이 기반 창 크기 클래스를 나타내는 다이어그램.

Reply 앱에서 WindowSizeClass API를 구현하려면 다음 단계를 따르세요.

  1. 모듈 build.gradle.kts 파일에 material3-window-size-class 종속 항목을 추가합니다.

build.gradle.kts

...
dependencies {
...
    implementation("androidx.compose.material3:material3-window-size-class")
...
  1. 종속 항목을 추가한 후 gradle을 동기화하려면 Sync Now를 클릭합니다.

b4c912a45fa8b7f4.png

build.gradle.kts 파일을 최신 상태로 만들었다면 이제 주어진 시점에 앱 창의 크기를 저장하는 변수를 만들 수 있습니다.

  1. MainActivity.kt 파일의 onCreate() 함수에서 매개변수를 통해 this 컨텍스트를 전달받는 calculateWindowSizeClass() 메서드를 windowSize라는 변수에 할당합니다.
  2. 적절한 calculateWindowSizeClass 패키지를 가져옵니다.

MainActivity.kt

...
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass

...

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

    setContent {
        ReplyTheme {
            val layoutDirection = LocalLayoutDirection.current
            Surface (
               // ...
            ) {
                val windowSize = calculateWindowSizeClass(this)
                ReplyApp()
...  
  1. calculateWindowSizeClass 구문에 빨간색 밑줄이 표시되어 있고 빨간색 전구가 나타난 것을 볼 수 있습니다. windowSize 변수 왼쪽의 빨간색 전구를 클릭하고 Opt in for 'ExperimentalMaterial3WindowSizeClassApi on 'onCreate'를 선택하여 onCreate() 메서드 위에 주석을 만듭니다.

f8029f61dfad0306.png

MainActivity.ktWindowWidthSizeClass 변수를 사용하여 여러 컴포저블에서 표시할 레이아웃을 정할 수 있습니다. ReplyApp 컴포저블이 이 값을 받도록 준비해 보겠습니다.

  1. ReplyApp.kt 파일에서 ReplyApp 컴포저블이 WindowWidthSizeClass를 매개변수로 받고 적절한 패키지를 가져오도록 수정합니다.

ReplyApp.kt

...
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
...

@Composable
fun ReplyApp(
    windowSize: WindowWidthSizeClass,
    modifier: Modifier = Modifier
) {
...  
  1. windowSize 변수를 MainActivity.kt 파일의 onCreate() 메서드의 ReplyApp 구성요소로 전달합니다.

MainActivity.kt

...
        setContent {
            ReplyTheme {
                Surface {
                    val windowSize = calculateWindowSizeClass(this)
                    ReplyApp(
                        windowSize = windowSize.widthSizeClass
                    )
...  

windowSize 매개변수의 앱 미리보기도 업데이트해야 합니다.

  1. WindowWidthSizeClass.Compact를 미리보기 구성요소의 ReplyApp 컴포저블에 windowSize 매개변수로 전달하고 적절한 패키지를 가져옵니다.

MainActivity.kt

...
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
...

@Preview(showBackground = true)
@Composable
fun ReplyAppCompactPreview() {
    ReplyTheme {
        Surface {
            ReplyApp(
                windowSize = WindowWidthSizeClass.Compact,
            )
        }
    }
}
  1. 화면 크기에 따라 앱 레이아웃을 변경하려면 WindowWidthSizeClass 값에 따라 ReplyApp 컴포저블에 when 문을 추가합니다.

ReplyApp.kt

...

@Composable
fun ReplyApp(
    windowSize: WindowWidthSizeClass,
    modifier: Modifier = Modifier
) {
    val viewModel: ReplyViewModel = viewModel()
    val replyUiState = viewModel.uiState.collectAsState().value
    
    when (windowSize) {
        WindowWidthSizeClass.Compact -> {
        }
        WindowWidthSizeClass.Medium -> {
        }
        WindowWidthSizeClass.Expanded -> {
        }
        else -> {
        }
    }
...  

이렇게 해서 WindowSizeClass 값을 사용하여 앱의 레이아웃을 변경하기 위한 기반을 구현했습니다. 다음 단계는 앱을 여러 화면 크기에서 어떻게 표시할지 정하는 것입니다.

7. 적응형 탐색 레이아웃 구현

적응형 UI 탐색 구현하기

하단 탐색 메뉴는 현재 모든 화면 크기에서 사용되고 있습니다.

f39984211e4dd665.png

앞에서 설명한 것처럼 이 탐색 요소는 사용자가 대형 화면에서 필수 탐색 요소에 닿기 어려울 수 있으므로 적절하지 않습니다. 다행히 반응형 UI 탐색에는 여러 창 크기 클래스에 따라 권장되는 여러 탐색 요소 패턴이 있습니다. Reply 앱의 경우 다음 요소를 구현할 수 있습니다.

창 크기 클래스와 표시되는 몇 가지 항목이 나열된 표. 소형 너비에는 하단 탐색 메뉴가 표시됨. 중현 너비에는 탐색 레일이 표시됨. 대형 너비에는 선행 가장자리가 있는 영구 탐색 창이 표시됨.

탐색 레일Material Design의 또 다른 탐색 구성요소로, 앱 측면에서 기본 대상을 위한 소형 탐색 옵션에 액세스할 수 있도록 지원합니다.

1c73d20ace67811c.png

마찬가지로, 영구 탐색 창Material Design에 의해 생성되는 또 다른 옵션으로, 대형 화면을 위한 인체공학적 액세스를 제공합니다.

6795fb31e6d4a564.png

탐색 창 구현

대형 화면의 탐색 창을 만들려면 navigationType 매개변수를 사용하면 됩니다. 다음 단계를 따르세요.

  1. 탐색 요소의 여러 유형을 나타내려면 ui 디렉터리 아래의 새 패키지 utils에 새 파일 WindowStateUtils.kt를 만듭니다.
  2. Enum 클래스를 추가하여 탐색 요소의 여러 유형을 나타냅니다.

WindowStateUtils.kt

package com.example.reply.ui.utils

enum class ReplyNavigationType {
    BOTTOM_NAVIGATION, NAVIGATION_RAIL, PERMANENT_NAVIGATION_DRAWER
}
 

탐색 창을 성공적으로 구현하려면 앱의 창 크기에 따라 탐색 유형을 정해야 합니다.

  1. ReplyApp 컴포저블에서 navigationType 변수를 만들고 when 문의 화면 크기에 따라 적절한 ReplyNavigationType 값을 할당합니다.

ReplyApp.kt

...
import com.example.reply.ui.utils.ReplyNavigationType
...
    val navigationType: ReplyNavigationType
    when (windowSize) {
        WindowWidthSizeClass.Compact -> {
            navigationType = ReplyNavigationType.BOTTOM_NAVIGATION
        }
        WindowWidthSizeClass.Medium -> {
            navigationType = ReplyNavigationType.NAVIGATION_RAIL
        }
        WindowWidthSizeClass.Expanded -> {
            navigationType = ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER
        }
        else -> {
            navigationType = ReplyNavigationType.BOTTOM_NAVIGATION
        }
    }
...
 

ReplyHomeScreen 컴포저블의 navigationType 값을 사용할 수 있습니다. 이를 컴포저블용 매개변수로 만들면 됩니다.

  1. ReplyHomeScreen 컴포저블에서 navigationType을 매개변수로 추가합니다.

ReplyHomeScreen.kt

...
@Composable
fun ReplyHomeScreen(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: (MailboxType) -> Unit,
    onEmailCardPressed: (Email) -> Unit,
    onDetailScreenBackPressed: () -> Unit,
    modifier: Modifier = Modifier
) 

...
 
  1. navigationTypeReplyHomeScreen 컴포저블에 전달합니다.

ReplyApp.kt

...
    ReplyHomeScreen(
        navigationType = navigationType,
        replyUiState = replyUiState,
        onTabPressed = { mailboxType: MailboxType ->
            viewModel.updateCurrentMailbox(mailboxType = mailboxType)
            viewModel.resetHomeScreenStates()
        },
        onEmailCardPressed = { email: Email ->
            viewModel.updateDetailsScreenStates(
                email = email
            )
        },
        onDetailScreenBackPressed = {
            viewModel.resetHomeScreenStates()
        },
        modifier = modifier
    )
...
 

다음으로, 사용자가 대형 화면에서 앱을 열고 홈 화면을 표시하는 경우 앱 콘텐츠를 탐색 창과 함께 표시하는 브랜치를 만들 수 있습니다.

  1. ReplyHomeScreen 컴포저블 본문에서 navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER && replyUiState.isShowingHomepage 조건에 대응되는 if 문을 추가합니다.

ReplyHomeScreen.kt

import androidx.compose.material3.PermanentNavigationDrawer
...
@Composable
fun ReplyHomeScreen(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: (MailboxType) -> Unit,
    onEmailCardPressed: (Email) -> Unit,
    onDetailScreenBackPressed: () -> Unit,
    modifier: Modifier = Modifier
) {
...
    if (navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER
        && replyUiState.isShowingHomepage
    ) {
    }

    if (replyUiState.isShowingHomepage) {
        ReplyAppContent(
            replyUiState = replyUiState,
...
  1. 영구 창을 만들려면 if 문 본문에 PermanentNavigationDrawer 컴포저블을 만들고 drawerContent 매개변수의 입력으로 NavigationDrawerContent 컴포저블을 추가합니다.
  2. ReplyAppContent 컴포저블을 PermanentNavigationDrawer의 마지막 람다 인수로 추가합니다.

ReplyHomeScreen.kt

...
    if (navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER
        && replyUiState.isShowingHomepage
    ) {
        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))
                    )
                }
            }
        ) {
            ReplyAppContent(
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier
            )
        }
    }

...
  1. 이전 컴포저블 본문을 사용하여 대형이 아닌 화면의 이전 브랜치를 유지하는 else 브랜치를 추가합니다.

ReplyHomeScreen.kt

...
if (navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER
        && replyUiState.isShowingHomepage
) {
        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))
                    )
                }
            }
        ) {
            ReplyAppContent(
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier
            )
        }
    } else {
        if (replyUiState.isShowingHomepage) {
            ReplyAppContent(
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier
            )
        } else {
            ReplyDetailsScreen(
                replyUiState = replyUiState,
                onBackPressed = onDetailScreenBackPressed,
                modifier = modifier
            )
        }
    }
}
...
  1. 태블릿 모드에서 앱을 실행합니다. 다음 화면이 표시됩니다.

2dbbc2f88d08f6a.png

탐색 레일 구현

탐색 창 구현과 마찬가지로 navigationType 매개변수를 사용하여 탐색 요소 간에 전환해야 합니다.

먼저 중형 화면을 위한 탐색 레일을 추가하겠습니다.

  1. 먼저 navigationType을 매개변수로 추가하여 ReplyAppContent 컴포저블을 준비합니다.

ReplyHomeScreen.kt

...
@Composable
private fun ReplyAppContent(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: ((MailboxType) -> Unit),
    onEmailCardPressed: (Email) -> Unit,
    navigationItemContentList: List<NavigationItemContent>,
    modifier: Modifier = Modifier
) {       
... 
  1. navigationType 값을 양쪽 ReplyAppContent 컴포저블에 전달합니다.

ReplyHomeScreen.kt

...
            ReplyAppContent(
                navigationType = navigationType,
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier
            )
        }
    } else {
        if (replyUiState.isShowingHomepage) {
            ReplyAppContent(
                navigationType = navigationType,
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier
            )
... 

다음으로, 앱에서 일부 시나리오를 위한 탐색 레일을 표시할 수 있도록 브랜치를 추가합니다.

  1. ReplyAppContent 컴포저블의 첫 번째 줄에서 ReplyNavigationRail 컴포저블을 AnimatedVisibility 컴포저블로 래핑하고 ReplyNavigationType 값이 NAVIGATION_RAIL이면 visible 매개변수가 true가 되도록 설정합니다.

ReplyHomeScreen.kt

...
@Composable
private fun ReplyAppContent(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: ((MailboxType) -> Unit),
    onEmailCardPressed: (Email) -> Unit,
    navigationItemContentList: List<NavigationItemContent>,
    modifier: Modifier = Modifier
) {
    Box(modifier = modifier) {
        AnimatedVisibility(visible = navigationType == ReplyNavigationType.NAVIGATION_RAIL) {
            ReplyNavigationRail(
                currentTab = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
navigationItemContentList = navigationItemContentList
            )
        }
        Column(
            modifier = Modifier
                .fillMaxSize()
                .background(
                    MaterialTheme.colorScheme.inverseOnSurface
            )
        ) {
            ReplyListOnlyContent(
                replyUiState = replyUiState,
                onEmailCardPressed = onEmailCardPressed,
                modifier = Modifier.weight(1f)
                    .padding(
                        horizontal = dimensionResource(R.dimen.email_list_only_horizontal_padding)
                    )
            )
            ReplyBottomNavigationBar(
                currentTab = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
                navigationItemContentList = navigationItemContentList,
                  modifier = Modifier
                      .fillMaxWidth()
            )
        }
    }
}     
... 
  1. 컴포저블을 올바르게 정렬하려면 ReplyAppContent 본문에 있는 AnimatedVisibility 컴포저블과 Column 컴포저블을 Row 컴포저블로 래핑합니다.

ReplyHomeScreen.kt

...
@Composable
private fun ReplyAppContent(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: ((MailboxType) -> Unit),
    onEmailCardPressed: (Email) -> Unit,
    navigationItemContentList: List<NavigationItemContent>,
    modifier: Modifier = Modifier,
) {
    Row(modifier = modifier) {
        AnimatedVisibility(visible = navigationType == ReplyNavigationType.NAVIGATION_RAIL) {
            val navigationRailContentDescription = stringResource(R.string.navigation_rail)
            ReplyNavigationRail(
                currentTab = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
                navigationItemContentList = navigationItemContentList
            )
        }
        Column(
            modifier = Modifier
                .fillMaxSize()
                .background(MaterialTheme.colorScheme.inverseOnSurface)
        ) {
            ReplyListOnlyContent(
                replyUiState = replyUiState,
                onEmailCardPressed = onEmailCardPressed,
                modifier = Modifier.weight(1f)
                    .padding(
                        horizontal = dimensionResource(R.dimen.email_list_only_horizontal_padding)
                )
            )
            ReplyBottomNavigationBar(
                currentTab = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = Modifier
                    .fillMaxWidth()
            )
        }
    }
}

... 

마지막으로, 몇 가지 시나리오에서는 하단 탐색이 표시되는지 확인해 봅니다.

  1. ReplyListOnlyContent 컴포저블 뒤에서 ReplyBottomNavigationBar 컴포저블을 AnimatedVisibility 컴포저블로 래핑합니다.
  2. ReplyNavigationType 값이 BOTTOM_NAVIGATION인 경우 visible 매개변수를 설정합니다.

ReplyHomeScreen.kt

...
ReplyListOnlyContent(
    replyUiState = replyUiState,
    onEmailCardPressed = onEmailCardPressed,
    modifier = Modifier.weight(1f)
        .padding(
            horizontal = dimensionResource(R.dimen.email_list_only_horizontal_padding)
        )

)
AnimatedVisibility(visible = navigationType == ReplyNavigationType.BOTTOM_NAVIGATION) {
    val bottomNavigationContentDescription = stringResource(R.string.navigation_bottom)
    ReplyBottomNavigationBar(
        currentTab = replyUiState.currentMailbox,
        onTabPressed = onTabPressed,
        navigationItemContentList = navigationItemContentList,
        modifier = Modifier
            .fillMaxWidth()
    )
}

... 
  1. 펼쳐진 폴더블 모드로 앱을 실행합니다. 다음 화면이 표시됩니다.

bfacf9c20a30b06b.png

8. 솔루션 코드 가져오기

완료된 Codelab의 코드를 다운로드하려면 다음 git 명령어를 사용하면 됩니다.

git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-reply-app.git 
cd basic-android-kotlin-compose-training-reply-app
git checkout nav-update

또는 ZIP 파일로 저장소를 다운로드한 다음 압축을 풀고 Android 스튜디오에서 열어도 됩니다.

솔루션 코드를 보려면 GitHub에서 확인하세요.

9. 마무리

축하합니다. 적응형 탐색 레이아웃을 구현해 봄으로써 Reply 앱이 모든 화면 크기에 맞게 조정되도록 만드는 데 한 걸음 더 다가갔습니다. 또한 여러 Android 폼 팩터를 사용하여 사용자 경험을 개선했습니다. 다음 Codelab에서는 적응형 콘텐츠 레이아웃, 테스트, 미리보기를 구현하여 적응형 앱을 처리하는 역량을 더욱 키워 보겠습니다.

#AndroidBasics를 사용해 작업한 결과물을 소셜 미디어로 공유해 보세요.

자세히 알아보기