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

1. 소개

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

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

기본 요건

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

학습할 내용

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

빌드할 항목

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

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

​​ 이 Codelab을 마치면 완성하게 될, 왼쪽에 탐색 창이 표시된 Reply 앱의 일러스트. 탐색 창에는 사용자가 선택할 수 있는 '받은편지함', '보낸편지함', '임시보관함', '스팸'의 4개 탭이 표시되어 있음. 탐색 창 오른쪽에는 샘플 이메일 목록이 표시됨.

필요한 항목

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

2. 앱 개요

Reply 앱 소개

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

Reply 앱은 전화 모드로 표시됩니다. 사용자가 읽을 수 있도록 샘플 이메일 목록이 표시됩니다. 화면 하단에는 받은편지함, 보낸편지함, 임시보관함, 스팸을 나타내는 4개의 아이콘이 있습니다.

Reply 앱은 받은편지함, 보낸편지함, 임시보관함, 스팸 탭에 4가지 카테고리를 포함합니다.

시작 코드 다운로드하기

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

  1. 프로젝트에 제공된 GitHub 저장소 페이지로 이동합니다.
  2. 브랜치 이름이 Codelab에 지정된 브랜치 이름과 일치하는지 확인합니다. 예를 들어 다음 스크린샷에서 브랜치 이름은 main입니다.

1e4c0d2c081a8fd2.png

  1. 프로젝트의 GitHub 페이지에서 Code 버튼을 클릭하여 팝업을 엽니다.

1debcf330fd04c7b.png

  1. 팝업에서 Download ZIP 버튼을 클릭하여 컴퓨터에 프로젝트를 저장합니다. 다운로드가 완료될 때까지 기다립니다.
  2. 컴퓨터에서 파일을 찾습니다(예: Downloads 폴더).
  3. ZIP 파일을 더블클릭하여 압축을 해제합니다. 프로젝트 파일이 포함된 새 폴더가 만들어집니다.

Android 스튜디오에서 프로젝트 열기

  1. Android 스튜디오를 시작합니다.
  2. Welcome to Android Studio 창에서 Open을 클릭합니다.

d8e9dbdeafe9038a.png

참고: Android 스튜디오가 이미 열려 있는 경우 File > Open 메뉴 옵션을 대신 선택합니다.

8d1fda7396afe8e5.png

  1. 파일 브라우저에서 압축 해제된 프로젝트 폴더가 있는 위치로 이동합니다(예: Downloads 폴더).
  2. 프로젝트 폴더를 더블클릭합니다.
  3. Android 스튜디오가 프로젝트를 열 때까지 기다립니다.
  4. Run 버튼 8de56cba7583251f.png을 클릭하여 앱을 빌드하고 실행합니다. 예상대로 작동하는지 확인합니다.

3. 시작 코드 둘러보기

Reply 앱의 중요 디렉터리

Reply 앱 파일 디렉터리의 'data'와 'ui'라는 2개의 하위 디렉터리가 펼쳐져 있음. ui 디렉터리에서는 MainActivity.kt가 선택되어 있음. MainActivity.kt는 콘텐츠 목록의 끝에 표시되어 있음.

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

Reply 앱의 데이터 초기화

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

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 클래스를 사용하지 않고도 여러 화면 간 탐색을 지원합니다.

홈 화면에서 세부정보 페이지로의 화면 변경을 보여주는 휴대전화 에뮬레이터의 Reply 앱 애니메이션 일러스트. 홈 화면에는 이메일 목록이 표시되고 하단에 받은편지함, 보낸편지함, 임시보관함, 스팸의 4개 아이콘이 있음. 세부정보 페이지에는 샘플 이메일의 전체 텍스트가 표시되며 그 아래에 Reply 버튼과 Reply All 버튼이 있음.

맞춤 뒤로 핸들러 만들기

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

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

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

ReplyDetailsScreen.kt

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

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

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

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

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

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

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

  1. Android 스튜디오 Chipmunk | 2021.2.1 및 이후 버전을 실행 중이어야 합니다.
  2. Android 스튜디오에서 Tools > Device Manager를 선택합니다.

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

  1. Device Manager에서 Create device를 클릭합니다. Device Manager 툴바에 'Virtual'과 'Physical'이라는 두 가지 메뉴 옵션이 표시되어 있음. 이러한 옵션 아래에는 Create Device 버튼이 있음.
  2. Phone 카테고리와 Resizable (Experimental) 기기를 선택합니다.
  3. Next를 클릭합니다.

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

  1. API Level 33을 선택합니다.
  2. Next를 클릭합니다.

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

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

Android Virtural Device(AVD)의 가상 구성 화면이 표시되어 있음. 구성 화면에는 AVD 이름을 입력할 수 있는 텍스트 필드가 있음. 이름 필드 아래에는 기기 정의(Resizable Experimental), 시스템 이미지(Tiramisu), 방향(기본적으로 Portrait 방향이 선택되어 있음)을 포함하는 기기 옵션 목록이 있음. 기기 정의 정보와 시스템 이미지 정보 옆에 'Change'라는 버튼이 있고 선택된 Portrait 방향 옵션 오른쪽에 Landscape 옵션이 있음. 오른쪽 하단에는 Cancel, Previous, Next, Finish 버튼이 있음. Next 버튼은 비활성화되어 선택할 수 없음.

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

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

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

