이번에 작성하고 있는 글은 Jetpack Compose의 Navigation 튜토리얼 부분이다.
이 튜토리얼에서 다루는 Cupcake라는 앱은 모든 컨텐츠가 단일 화면에 표시되는 대신 앱에는 개별 화면 4개가 존재하고, 사용자는 컵케이크 주문과 동시에 각 화면을 이동할 수 있게 만드는 것이 목표라고 한다.
화면 구성은 총 네 가지로
- 주문 시작 화면
- 맛 선택 화면
- 수령일 선택 화면
- 주문 요약 화면
으로 나뉜다.
앱의 현재 상태는 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 버튼도 포함한다.
Navigation 구성요소의 부분
Navigation 구성요소에는 다음과 같은 세 가지 주요 부분이 있다.
- NavController: 대상(즉, 앱의 화면) 간 이동을 담당한다.
- NavGraph: 이동할 컴포저블 대상을 매핑한다.
- NavHost: NavGraph의 현재 대상을 표시하는 컨테이너 역할을 하는 컴포저블이다.
1. 앱에서 대상 경로 정의
Compose 앱에서 탐색의 기본 개념 중 하나는 경로이다. 경로는 대상에 상응하는 문자열이다. 이 개념은 URL의 개념과 유사하다. 다른 URL이 웹사이트의 다른 페이지에 매핑되는 것처럼 경로는 대상에 매핑되어 고유한 식별자 역할을 하는 문자열이다. 대상은 일반적으로 사용자에게 표시되는 항목에 상응하는 단일 컴포저블이거나 컴포저블 그룹이다. Cupcake 앱에는 주문 시작 화면, 맛 화면, 수령일 화면, 주문 요약 화면을 위한 대상이 필요하다.
앱의 화면 수는 한정되어 있으므로 경로도 한정된다. enum 클래스를 사용하여 앱의 경로를 정의할 수 있다. Kotlin의 enum 클래스에는 속성 이름이 포함된 문자열을 반환하는 이름 속성이 있다.
먼저 앱에 네 가지 경로를 정의해야한다.
- Start: 버튼 세 개 중에서 원하는 컵케이크 수량 하나를 선택한다.
- Flavor: 옵션 목록에서 맛을 선택한다.
- Pickup: 옵션 목록에서 수령일을 선택한다.
- Summary: 선택한 내용을 검토하고 주문을 전송하거나 취소한다.
enum 클래스를 추가하여 경로를 정의한다.
enum class CupcakeScreen() {
Start,
Flavor,
Pickup,
Summary
}
앱에 NavHost 추가
NavHost는 지정된 경로를 기반으로 다른 컴포저블 대상을 표시하는 컴포저블이다. 예를 들어 경로가 Flavor인 경우 NavHost는 컵케이크 맛을 선택하는 화면을 표시한다. 경로가 Summary이면 앱에는 요약 화면이 표시된다.
NavHost 문법은 다른 컴포저블과 같다.
주목할 만한 매개변수는 다음과 같다.
- navController: NavHostController 클래스의 인스턴스로써, navigate() 메서드를 호출하여 다른 대상으로 이동하는 등의 방식으로 화면 간에 이동할 때 이 객체를 사용할 수 있다. 구성 가능한 함수에서 rememberNavController()를 호출하여 NavHostController를 가져올 수 있다.
- startDestination: 앱에서 NavHost를 처음 표시할 때 기본적으로 표시되는 대상을 정의하는 문자열 경로이다. Cupcake 앱의 경우 Start 경로이다.
다른 컴포저블과 마찬가지로 NavHost도 modifier 매개변수를 사용한다.
NavHost에서 경로 처리
다른 컴포저블과 마찬가지로 NavHost는 콘텐츠의 함수 유형을 사용한다.
NavHost의 콘텐츠 함수 내부에서 composable() 함수를 호출한다. composable() 함수에는 필수 매개변수가 두 개 있다.
- route: 경로 이름에 해당하는 문자열. 모든 고유 문자열을 사용할 수 있다. 위에서 정의한 CupcakeScreen enum의 상수 이름 속성을 여기서 사용한다.
- content: 여기에서 특정 경로에 표시할 컴포저블을 호출할 수 있다.
위에서도 말한 네 가지 경로에 전부 한 번씩 composable() 함수를 호출한다.
route는 각각 경로에 맞는 enum.name 속성을 넣고 내부 content에 표시할 컴포저블을 호출한다.
Scaffold(
...
) {
val uiState by viewModel.uiState.collectAsState()
NavHost(
navController = navController,
startDestination = CupcakeScreen.Start.name,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(it)
) {
composable(route = CupcakeScreen.Start.name) {
StartOrderScreen(
quantityOptions = DataSource.quantityOptions,
modifier = Modifier
.fillMaxSize()
.padding(dimensionResource(R.dimen.padding_medium))
)
}
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()
)
}
composable(route = CupcakeScreen.Pickup.name) {
SelectOptionScreen(
subtotal = uiState.price,
options = uiState.pickupOptions,
onSelectionChanged = { viewModel.setDate(it) },
modifier = Modifier.fillMaxHeight()
)
}
composable(route = CupcakeScreen.Summary.name) {
OrderSummaryScreen(
orderUiState = uiState,
modifier = Modifier.fillMaxHeight()
)
}
}
}
2. 경로 간 이동
경로를 정의하고 이를 NavHost의 컴포저블에 매핑했으니 다음은 화면 간 이동이다. rememberNavController() 호출의 navController 속성인 NavHostController는 경로 간 이동을 담당한다. navController를 매개변수로 각 컴포저블에 전달하면 된다.
이 접근법은 효과가 있으나 앱을 설계하는 가장 좋은 방법은 아니다. NavHost를 사용하여 앱 탐색을 처리하는 경우 탐색 로직이 개별 UI와 별도로 유지된다는 이점이 존재한다. 해당 옵션 사용시 몇 가지 주요 단점을 방지할 수 있다.
- 탐색 로직이 한곳에 유지되므로 코드 유지보수가 쉽고 실수로 개별 화면에 앱의 탐색을 자유롭게 허용하지 않음으로 버그 방지가 가능하다.
- 다양한 폼 팩터(세로 모드, 폴더블, 대형 화면 태블릿)에서 작동해야 하는 앱에서는 버튼이 앱의 레이아웃에 따라 탐색을 트리거할 수도 안할 수도 있다. 개별 화면은 독립적이어야 하며 앱의 다른 화면을 인식할 필요가 없다.
대신 사용자가 버튼을 클릭할 때 발생하는 일에 관해 각 컴포저블에 함수 유형을 전달하는 것이 좋다. 이렇게 하면 컴포저블과 그 하위 컴포저블이 함수를 호출할 시기를 결정한다. 그러나 탐색 로직은 앱의 개별화면에 노출되지 않는다. 모든 탐색 동작은 NavHost에서 처리된다.
이동할 컴포저블에 버튼 핸들러 추가
이동할 첫번째 화면인 StartOrderScreen에서 수량 버튼 중 하나를 누르면 호출되는 함수 유형 매개변수를 추가한다. 이 함수는 StartOrderScreen 컴포저블에 전달되며 뷰 모델을 업데이트하고 다음 화면으로 이동한다.
@Composable
fun StartOrderScreen(
quantityOptions: List<Pair<Int, Int>>,
onNextButtonClicked: () -> Unit,
modifier: Modifier = Modifier
){
...
}
각 버튼은 다양한 컵케이크 수량에 해당되며, 이 정보를 통해 onNextButtonClicked에 전달된 함수가 적절하게 viewmodel을 업데이트할 수 있다. 수량에 해당되므로 onNextButtonClicked 매개변수 유형을 Int 매개변수 유형을 사용하도록 변경한다.
onNextButtonClicked: (Int) -> Unit,
onNextButtonClicked()를 호출할 때 Int가 전달되도록 하려면 quantityOptions 매개변수 유형을 살펴봐야한다.
유형은 List<Pair<Int, Int>> 또는 Pair<Int, Int> 목록이다.
보여질 화면의 컴포저블 내부에 해당하는 버튼의 onClick 매개변수에 위에서 작성한 onNextButtonClicked로 받은 컵케이크 수(Int)를 전달한다.
quantityOptions.forEach { item ->
SelectQuantityButton(
labelResourceId = item.first,
onClick = { onNextButtonClicked(item.second) }
)
}
이후 나머지 화면에 있는 버튼에 대해서도 적절히 용도에 맞게 코드를 작성한다.
다른 경로로 이동
다른 경로로 이동하려면 NavHostController 인스턴스에서 navigate() 메서드를 호출하면 된다.
navigate 메서드는 단일 매개변수를 사용한다. 즉, NavHost에 정의된 경로에 해당하는 String이다. 경로가 NavHost의 composable() 호출 중 하나와 일치하면 앱이 그 화면으로 이동한다.
사용자가 Start, Flavor, Pickup 화면에서 버튼을 누르면 navigate()를 호출하는 함수가 전달된다.
이제 NavHost()를 정의한 곳을 가보면 경로를 정의한 composable() 함수에 오류 문구가 생겨있다.
매개변수가 추가로 생겼는데 매칭되는 변수가 없기 때문이다.
다음과 같이 위에서 추가한 매개변수 onNextButtonClicked를 추가하면 된다.
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))
)
}
viewModel에서 setQuantity를 호출하여 it를 전달한다. 이 it는 컵케이크의 수에 해당한다.
navController.navigate()를 사용해 이동할 경로를 전달한다.
이렇게 설정할 경우 StartOrderScreen()에서 버튼을 클릭할 경우 버튼 순서에 맞게 정의된 quantityOptions에서 Int 값을 가져와 viewModel에 Quantity를 전달하고 viewModel은 OrderUiState에 변경된 Quantity 값을 저장한다.
val quantityOptions = listOf(
Pair(R.string.one_cupcake, 1),
Pair(R.string.six_cupcakes, 6),
Pair(R.string.twelve_cupcakes, 12)
)
fun setFlavor(desiredFlavor: String) {
_uiState.update { currentState ->
currentState.copy(flavor = desiredFlavor)
}
}
data class OrderUiState(
/** Selected cupcake quantity (1, 6, 12) */
val quantity: Int = 0,
/** Flavor of the cupcakes in the order (such as "Chocolate", "Vanilla", etc..) */
val flavor: String = "",
/** Selected date for pickup (such as "Jan 1") */
val date: String = "",
/** Total price for the order */
val price: String = "",
/** Available pickup dates for the order*/
val pickupOptions: List<String> = listOf()
)
위와 같이 버튼 별로 추가로 설정해줘야할 것과 navigate() 호출을 끝내면 이제 앱의 각 화면을 이동할 수 있다. navigate()를 호출할 경우 화면이 변경될 뿐만 아니라 실제로 백 스택 위에 배치되고, 시스템적으로 뒤로가기 버튼을 누르면 이전화면으로 돌아갈 수 있게 된다.
앱은 각 화면을 이전 화면 위에 쌓고 뒤로 버튼을 통해 화면을 삭제할 수 있다. 하단의 startDestination부터 방금 표시된 최상단 화면까지의 화면 기록을 백스텍이라고 한다.
++
애플리케이션의 뒤로가기에도 종류가 존재한다.
- 히스토리 백이란 말 그대로 위에서 설명했듯이 백스텍을 쌓아둬 최신화면에서 뒤로가기를 통해 직전화면들로 되돌아가는 것을 뜻하며, 최종적으로 시작화면으로 돌아가기까지 계속해서 뒤로가기가 가능할 수도 있다.
- 하이어라키 백의 경우 위의 그림에서 B까지 진행했다가 B에서 하이어라키 백 버튼이 구현되어 있어 그 버튼을 누를경우 바로 A 화면으로 이동되면서 그 사이에 쌓여있던 스택을 비운다. 그러면 뒤로가기를 누르더라도 B로 가는게 아니라 A의 이전인 시작화면으로 이동될 것이다.
이 UI/UX 적인 개념을 인지하고 다음을 살펴보자.
++
시작화면으로 돌아가기
시스템 뒤로 버튼과 달리 Cancel 버튼은 이전화면으로 돌아가지 안흔다. 대신 백스텍의 모든 화면이 삭제되고 시작화면으로 돌아간다.
popBackStack() 메서드를 호출한다.
popBackStack() 메서드에는 두 가지 필수 매개변수가 존재한다.
- route: 다시 돌아갈 대상의 경로를 나타내는 문자열이다.
- inclusive: 불리언 값으로, true이면 지정된 경로 삭제, false이면 popBackStack()은 시작 대상 위의 모든 대상을(시작 화면은 삭제하지 않음) 삭제하여 시작 대상을 사용자에게 표시하는 최상단 화면으로 둔다.
사용자가 어느 화면에서든 Cancel 버튼을 누를 경우 앱은 뷰 모델의 상태를 재설정하고 popBackStack()을 호출한다.
composable(route = CupcakeScreen.Pickup.name) {
SelectOptionScreen(
subtotal = uiState.price,
options = uiState.pickupOptions,
onCancelButtonClicked = { cancelOrderAndNavigateToStart(viewModel, navController) },
onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
onSelectionChanged = { viewModel.setDate(it) },
modifier = Modifier.fillMaxHeight()
)
}
private fun cancelOrderAndNavigateToStart(
viewModel: OrderViewModel,
navController: NavHostController
) {
viewModel.resetOrder()
navController.popBackStack(CupcakeScreen.Start.name, inclusive = false)
}
fun resetOrder() {
_uiState.value = OrderUiState(pickupOptions = pickupOptions())
}
이제 앱을 실행해보면 뒤로가기를 눌렀을때는 위에서 설명한 히스토리 백을 실행하고, Cancel 버튼을 누를 경우 하이어라키 백을 실행하는 것을 알 수 있다.
3. 다른 앱으로 이동
지금까진 앱 내부에서 화면을 이동하는 방법에 대해 다뤘고, 이 Cupcake 앱에는 사용자가 주문 요약 화면에서 주문을 다른 앱으로 전송하는 것까지 구현한다.
Android 운영체제에서 제공하는 Sharesheet 옵션을 사용해 구현한다. 이 시스템 UI는 navController에서 호출하는 것이 아닌 Intent를 사용한다. Intent는 시스템이 작업을 실행하도록 요청하는 것으로 일반적으로는 새 활동이 표시된다. 여러 가지 내용이 존재하며 여기서 사용할 Intent는 ACTION_SEND이다.
ACTION_SEND는 문자열과 같은 일부 데이터와 함께 제공하고 해당 데이터에 적절한 공유 작업을 제공할 수 있다.
인텐트를 설정하는 기본 프로세스는 다음과 같다.
- 인텐트 객체를 만들고 ACTION_SEND 등의 인텐트를 지정한다.
- 인텐트와 함께 전송되는 추가 데이터의 유형을 지정한다. 간단한 텍스트에는 "text/plain"을 사용할 수 있지만 "image/*" 또는 "video/*"와 같은 다른 유형도 사용할 수 있다.
- putExtra() 메서드를 호출하는 방식으로 공유할 텍스트 또는 이미지와 같은 추가 데이터를 인텐트에 전달한다. 이 인텐트는 두 가지 추가 항목인 EXTRA_SUBJECT과 EXTRA_TEXT를 사용한다.
- 컨텍스트의 startActivity() 메서드를 호출하여 인텐트에서 생성된 활동을 전달한다.
공유 작업 인텐트를 만드는 방법을 설명하겠지만 이 프로세스는 다른 유형의 인텐트에도 동일하다. 향후 프로젝트의 경우 특정 데이터 유형과 필요한 추가 항목에 관해 필요에 따라 문서를 참조하는 것이 좋다.
private fun shareOrder(
context: Context,
subject: String,
summary: String
) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, subject)
putExtra(Intent.EXTRA_TEXT, summary)
}
context.startActivity(
Intent.createChooser(intent, context.getString(R.string.new_cupcake_order))
)
}
composable(route = CupcakeScreen.Summary.name) {
val context = LocalContext.current
OrderSummaryScreen(
orderUiState = uiState,
onCancelButtonClicked = { cancelOrderAndNavigateToStart(viewModel, navController) },
onSendButtonClicked = { subject: String, summary: String ->
shareOrder(context, subject, summary)
},
modifier = Modifier.fillMaxHeight()
)
}
이제 앱을 실행해 마지막에 가서 버튼을 누르게 되면 다음과 같이 하단에 시스템 UI로 주문 정보가 요약되어 다른 애플리케이션에 공유할 수 있게 된다.
4. 앱 바가 탐색에 응답하도록 설정
앱이 작동하고 모든 화면 간에 이동할 수 있지만 앱 바가 스크린에 따라 Title이 변경되지 않는 점과 Back 버튼이 없는 점이 존재했다.
시작 코드에는 이름이 CupcakeAppBar인 AppBar를 관리하는 컴포저블이 존재하는데, 앱에 탐색이 구현되었으므로 백 스텍의 정보를 사용해 올바른 제목을 표시하고 적절한 경우 위로 버튼을 표시할 수 있다.
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)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CupcakeAppBar(
currentScreen: CupcakeScreen,
canNavigateBack: Boolean,
navigateUp: () -> Unit,
modifier: Modifier = Modifier
) {
TopAppBar(
title = { Text(stringResource(currentScreen.title)) },
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
modifier = modifier,
navigationIcon = {
if (canNavigateBack) {
IconButton(onClick = navigateUp) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.back_button)
)
}
}
}
)
}
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun CupcakeApp(
viewModel: OrderViewModel = viewModel(),
navController: NavHostController = rememberNavController()
) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentScreen = CupcakeScreen.valueOf(backStackEntry?.destination?.route ?: CupcakeScreen.Start.name)
Scaffold(
topBar = {
CupcakeAppBar(
currentScreen = currentScreen,
canNavigateBack = navController.previousBackStackEntry != null,
navigateUp = { navController.navigateUp() }
)
}
) {
val uiState by viewModel.uiState.collectAsState()
NavHost() {
composable(route = CupcakeScreen.Start.name) {
...
}
...
}
}
}
이제 앱을 실행해서 경로를 탐색해보면 상단 앱 바가 변경되는 모습을 확인할 수 있다.
'개발 > 안드로이드' 카테고리의 다른 글
적응형 앱, 반응형 UI 탐색 (1) | 2024.05.16 |
---|---|
탐색 그래프 없이 화면 변경 (1) | 2024.05.16 |
애플리케이션 테스트 02. ViewModel의 단위 테스트 작성 (1) | 2024.05.09 |
애플리케이션 테스트 01. 자동테스트란? (0) | 2024.05.09 |
앱 아키텍처 알아보기 (0) | 2024.05.08 |
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!