Compose를 사용하여 화면 간 이동

1. 시작하기 전에

지금까지 작업한 앱은 단일 화면으로 구성되었습니다. 하지만 실제로 사용하는 많은 앱의 경우 이동 가능한 여러 개의 화면으로 이루어져 있습니다. 예를 들어 설정 앱에는 여러 페이지의 콘텐츠가 여러 화면에 걸쳐 있습니다.

Modern Android Development에서는 멀티스크린 앱이 Jetpack 탐색 구성요소를 사용하여 만들어집니다. 탐색 Compose 구성요소를 사용하면 사용자 인터페이스 빌드와 마찬가지로 선언적 접근 방식을 사용하여 Compose에서 멀티스크린 앱을 쉽게 빌드할 수 있습니다. 이 Codelab에서는 탐색 Compose 구성요소의 기본사항과 AppBar를 반응형으로 만드는 방법, 인텐트를 사용하여 내 앱에서 다른 앱으로 데이터를 전송하는 방법을 소개합니다. 동시에 더욱 복잡한 앱의 권장사항도 설명합니다.

기본 요건

  • 함수 유형, 람다, 범위 함수를 비롯한 Kotlin 언어에 관한 지식
  • Compose의 기본 RowColumn 레이아웃에 관한 지식

학습할 내용

  • NavHost 컴포저블을 만들어 앱의 경로와 화면을 정의합니다.
  • NavHostController를 사용하여 화면 간에 이동합니다.
  • 백 스택을 조작하여 이전 화면으로 이동합니다.
  • 인텐트를 사용하여 다른 앱과 데이터를 공유합니다.
  • 제목과 뒤로 버튼을 포함하여 AppBar를 맞춤설정합니다.

빌드할 항목

  • 멀티스크린 앱에서 탐색을 구현합니다.

필요한 항목

  • 최신 버전의 Android 스튜디오
  • 시작 코드를 다운로드하기 위한 인터넷 연결

2. 시작 코드 다운로드하기

시작하려면 시작 코드를 다운로드하세요.

GitHub 저장소를 클론하여 코드를 가져와도 됩니다.

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-cupcake.git
$ cd basic-android-kotlin-compose-training-cupcake
$ git checkout starter

이 Codelab의 시작 코드는 GitHub에서 확인하세요.

3. 앱 둘러보기

Cupcake 앱은 지금까지 작업해 본 앱과 약간 다릅니다. 모든 콘텐츠가 단일 화면에 표시되는 대신 앱에는 개별 화면 4개가 있으며 사용자는 컵케이크를 주문하면서 각 화면을 이동할 수 있습니다. 앱을 실행하면 아무것도 표시되지 않고 이러한 화면 간에 이동할 수 없습니다. 탐색 구성요소가 앱 코드에 아직 추가되지 않았기 때문입니다. 그러나 각 화면의 컴포저블 미리보기를 확인하여 아래 최종 앱 화면과 일치시킬 수는 있습니다.

주문 시작 화면

첫 번째 화면에는 주문할 컵케이크 수량에 해당하는 버튼 세 개가 표시됩니다.

코드에서 이는 StartOrderScreen.ktStartOrderScreen 컴포저블로 표현됩니다.

화면은 이미지와 텍스트가 있는 단일 열과 다양한 컵케이크 수량을 주문하는 맞춤 버튼 3개로 구성됩니다. 맞춤 버튼은 StartOrderScreen.ktSelectQuantityButton 컴포저블로 구현됩니다.

맛 선택 화면

수량을 선택하고 나면 앱에서 컵케이크 맛을 선택하라는 메시지를 사용자에게 표시합니다. 앱에서는 라디오 버튼을 사용하여 여러 옵션을 표시합니다. 사용자는 가능한 맛 옵션 중에서 하나를 선택할 수 있습니다.

가능한 맛 목록은 문자열 리소스 ID 목록으로 data.DataSource.kt에 저장됩니다.

수령일 선택 화면

맛을 선택하면 앱에서는 수령일을 선택할 수 있는 여러 라디오 버튼을 사용자에게 표시합니다. 수령 옵션은 OrderViewModelpickupOptions() 함수로 반환된 목록에서 가져옵니다.

Choose Flavor 화면과 Choose Pickup Date 화면은 모두 SelectOptionScreen.ktSelectOptionScreen인 동일한 컴포저블로 표현됩니다. 동일한 컴포저블을 사용하는 이유는 이러한 화면의 레이아웃이 정확히 동일하기 때문입니다. 유일한 차이점은 데이터이지만 동일한 컴포저블을 사용하여 맛 화면과 수령일 화면을 모두 표시할 수 있습니다.

주문 요약 화면

수령일을 선택하고 나면 앱에 Order Summary 화면이 표시되며 여기서 사용자는 주문을 검토하고 완료할 수 있습니다.

이 화면은 SummaryScreen.ktOrderSummaryScreen 컴포저블로 구현됩니다.

레이아웃은 주문에 관한 정보가 모두 포함된 Column과 소계에 관한 Text 컴포저블, 주문을 다른 앱으로 전송하거나 주문을 취소하고 첫 번째 화면으로 돌아가는 버튼으로 구성됩니다.

사용자가 주문을 다른 앱으로 전송하도록 선택하면 Cupcake 앱에는 다양한 공유 옵션을 보여주는 Android ShareSheet가 표시됩니다.

13bde33712e135a4.png

앱의 현재 상태는 data.OrderUiState.kt에 저장됩니다. OrderUiState 데이터 클래스에는 사용자가 각 화면에서 선택한 사항을 저장하는 속성이 포함되어 있습니다.

