적응형 레이아웃 빌드하기

앱 UI는 다양한 화면 크기, 방향, 폼 팩터를 처리할 수 있도록 반응해야 합니다. 적응형 레이아웃은 사용할 수 있는 화면 공간에 따라 변경됩니다. 이러한 변경은 간단한 레이아웃 조정을 통해 공간을 채우는 것에서부터 레이아웃을 완전히 바꿔 추가 공간을 활용하는 것까지 다양합니다.

Jetpack Compose는 선언형 UI 도구 키트로 레이아웃 자체를 조정하여 다양한 크기에 맞게 콘텐츠를 렌더링하는 레이아웃을 설계하고 구현하는 데 매우 적합합니다. 이 문서에는 Compose를 사용하여 UI를 반응형 UI로 만드는 방법에 관한 가이드라인이 포함되어 있습니다.

화면 수준 컴포저블의 대규모 레이아웃 변경을 명시하기

Compose를 사용하여 전체 애플리케이션을 배치하면 앱 수준 및 화면 수준의 컴포저블은 렌더링을 위해 제공된 앱의 모든 공간을 차지합니다. 설계 시 이 수준에서는 더 큰 화면을 활용하도록 화면의 전체 레이아웃을 변경하는 것이 좋습니다.

레이아웃을 결정할 때 실제 하드웨어 값을 사용하지 않습니다. 실재하는 고정된 값을 기반으로 결정하고 싶을 수 있지만(기기가 태블릿인가요? 실제 화면의 가로세로 비율이 일정한가요?), 이러한 질문에 대한 답변은 UI가 사용할 수 있는 공간을 결정하는 데 유용하지 않을 수 있습니다.

다양한 기기의 폼 팩터(스마트폰, 폴더블, 태블릿, 노트북)를 보여주는 다이어그램

태블릿에서는 앱이 멀티 윈도우 모드에서 실행될 수 있습니다. 즉, 앱이 다른 앱과 화면을 분할할 수 있습니다. ChromeOS에서는 앱이 크기 조절 가능한 창에 있을 수 있습니다. 폴더블 기기와 같이 실제 화면이 두 개 이상 있을 수도 있습니다. 이러한 경우는 모두 실제 화면 크기와 콘텐츠 표시 방법을 결정하는 것 사이에 아무 관련이 없습니다.

대신, Jetpack WindowManager 라이브러리에서 제공하는 현재 창 측정항목과 같이 앱에 할당된 화면의 실제 부분에 따라 결정해야 합니다. Compose 앱에서 WindowManager를 사용하는 방법을 알아보려면 JetNews 샘플을 확인하세요.

이 방법을 따르면 위의 모든 시나리오에서 앱이 제대로 작동하기 때문에 앱이 더 유연해집니다. 레이아웃을 사용할 수 있는 화면 공간에 맞게 조정하면 ChromeOS와 같은 플랫폼과 태블릿 및 폴더블과 같은 폼 팩터를 지원하는 특수 처리의 양도 줄어듭니다.

앱에 사용할 수 있는 관련 공간을 관찰한 후에는 창 크기 클래스에 설명된 대로 원시 크기를 의미 있는 크기의 클래스로 변환하는 것이 좋습니다. 이는 표준 크기 버킷으로 크기를 그룹화합니다. 이 버킷은 가장 고유한 사례에 맞게 앱을 최적화하는 유연성과 단순성 사이의 균형을 맞추기 위해 설계된 중단점입니다. 이러한 크기 클래스는 앱의 전체 창을 참조하므로 전체 화면 레이아웃에 영향을 주는 레이아웃 결정에 이 클래스를 사용합니다. 이러한 크기 클래스를 상태로 전달하거나 추가 로직을 실행하여 중첩된 컴포저블에 전달하기 위한 파생 상태를 만들 수 있습니다.

class MainActivity : ComponentActivity() {
    @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            val windowSizeClass = calculateWindowSizeClass(this)
            MyApp(windowSizeClass)
        }
    }
}
@Composable
fun MyApp(windowSizeClass: WindowSizeClass) {
    // Perform logic on the size class to decide whether to show
    // the top app bar.
    val showTopAppBar = windowSizeClass.heightSizeClass != WindowHeightSizeClass.Compact

    // MyScreen knows nothing about window sizes, and performs logic
    // based on a Boolean flag.
    MyScreen(
        showTopAppBar = showTopAppBar,
        /* ... */
    )
}

