적응형 UI용 탐색 구현

탐색은 사용자가 앱 내의 다양한 콘텐츠를 탐색하여 원하는 콘텐츠로 이동하고 콘텐츠에서 나올 수 있는 상호작용을 의미합니다. Compose의 적응형 UI는 근본적으로 탐색 프로세스를 변경하지 않으며 개발자는 여전히 탐색 원칙을 모두 준수해야 합니다. 탐색 구성요소를 사용하면 권장 패턴을 쉽게 적용할 수 있고 우수한 적응형 레이아웃이 있는 앱에서 계속 사용할 수 있습니다.

위의 원칙 외에도 적응형 레이아웃을 사용하는 앱에서 뛰어난 사용자 환경을 제공하기 위한 고려사항이 몇 가지 더 있습니다. 적응형 레이아웃 빌드 가이드에 설명된 대로 UI 구조는 앱에서 사용할 수 있는 공간에 따라 다를 수 있습니다. 이러한 추가 탐색 원칙은 모두 앱에서 사용할 수 있는 화면 공간이 변경될 때 발생하는 상황을 고려합니다.

반응형 탐색 UI

사용자에게 가능한 최상의 탐색 환경을 제공하려면 앱에서 사용할 수 있는 공간에 맞춤설정된 탐색 UI를 제공해야 합니다. 하단 앱 바나 항상 표시되거나 접을 수 있는 탐색 창, 레일, 사용할 수 있는 화면 공간과 앱의 고유한 스타일에 기반한 완전히 새로운 것을 사용하는 것이 좋습니다.

이러한 구성요소는 화면의 전체 너비나 높이를 차지하므로 사용해야 하는 구성요소를 결정하는 로직은 화면 수준 레이아웃 결정입니다. 따라서 표시할 탐색 UI의 유형을 결정하는 데는 창 크기 클래스를 사용하는 것이 좋습니다. 창 크기 클래스는 단순성과 가장 고유한 사례에 맞게 앱을 최적화하는 유연성 사이에서 균형을 이루도록 설계된 중단점입니다.

enum class WindowSizeClass { Compact, Medium, Expanded }

@Composable
fun MyApp(windowSizeClass: WindowSizeClass) {
    // Perform logic on the size class to decide whether to show
    // the nav rail
    val showNavRail = windowSizeClass != WindowSizeClass.Compact
    MyScreen(
        showNavRail = showNavRail,
        /* ... */
    )
}

완전한 반응형 대상

Chrome OS의 멀티 윈도우 모드, 폴더블, 자유 형식 창으로 인해 앱에서 사용할 수 있는 공간이 그 어느 때보다 많이 변경될 수 있습니다.

원활한 사용자 환경을 제공하려면 탐색 호스트 내에서 각 대상이 반응형단일 탐색 그래프를 사용하세요. 이 접근 방식은 반응형 UI의 주요 원칙인 유연성과 연속성을 강화합니다. 개별 대상이 크기 조절 이벤트를 적절하게 처리하면 변경사항은 UI에만 적용되고 나머지 앱 상태(탐색 포함)는 유지되므로 연속성이 지원됩니다.

병렬 탐색 그래프를 사용하면 앱이 다른 크기 클래스로 전환될 때마다 다른 그래프에서 사용자의 현재 대상을 확인하고 백 스택을 재구성하며 그래프 간에 다른 기타 상태 정보를 조정해야 합니다. 이 접근 방식은 복잡하며 오류가 발생하기 쉽습니다.

특정 대상에는 레이아웃을 반응형으로 만드는 여러 옵션이 있습니다. 간격을 조정하거나 대체 레이아웃을 사용하거나 정보 열을 추가하여 공간을 더 많이 사용하거나 작은 공간에 적합하지 않은 추가 세부정보를 표시할 수 있습니다. 이러한 변경사항을 구현하는 데 사용할 수 있는 도구에 관한 자세한 내용은 적응형 레이아웃 빌드를 참고하세요.

더 나은 사용자 환경을 위해 목록/세부정보 보기와 같은 대형 화면 표준 레이아웃을 사용하여 특정 대상에 콘텐츠를 더 추가할 수 있습니다. 이러한 디자인의 탐색 고려사항은 아래에 설명되어 있습니다.

경로와 화면 구별

탐색 구성요소를 사용하면 경로를 정의할 수 있으며 각 경로는 대상에 상응합니다. 탐색하면 현재 표시되는 대상이 변경되고 백 스택(사용자가 이전에 있던 대상 목록)이 추적됩니다.

특정 대상에서 원하는 콘텐츠를 표시할 수 있습니다. 앱의 기본 탐색을 처리하는 NavHost의 경우 일반적으로 각 대상에 다른 화면을 표시하므로 앱에서 사용할 수 있는 전체 공간을 차지합니다.

각 대상은 단일 화면을 표시하는 역할을 하고 각 화면은 한 대상에만 표시되는 것이 가장 일반적이나 엄격한 요구사항은 아닙니다. 실제로 앱에서 사용할 수 있는 크기에 따라 표시할 여러 화면 중에서 대상이 선택되도록 하는 것이 매우 유용할 수 있습니다.