앱의 화면은 CupcakeApp 컴포저블에 표시됩니다. 그러나 시작 프로젝트에서는 앱이 단순히 첫 번째 화면을 표시합니다. 지금 앱의 모든 화면을 살펴볼 수는 없지만 걱정하지 마세요. 여기서 배우게 됩니다. 탐색 경로를 정의하고, 화면(대상이라고도 함) 간에 이동하도록 NavHost 컴포저블을 설정하고, 인텐트를 실행하여 공유 화면과 같은 시스템 UI 구성요소와 통합하며, AppBar가 탐색 변경사항에 응답하도록 하는 방법을 알아봅니다.

재사용 가능한 컴포저블

적절한 경우 이 과정의 샘플 앱은 권장사항을 구현하도록 설계되었습니다. Cupcake 앱도 예외가 아닙니다. ui.components 패키지에서 FormattedPriceLabel 컴포저블이 포함된 CommonUi.kt 파일을 확인할 수 있습니다. 앱의 여러 화면에서는 이 컴포저블을 사용하여 주문 가격의 형식을 일관되게 지정합니다. 동일한 Text 컴포저블을 동일한 형식 및 수정자로 복제하는 대신 FormattedPriceLabel을 한 번 정의한 다음 다른 화면에 필요한 만큼 재사용할 수 있습니다.

맛 화면과 수령일 화면은 SelectOptionScreen 컴포저블을 사용하며 이 컴포저블도 재사용할 수 있습니다. 이 컴포저블은 표시할 옵션을 나타내는 List<String> 유형의 options라는 매개변수를 사용합니다. 옵션은 Row에 표시되며 RadioButton 컴포저블과 각 문자열이 포함된 Text 컴포저블로 구성됩니다. Column은 전체 레이아웃을 둘러싸며 형식이 지정된 가격을 표시하는 Text 컴포저블과 Cancel 버튼, Next 버튼도 포함합니다.

4. 경로 정의 및 NavHostController 만들기

Navigation 구성요소의 부분

Navigation 구성요소에는 다음과 같은 세 가지 주요 부분이 있습니다.

  • NavController: 대상(즉, 앱의 화면) 간 이동을 담당합니다.
  • NavGraph: 이동할 컴포저블 대상을 매핑합니다.
  • NavHost: NavGraph의 현재 대상을 표시하는 컨테이너 역할을 하는 컴포저블입니다.

이 Codelab에서는 NavController와 NavHost를 집중적으로 살펴봅니다. NavHost 내에서 Cupcake 앱의 NavGraph 대상을 정의합니다.

앱에서 대상 경로 정의

Compose 앱에서 탐색의 기본 개념 중 하나는 경로입니다. 경로는 대상에 상응하는 문자열입니다. 이 개념은 URL의 개념과 유사합니다. 다른 URL이 웹사이트의 다른 페이지에 매핑되는 것처럼 경로는 대상에 매핑되어 고유한 식별자 역할을 하는 문자열입니다. 대상은 일반적으로 사용자에게 표시되는 항목에 상응하는 단일 컴포저블이거나 컴포저블 그룹입니다. Cupcake 앱에는 주문 시작 화면, 맛 화면, 수령일 화면, 주문 요약 화면을 위한 대상이 필요합니다.

앱의 화면 수는 한정되어 있으므로 경로도 한정됩니다. enum 클래스를 사용하여 앱의 경로를 정의할 수 있습니다. Kotlin의 enum 클래스에는 속성 이름이 포함된 문자열을 반환하는 이름 속성이 있습니다.

먼저 Cupcake 앱의 네 가지 경로를 정의합니다.

  • Start: 버튼 세 개 중에서 원하는 컵케이크 수량 하나를 선택합니다.
  • Flavor: 옵션 목록에서 맛을 선택합니다.
  • Pickup: 옵션 목록에서 수령일을 선택합니다.
  • Summary: 선택한 내용을 검토하고 주문을 전송하거나 취소합니다.

enum 클래스를 추가하여 경로를 정의합니다.

  1. CupcakeScreen.kt에서 CupcakeAppBar 컴포저블 위에 enum 클래스 CupcakeScreen을 추가합니다.
enum class CupcakeScreen() {

}
  1. enum 클래스에 4가지 사례 Start, Flavor, Pickup, Summary를 추가합니다.
enum class CupcakeScreen() {
    Start,
    Flavor,
    Pickup,
    Summary
}

앱에 NavHost 추가

NavHost는 지정된 경로를 기반으로 다른 컴포저블 대상을 표시하는 컴포저블입니다. 예를 들어 경로가 Flavor인 경우 NavHost는 컵케이크 맛을 선택하는 화면을 표시합니다. 경로가 Summary이면 앱에는 요약 화면이 표시됩니다.

NavHost 문법은 다른 컴포저블과 같습니다.

fae7688d6dd53de9.png

주목할 만한 매개변수가 두 가지 있습니다.

  • navController: NavHostController 클래스의 인스턴스입니다. navigate() 메서드를 호출하여 다른 대상으로 이동하는 등의 방식으로 화면 간에 이동하는 데 이 객체를 사용할 수 있습니다. 구성 가능한 함수에서 rememberNavController()를 호출하여 NavHostController를 가져올 수 있습니다.
  • startDestination: 앱에서 NavHost를 처음 표시할 때 기본적으로 표시되는 대상을 정의하는 문자열 경로입니다. Cupcake 앱의 경우에는 Start 경로입니다.

다른 컴포저블과 마찬가지로 NavHostmodifier 매개변수를 사용합니다.

CupcakeScreen.ktCupcakeApp 컴포저블에 NavHost를 추가합니다. 먼저 탐색 컨트롤러 참조가 필요합니다. 지금 추가하는 NavHost와 이후 단계에서 추가할 AppBar에서 모두 탐색 컨트롤러를 사용할 수 있습니다. 따라서 CupcakeApp() 컴포저블에서 변수를 선언해야 합니다.

  1. CupcakeScreen.kt를 엽니다.
  2. ScaffolduiState 변수 아래에 NavHost 컴포저블을 추가합니다.