이렇게 계층화된 접근 방식은 화면 크기 로직을 동기화를 유지해야 하는 앱의 여러 위치에 분산하는 대신 단일 위치로 제한합니다. 이 단일 위치는 상태를 생성하며, 이 상태는 다른 앱 상태와 마찬가지로 다른 컴포저블에 명시적으로 전달될 수 있습니다. 명시적으로 상태를 전달하면 개별 컴포저블이 간소화됩니다. 이러한 컴포저블은 크기 클래스 또는 다른 데이터와 함께 지정된 구성을 사용하는 일반적인 구성 가능한 함수일 뿐이기 때문입니다.

유연한 중첩 컴포저블은 재사용 가능함

컴포저블은 다양한 위치에 배치할 수 있을 때 더 많이 재사용할 수 있습니다. 컴포저블이 항상 특정 크기로 특정 위치에 배치된다고 가정하면 다른 위치 또는 가용 공간의 크기가 다른 위치에서는 재사용하기가 더 어려워집니다. 즉, 재사용 가능한 개별 컴포저블은 '전역' 크기 정보에 암시적으로 종속되는 것을 피해야 합니다.

한 가지 예를 살펴보겠습니다. 목록 세부정보 레이아웃을 구현하는 중첩된 컴포저블이 있고 이 컴포저블이 하나의 창 또는 두 개의 창을 나란히 표시한다고 가정해 보세요.

두 창을 나란히 표시하는 앱의 스크린샷

그림 1. 일반적인 목록/세부정보 레이아웃을 보여주는 앱의 스크린샷 1은 목록 영역이고 2는 세부정보 영역입니다.

이 결정을 앱의 전체 레이아웃의 일부로 만들고자 하므로 위에서 본 것처럼 화면 수준의 컴포저블에서 결정을 전달합니다.

@Composable
fun AdaptivePane(
    showOnePane: Boolean,
    /* ... */
) {
    if (showOnePane) {
        OnePane(/* ... */)
    } else {
        TwoPane(/* ... */)
    }
}

대신 컴포저블이 사용할 수 있는 공간에 따라 독립적으로 레이아웃을 변경하도록 하려면 어떻게 해야 할까요? 예를 들어 공간이 허용되는 경우 추가 세부정보를 표시하려는 카드가 있습니다. 사용 가능한 일부 크기를 기반으로 로직을 실행하려고 하지만 구체적으로 어떤 크기일까요?

두 가지 카드의 예: 아이콘과 제목만 보여주는 좁은 카드와 아이콘, 제목, 짧은 설명을 보여주는 더 넓은 카드

위에서 본 것처럼 기기의 실제 화면 크기를 사용하지 않아야 합니다. 이 작업은 여러 화면에서 정확하지 않을 수 있으며 앱이 전체 화면이 아닌 경우에도 정확하지 않을 수 있습니다.

이 컴포저블은 화면 수준의 컴포저블이 아니므로 재사용성을 최대화하기 위해 현재 창 측정항목을 직접 사용해서도 안 됩니다. 구성요소가 패딩과 함께 배치되거나(예: 인셋) 탐색 레일 또는 앱 바와 같은 구성요소가 있는 경우 컴포저블에 사용할 수 있는 공간의 양은 앱에 사용할 수 있는 전체 공간과 크게 다를 수 있습니다.

따라서 컴포저블이 컴포저블 자체를 렌더링하는 데 실제로 제공되는 너비를 사용해야 합니다. 이 너비를 얻는 방법은 두 가지가 있습니다.

콘텐츠가 표시되는 위치 또는 방법을 변경하려면 수정자 모음 또는 맞춤 레이아웃을 사용하여 레이아웃을 반응형으로 만들면 됩니다. 이는 일부 하위 요소가 사용 가능한 공간을 모두 채우도록 하거나, 충분한 공간이 있는 경우 여러 개의 열로 하위 요소를 배치하는 작업만큼 간단한 작업일 수 있습니다.