크기 조절 가능한 에뮬레이터의 휴대전화 화면에 Reply 앱이 표시되어 있음. 앱에 메시지 목록이 표시되어 있고 화면 하단에 받은편지함, 보낸편지함, 임시보관함, 스팸 아이콘이 있음.

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

크기 조절 가능한 에뮬레이터에 Reply 앱이 태블릿 화면으로 표시되어 본문이 길게 나타나 있음. 화면 하단에 받은편지함, 보낸편지함, 임시보관함, 스팸 아이콘이 있음.

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

대형 화면용으로 디자인하기

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

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

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

홈페이지에 세부정보 화면과 함께 탐색 창과 이메일 목록이 표시된 Reply 앱. 이메일 목록 오른쪽에 샘플 이메일이 표시됨. 샘플 이메일 아래에 Reply 버튼과 Reply All 버튼이 있음.

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

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 파일에 material3-window-size-class 종속 항목을 추가합니다.

build.gradle

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

여러 .kt 및 .gradle 파일을 선택할 수 있는 탭 아래에 Sync Now 버튼이 표시되어 있음. Sync Now 버튼 오른쪽에는 Ignore these changes 버튼이 있음.

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

  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 windowSize = calculateWindowSizeClass(this)
                ReplyApp()

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

코드에서 'val windowSize = calculateWindowSizeClass(this)' 줄이 선택되어 있고 코드 줄 왼쪽에 전구 아이콘이 표시되어 있음. 선택한 전구 아래에 오류 해결 옵션 목록이 표시되어 있고 'Opt in for 'ExperimentalMaterial3WindowSizeClassApi' on 'onCreate''가 선택되어 있음.

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 {
                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 ReplyAppPreview() {
    ReplyTheme {
        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 탐색 구현하기

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

Reply 앱의 하단 탐색 메뉴.

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

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

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

Reply 앱의 샘플 탐색 레일에 받은편지함, 보낸편지함, 임시보관함, 스팸 등 네 개의 아이콘이 세로로 표시되어 있음.

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

Reply 앱의 영구 탐색 창에 받은편지함, 보낸편지함, 임시보관함, 스팸 탭의 아이콘과 이름이 세로로 나열되어 있음.

탐색 창 구현하기

대형 화면의 탐색 창을 만들려면 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
...
    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
...
@OptIn(ExperimentalMaterial3Api::class)
@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 = {
                NavigationDrawerContent(
                    selectedDestination = replyUiState.currentMailbox,
                    onTabPressed = onTabPressed,
                    navigationItemContentList = navigationItemContentList
                )
            }
        ) {
            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 = {
                NavigationDrawerContent(
                    selectedDestination = replyUiState.currentMailbox,
                    onTabPressed = onTabPressed,
                    navigationItemContentList = navigationItemContentList
                )
            }
        ) {
            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. ReplyHomeScreen 컴포저블에 실험용 주석을 추가합니다. PermanentNavigationDrawer API는 아직 실험 단계에 있으므로 주석을 추가하는 작업이 필요합니다.

ReplyHomeScreen.kt

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReplyHomeScreen(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: (MailboxType) -> Unit = {},
    onEmailCardPressed: (Email) -> Unit = {},
    onDetailScreenBackPressed: () -> Unit = {},
    modifier: Modifier = Modifier
) {
...
  1. 태블릿 모드에서 앱을 실행합니다. 다음 화면이 표시됩니다.

화면 왼쪽에 탐색 창이 있고 오른쪽에 이메일 목록이 있는 태블릿 모드의 Reply 앱.

탐색 레일 구현하기

탐색 창 구현과 마찬가지로 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 값이 NavigationRail이면 visibility 매개변수가 true가 되도록 설정합니다.

ReplyHomeScreen.kt

...
@Composable
private fun ReplyAppContent(
    navigationType: ReplyNavigationType,
    replyUiState: ReplyUiState,
    onTabPressed: ((MailboxType) -> Unit) = {},
    onEmailCardPressed: (Email) -> Unit = {},
    navigationItemContentList: List<NavigationItemContent>,
    modifier: 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)
            )
            ReplyBottomNavigationBar(
                currentTab = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
                navigationItemContentList = navigationItemContentList

            )
        }

}
...
  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.fillMaxSize()) {
        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)
            )
            ReplyBottomNavigationBar(
                currentTab = replyUiState.currentMailbox,
                onTabPressed = onTabPressed,
                navigationItemContentList = navigationItemContentList

            )
        }
    }
}
...

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

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

ReplyHomeScreen.kt

...
            ReplyListOnlyContent(
                replyUiState = replyUiState,
                onEmailCardPressed = onEmailCardPressed,
                modifier = Modifier.weight(1f)
            )
            AnimatedVisibility(visible = navigationType == ReplyNavigationType.BOTTOM_NAVIGATION) {
                ReplyBottomNavigationBar(
                    currentTab = replyUiState.currentMailbox,
                    onTabPressed = onTabPressed,
                    navigationItemContentList = navigationItemContentList
                )
            }
...
  1. 펼쳐진 폴더블 모드로 앱을 실행합니다. 다음 화면이 표시됩니다.

화면 왼쪽에 탐색 레일이 있고 오른쪽에 이메일 목록이 있는 폴더블 모드의 Reply 앱.

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를 사용해 작업한 결과물을 소셜 미디어로 공유해 보세요.

자세히 알아보기