import androidx.navigation.compose.NavHost

Scaffold(
    ...
) { innerPadding ->
    val uiState by viewModel.uiState.collectAsState()

    NavHost()
}
  1. navController 매개변수에 navController 변수를 전달하고 startDestination 매개변수에 CupcakeScreen.Start.name을 전달합니다. CupcakeApp()에 전달된 수정자를 수정자 매개변수에 전달합니다. 최종 매개변수에 빈 후행 람다를 전달합니다.
import androidx.compose.foundation.layout.padding

NavHost(
    navController = navController,
    startDestination = CupcakeScreen.Start.name,
    modifier = Modifier.padding(innerPadding)
) {

}

NavHost에서 경로 처리

다른 컴포저블과 마찬가지로 NavHost는 콘텐츠의 함수 유형을 사용합니다.

f67974b7fb3f0377.png

NavHost의 콘텐츠 함수 내에서 composable() 함수를 호출합니다. composable() 함수에는 필수 매개변수가 두 개 있습니다.

  • route: 경로 이름에 해당하는 문자열입니다. 모든 고유 문자열을 사용할 수 있습니다. CupcakeScreen enum의 상수 이름 속성을 사용합니다.
  • content: 여기에서 특정 경로에 표시할 컴포저블을 호출할 수 있습니다.

각 4가지 경로에 한 번씩 composable() 함수를 호출합니다.

  1. composable() 함수를 호출하여 routeCupcakeScreen.Start.name을 전달합니다.
import androidx.navigation.compose.composable

NavHost(
    navController = navController,
    startDestination = CupcakeScreen.Start.name,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = CupcakeScreen.Start.name) {

    }
}
  1. 후행 람다 내에서 StartOrderScreen 컴포저블을 호출하여 quantityOptions 속성에 quantityOptions를 전달합니다. modifier의 경우 Modifier.fillMaxSize().padding(dimensionResource(R.dimen.padding_medium))을 전달합니다.
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.res.dimensionResource
import com.example.cupcake.ui.StartOrderScreen
import com.example.cupcake.data.DataSource

NavHost(
    navController = navController,
    startDestination = CupcakeScreen.Start.name,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = CupcakeScreen.Start.name) {
        StartOrderScreen(
            quantityOptions = DataSource.quantityOptions,
            modifier = Modifier
                .fillMaxSize()
                .padding(dimensionResource(R.dimen.padding_medium))
        )
    }
}
  1. 첫 번째 composable() 호출 아래에서 composable()을 다시 호출하여 routeCupcakeScreen.Flavor.name을 전달합니다.
composable(route = CupcakeScreen.Flavor.name) {

}
  1. 후행 람다 내에서 LocalContext.current 참조를 가져와서 context라는 변수에 저장합니다. Context는 Android 시스템에서 구현을 제공하는 추상 클래스입니다. 이를 사용하면 애플리케이션별 리소스와 클래스에 액세스할 수 있을 뿐만 아니라 활동 시작과 같은 애플리케이션 수준 작업을 위한 up-call도 사용할 수 있습니다. 이 변수를 사용하여 뷰 모델의 리소스 ID 목록에서 문자열을 가져와 맛 목록을 표시할 수 있습니다.
import androidx.compose.ui.platform.LocalContext

composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
}
  1. SelectOptionScreen 컴포저블을 호출합니다.
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(

    )
}
  1. 맛 화면에서는 사용자가 맛을 선택하면 소계를 표시하고 업데이트해야 합니다. subtotal 매개변수에 uiState.price를 전달합니다.
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price
    )
}
  1. 맛 화면은 앱의 문자열 리소스에서 맛 목록을 가져옵니다. map() 함수를 사용하고 각 맛의 context.resources.getString(id)를 호출하여 리소스 ID 목록을 문자열 목록으로 변환합니다.
import com.example.cupcake.ui.SelectOptionScreen

composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        options = DataSource.flavors.map { id -> context.resources.getString(id) }
    )
}
  1. onSelectionChanged 매개변수의 경우 뷰 모델에서 setFlavor()를 호출하는 람다 표현식을 전달하여 it(onSelectionChanged()에 전달된 인수)를 전달합니다. modifier 매개변수의 경우 Modifier.fillMaxHeight().를 전달합니다.
import androidx.compose.foundation.layout.fillMaxHeight
import com.example.cupcake.data.DataSource.flavors

composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        options = DataSource.flavors.map { id -> context.resources.getString(id) },
        onSelectionChanged = { viewModel.setFlavor(it) },
        modifier = Modifier.fillMaxHeight()
    )
}

수령일 화면은 맛 화면과 유사합니다. 유일한 차이점은 SelectOptionScreen 컴포저블에 전달된 데이터입니다.

  1. composable() 함수를 다시 호출하여 route 매개변수에 CupcakeScreen.Pickup.name을 전달합니다.
composable(route = CupcakeScreen.Pickup.name) {

}
  1. 후행 람다에서 SelectOptionScreen 컴포저블을 호출하고 이전과 같이 subtotaluiState.price를 전달합니다. options 매개변수에 uiState.pickupOptions를 전달하고 onSelectionChanged 매개변수에는 viewModel에서 setDate()를 호출하는 람다 표현식을 전달합니다. modifier 매개변수의 경우 Modifier.fillMaxHeight().를 전달합니다.
SelectOptionScreen(
    subtotal = uiState.price,
    options = uiState.pickupOptions,
    onSelectionChanged = { viewModel.setDate(it) },
    modifier = Modifier.fillMaxHeight()
)
  1. composable()을 한 번 더 호출하여 routeCupcakeScreen.Summary.name을 전달합니다.
