반응형 UI 탐색

탐색은 애플리케이션의 UI와 상호작용하여 앱의 콘텐츠 대상에 액세스하는 프로세스입니다. Android의 탐색 원리는 일관되고 직관적인 앱 탐색을 만드는 데 도움이 되는 가이드라인을 제공합니다.

반응형 UI는 반응형 콘텐츠 대상을 제공하며, 디스플레이 크기 변경에 반응하여 다양한 유형의 탐색 요소(예: 작은 디스플레이의 하단 탐색 메뉴나 중간 크기 디스플레이의 탐색 레일, 큰 디스플레이의 지속적 탐색 창)를 포함하는 경우가 많습니다. 단, 반응형 UI도 탐색 원리를 준수해야 합니다.

Jetpack 탐색 구성요소는 탐색 원리를 구현하며 반응형 UI로 앱 개발을 용이하게 하는 데 사용할 수 있습니다.

그림 1. 탐색 창, 레일, 하단 탐색 메뉴가 있는 확장, 중형 및 소형 디스플레이

반응형 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, 작은 창 크기의 경우 목록 항목을 선택했는지 여부에 따라 ListOfItemsItemDetail을 내보냅니다.

경로는 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.activityViewModels()

활동과 활동에 연결된 모든 프래그먼트
탐색 그래프 Fragment.navGraphViewModels() 같은 탐색 그래프의 모든 프래그먼트

중첩된 탐색 호스트(위 내용 참고)를 사용하고 있다면 호스트의 대상은 navGraphViewModels()를 사용할 때 ViewModels를 호스트 외부 대상과 공유할 수 없습니다. 그래프가 연결되어 있지 않기 때문입니다. 이 경우 활동 범위를 대신 사용할 수 있습니다.

끌어올린 상태

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?,
) { /*...*/ }

추가 리소스