1. 시작하기 전에
지금까지 작업한 앱은 단일 화면으로 구성되었습니다. 하지만 실제로 사용하는 많은 앱의 경우 이동 가능한 여러 개의 화면으로 이루어져 있습니다. 예를 들어 설정 앱에는 여러 페이지의 콘텐츠가 여러 화면에 걸쳐 있습니다.
Modern Android Development에서는 멀티스크린 앱이 Jetpack 탐색 구성요소를 사용하여 만들어집니다. 탐색 Compose 구성요소를 사용하면 사용자 인터페이스 빌드와 마찬가지로 선언적 접근 방식을 사용하여 Compose에서 멀티스크린 앱을 쉽게 빌드할 수 있습니다. 이 Codelab에서는 탐색 Compose 구성요소의 기본사항과 AppBar를 반응형으로 만드는 방법, 인텐트를 사용하여 내 앱에서 다른 앱으로 데이터를 전송하는 방법을 소개합니다. 동시에 더욱 복잡한 앱의 권장사항도 설명합니다.
기본 요건
- 함수 유형, 람다, 범위 함수를 비롯한 Kotlin 언어에 관한 지식
- Compose의 기본
Row
및Column
레이아웃에 관한 지식
학습할 내용
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.kt
의 StartOrderScreen
컴포저블로 표현됩니다.
화면은 이미지와 텍스트가 있는 단일 열과 다양한 컵케이크 수량을 주문하는 맞춤 버튼 3개로 구성됩니다. 맞춤 버튼은 StartOrderScreen.kt
의 SelectQuantityButton
컴포저블로 구현됩니다.
맛 선택 화면
수량을 선택하고 나면 앱에서 컵케이크 맛을 선택하라는 메시지를 사용자에게 표시합니다. 앱에서는 라디오 버튼을 사용하여 여러 옵션을 표시합니다. 사용자는 가능한 맛 옵션 중에서 하나를 선택할 수 있습니다.
가능한 맛 목록은 문자열 리소스 ID 목록으로 data.DataSource.kt
에 저장됩니다.
수령일 선택 화면
맛을 선택하면 앱에서는 수령일을 선택할 수 있는 여러 라디오 버튼을 사용자에게 표시합니다. 수령 옵션은 OrderViewModel
의 pickupOptions()
함수로 반환된 목록에서 가져옵니다.
Choose Flavor 화면과 Choose Pickup Date 화면은 모두 SelectOptionScreen.kt
의 SelectOptionScreen
인 동일한 컴포저블로 표현됩니다. 동일한 컴포저블을 사용하는 이유는 이러한 화면의 레이아웃이 정확히 동일하기 때문입니다. 유일한 차이점은 데이터이지만 동일한 컴포저블을 사용하여 맛 화면과 수령일 화면을 모두 표시할 수 있습니다.
주문 요약 화면
수령일을 선택하고 나면 앱에 Order Summary 화면이 표시되며 여기서 사용자는 주문을 검토하고 완료할 수 있습니다.
이 화면은 SummaryScreen.kt
의 OrderSummaryScreen
컴포저블로 구현됩니다.
레이아웃은 주문에 관한 정보가 모두 포함된 Column
과 소계에 관한 Text
컴포저블, 주문을 다른 앱으로 전송하거나 주문을 취소하고 첫 번째 화면으로 돌아가는 버튼으로 구성됩니다.
사용자가 주문을 다른 앱으로 전송하도록 선택하면 Cupcake 앱에는 다양한 공유 옵션을 보여주는 Android ShareSheet가 표시됩니다.
앱의 현재 상태는 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 클래스를 추가하여 경로를 정의합니다.
CupcakeScreen.kt
에서CupcakeAppBar
컴포저블 위에 enum 클래스CupcakeScreen
을 추가합니다.
enum class CupcakeScreen() {
}
- enum 클래스에 4가지 사례
Start
,Flavor
,Pickup
,Summary
를 추가합니다.
enum class CupcakeScreen() {
Start,
Flavor,
Pickup,
Summary
}
앱에 NavHost 추가
NavHost는 지정된 경로를 기반으로 다른 컴포저블 대상을 표시하는 컴포저블입니다. 예를 들어 경로가 Flavor
인 경우 NavHost
는 컵케이크 맛을 선택하는 화면을 표시합니다. 경로가 Summary
이면 앱에는 요약 화면이 표시됩니다.
NavHost
문법은 다른 컴포저블과 같습니다.
주목할 만한 매개변수가 두 가지 있습니다.
navController
:NavHostController
클래스의 인스턴스입니다.navigate()
메서드를 호출하여 다른 대상으로 이동하는 등의 방식으로 화면 간에 이동하는 데 이 객체를 사용할 수 있습니다. 구성 가능한 함수에서rememberNavController()
를 호출하여NavHostController
를 가져올 수 있습니다.startDestination
: 앱에서NavHost
를 처음 표시할 때 기본적으로 표시되는 대상을 정의하는 문자열 경로입니다. Cupcake 앱의 경우에는Start
경로입니다.
다른 컴포저블과 마찬가지로 NavHost
도 modifier
매개변수를 사용합니다.
CupcakeScreen.kt
의 CupcakeApp
컴포저블에 NavHost
를 추가합니다. 먼저 탐색 컨트롤러 참조가 필요합니다. 지금 추가하는 NavHost
와 이후 단계에서 추가할 AppBar
에서 모두 탐색 컨트롤러를 사용할 수 있습니다. 따라서 CupcakeApp()
컴포저블에서 변수를 선언해야 합니다.
CupcakeScreen.kt
를 엽니다.Scaffold
내uiState
변수 아래에NavHost
컴포저블을 추가합니다.
import androidx.navigation.compose.NavHost
Scaffold(
...
) { innerPadding ->
val uiState by viewModel.uiState.collectAsState()
NavHost()
}
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
는 콘텐츠의 함수 유형을 사용합니다.
NavHost
의 콘텐츠 함수 내에서 composable()
함수를 호출합니다. composable()
함수에는 필수 매개변수가 두 개 있습니다.
route
: 경로 이름에 해당하는 문자열입니다. 모든 고유 문자열을 사용할 수 있습니다.CupcakeScreen
enum의 상수 이름 속성을 사용합니다.content
: 여기에서 특정 경로에 표시할 컴포저블을 호출할 수 있습니다.
각 4가지 경로에 한 번씩 composable()
함수를 호출합니다.
composable()
함수를 호출하여route
에CupcakeScreen.Start.name
을 전달합니다.
import androidx.navigation.compose.composable
NavHost(
navController = navController,
startDestination = CupcakeScreen.Start.name,
modifier = Modifier.padding(innerPadding)
) {
composable(route = CupcakeScreen.Start.name) {
}
}
- 후행 람다 내에서
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))
)
}
}
- 첫 번째
composable()
호출 아래에서composable()
을 다시 호출하여route
에CupcakeScreen.Flavor.name
을 전달합니다.
composable(route = CupcakeScreen.Flavor.name) {
}
- 후행 람다 내에서
LocalContext.current
참조를 가져와서context
라는 변수에 저장합니다.Context
는 Android 시스템에서 구현을 제공하는 추상 클래스입니다. 이를 사용하면 애플리케이션별 리소스와 클래스에 액세스할 수 있을 뿐만 아니라 활동 시작과 같은 애플리케이션 수준 작업을 위한 up-call도 사용할 수 있습니다. 이 변수를 사용하여 뷰 모델의 리소스 ID 목록에서 문자열을 가져와 맛 목록을 표시할 수 있습니다.
import androidx.compose.ui.platform.LocalContext
composable(route = CupcakeScreen.Flavor.name) {
val context = LocalContext.current
}
SelectOptionScreen
컴포저블을 호출합니다.
composable(route = CupcakeScreen.Flavor.name) {
val context = LocalContext.current
SelectOptionScreen(
)
}
- 맛 화면에서는 사용자가 맛을 선택하면 소계를 표시하고 업데이트해야 합니다.
subtotal
매개변수에uiState.price
를 전달합니다.
composable(route = CupcakeScreen.Flavor.name) {
val context = LocalContext.current
SelectOptionScreen(
subtotal = uiState.price
)
}
- 맛 화면은 앱의 문자열 리소스에서 맛 목록을 가져옵니다.
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) }
)
}
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
컴포저블에 전달된 데이터입니다.
composable()
함수를 다시 호출하여route
매개변수에CupcakeScreen.Pickup.name
을 전달합니다.
composable(route = CupcakeScreen.Pickup.name) {
}
- 후행 람다에서
SelectOptionScreen
컴포저블을 호출하고 이전과 같이subtotal
에uiState.price
를 전달합니다.options
매개변수에uiState.pickupOptions
를 전달하고onSelectionChanged
매개변수에는viewModel
에서setDate()
를 호출하는 람다 표현식을 전달합니다.modifier
매개변수의 경우Modifier.fillMaxHeight().
를 전달합니다.
SelectOptionScreen(
subtotal = uiState.price,
options = uiState.pickupOptions,
onSelectionChanged = { viewModel.setDate(it) },
modifier = Modifier.fillMaxHeight()
)
composable()
을 한 번 더 호출하여route
에CupcakeScreen.Summary.name
을 전달합니다.
composable(route = CupcakeScreen.Summary.name) {
}
- 후행 람다에서
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
컴포저블에 전달되며 뷰 모델을 업데이트하고 다음 화면으로 이동합니다.
StartOrderScreen.kt
를 엽니다.quantityOptions
매개변수 아래 그리고 수정자 매개변수 앞에() -> Unit
유형의onNextButtonClicked
라는 매개변수를 추가합니다.
@Composable
fun StartOrderScreen(
quantityOptions: List<Pair<Int, Int>>,
onNextButtonClicked: () -> Unit,
modifier: Modifier = Modifier
){
...
}
- 이제
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을 업데이트할 수 있습니다.
Int
매개변수를 사용하도록onNextButtonClicked
매개변수의 유형을 수정합니다.
onNextButtonClicked: (Int) -> Unit,
onNextButtonClicked()
를 호출할 때 Int
가 전달되도록 하려면 quantityOptions
매개변수 유형을 살펴보세요.
유형은 List<Pair<Int, Int>>
또는 Pair<Int, Int>
목록입니다. Pair
유형이 익숙하지 않을 수 있지만 이름에서 알 수 있듯이 값 쌍일 뿐입니다. Pair
는 두 가지 일반 유형 매개변수를 사용합니다. 여기서는 둘 다 Int
유형입니다.
한 쌍의 각 항목은 첫 번째 속성이나 두 번째 속성에서 액세스합니다. StartOrderScreen
컴포저블의 quantityOptions
매개변수의 경우 첫 번째 Int
는 각 버튼에 표시할 문자열의 리소스 ID입니다. 두 번째 Int
는 컵케이크의 실제 수량입니다.
onNextButtonClicked()
함수를 호출할 때 선택된 쌍의 두 번째 속성을 전달합니다.
SelectQuantityButton
의onClick
매개변수에 관한 빈 람다 표현식을 찾습니다.
quantityOptions.forEach { item ->
SelectQuantityButton(
labelResourceId = item.first,
onClick = {}
)
}
- 람다 표현식 내에서
onNextButtonClicked
를 호출하여 컵케이크 수인item.second
를 전달합니다.
quantityOptions.forEach { item ->
SelectQuantityButton(
labelResourceId = item.first,
onClick = { onNextButtonClicked(item.second) }
)
}
SelectOptionScreen에 버튼 핸들러 추가
SelectOptionScreen.kt
에서SelectOptionScreen
컴포저블의onSelectionChanged
매개변수 아래에 기본값이{}
인() -> Unit
유형의 매개변수onCancelButtonClicked
를 추가합니다.
@Composable
fun SelectOptionScreen(
subtotal: String,
options: List<String>,
onSelectionChanged: (String) -> Unit = {},
onCancelButtonClicked: () -> Unit = {},
modifier: Modifier = Modifier
)
onCancelButtonClicked
매개변수 아래에() -> Unit
유형의 또 다른 매개변수onNextButtonClicked
(기본값{}
)를 추가합니다.
@Composable
fun SelectOptionScreen(
subtotal: String,
options: List<String>,
onSelectionChanged: (String) -> Unit = {},
onCancelButtonClicked: () -> Unit = {},
onNextButtonClicked: () -> Unit = {},
modifier: Modifier = Modifier
)
- 취소 버튼의
onClick
매개변수에onCancelButtonClicked
를 전달합니다.
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = onCancelButtonClicked
) {
Text(stringResource(R.string.cancel))
}
- 다음 버튼의
onClick
매개변수에onNextButtonClicked
를 전달합니다.
Button(
modifier = Modifier.weight(1f),
enabled = selectedValue.isNotEmpty(),
onClick = onNextButtonClicked
) {
Text(stringResource(R.string.next))
}
SummaryScreen에 버튼 핸들러 추가
마지막으로 요약 화면에서 Cancel 및 Send 버튼을 위한 버튼 핸들러 함수를 추가합니다.
SummaryScreen.kt
의OrderSummaryScreen
컴포저블에서() -> Unit
유형의onCancelButtonClicked
라는 매개변수를 추가합니다.
@Composable
fun OrderSummaryScreen(
orderUiState: OrderUiState,
onCancelButtonClicked: () -> Unit,
modifier: Modifier = Modifier
){
...
}
- 또 다른
(String, String) -> Unit
유형 매개변수를 추가하고 이름을onSendButtonClicked
로 지정합니다.
@Composable
fun OrderSummaryScreen(
orderUiState: OrderUiState,
onCancelButtonClicked: () -> Unit,
onSendButtonClicked: (String, String) -> Unit,
modifier: Modifier = Modifier
){
...
}
- 이제
OrderSummaryScreen
컴포저블이onSendButtonClicked
및onCancelButtonClicked
값을 예상합니다.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()
)
}
}
- Send 버튼의
onClick
매개변수에onSendButtonClicked
를 전달합니다. 이전에OrderSummaryScreen
에 정의된 두 변수인newOrder
와orderSummary
를 전달합니다. 이러한 문자열은 사용자가 다른 앱과 공유할 수 있는 실제 데이터로 구성됩니다.
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { onSendButtonClicked(newOrder, orderSummary) }
) {
Text(stringResource(R.string.send))
}
- Cancel 버튼의
onClick
매개변수에onCancelButtonClicked
를 전달합니다.
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = onCancelButtonClicked
) {
Text(stringResource(R.string.cancel))
}
다른 경로로 이동
다른 경로로 이동하려면 NavHostController
인스턴스에서 navigate()
메서드를 호출하면 됩니다.
navigate 메서드는 단일 매개변수를 사용합니다. 즉, NavHost
에 정의된 경로에 해당하는 String
입니다. 경로가 NavHost
의 composable()
호출 중 하나와 일치하면 앱이 그 화면으로 이동합니다.
사용자가 Start
, Flavor
, Pickup
화면에서 버튼을 누르면 navigate()
를 호출하는 함수가 전달됩니다.
CupcakeScreen.kt
에서 시작 화면의composable()
호출을 찾습니다.onNextButtonClicked
매개변수에 람다 표현식을 전달합니다.
StartOrderScreen(
quantityOptions = DataSource.quantityOptions,
onNextButtonClicked = {
}
)
컵케이크 수에 관해 이 함수에 전달된 Int
속성이 기억나나요? 다음 화면으로 이동하기 전에 앱이 올바른 소계를 표시하도록 뷰 모델을 업데이트해야 합니다.
viewModel
에서setQuantity
를 호출하여it
을 전달합니다.
onNextButtonClicked = {
viewModel.setQuantity(it)
}
navController
에서navigate()
를 호출하여route
의CupcakeScreen.Flavor.name
을 전달합니다.
onNextButtonClicked = {
viewModel.setQuantity(it)
navController.navigate(CupcakeScreen.Flavor.name)
}
- 맛 화면의
onNextButtonClicked
매개변수의 경우navigate()
를 호출하는 람다를 전달하여route
에CupcakeScreen.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()
)
}
- 다음에 구현할
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()
)
- 수령 화면의
onNextButtonClicked
매개변수에navigate()
를 호출하는 람다를 전달하고route
에CupcakeScreen.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()
)
}
- 다시
onCancelButtonClicked()
에 빈 람다를 전달합니다.
SelectOptionScreen(
subtotal = uiState.price,
onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
onCancelButtonClicked = {},
options = uiState.pickupOptions,
onSelectionChanged = { viewModel.setDate(it) },
modifier = Modifier.fillMaxHeight()
)
OrderSummaryScreen
의 경우onCancelButtonClicked
및onSendButtonClicked
에 빈 람다를 전달합니다.onSendButtonClicked
에 전달되는subject
및summary
매개변수를 추가합니다. 이는 곧 구현됩니다.
composable(route = CupcakeScreen.Summary.name) {
OrderSummaryScreen(
orderUiState = uiState,
onCancelButtonClicked = {},
onSendButtonClicked = { subject: String, summary: String ->
},
modifier = Modifier.fillMaxHeight()
)
}
이제 앱의 각 화면을 이동할 수 있습니다. navigate()
를 호출하면 화면이 변경될 뿐만 아니라 실제로 백 스택 위에 배치됩니다. 또한 시스템 뒤로 버튼을 누르면 이전 화면으로 돌아갈 수 있습니다.
앱은 각 화면을 이전 화면 위에 쌓고 뒤로 버튼()을 통해 화면을 삭제할 수 있습니다. 하단의 startDestination
부터 방금 표시된 최상단 화면까지의 화면 기록을 백 스택이라고 합니다.
시작 화면으로 돌아가기
시스템 뒤로 버튼과 달리 Cancel 버튼은 이전 화면으로 돌아가지 않습니다. 대신 백 스택의 모든 화면이 삭제되고 시작 화면으로 돌아가야 합니다.
popBackStack()
메서드를 호출하면 됩니다.
popBackStack()
메서드에는 두 가지 필수 매개변수가 있습니다.
route
: 다시 돌아갈 대상의 경로를 나타내는 문자열입니다.inclusive
: 불리언 값으로, true이면 지정된 경로를 삭제합니다. false인 경우popBackStack()
은 시작 대상 위의 모든 대상을 삭제하여(시작 대상은 제외) 시작 대상을 사용자에게 표시되는 최상단 화면으로 둡니다.
사용자가 어느 화면에서든 Cancel 버튼을 누르면 앱은 뷰 모델의 상태를 재설정하고 popBackStack()
을 호출합니다. 먼저 이 작업을 실행하는 메서드를 구현하고 Cancel 버튼이 있는 세 화면에서 모두 적절한 매개변수에 이를 전달합니다.
CupcakeApp()
함수 다음에 비공개 함수cancelOrderAndNavigateToStart()
를 정의합니다.
private fun cancelOrderAndNavigateToStart() {
}
- 다음 두 매개변수를 추가합니다.
OrderViewModel
유형viewModel
,NavHostController
유형navController
private fun cancelOrderAndNavigateToStart(
viewModel: OrderViewModel,
navController: NavHostController
) {
}
- 함수 본문의
viewModel
에서resetOrder()
를 호출합니다.
private fun cancelOrderAndNavigateToStart(
viewModel: OrderViewModel,
navController: NavHostController
) {
viewModel.resetOrder()
}
navController
에서popBackStack()
을 호출하여route
에CupcakeScreen.Start.name
을 전달하고inclusive
에false
를 전달합니다.
private fun cancelOrderAndNavigateToStart(
viewModel: OrderViewModel,
navController: NavHostController
) {
viewModel.resetOrder()
navController.popBackStack(CupcakeScreen.Start.name, inclusive = false)
}
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()
)
}
- 앱을 실행하고 화면에서 Cancel 버튼을 누르면 사용자가 첫 번째 화면으로 다시 이동하는지 테스트합니다.
6. 다른 앱으로 이동
지금까지 앱에서 다른 화면으로 이동하는 방법과 홈 화면으로 다시 이동하는 방법을 알아봤습니다. Cupcake 앱에는 탐색을 구현하는 다른 단계가 하나 더 있습니다. 사용자는 주문 요약 화면에서 주문을 다른 앱으로 전송할 수 있습니다. 이 옵션을 선택하면 공유 옵션을 보여주는 Sharesheet(화면의 하단부를 덮는 사용자 인터페이스 구성요소)가 표시됩니다.
이 UI 요소는 Cupcake 앱에 포함되어 있지 않습니다. 실제로 Android 운영체제에서 제공합니다. 시스템 UI(예: 공유 화면)는 navController
에서 호출하지 않습니다. 대신 인텐트라는 것을 사용합니다.
인텐트는 시스템이 작업을 실행하도록 요청하는 것으로, 일반적으로 새 활동이 표시됩니다. 인텐트에는 여러 가지가 있으며 전체 목록은 문서를 참조하세요. 하지만 여기서 관심 있는 인텐트는 ACTION_SEND
입니다. 이 인텐트를 문자열과 같은 일부 데이터와 함께 제공하고 해당 데이터에 적절한 공유 작업을 제공할 수 있습니다.
인텐트를 설정하는 기본 프로세스는 다음과 같습니다.
- 인텐트 객체를 만들고
ACTION_SEND
등의 인텐트를 지정합니다. - 인텐트와 함께 전송되는 추가 데이터의 유형을 지정합니다. 간단한 텍스트에는
"text/plain"
을 사용할 수 있지만"image/*"
또는"video/*"
와 같은 다른 유형도 사용할 수 있습니다. putExtra()
메서드를 호출하는 방식으로 공유할 텍스트 또는 이미지와 같은 추가 데이터를 인텐트에 전달합니다. 이 인텐트는 두 가지 추가 항목인EXTRA_SUBJECT
과EXTRA_TEXT
를 사용합니다.- 컨텍스트의
startActivity()
메서드를 호출하여 인텐트에서 생성된 활동을 전달합니다.
공유 작업 인텐트를 만드는 방법을 설명하겠지만 이 프로세스는 다른 유형의 인텐트에도 동일합니다. 향후 프로젝트의 경우 특정 데이터 유형과 필요한 추가 항목에 관해 필요에 따라 문서를 참조하는 것이 좋습니다.
컵케이크 주문을 다른 앱으로 전송하는 인텐트를 만들려면 다음 단계를 완료하세요.
- CupcakeScreen.kt의
CupcakeApp
컴포저블 아래에서 비공개 함수shareOrder()
를 만듭니다.
private fun shareOrder()
Context
유형의context
라는 매개변수를 추가합니다.
import android.content.Context
private fun shareOrder(context: Context) {
}
String
매개변수 두 개(subject
및summary
)를 추가합니다. 이러한 문자열은 공유 작업 시트에 표시됩니다.
private fun shareOrder(context: Context, subject: String, summary: String) {
}
- 함수 본문 내에서
intent
라는 인텐트를 만들고Intent.ACTION_SEND
를 인수로 전달합니다.
import android.content.Intent
val intent = Intent(Intent.ACTION_SEND)
이 Intent
객체는 한 번만 구성하면 되므로 이전 Codelab에서 배운 apply()
함수를 사용하여 다음 코드 몇 줄은 더 간결하게 만들 수 있습니다.
- 새로 만든 인텐트에서
apply()
를 호출하고 람다 표현식을 전달합니다.
val intent = Intent(Intent.ACTION_SEND).apply {
}
- 람다 본문에서 유형을
"text/plain"
으로 설정합니다.apply()
에 전달된 함수에서 이 작업을 실행하므로 객체의 식별자인intent
를 참조할 필요가 없습니다.
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
}
putExtra()
를 호출하여EXTRA_SUBJECT
의 제목을 전달합니다.
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, subject)
}
putExtra()
를 호출하여EXTRA_TEXT
의 요약을 전달합니다.
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, subject)
putExtra(Intent.EXTRA_TEXT, summary)
}
startActivity()
컨텍스트 메서드를 호출합니다.
context.startActivity(
)
startActivity()
에 전달된 람다 내에서 클래스 메서드createChooser()
를 호출하여 인텐트에서 활동을 만듭니다. 첫 번째 인수와new_cupcake_order
문자열 리소스에 관한 인텐트를 전달합니다.
context.startActivity(
Intent.createChooser(
intent,
context.getString(R.string.new_cupcake_order)
)
)
CupcakeApp
컴포저블의CucpakeScreen.Summary.name
용composable()
호출에서shareOrder()
함수에 전달할 수 있도록 컨텍스트 객체 참조를 가져옵니다.
composable(route = CupcakeScreen.Summary.name) {
val context = LocalContext.current
...
}
onSendButtonClicked()
의 람다 본문에서shareOrder()
를 호출하여context
,subject
,summary
를 인수로 전달합니다.
onSendButtonClicked = { subject: String, summary: String ->
shareOrder(context, subject = subject, summary = summary)
}
- 앱을 실행하고 화면을 탐색합니다.
Send Order to Another App을 클릭하면 추가 항목으로 제공된 제목 및 요약과 함께 하단 시트에 Messaging 및 Bluetooth와 같은 공유 작업이 표시됩니다.
7. 앱 바가 탐색에 응답하도록 설정
앱이 작동하고 모든 화면 간에 이동할 수 있지만 이 Codelab 시작 부분의 스크린샷에는 여전히 누락된 내용이 있습니다. 앱 바가 탐색에 자동으로 응답하지 않습니다. 앱이 새 경로로 이동할 때 제목이 업데이트되지 않고 적절한 경우 제목 앞에 위로 버튼이 표시되지도 않습니다.
시작 코드에는 이름이 CupcakeAppBar
인 AppBar
를 관리하는 컴포저블이 포함되어 있습니다. 이제 앱에 탐색을 구현했으므로 백 스택의 정보를 사용하여 올바른 제목을 표시하고 적절한 경우 위로 버튼을 표시할 수 있습니다. CupcakeAppBar
컴포저블은 제목이 적절하게 업데이트되도록 현재 화면을 인식해야 합니다.
- CupcakeScreen.kt의
CupcakeScreen
enum에서@StringRes
주석을 사용하여title
이라는Int
유형의 매개변수를 추가합니다.
import androidx.annotation.StringRes
enum class CupcakeScreen(@StringRes val title: Int) {
Start,
Flavor,
Pickup,
Summary
}
- 각 화면의 제목 텍스트에 상응하는 각 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)
}
CupcakeScreen
유형의 매개변수currentScreen
을CupcakeAppBar
컴포저블에 추가합니다.
fun CupcakeAppBar(
currentScreen: CupcakeScreen,
canNavigateBack: Boolean,
navigateUp: () -> Unit = {},
modifier: Modifier = Modifier
)
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
이 표시됨) 위로 버튼은 표시되지 않아야 합니다. 이를 확인하려면 백 스택 참조가 필요합니다.
CupcakeApp
컴포저블의navController
변수 아래에backStackEntry
라는 변수를 만들고by
위임을 사용하여navController
의currentBackStackEntryAsState()
메서드를 호출합니다.
import androidx.navigation.compose.currentBackStackEntryAsState
@Composable
fun CupcakeApp(
viewModel: OrderViewModel = viewModel(),
navController: NavHostController = rememberNavController()
){
val backStackEntry by navController.currentBackStackEntryAsState()
...
}
- 현재 화면의 제목을
CupcakeScreen
값으로 변환합니다.backStackEntry
변수 아래에서CupcakeScreen
의valueOf()
클래스 함수를 호출한 결과와 동일한currentScreen
이라는val
을 사용하여 변수를 만들고backStackEntry
대상의 경로를 전달합니다. elvis 연산자를 사용하여CupcakeScreen.Start.name
의 기본값을 제공합니다.
val currentScreen = CupcakeScreen.valueOf(
backStackEntry?.destination?.route ?: CupcakeScreen.Start.name
)
currentScreen
변수의 값을CupcakeAppBar
컴포저블과 동일한 이름의 매개변수에 전달합니다.
CupcakeAppBar(
currentScreen = currentScreen,
canNavigateBack = false,
navigateUp = {}
)
백 스택에 현재 화면 뒤에 화면이 있는 한 위로 버튼이 표시되어야 합니다. 불리언 표현식을 사용하여 위로 버튼을 표시해야 하는지 식별할 수 있습니다.
canNavigateBack
매개변수의 경우navController
의previousBackStackEntry
속성이 null과 같지 않은지 확인하는 불리언 표현식을 전달합니다.
canNavigateBack = navController.previousBackStackEntry != null,
- 실제로 이전 화면으로 돌아가려면
navController
의navigateUp()
메서드를 호출합니다.
navigateUp = { navController.navigateUp() }
- 앱을 실행합니다.
이제 AppBar
제목이 업데이트되어 현재 화면이 반영됩니다. StartOrderScreen
이 아닌 다른 화면으로 이동하면 위로 버튼이 표시되고 이전 화면으로 돌아갑니다.
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에서 이를 처리하고, 함수 유형 매개변수를 사용하여 탐색 로직을 개별 화면에서 분리했습니다. 인텐트를 사용하여 다른 앱으로 데이터를 전송하고 탐색에 응답하여 앱 바를 맞춤설정하는 방법도 알아봤습니다. 다음 단원에서는 점점 더 복잡해지는 다른 여러 멀티스크린 앱을 작업하면서 이러한 기술을 계속 사용합니다.