composable(route = CupcakeScreen.Summary.name) {

}
  1. 후행 람다에서 OrderSummaryScreen() 컴포저블을 호출하고 orderUiState 매개변수에 uiState 변수를 전달합니다. modifier 매개변수의 경우 Modifier.fillMaxHeight().를 전달합니다.
import com.example.cupcake.ui.OrderSummaryScreen

composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        modifier = Modifier.fillMaxHeight()
    )
}

이것으로 NavHost 설정을 완료했습니다. 다음 섹션에서는 사용자가 각 버튼을 탭할 때 앱이 경로를 변경하고 화면 간에 이동하도록 합니다.

5. 경로 간 이동

경로를 정의하고 이를 NavHost의 컴포저블에 매핑했으므로 이제 화면 간에 이동해 보겠습니다. rememberNavController() 호출의 navController 속성인 NavHostController는 경로 간 이동을 담당합니다. 그러나 이 속성은 CupcakeApp 컴포저블에 정의되어 있습니다. 앱의 다양한 화면에서 액세스하는 방법이 필요합니다.

정말 쉽죠? navController를 매개변수로 각 컴포저블에 전달하기만 하면 됩니다.

이 접근법은 효과가 있지만 앱을 설계하는 가장 좋은 방법은 아닙니다. NavHost를 사용하여 앱 탐색을 처리하는 경우 탐색 로직이 개별 UI와 별도로 유지된다는 이점이 있습니다. 이 옵션을 사용하면 navController를 매개변수로 전달할 때 발생하는 몇 가지 주요 단점을 방지할 수 있습니다.

  • 탐색 로직이 한곳에 유지되므로 코드를 쉽게 유지관리할 수 있고 실수로 개별 화면에 앱의 탐색을 자유롭게 허용하지 않음으로써 버그를 방지할 수 있습니다.
  • 다양한 폼 팩터(예: 세로 모드 휴대전화, 폴더블 휴대전화, 대형 화면 태블릿)에서 작동해야 하는 앱에서는 버튼이 앱의 레이아웃에 따라 탐색을 트리거할 수도 있고 트리거하지 않을 수도 있습니다. 개별 화면은 독립적이어야 하며 앱의 다른 화면을 인식할 필요가 없습니다.

대신 사용자가 버튼을 클릭할 때 발생하는 일에 관해 각 컴포저블에 함수 유형을 전달하는 것이 좋습니다. 이렇게 하면 컴포저블과 그 하위 컴포저블이 함수를 호출할 시기를 결정합니다. 그러나 탐색 로직은 앱의 개별 화면에 노출되지 않습니다. 모든 탐색 동작은 NavHost에서 처리됩니다.

StartOrderScreen에 버튼 핸들러 추가

먼저 첫 번째 화면에서 수량 버튼 중 하나를 누르면 호출되는 함수 유형 매개변수를 추가합니다. 이 함수는 StartOrderScreen 컴포저블에 전달되며 뷰 모델을 업데이트하고 다음 화면으로 이동합니다.

  1. StartOrderScreen.kt를 엽니다.
  2. quantityOptions 매개변수 아래 그리고 수정자 매개변수 앞에 () -> Unit 유형의 onNextButtonClicked라는 매개변수를 추가합니다.
@Composable
fun StartOrderScreen(
    quantityOptions: List<Pair<Int, Int>>,
    onNextButtonClicked: () -> Unit,
    modifier: Modifier = Modifier
){
    ...
}
  1. 이제 StartOrderScreen 컴포저블이 onNextButtonClicked 값을 예상하므로 StartOrderPreview를 찾아 빈 람다 본문을 onNextButtonClicked 매개변수에 전달합니다.
@Preview
@Composable
fun StartOrderPreview() {
    CupcakeTheme {
        StartOrderScreen(
            quantityOptions = DataSource.quantityOptions,
            onNextButtonClicked = {},
            modifier = Modifier
                .fillMaxSize()
                .padding(dimensionResource(R.dimen.padding_medium))
        )
    }
}

각 버튼은 다양한 컵케이크 수량에 해당합니다. 이 정보를 통해, onNextButtonClicked에 전달된 함수가 적절하게 viewmodel을 업데이트할 수 있습니다.

  1. Int 매개변수를 사용하도록 onNextButtonClicked 매개변수의 유형을 수정합니다.
onNextButtonClicked: (Int) -> Unit,

onNextButtonClicked()를 호출할 때 Int가 전달되도록 하려면 quantityOptions 매개변수 유형을 살펴보세요.

유형은 List<Pair<Int, Int>> 또는 Pair<Int, Int> 목록입니다. Pair 유형이 익숙하지 않을 수 있지만 이름에서 알 수 있듯이 값 쌍일 뿐입니다. Pair는 두 가지 일반 유형 매개변수를 사용합니다. 여기서는 둘 다 Int 유형입니다.

8326701a77706258.png

한 쌍의 각 항목은 첫 번째 속성이나 두 번째 속성에서 액세스합니다. StartOrderScreen 컴포저블의 quantityOptions 매개변수의 경우 첫 번째 Int는 각 버튼에 표시할 문자열의 리소스 ID입니다. 두 번째 Int는 컵케이크의 실제 수량입니다.

onNextButtonClicked() 함수를 호출할 때 선택된 쌍의 두 번째 속성을 전달합니다.

  1. SelectQuantityButtononClick 매개변수에 관한 빈 람다 표현식을 찾습니다.
quantityOptions.forEach { item ->
    SelectQuantityButton(
        labelResourceId = item.first,
        onClick = {}
    )
}
  1. 람다 표현식 내에서 onNextButtonClicked를 호출하여 컵케이크 수인 item.second를 전달합니다.