공식 Compose 샘플 중 하나인 JetNews를 살펴보겠습니다. 앱의 기본 기능은 사용자가 목록에서 선택할 수 있는 기사를 표시하는 것입니다. 앱에 공간이 충분하면 목록과 기사를 모두 동시에 표시할 수 있습니다. 다음 인터페이스는 머티리얼 디자인 표준 레이아웃 중 하나인 목록/세부정보 레이아웃입니다.

JetNews의 목록, 세부정보, 목록 + 세부정보 화면

이 이미지 3개는 시각적으로 구별되는 화면이지만 앱은 세 화면을 모두 같은 "home" 경로 아래에 표시합니다.

코드에서 대상은 HomeRoute를 호출합니다.

@Composable
fun JetnewsNavGraph(
    navController: NavHostController,
    isExpandedScreen: Boolean,
    // ...
) {
    // ...
    NavHost(
        navController = navController,
        startDestination = JetnewsDestinations.HomeRoute
    ) {
        composable(JetnewsDestinations.HomeRoute) {
            // ...
            HomeRoute(
                isExpandedScreen = isExpandedScreen,
                // ...
            )
        }
        // ...
    }
}

그런 다음 HomeRoute 코드는 각각 Screen 접미사가 붙은 컴포저블인 세 화면 중 표시할 화면을 결정합니다. 앱에서는 HomeViewModel에 저장된 앱 상태와 현재 사용 가능한 공간을 설명하는 창 크기 클래스 조합에 기반하여 결정을 내립니다.

@Composable
fun HomeRoute(
    // if the window size class is expanded
    isExpandedScreen: Boolean,
    // if the user is focused on the selected article
    isArticleOpen: Boolean,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(/* ... */)
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(/* ... */)
        } else {
            HomeListScreen(/* ... */)
        }
    }
}

이 접근 방식을 통해 앱은 NavController)에서 navigate()를 호출하여 전체 HomeRoute를 다른 대상으로 대체하는 탐색 작업을 이 대상 내 콘텐츠에만 영향을 미치는 탐색 작업(예: 목록에서 기사 선택)과 명확하게 구분합니다. 앱에서 단일 창만 표시하는 경우 목록과 기사 화면 간 전환이 사용자에게 탐색 작업으로 보이더라도 모든 창 크기에 적용되는 공유 상태를 업데이트하여 이러한 이벤트를 처리하는 것이 좋습니다.

따라서 목록에서 기사를 탭하면 부울 플래그 isArticleOpen:이 업데이트됩니다.

class HomeViewModel(/* ... */) {
    fun selectArticle(articleId: String) {
        viewModelState.update {
            it.copy(
                isArticleOpen = true,
                selectedArticleId = articleId
            )
        }
    }
}

@Composable
fun HomeRoute(
    isExpandedScreen: Boolean,
    isArticleOpen: Boolean,
    selectedArticleId: String,
    onSelectArticle: (String) -> Unit,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(
            selectedArticleId = selectedArticleId,
            onSelectArticle = onSelectArticle,
            // ...
        )
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                selectedArticleId = selectedArticleId,
                // ...
            )
        } else {
            HomeListScreen(
                onSelectArticle = onSelectArticle,
                // ...
            )
        }
    }
}

마찬가지로 기사 화면만 표시되면 맞춤 BackHandler를 설치하여 isArticleOpen가 다시 false로 설정됩니다.

class HomeViewModel(/* ... */) {
    fun onArticleBackPress() {
        viewModelState.update {
            it.copy(isArticleOpen = false)
        }
    }
}

@Composable
fun HomeRoute(
    isExpandedScreen: Boolean,
    isArticleOpen: Boolean,
    selectedArticleId: String,
    onSelectArticle: (String) -> Unit,
    onArticleBackPress: () -> Unit,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(/* ... */)
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                selectedArticleId = selectedArticleId,
                onUpPressed = onArticleBackPress,
                // ...
            )
            BackHandler {
                onArticleBackPress()
            }
        } else {
            HomeListScreen(/* ... */)
        }
    }
}

이러한 레이어링은 Compose 앱을 설계할 때 여러 중요한 개념을 통합합니다. 화면을 재사용 가능하게 하고 중요한 상태를 끌어올릴 수 있도록 하여 전체 화면을 쉽게 교체할 수 있습니다. ViewModel의 앱 상태를 사용 가능한 크기 정보와 결합함으로써 표시할 화면을 결정하는 작업에는 간단한 로직이 적용됩니다. 마지막으로 단방향 데이터 흐름을 유지함으로써 적응형 UI는 항상 사용 가능한 공간을 활용하면서 사용자의 상태를 유지합니다.

전체 구현은 GitHub의 JetNews 샘플을 확인하세요.

사용자의 상태 유지