표시되는 내용을 변경하려면 더 강력한 대안으로 BoxWithConstraints를 사용하면 됩니다. 이 컴포저블은 사용 가능한 공간에 따라 다양한 컴포저블을 호출하는 데 사용할 수 있는 측정 제약 조건을 제공합니다. 하지만 이는 알려진 제약 조건이 있을 때 BoxWithConstraints가 레이아웃 단계까지 구성을 지연하여 레이아웃 중에 더 많은 작업이 진행되도록 하므로 비용이 발생합니다.

@Composable
fun Card(/* ... */) {
    BoxWithConstraints {
        if (maxWidth < 400.dp) {
            Column {
                Image(/* ... */)
                Title(/* ... */)
            }
        } else {
            Row {
                Column {
                    Title(/* ... */)
                    Description(/* ... */)
                }
                Image(/* ... */)
            }
        }
    }
}

모든 데이터를 다양한 크기로 사용할 수 있는지 확인

추가 화면 공간을 활용하면 대형 화면에서는 작은 화면보다 더 많은 콘텐츠를 사용자에게 보여줄 수 있습니다. 이 동작으로 컴포저블을 구현할 때는 효율적으로 구현하고 싶고 데이터를 현재 크기의 부작용으로 로드하려는 경향이 있을 수 있습니다.

그러나 이는 단방향 데이터 흐름의 원칙에 어긋납니다. 이 흐름에서는 데이터를 끌어올릴 수 있고 단순히 적절한 렌더링을 위해 컴포저블에 제공할 수 있습니다. 데이터의 일부가 항상 사용되지는 않더라도 모든 크기에서 표시하는 데 필요한 것을 컴포저블이 항상 보유하도록 충분한 데이터가 컴포저블에 제공되어야 합니다.

@Composable
fun Card(
    imageUrl: String,
    title: String,
    description: String
) {
    BoxWithConstraints {
        if (maxWidth < 400.dp) {
            Column {
                Image(imageUrl)
                Title(title)
            }
        } else {
            Row {
                Column {
                    Title(title)
                    Description(description)
                }
                Image(imageUrl)
            }
        }
    }
}

Card 예를 기반으로, 항상 descriptionCard에 전달합니다. 너비가 설명을 표시하도록 허용하는 경우에만 description을 사용하더라도 사용할 수 있는 너비에 상관없이 Card는 항상 이 값을 요구합니다.

항상 데이터를 전달하면 레이아웃을 덜 스테이트풀하게 만들어서 적응형 레이아웃을 더 간단하게 만들 수 있고 크기 간에 전환 시 창 크기 조절, 방향 변경 또는 기기를 접거나 펼치는 동작으로 인해 발생할 수 있는 부작용이 발생하지 않습니다.

또한, 이 원칙을 사용하면 레이아웃을 변경해도 상태를 유지할 수 있습니다. 일부 크기에서 사용되지 않을 수도 있는 정보를 호이스팅하여 레이아웃 크기가 변경될 때 사용자의 상태를 유지할 수 있습니다. 예를 들어, 크기 조절로 인해 레이아웃이 설명 숨기기와 설명 표시하기 간에 전환될 때 사용자 상태가 유지되도록 showMore 불리언 플래그를 호이스팅할 수 있습니다.

@Composable
fun Card(
    imageUrl: String,
    title: String,
    description: String
) {
    var showMore by remember { mutableStateOf(false) }

    BoxWithConstraints {
        if (maxWidth < 400.dp) {
            Column {
                Image(imageUrl)
                Title(title)
            }
        } else {
            Row {
                Column {
                    Title(title)
                    Description(
                        description = description,
                        showMore = showMore,
                        onShowMoreToggled = { newValue ->
                            showMore = newValue
                        }
                    )
                }
                Image(imageUrl)
            }
        }
    }
}

자세히 알아보기

Compose의 맞춤 레이아웃에 관한 자세한 내용은 다음 추가 리소스를 참고하세요.

샘플 앱

  • 대형 화면 표준 레이아웃은 대형 화면 기기에서 최적의 사용자 환경을 제공하는 검증된 디자인 패턴의 저장소입니다.
  • JetNews는 가용 공간을 활용할 수 있도록 UI를 조정하는 앱을 디자인하는 방법을 보여줍니다.
  • Reply는 모바일, 태블릿, 폴더블을 지원하는 적응형 샘플입니다.
  • Now in Android는 적응형 레이아웃을 사용하여 다양한 화면 크기를 지원하는 앱입니다.

동영상