quantityOptions.forEach { item ->
    SelectQuantityButton(
        labelResourceId = item.first,
        onClick = { onNextButtonClicked(item.second) }
    )
}

SelectOptionScreen에 버튼 핸들러 추가

  1. SelectOptionScreen.kt에서 SelectOptionScreen 컴포저블의 onSelectionChanged 매개변수 아래에 기본값이 {}() -> Unit 유형의 매개변수 onCancelButtonClicked를 추가합니다.
@Composable
fun SelectOptionScreen(
    subtotal: String,
    options: List<String>,
    onSelectionChanged: (String) -> Unit = {},
    onCancelButtonClicked: () -> Unit = {},
    modifier: Modifier = Modifier
)
  1. onCancelButtonClicked 매개변수 아래에 () -> Unit 유형의 또 다른 매개변수 onNextButtonClicked(기본값 {})를 추가합니다.
@Composable
fun SelectOptionScreen(
    subtotal: String,
    options: List<String>,
    onSelectionChanged: (String) -> Unit = {},
    onCancelButtonClicked: () -> Unit = {},
    onNextButtonClicked: () -> Unit = {},
    modifier: Modifier = Modifier
)
  1. 취소 버튼의 onClick 매개변수에 onCancelButtonClicked를 전달합니다.
OutlinedButton(
    modifier = Modifier.weight(1f),
    onClick = onCancelButtonClicked
) {
    Text(stringResource(R.string.cancel))
}
  1. 다음 버튼의 onClick 매개변수에 onNextButtonClicked를 전달합니다.
Button(
    modifier = Modifier.weight(1f),
    enabled = selectedValue.isNotEmpty(),
    onClick = onNextButtonClicked
) {
    Text(stringResource(R.string.next))
}

SummaryScreen에 버튼 핸들러 추가

마지막으로 요약 화면에서 CancelSend 버튼을 위한 버튼 핸들러 함수를 추가합니다.

  1. SummaryScreen.ktOrderSummaryScreen 컴포저블에서 () -> Unit 유형의 onCancelButtonClicked라는 매개변수를 추가합니다.
@Composable
fun OrderSummaryScreen(
    orderUiState: OrderUiState,
    onCancelButtonClicked: () -> Unit,
    modifier: Modifier = Modifier
){
    ...
}
  1. 또 다른 (String, String) -> Unit 유형 매개변수를 추가하고 이름을 onSendButtonClicked로 지정합니다.
@Composable
fun OrderSummaryScreen(
    orderUiState: OrderUiState,
    onCancelButtonClicked: () -> Unit,
    onSendButtonClicked: (String, String) -> Unit,
    modifier: Modifier = Modifier
){
    ...
}
  1. 이제 OrderSummaryScreen 컴포저블이 onSendButtonClickedonCancelButtonClicked 값을 예상합니다. OrderSummaryPreview를 찾아 두 개의 String 매개변수가 있는 빈 람다 본문을 onSendButtonClicked에 전달하고 빈 람다 본문을 onCancelButtonClicked 매개변수에 전달합니다.
@Preview
@Composable
fun OrderSummaryPreview() {
   CupcakeTheme {
       OrderSummaryScreen(
           orderUiState = OrderUiState(0, "Test", "Test", "$300.00"),
           onSendButtonClicked = { subject: String, summary: String -> },
           onCancelButtonClicked = {},
           modifier = Modifier.fillMaxHeight()
       )
   }
}
  1. Send 버튼의 onClick 매개변수에 onSendButtonClicked를 전달합니다. 이전에 OrderSummaryScreen에 정의된 두 변수인 newOrderorderSummary를 전달합니다. 이러한 문자열은 사용자가 다른 앱과 공유할 수 있는 실제 데이터로 구성됩니다.
Button(
    modifier = Modifier.fillMaxWidth(),
    onClick = { onSendButtonClicked(newOrder, orderSummary) }
) {
    Text(stringResource(R.string.send))
}
  1. Cancel 버튼의 onClick 매개변수에 onCancelButtonClicked를 전달합니다.
OutlinedButton(
    modifier = Modifier.fillMaxWidth(),
    onClick = onCancelButtonClicked
) {
    Text(stringResource(R.string.cancel))
}

다른 경로로 이동하려면 NavHostController 인스턴스에서 navigate() 메서드를 호출하면 됩니다.

fc8aae3911a6a25d.png

navigate 메서드는 단일 매개변수를 사용합니다. 즉, NavHost에 정의된 경로에 해당하는 String입니다. 경로가 NavHostcomposable() 호출 중 하나와 일치하면 앱이 그 화면으로 이동합니다.

사용자가 Start, Flavor, Pickup 화면에서 버튼을 누르면 navigate()를 호출하는 함수가 전달됩니다.

  1. CupcakeScreen.kt에서 시작 화면의 composable() 호출을 찾습니다. onNextButtonClicked 매개변수에 람다 표현식을 전달합니다.
StartOrderScreen(
    quantityOptions = DataSource.quantityOptions,
    onNextButtonClicked = {
    }
)

컵케이크 수에 관해 이 함수에 전달된 Int 속성이 기억나나요? 다음 화면으로 이동하기 전에 앱이 올바른 소계를 표시하도록 뷰 모델을 업데이트해야 합니다.

  1. viewModel에서 setQuantity를 호출하여 it을 전달합니다.
onNextButtonClicked = {
    viewModel.setQuantity(it)
}
  1. navController에서 navigate()를 호출하여 routeCupcakeScreen.Flavor.name을 전달합니다.