적응형 UI에서 가장 중요한 고려사항은 기기가 회전하거나 접히거나 또는 앱의 창 크기가 조절될 때 사용자의 상태를 유지하는 것입니다. 특히 이러한 크기 조절은 모두 되돌릴 수 있어야 합니다.

예를 들어 사용자가 앱에서 화면을 보다가 기기를 회전한다고 가정해 보겠습니다. 회전을 실행취소, 즉 기기를 시작한 위치로 다시 회전하면 상태가 모두 유지된 채로 시작했던 정확히 같은 화면으로 돌아가야 합니다. 회전하기 전에 콘텐츠를 어느 정도 스크롤했다면 다시 회전한 후 같은 스크롤 위치로 돌아가야 합니다.

회전 후 목록 스크롤 위치 저장

방향 변경 및 창 크기 조절로 인해 구성이 변경되므로 기본적으로 Activity와 컴포저블이 다시 생성됩니다. 상태는 rememberSaveable 또는 ViewModel를 사용하여 이러한 구성 변경을 통해 저장할 수 있습니다. 자세한 내용은 상태 및 Jetpack Compose를 참고하세요. 이러한 도구를 사용하지 않으면 사용자의 상태가 손실됩니다.

적응형 레이아웃은 추가 상태를 보유하는 경향이 있습니다. 다양한 화면 크기에 여러 콘텐츠를 표시할 수 있기 때문입니다. 따라서 더 이상 표시되지 않는 구성요소의 경우에도 이러한 추가 콘텐츠의 사용자 상태를 저장하는 것도 중요합니다.

일부 스크롤 콘텐츠가 너비가 더 넓을 때만 표시된다고 가정해 보겠습니다. 회전으로 인해 너비가 너무 좁아져 스크롤 콘텐츠를 표시할 수 없으면 스크롤 콘텐츠가 숨겨집니다. 사용자가 기기를 다시 회전하면 스크롤 콘텐츠가 다시 표시되고 원래 스크롤 위치가 복원되어야 합니다.

회전하는 동안 세부정보 스크롤 위치 저장

Compose에서는 상태 끌어올리기를 사용하면 됩니다. 컴포지션 트리에서 컴포저블 상태를 더 높게 끌어올려 컴포저블이 더 이상 표시되지 않는 동안에도 상태를 유지할 수 있습니다.

JetNews에서는 상태를 HomeRoute로 끌어올려 표시되는 화면을 변경하는 동안 상태를 유지하고 재사용합니다.

@Composable
fun HomeRoute(
    // if the window size class is expanded
    isExpandedScreen: Boolean,
    // if the user is focused on the selected article
    isArticleOpen: Boolean,
    selectedArticleId: String,
    // ...
) {
    val homeListState = rememberHomeListState()
    val articleState = rememberSaveable(
        selectedArticleId,
        saver = ArticleState.Saver
    ) {
        ArticleState()
    }

    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(
            homeListState = homeListState,
            articleState = articleState,
            // ...
        )
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                articleState = articleState,
                // ...
            )
        } else {
            HomeListScreen(
                homeListState = homeListState,
                // ...
            )
        }
    }
}

크기 변경의 부수 효과로 탐색 방지

큰 디스플레이에서 제공할 수 있는 추가 공간을 활용하는 화면을 앱에 추가하면 새로 디자인된 레이아웃을 위해 앱에 새 대상을 추가하려고 할 수 있습니다.

그러나 사용자가 폴더블 기기의 내부 화면에서 이 새로운 레이아웃을 보고 있다고 가정해 보겠습니다. 사용자가 기기를 접으면 외부 화면에 새 레이아웃을 표시할 공간이 충분하지 않을 수 있습니다. 이로 인해 새 화면 크기가 너무 작으면 다른 곳으로 이동해야 하는 요구사항이 생겨납니다. 여기에는 몇 가지 문제가 있습니다.

  • 컴포지션의 부수 효과로 탐색하면 이전 대상이 일시적으로 표시될 수 있습니다. 탐색이 발생하기 전에 표시되어야 하기 때문입니다.
  • 가역성을 유지하기 위해 기기를 펼 때 뒤로 이동해야 할 수도 있습니다.
  • 이러한 변경 간에 사용자 상태를 유지하기는 매우 어렵습니다. 탐색 시 백 스택이 표시될 때 이전 상태가 완전히 삭제될 수 있기 때문입니다.

추가 고려사항으로는 이러한 변경이 발생하는 동안 앱이 포그라운드에 있지 않을 수도 있다는 점입니다. 앱에서 공간이 더 많이 필요한 레이아웃을 표시할 수 있고 그러면 사용자는 앱을 백그라운드에 배치합니다. 사용자가 나중에 앱으로 돌아오면 앱이 마지막으로 재개된 이후 방향과 크기, 실제 화면이 모두 변경되었을 수 있습니다.

특정 화면 크기의 대상만 표시하고 싶다면 관련 대상을 단일 경로로 결합하고 위에서 설명한 대로 이 경로에 여러 화면을 표시하는 것이 좋습니다.