탐색은 애플리케이션의 UI와 상호작용하여 앱의 콘텐츠 대상에 액세스하는 프로세스입니다. Android의 탐색 원리는 일관되고 직관적인 앱 탐색을 만드는 데 도움이 되는 가이드라인을 제공합니다.
반응형 UI는 반응형 콘텐츠 대상을 제공하며, 디스플레이 크기 변경에 반응하여 다양한 유형의 탐색 요소(예: 작은 디스플레이의 하단 탐색 메뉴나 중간 크기 디스플레이의 탐색 레일, 큰 디스플레이의 지속적 탐색 창)를 포함하는 경우가 많습니다. 단, 반응형 UI도 탐색 원리를 준수해야 합니다.
Jetpack 탐색 구성요소는 탐색 원리를 구현하며 반응형 UI로 앱 개발을 용이하게 하는 데 사용할 수 있습니다.
반응형 UI 탐색
앱이 차지하는 디스플레이 창의 크기는 인체공학과 사용성에 영향을 미칩니다. 창 크기 클래스를 사용하면 적절한 탐색 요소(예: 탐색 메뉴나 탐색 레일, 탐색 창)를 결정하여 사용자가 쉽게 액세스할 수 있는 위치에 배치할 수 있습니다. Material Design 레이아웃 가이드라인에서 탐색 요소는 디스플레이의 선행 가장자리에서 영구 공간을 차지하며 앱의 너비가 좁을 때 하단 가장자리로 이동할 수 있습니다. 탐색 요소는 주로 앱 창의 크기 및 각 요소가 보유해야 하는 항목의 수에 따라 선택하게 됩니다.
창 크기 클래스 | 일부 항목 | 여러 항목 |
---|---|---|
좁은 너비 | 하단 탐색 메뉴 | 탐색 창(선행 가장자리 또는 하단) |
중간 너비 | 탐색 레일 | 탐색 창(선행 가장자리) |
확장 너비 | 탐색 레일 | 지속적 탐색 창(선행 가장자리) |
뷰 기반 레이아웃에서 레이아웃 리소스 파일은 창 크기 클래스 중단점에 따라 검증되어 다양한 디스플레이 크기에 맞는 여러 탐색 요소를 사용할 수 있습니다. Jetpack Compose는 Window Size Class API에서 제공하는 중단점을 사용하여 프로그래매틱 방식으로 앱 창에 가장 적합한 탐색 요소를 결정할 수 있습니다.
뷰
<!-- res/layout/main_activity.xml --> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent"> <com.google.android.material.bottomnavigation.BottomNavigationView android:layout_width="0dp" android:layout_height="wrap_content" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" ... /> <!-- Content view(s) --> </androidx.constraintlayout.widget.ConstraintLayout> <!-- res/layout-w600dp/main_activity.xml --> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent"> <com.google.android.material.navigationrail.NavigationRailView android:layout_width="wrap_content" android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" ... /> <!-- Content view(s) --> </androidx.constraintlayout.widget.ConstraintLayout> <!-- res/layout-w1240dp/main_activity.xml --> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent"> <com.google.android.material.navigation.NavigationView android:layout_width="wrap_content" android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" ... /> <!-- Content view(s) --> </androidx.constraintlayout.widget.ConstraintLayout>
Compose
// This method should be run inside a Composable function. val widthSizeClass = calculateWindowSizeClass(this).widthSizeClass // You can get the height of the current window by invoking heightSizeClass instead. @Composable fun MyApp(widthSizeClass: WindowWidthSizeClass) { // Select a navigation element based on window size. when (widthSizeClass) { WindowWidthSizeClass.Compact -> { CompactScreen() } WindowWidthSizeClass.Medium -> { MediumScreen() } WindowWidthSizeClass.Expanded -> { ExpandedScreen() } } } @Composable fun CompactScreen() { Scaffold(bottomBar = { NavigationBar { icons.forEach { item -> NavigationBarItem( selected = isSelected, onClick = { ... }, icon = { ... }) } } } ) { // Other content } } @Composable fun MediumScreen() { Row(modifier = Modifier.fillMaxSize()) { NavigationRail { icons.forEach { item -> NavigationRailItem( selected = isSelected, onClick = { ... }, icon = { ... }) } } // Other content } } @Composable fun ExpandedScreen() { PermanentNavigationDrawer( drawerContent = { icons.forEach { item -> NavigationDrawerItem( icon = { ... }, label = { ... }, selected = isSelected, onClick = { ... } ) } }, content = { // Other content } ) }
반응형 콘텐츠 대상
반응형 UI에서 각 콘텐츠 대상의 레이아웃은 창 크기 변경에 맞게 조정되어야 합니다. 앱에서는 레이아웃 간격을 조정하거나 요소의 위치를 변경하거나 콘텐츠를 추가 또는 삭제하거나 탐색 요소를 비롯한 UI 요소를 변경할 수 있습니다. 반응형 레이아웃으로 UI 이전 및 다양한 화면 크기 지원을 참고하세요.
개별 대상이 크기 조절 이벤트를 성공적으로 처리하면 변경사항이 UI에만 적용됩니다. 탐색을 포함한 앱 상태의 나머지 부분은 영향을 받지 않습니다.
창 크기 변경의 부수 효과로 탐색이 발생하면 안 됩니다. 다양한 창 크기를 반영할 목적으로 콘텐츠 대상을 만들지는 마세요. 예를 들어 폴더블 기기의 여러 화면에 맞도록 콘텐츠 대상을 여러 개 만들어서는 안 됩니다.
창 크기 변경의 부수 효과로 탐색하면 다음 문제가 발생합니다.
- 새 대상으로 이동하기 전에 이전 대상(이전 창 크기)이 잠시 표시될 수 있습니다.
- (예를 들어 기기를 접었다가 펴는 상황에서) 가역성을 유지하기 위해 창 크기마다 탐색이 필요합니다.
- 탐색 시 백 스택이 표시될 때 상태가 소멸될 수 있기 때문에 대상이 바뀔 때 애플리케이션 상태를 유지하기가 어려울 수 있습니다.
또한 창 크기가 변경되는 동안 앱이 포그라운드에 있지 않게 될 수도 있습니다. 앱의 레이아웃에는 포그라운드 앱보다 공간이 더 많이 필요할 수 있으며 사용자가 앱으로 돌아올 때 방향과 창 크기가 모두 변경되었을 수 있습니다.
앱에 창 크기에 기반한 고유한 콘텐츠 대상이 필요한 경우 관련 대상을 대체 레이아웃이 포함된 단일 대상으로 결합하는 것이 좋습니다.
대체 레이아웃이 있는 콘텐츠 대상
반응형 디자인의 일환으로 단일 탐색 대상은 앱 창 크기에 따라 대체 레이아웃을 보유할 수 있습니다. 각 레이아웃은 전체 창을 차지하지만 다양한 레이아웃이 여러 창 크기에 맞게 표시됩니다.
표준적인 예로는 목록 세부정보 뷰가 있습니다. 작은 창 크기의 경우 앱은 목록에 대한 콘텐츠 레이아웃을 하나 표시하고 세부정보에 대해 하나를 표시합니다. 목록 세부정보 뷰 대상으로 이동하면 처음에는 목록 레이아웃만 표시됩니다. 목록 항목을 선택하면 앱에서 세부정보 레이아웃을 표시하여 목록을 대체합니다. 뒤로 컨트롤을 선택하면 목록 레이아웃이 표시되어 세부정보를 대체합니다. 그러나 확장된 창 크기에서는 목록 레이아웃과 세부정보 레이아웃이 나란히 표시됩니다.
뷰
SlidingPaneLayout
을 사용하면 큰 화면에는 콘텐츠 창 두 개를 나란히 표시하지만 휴대전화와 같이 화면이 작은 기기에서는 한 번에 한 개 창만 표시하는 단일 탐색 대상을 만들 수 있습니다.
<!-- Single destination for list and detail. -->
<navigation ...>
<!-- Fragment that implements SlidingPaneLayout. -->
<fragment
android:id="@+id/article_two_pane"
android:name="com.example.app.ListDetailTwoPaneFragment" />
<!-- Other destinations... -->
</navigation>
SlidingPaneLayout
을 사용하여 목록 세부정보 레이아웃을 구현하는 방법에 관한 자세한 내용은 두 개의 창 레이아웃 만들기를 참고하세요.
Compose
Compose에서는 창 크기 클래스를 사용하여 각 크기 클래스에 적절한 컴포저블을 내보내는 단일 경로에서 대체 컴포저블을 결합하여 목록 세부정보 뷰를 구현할 수 있습니다.
경로는 콘텐츠 대상 탐색 경로이며 이는 일반적으로 단일 컴포저블이지만 대체 컴포저블을 사용할 수도 있습니다. 비즈니스 로직에 따라 표시되는 대체 컴포저블이 정해집니다. 컴포저블은 표시되는 대체 옵션과 상관없이 앱 창을 채웁니다.
목록 세부정보 뷰는 세 가지 컴포저블로 구성됩니다. 예를 들면 다음과 같습니다.
/* Displays a list of items. */
@Composable
fun ListOfItems(
onItemSelected: (String) -> Unit,
) { /*...*/ }
/* Displays the detail for an item. */
@Composable
fun ItemDetail(
selectedItemId: String? = null,
) { /*...*/ }
/* Displays a list and the detail for an item side by side. */
@Composable
fun ListAndDetail(
selectedItemId: String? = null,
onItemSelected: (String) -> Unit,
) {
Row {
ListOfItems(onItemSelected = onItemSelected)
ItemDetail(selectedItemId = selectedItemId)
}
}
단일 탐색 경로는 목록 세부정보 뷰 액세스 권한을 제공합니다.
@Composable
fun ListDetailRoute(
// Indicates that the display size is represented by the expanded window size class.
isExpandedWindowSize: Boolean = false,
// Identifies the item selected from the list. If null, a item has not been selected.
selectedItemId: String?,
) {
if (isExpandedWindowSize) {
ListAndDetail(
selectedItemId = selectedItemId,
/*...*/
)
} else {
// If the display size cannot accommodate both the list and the item detail,
// show one of them based on the user's focus.
if (selectedItemId != null) {
ItemDetail(
selectedItemId = selectedItemId,
/*...*/
)
} else {
ListOfItems(/*...*/)
}
}
}
ListDetailRoute
(탐색 대상)는 세 가지 컴포저블 중 어떤 것을 내보낼지 결정합니다. 확장된 창 크기의 경우 ListAndDetail
, 작은 창 크기의 경우 목록 항목을 선택했는지 여부에 따라 ListOfItems
나 ItemDetail
을 내보냅니다.
경로는 NavHost
에 포함됩니다. 예를 들면 다음과 같습니다.
NavHost(navController = navController, startDestination = "listDetailRoute") {
composable("listDetailRoute") {
ListDetailRoute(isExpandedWindowSize = isExpandedWindowSize,
selectedItemId = selectedItemId)
}
/*...*/
}
앱의 WindowMetrics를 검사하여 isExpandedWindowSize
인수를 제공할 수 있습니다.
selectedItemId
인수는 모든 창 크기에서 상태를 유지하는 ViewModel
로 제공할 수 있습니다. 사용자가 목록에서 항목을 선택하면 selectedItemId
상태 변수가 업데이트됩니다.
class ListDetailViewModel : ViewModel() {
data class ListDetailUiState(
val selectedItemId: String? = null,
)
private val viewModelState = MutableStateFlow(ListDetailUiState())
fun onItemSelected(itemId: String) {
viewModelState.update {
it.copy(selectedItemId = itemId)
}
}
}
val listDetailViewModel = ListDetailViewModel()
@Composable
fun ListDetailRoute(
isExpandedWindowSize: Boolean = false,
selectedItemId: String?,
onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
) {
if (isExpandedWindowSize) {
ListAndDetail(
selectedItemId = selectedItemId,
onItemSelected = onItemSelected,
/*...*/
)
} else {
if (selectedItemId != null) {
ItemDetail(
selectedItemId = selectedItemId,
/*...*/
)
} else {
ListOfItems(
onItemSelected = onItemSelected,
/*...*/
)
}
}
}
항목 세부정보 컴포저블이 전체 앱 창을 차지하는 경우 경로에는 맞춤 BackHandler
도 포함됩니다.
class ListDetailViewModel : ViewModel() {
data class ListDetailUiState(
val selectedItemId: String? = null,
)
private val viewModelState = MutableStateFlow(ListDetailUiState())
fun onItemSelected(itemId: String) {
viewModelState.update {
it.copy(selectedItemId = itemId)
}
}
fun onItemBackPress() {
viewModelState.update {
it.copy(selectedItemId = null)
}
}
}
val listDetailViewModel = ListDetailViewModel()
@Composable
fun ListDetailRoute(
isExpandedWindowSize: Boolean = false,
selectedItemId: String?,
onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
onItemBackPress: () -> Unit = { listDetailViewModel.onItemBackPress() },
) {
if (isExpandedWindowSize) {
ListAndDetail(
selectedItemId = selectedItemId,
onItemSelected = onItemSelected,
/*...*/
)
} else {
if (selectedItemId != null) {
ItemDetail(
selectedItemId = selectedItemId,
/*...*/
)
BackHandler {
onItemBackPress()
}
} else {
ListOfItems(
onItemSelected = onItemSelected,
/*...*/
)
}
}
}
ViewModel
의 앱 상태를 창 크기 클래스 정보와 결합하면 간단한 로직으로 적절한 컴포저블을 선택할 수 있습니다. 단방향 데이터 흐름을 유지하면 앱이 사용 가능한 디스플레이 공간을 완전히 사용하면서 애플리케이션 상태를 유지할 수 있습니다.
Compose에서 전체 목록 세부정보 뷰를 구현하는 방법은 GitHub의 JetNews 샘플을 참고하세요.
단일 탐색 그래프
모든 기기 또는 창 크기에서 일관된 사용자 경험을 제공하려면 각 콘텐츠 대상의 레이아웃이 반응형인 단일 탐색 그래프를 사용하세요.
창 크기 클래스별로 다른 탐색 그래프를 사용한다면 앱이 특정 크기 클래스에서 다른 크기 클래스로 전환될 때마다 다른 그래프에서 사용자의 현재 대상을 확인하고 백 스택을 구성하고 그래프 간에 다른 상태 정보를 조정해야 합니다.
중첩된 탐색 호스트
앱은 자체 콘텐츠 대상이 있는 콘텐츠 대상을 포함할 수 있습니다. 예를 들어 목록 세부정보 뷰에서 항목 세부정보 창에는 항목 세부정보를 대체하는 콘텐츠로 이동하는 UI 요소가 포함될 수 있습니다.
이러한 유형의 하위 탐색을 구현하기 위해 세부정보 창은 세부정보 창에서 액세스하는 대상을 지정하는 자체 탐색 그래프가 있는 중첩된 탐색 호스트가 될 수 있습니다.
뷰
<!-- layout/two_pane_fragment.xml --> <androidx.slidingpanelayout.widget.SlidingPaneLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/sliding_pane_layout" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/list_pane" android:layout_width="280dp" android:layout_height="match_parent" android:layout_gravity="start"/> <!-- Detail pane is a nested navigation host. Its graph is not connected to the main graph that contains the two_pane_fragment destination. --> <androidx.fragment.app.FragmentContainerView android:id="@+id/detail_pane" android:layout_width="300dp" android:layout_weight="1" android:layout_height="match_parent" android:name="androidx.navigation.fragment.NavHostFragment" app:navGraph="@navigation/detail_pane_nav_graph" /> </androidx.slidingpanelayout.widget.SlidingPaneLayout>
Compose
@Composable fun ItemDetail(selectedItemId: String? = null) { val navController = rememberNavController() NavHost(navController, "itemSubdetail1") { composable("itemSubdetail1") { ItemSubdetail1(...) } composable("itemSubdetail2") { ItemSubdetail2(...) } composable("itemSubdetail3") { ItemSubdetail3(...) } } }
이 방법은 중첩된 탐색 그래프와 다릅니다. 중첩된 NavHost
의 탐색 그래프가 기본 탐색 그래프에 연결되지 않았기 때문입니다. 즉, 특정 그래프의 대상에서 다른 그래프의 대상으로 직접 이동할 수 없습니다.
자세한 내용은 중첩된 탐색 그래프와 Compose를 통해 이동을 참고하세요.
유지된 상태
반응형 콘텐츠 대상을 제공하려면 기기가 회전 또는 접히거나 앱 창의 크기가 조절될 때 앱이 상태를 유지해야 합니다. 기본적으로 이와 같은 구성 변경으로 앱의 활동과 프래그먼트, 뷰 계층 구조, 컴포저블이 다시 만들어집니다. 권장되는 UI 상태 저장 방법은 구성 변경 후에도 유지되는 ViewModel
이나 rememberSaveable
을 사용하는 것입니다. UI 상태 저장과 상태 및 Jetpack Compose를 참고하세요.
크기 변경은 되돌릴 수 있어야 합니다. 예를 들어 사용자가 기기를 회전한 후 다시 회전하는 경우입니다.
반응형 레이아웃은 다양한 창 크기에 여러 콘텐츠를 표시할 수 있습니다. 따라서 반응형 레이아웃은 상태가 현재 창 크기에 적용되지 않더라도 콘텐츠와 관련된 추가 상태를 저장해야 하는 때가 많습니다. 예를 들어 레이아웃에는 더 큰 창 너비에서만 추가 스크롤 위젯을 표시할 공간이 있을 수 있습니다. 크기 조절 이벤트로 창 너비가 너무 작아지면 위젯이 숨겨집니다. 앱 크기가 이전 크기로 조절되면 스크롤 위젯이 다시 표시되며 원래 스크롤 위치는 복원되어야 합니다.
ViewModel 범위
탐색 구성요소로 이전 개발자 가이드에서는 대상이 프래그먼트로 구현되고 ViewModel
을 사용하여 데이터 모델이 구현되는 단일 활동 아키텍처를 권장합니다.
ViewModel
은 항상 수명 주기로 범위가 지정되고 수명 주기가 영구적으로 종료되면 ViewModel
은 지워지며 삭제될 수 있습니다. ViewModel
의 범위로 지정된 수명 주기(ViewModel
을 공유할 수 있는 범위)는 ViewModel
을 가져오는 데 사용되는 속성 위임에 따라 다릅니다.
가장 간단한 경우 모든 탐색 대상은 완전히 격리된 UI 상태가 있는 단일 프래그먼트입니다. 따라서 각 프래그먼트는 viewModels()
속성 위임을 사용하여 프래그먼트로 범위가 지정된 ViewModel
을 가져올 수 있습니다.
프래그먼트 간에 UI 상태를 공유하려면 프래그먼트에서 activityViewModels()
를 호출하여 ViewModel
의 범위를 활동으로 지정합니다(viewModels()
만 활동에 해당하게 됨). 이렇게 하면 활동과 활동에 연결되는 프래그먼트가 ViewModel
인스턴스를 공유할 수 있습니다. 그러나 단일 활동 아키텍처에서는 이 ViewModel
범위가 앱이 지속되는 한 사실상 지속되므로 프래그먼트에서 ViewModel을 사용하지 않더라도 ViewModel
이 메모리에 유지됩니다.
탐색 그래프에 결제 흐름을 나타내는 프래그먼트 대상 시퀀스가 있고 전체 결제 환경의 현재 상태가 프래그먼트 간에 공유되는 ViewModel
이라고 가정해 보겠습니다. ViewModel
의 범위를 활동으로 지정하는 것은 너무 광범위할 뿐 아니라 실제로 또 다른 문제를 노출합니다. 사용자가 특정 주문의 결제 흐름을 진행한 후 두 번째 주문의 결제 흐름을 다시 진행하면 두 주문에서 모두 같은 결제 ViewModel
인스턴스를 사용합니다. 두 번째 주문 결제 전에 첫 번째 주문의 데이터를 수동으로 지워야 하며 이때 실수가 발생하면 사용자에게 큰 손실이 발생할 수 있습니다.
대신 현재 NavController
의 탐색 그래프로 ViewModel
의 범위를 지정합니다. 중첩된 탐색 그래프를 만들어 결제 흐름의 일부인 대상을 캡슐화합니다. 그런 다음 이러한 각 프래그먼트 대상에서 navGraphViewModels()
속성 위임을 사용하고 탐색 그래프의 ID를 전달하여 공유된 ViewModel
을 가져옵니다. 이렇게 하면 사용자가 결제 흐름을 종료하고 중첩된 탐색 그래프가 범위를 벗어나면 상응하는 ViewModel
인스턴스가 삭제되고 다음 결제에 사용되지 않습니다.
범위 | 속성 위임 | ViewModel 공유 대상 |
---|---|---|
프래그먼트 | Fragment.viewModels() |
현재 프래그먼트만 |
활동 | Activity.viewModels()
|
활동과 활동에 연결된 모든 프래그먼트 |
탐색 그래프 | Fragment.navGraphViewModels() |
같은 탐색 그래프의 모든 프래그먼트 |
중첩된 탐색 호스트(위 내용 참고)를 사용하고 있다면 호스트의 대상은 navGraphViewModels()
를 사용할 때 ViewModel
s를 호스트 외부 대상과 공유할 수 없습니다. 그래프가 연결되어 있지 않기 때문입니다. 이 경우 활동 범위를 대신 사용할 수 있습니다.
끌어올린 상태
Compose에서는 상태 끌어올리기를 통해 창 크기가 변경되는 동안 상태를 유지할 수 있습니다. 컴포저블 상태를 컴포지션 트리에서 더 높은 위치로 끌어올려 컴포저블이 더 이상 표시되지 않는 동안에도 상태를 유지할 수 있습니다.
위의 대체 레이아웃이 있는 콘텐츠 대상의 Compose 섹션에서 목록 세부정보 뷰 컴포저블의 상태를 ListDetailRoute
로 끌어올려 어떤 컴포저블이 표시되는지와 상관없이 상태가 유지되도록 했습니다.
@Composable
fun ListDetailRoute(
// Indicates that the display size is represented by the expanded window size class.
isExpandedWindowSize: Boolean = false,
// Identifies the item selected from the list. If null, a item has not been selected.
selectedItemId: String?,
) { /*...*/ }
추가 리소스
추천 서비스
- 참고: JavaScript가 사용 중지되어 있으면 링크 텍스트가 표시됩니다.
- Jetpack Navigation을 Navigation Compose로 이전
- Compose를 사용하여 탐색
- 동적 탐색으로 적응형 앱 빌드