onNextButtonClicked = {
    viewModel.setQuantity(it)
    navController.navigate(CupcakeScreen.Flavor.name)
}
  1. 맛 화면의 onNextButtonClicked 매개변수의 경우 navigate()를 호출하는 람다를 전달하여 routeCupcakeScreen.Pickup.name을 전달하면 됩니다.
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
        options = DataSource.flavors.map { id -> context.resources.getString(id) },
        onSelectionChanged = { viewModel.setFlavor(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
  1. 다음에 구현할 onCancelButtonClicked에 빈 람다를 전달합니다.
SelectOptionScreen(
    subtotal = uiState.price,
    onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
    onCancelButtonClicked = {},
    options = DataSource.flavors.map { id -> context.resources.getString(id) },
    onSelectionChanged = { viewModel.setFlavor(it) },
    modifier = Modifier.fillMaxHeight()
)
  1. 수령 화면의 onNextButtonClicked 매개변수에 navigate()를 호출하는 람다를 전달하고 routeCupcakeScreen.Summary.name을 전달합니다.
composable(route = CupcakeScreen.Pickup.name) {
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
        options = uiState.pickupOptions,
        onSelectionChanged = { viewModel.setDate(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
  1. 다시 onCancelButtonClicked()에 빈 람다를 전달합니다.
SelectOptionScreen(
    subtotal = uiState.price,
    onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
    onCancelButtonClicked = {},
    options = uiState.pickupOptions,
    onSelectionChanged = { viewModel.setDate(it) },
    modifier = Modifier.fillMaxHeight()
)
  1. OrderSummaryScreen의 경우 onCancelButtonClickedonSendButtonClicked에 빈 람다를 전달합니다. onSendButtonClicked에 전달되는 subjectsummary 매개변수를 추가합니다. 이는 곧 구현됩니다.
composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        onCancelButtonClicked = {},
        onSendButtonClicked = { subject: String, summary: String ->

        },
        modifier = Modifier.fillMaxHeight()
    )
}

이제 앱의 각 화면을 이동할 수 있습니다. navigate()를 호출하면 화면이 변경될 뿐만 아니라 실제로 백 스택 위에 배치됩니다. 또한 시스템 뒤로 버튼을 누르면 이전 화면으로 돌아갈 수 있습니다.

앱은 각 화면을 이전 화면 위에 쌓고 뒤로 버튼(bade5f3ecb71e4a2.png)을 통해 화면을 삭제할 수 있습니다. 하단의 startDestination부터 방금 표시된 최상단 화면까지의 화면 기록을 백 스택이라고 합니다.

시작 화면으로 돌아가기

시스템 뒤로 버튼과 달리 Cancel 버튼은 이전 화면으로 돌아가지 않습니다. 대신 백 스택의 모든 화면이 삭제되고 시작 화면으로 돌아가야 합니다.

popBackStack() 메서드를 호출하면 됩니다.

2f382e5eb319b4b8.png

popBackStack() 메서드에는 두 가지 필수 매개변수가 있습니다.

  • route: 다시 돌아갈 대상의 경로를 나타내는 문자열입니다.
  • inclusive: 불리언 값으로, true이면 지정된 경로를 삭제합니다. false인 경우 popBackStack()은 시작 대상 위의 모든 대상을 삭제하여(시작 대상은 제외) 시작 대상을 사용자에게 표시되는 최상단 화면으로 둡니다.

사용자가 어느 화면에서든 Cancel 버튼을 누르면 앱은 뷰 모델의 상태를 재설정하고 popBackStack()을 호출합니다. 먼저 이 작업을 실행하는 메서드를 구현하고 Cancel 버튼이 있는 세 화면에서 모두 적절한 매개변수에 이를 전달합니다.

  1. CupcakeApp() 함수 다음에 비공개 함수 cancelOrderAndNavigateToStart()를 정의합니다.
private fun cancelOrderAndNavigateToStart() {
}
  1. 다음 두 매개변수를 추가합니다. OrderViewModel 유형 viewModel, NavHostController 유형 navController
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
}
  1. 함수 본문의 viewModel에서 resetOrder()를 호출합니다.
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
    viewModel.resetOrder()
}
  1. navController에서 popBackStack()을 호출하여 routeCupcakeScreen.Start.name을 전달하고 inclusivefalse를 전달합니다.
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
    viewModel.resetOrder()
    navController.popBackStack(CupcakeScreen.Start.name, inclusive = false)
}
  1. CupcakeApp() 컴포저블에서 두 SelectOptionScreen 컴포저블과 OrderSummaryScreen 컴포저블의 onCancelButtonClicked 매개변수에 cancelOrderAndNavigateToStart를 전달합니다.
composable(route = CupcakeScreen.Start.name) {
    StartOrderScreen(
        quantityOptions = DataSource.quantityOptions,
        onNextButtonClicked = {
            viewModel.setQuantity(it)
            navController.navigate(CupcakeScreen.Flavor.name)
        },
        modifier = Modifier
            .fillMaxSize()
            .padding(dimensionResource(R.dimen.padding_medium))
    )
}
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        },
        options = DataSource.flavors.map { id -> context.resources.getString(id) },
        onSelectionChanged = { viewModel.setFlavor(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
composable(route = CupcakeScreen.Pickup.name) {
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        },
        options = uiState.pickupOptions,
        onSelectionChanged = { viewModel.setDate(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        },
        onSendButtonClicked = { subject: String, summary: String ->

        },
        modifier = Modifier.fillMaxHeight()
   )
}
  1. 앱을 실행하고 화면에서 Cancel 버튼을 누르면 사용자가 첫 번째 화면으로 다시 이동하는지 테스트합니다.

6. 다른 앱으로 이동

지금까지 앱에서 다른 화면으로 이동하는 방법과 홈 화면으로 다시 이동하는 방법을 알아봤습니다. Cupcake 앱에는 탐색을 구현하는 다른 단계가 하나 더 있습니다. 사용자는 주문 요약 화면에서 주문을 다른 앱으로 전송할 수 있습니다. 이 옵션을 선택하면 공유 옵션을 보여주는 Sharesheet(화면의 하단부를 덮는 사용자 인터페이스 구성요소)가 표시됩니다.

이 UI 요소는 Cupcake 앱에 포함되어 있지 않습니다. 실제로 Android 운영체제에서 제공합니다. 시스템 UI(예: 공유 화면)는 navController에서 호출하지 않습니다. 대신 인텐트라는 것을 사용합니다.

인텐트는 시스템이 작업을 실행하도록 요청하는 것으로, 일반적으로 새 활동이 표시됩니다. 인텐트에는 여러 가지가 있으며 전체 목록은 문서를 참조하세요. 하지만 여기서 관심 있는 인텐트는 ACTION_SEND입니다. 이 인텐트를 문자열과 같은 일부 데이터와 함께 제공하고 해당 데이터에 적절한 공유 작업을 제공할 수 있습니다.

인텐트를 설정하는 기본 프로세스는 다음과 같습니다.

  1. 인텐트 객체를 만들고 ACTION_SEND 등의 인텐트를 지정합니다.
  2. 인텐트와 함께 전송되는 추가 데이터의 유형을 지정합니다. 간단한 텍스트에는 "text/plain"을 사용할 수 있지만 "image/*" 또는 "video/*"와 같은 다른 유형도 사용할 수 있습니다.
  3. putExtra() 메서드를 호출하는 방식으로 공유할 텍스트 또는 이미지와 같은 추가 데이터를 인텐트에 전달합니다. 이 인텐트는 두 가지 추가 항목인 EXTRA_SUBJECTEXTRA_TEXT를 사용합니다.
  4. 컨텍스트의 startActivity() 메서드를 호출하여 인텐트에서 생성된 활동을 전달합니다.

공유 작업 인텐트를 만드는 방법을 설명하겠지만 이 프로세스는 다른 유형의 인텐트에도 동일합니다. 향후 프로젝트의 경우 특정 데이터 유형과 필요한 추가 항목에 관해 필요에 따라 문서를 참조하는 것이 좋습니다.

컵케이크 주문을 다른 앱으로 전송하는 인텐트를 만들려면 다음 단계를 완료하세요.

  1. CupcakeScreen.ktCupcakeApp 컴포저블 아래에서 비공개 함수 shareOrder()를 만듭니다.
private fun shareOrder()
  1. Context 유형의 context라는 매개변수를 추가합니다.
import android.content.Context

private fun shareOrder(context: Context) {
}
  1. String 매개변수 두 개(subjectsummary)를 추가합니다. 이러한 문자열은 공유 작업 시트에 표시됩니다.
private fun shareOrder(context: Context, subject: String, summary: String) {
}
  1. 함수 본문 내에서 intent라는 인텐트를 만들고 Intent.ACTION_SEND를 인수로 전달합니다.
import android.content.Intent

val intent = Intent(Intent.ACTION_SEND)

Intent 객체는 한 번만 구성하면 되므로 이전 Codelab에서 배운 apply() 함수를 사용하여 다음 코드 몇 줄은 더 간결하게 만들 수 있습니다.

  1. 새로 만든 인텐트에서 apply()를 호출하고 람다 표현식을 전달합니다.
val intent = Intent(Intent.ACTION_SEND).apply {

}
  1. 람다 본문에서 유형을 "text/plain"으로 설정합니다. apply()에 전달된 함수에서 이 작업을 실행하므로 객체의 식별자인 intent를 참조할 필요가 없습니다.
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
}
  1. putExtra()를 호출하여 EXTRA_SUBJECT의 제목을 전달합니다.
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_SUBJECT, subject)
}
  1. putExtra()를 호출하여 EXTRA_TEXT의 요약을 전달합니다.
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_SUBJECT, subject)
    putExtra(Intent.EXTRA_TEXT, summary)
}
  1. startActivity() 컨텍스트 메서드를 호출합니다.
context.startActivity(

)
  1. startActivity()에 전달된 람다 내에서 클래스 메서드 createChooser()를 호출하여 인텐트에서 활동을 만듭니다. 첫 번째 인수와 new_cupcake_order 문자열 리소스에 관한 인텐트를 전달합니다.
context.startActivity(
    Intent.createChooser(
        intent,
        context.getString(R.string.new_cupcake_order)
    )
)
  1. CupcakeApp 컴포저블의 CucpakeScreen.Summary.namecomposable() 호출에서 shareOrder() 함수에 전달할 수 있도록 컨텍스트 객체 참조를 가져옵니다.
composable(route = CupcakeScreen.Summary.name) {
    val context = LocalContext.current

    ...
}
  1. onSendButtonClicked()의 람다 본문에서 shareOrder()를 호출하여 context, subject, summary를 인수로 전달합니다.
onSendButtonClicked = { subject: String, summary: String ->
    shareOrder(context, subject = subject, summary = summary)
}
  1. 앱을 실행하고 화면을 탐색합니다.

Send Order to Another App을 클릭하면 추가 항목으로 제공된 제목 및 요약과 함께 하단 시트에 MessagingBluetooth와 같은 공유 작업이 표시됩니다.

13bde33712e135a4.png

7. 앱 바가 탐색에 응답하도록 설정

앱이 작동하고 모든 화면 간에 이동할 수 있지만 이 Codelab 시작 부분의 스크린샷에는 여전히 누락된 내용이 있습니다. 앱 바가 탐색에 자동으로 응답하지 않습니다. 앱이 새 경로로 이동할 때 제목이 업데이트되지 않고 적절한 경우 제목 앞에 위로 버튼이 표시되지도 않습니다.

시작 코드에는 이름이 CupcakeAppBarAppBar를 관리하는 컴포저블이 포함되어 있습니다. 이제 앱에 탐색을 구현했으므로 백 스택의 정보를 사용하여 올바른 제목을 표시하고 적절한 경우 위로 버튼을 표시할 수 있습니다. CupcakeAppBar 컴포저블은 제목이 적절하게 업데이트되도록 현재 화면을 인식해야 합니다.

  1. CupcakeScreen.ktCupcakeScreen enum에서 @StringRes 주석을 사용하여 title이라는 Int 유형의 매개변수를 추가합니다.
import androidx.annotation.StringRes

enum class CupcakeScreen(@StringRes val title: Int) {
    Start,
    Flavor,
    Pickup,
    Summary
}
  1. 각 화면의 제목 텍스트에 상응하는 각 enum 사례의 리소스 값을 추가합니다. Start 화면에는 app_name을 사용하고 Flavor 화면에는 choose_flavor, Pickup 화면에는 choose_pickup_date, Summary 화면에는 order_summary를 사용합니다.
enum class CupcakeScreen(@StringRes val title: Int) {
    Start(title = R.string.app_name),
    Flavor(title = R.string.choose_flavor),
    Pickup(title = R.string.choose_pickup_date),
    Summary(title = R.string.order_summary)
}
  1. CupcakeScreen 유형의 매개변수 currentScreenCupcakeAppBar 컴포저블에 추가합니다.
fun CupcakeAppBar(
    currentScreen: CupcakeScreen,
    canNavigateBack: Boolean,
    navigateUp: () -> Unit = {},
    modifier: Modifier = Modifier
)
  1. CupcakeAppBar 내에서 TopAppBar 제목 매개변수의 stringResource() 호출에 currentScreen.title을 전달하여 하드 코딩 앱 이름을 현재 화면의 제목으로 바꿉니다.
TopAppBar(
    title = { Text(stringResource(currentScreen.title)) },
    modifier = modifier,
    navigationIcon = {
        if (canNavigateBack) {
            IconButton(onClick = navigateUp) {
                Icon(
                    imageVector = Icons.Filled.ArrowBack,
                    contentDescription = stringResource(R.string.back_button)
                )
            }
        }
    }
)

위로 버튼은 백 스택에 컴포저블이 있는 경우에만 표시됩니다. 앱의 백 스택에 화면이 없는 경우(StartOrderScreen이 표시됨) 위로 버튼은 표시되지 않아야 합니다. 이를 확인하려면 백 스택 참조가 필요합니다.

  1. CupcakeApp 컴포저블의 navController 변수 아래에 backStackEntry라는 변수를 만들고 by 위임을 사용하여 navControllercurrentBackStackEntryAsState() 메서드를 호출합니다.
import androidx.navigation.compose.currentBackStackEntryAsState

@Composable
fun CupcakeApp(
    viewModel: OrderViewModel = viewModel(),
    navController: NavHostController = rememberNavController()
){

    val backStackEntry by navController.currentBackStackEntryAsState()

    ...
}
  1. 현재 화면의 제목을 CupcakeScreen 값으로 변환합니다. backStackEntry 변수 아래에서 CupcakeScreenvalueOf() 클래스 함수를 호출한 결과와 동일한 currentScreen이라는 val을 사용하여 변수를 만들고 backStackEntry 대상의 경로를 전달합니다. elvis 연산자를 사용하여 CupcakeScreen.Start.name의 기본값을 제공합니다.
val currentScreen = CupcakeScreen.valueOf(
    backStackEntry?.destination?.route ?: CupcakeScreen.Start.name
)
  1. currentScreen 변수의 값을 CupcakeAppBar 컴포저블과 동일한 이름의 매개변수에 전달합니다.
CupcakeAppBar(
    currentScreen = currentScreen,
    canNavigateBack = false,
    navigateUp = {}
)

백 스택에 현재 화면 뒤에 화면이 있는 한 위로 버튼이 표시되어야 합니다. 불리언 표현식을 사용하여 위로 버튼을 표시해야 하는지 식별할 수 있습니다.

  1. canNavigateBack 매개변수의 경우 navControllerpreviousBackStackEntry 속성이 null과 같지 않은지 확인하는 불리언 표현식을 전달합니다.
canNavigateBack = navController.previousBackStackEntry != null,
  1. 실제로 이전 화면으로 돌아가려면 navControllernavigateUp() 메서드를 호출합니다.
navigateUp = { navController.navigateUp() }
  1. 앱을 실행합니다.

이제 AppBar 제목이 업데이트되어 현재 화면이 반영됩니다. StartOrderScreen이 아닌 다른 화면으로 이동하면 위로 버튼이 표시되고 이전 화면으로 돌아갑니다.

3fd023516061f522.gif

8. 솔루션 코드 가져오기

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

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-cupcake.git
$ cd basic-android-kotlin-compose-training-cupcake
$ git checkout navigation

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

이 Codelab의 솔루션 코드는 GitHub에서 확인하세요.

9. 요약

축하합니다. 지금까지 Jetpack Navigation 구성요소를 사용하여 간단한 단일 페이지 애플리케이션에서 여러 화면 간에 이동하는 복잡한 멀티스크린 앱을 만들어 보았습니다. 경로를 정의하고, NavHost에서 이를 처리하고, 함수 유형 매개변수를 사용하여 탐색 로직을 개별 화면에서 분리했습니다. 인텐트를 사용하여 다른 앱으로 데이터를 전송하고 탐색에 응답하여 앱 바를 맞춤설정하는 방법도 알아봤습니다. 다음 단원에서는 점점 더 복잡해지는 다른 여러 멀티스크린 앱을 작업하면서 이러한 기술을 계속 사용합니다.

자세히 알아보기