1. 시작하기 전에
Android 기기는 다양한 모양과 크기로 제공됩니다. 따라서 단일 Android 패키지(APK) 또는 Android App Bundle(AAB)을 사용하는 여러 기기와 사용자가 이용 가능하도록 앱의 레이아웃이 서로 다른 화면 크기에 맞게 조정되도록 해야 합니다. 이렇게 하려면 특정 화면 크기와 가로세로 비율을 취하는 정적 크기로 앱을 정의하는 대신 반응형 레이아웃과 적응형 레이아웃을 사용하여 앱을 정의해야 합니다. 적응형 레이아웃은 사용할 수 있는 화면 공간에 따라 변경됩니다.
이 Codelab에서는 적응형 UI를 빌드하고, 스포츠 목록과 각 스포츠의 세부정보를 표시하는 앱이 대형 화면 기기를 지원하도록 조정하는 방법의 기본사항을 배웁니다. Sports 앱은 홈, 즐겨찾기, 설정의 세 가지 화면으로 구성됩니다. 홈 화면에는 스포츠 목록과 목록에서 스포츠를 선택할 경우 볼 수 있는 뉴스의 자리표시자가 표시됩니다. 즐겨찾기 화면과 설정 화면에도 자리표시자 텍스트가 표시됩니다. 하단 탐색 메뉴에서 연결 항목을 선택하여 화면을 전환할 수 있습니다.
대형 화면에서는 앱에 다음과 같은 레이아웃 문제가 발생합니다.
- 세로 방향으로 앱을 사용할 수 없습니다.
- 대형 화면에 빈 공간이 많이 표시됩니다.
- 대형 화면에는 항상 하단 탐색 메뉴가 표시됩니다.
적응형 앱을 만들면 다음과 같은 장점이 있습니다.
- 가로 모드 및 세로 모드 방향을 지원합니다.
- 가로 공간이 충분하면 스포츠 목록과 각 스포츠의 뉴스를 나란히 표시합니다.
- Material Design 가이드라인에 따라 탐색 구성요소를 표시합니다.
이 앱은 프래그먼트가 몇 개 있는 단일 활동 앱입니다. 다음 파일로 작업하게 됩니다.
AndroidManifest.xml
파일: Sports 앱에 관한 메타데이터를 제공합니다.MainActivity.kt
파일:activity_main.xml
파일로 생성된 코드와override
주석, 너비 창-클래스 크기를 나타내는enum
클래스, 앱 창의 너비 창-크기 클래스를 가져오기 위한 메서드 정의가 포함되어 있습니다. 하단 탐색 메뉴는 활동이 만들어질 때 초기화됩니다.activity_main.xml
파일:Main
활동의 기본 레이아웃을 정의합니다.layout-sw600dp/activity_main.xml
파일:Main
활동의 대체 레이아웃을 정의합니다. 대체 레이아웃은 앱의 창 너비가600dp
값보다 크거나 같을 때 효과적입니다. 콘텐츠는 시작점의 기본 레이아웃과 동일합니다.SportsListFragment.kt
파일: 스포츠-목록 구현과 맞춤 뒤로 탐색이 포함되어 있습니다.fragment_sports_list.xml
파일: 스포츠 목록의 레이아웃을 정의합니다.navigation_menu.xml
파일: 하단 탐색 메뉴 항목을 정의합니다.
그림 1. 단일 APK 또는 AAB를 사용하는 Sports 앱은 다양한 창 크기를 지원합니다.
기본 요건
- 뷰 기반 UI 개발에 관한 기본 지식
- 람다 함수를 포함한 Kotlin 구문 사용 경험
- Jetpack Compose 기본사항 Codelab 완료
학습할 내용
- 구성 변경을 지원하는 방법
- 코드를 거의 수정하지 않고 대체 레이아웃을 추가하는 방법
- 다양한 창 크기에 따라 다르게 동작하는 목록 세부정보 UI를 구현하는 방법
빌드할 항목
다음을 지원하는 Android 앱
- 가로 모드 기기 방향
- 태블릿, 데스크톱 및 휴대기기
- 다양한 화면 크기의 목록 세부정보 동작
필요한 항목
- Android 스튜디오 Bumblebee | 2021.1.1 이상
- Android 태블릿 또는 에뮬레이터
2. 설정
다음과 같이 이 Codelab의 코드를 다운로드하고 프로젝트를 설정합니다.
- 명령줄에서 이 GitHub 저장소의 코드를 클론합니다.
$ git clone https://github.com/android/add-adaptive-layouts
- Android 스튜디오에서
AddingAdaptiveLayout
프로젝트를 엽니다. 프로젝트는 여러 git 브랜치로 빌드됩니다.
main
브랜치에는 이 프로젝트의 시작 코드가 포함되어 있습니다. 이 브랜치를 변경하여 Codelab을 완료하게 됩니다.end
브랜치에는 이 Codelab의 솔루션이 포함되어 있습니다.
3. 상단 탐색 구성요소를 Compose로 이전
Sports 앱은 하단 탐색 메뉴를 상단 탐색 구성요소로 사용합니다. 이 탐색 구성요소는 BottomNavigationView
클래스로 구현됩니다. 이 섹션에서는 상단 탐색 구성요소를 Compose로 이전합니다.
연결된 메뉴 리소스의 콘텐츠를 봉인 클래스로 표현
navigation_menu.xml
파일의 콘텐츠를 표시하기 위한 봉인MenuItem
클래스를 만들고 이를iconId
매개변수,labelId
매개변수,destinationId
매개변수에 전달합니다.Home
객체,Favorite
객체,Settings
객체를 대상에 해당하는 서브클래스로 추가합니다.
MenuItem.kt
sealed class MenuItem(
// Resource ID of the icon for the menu item
@DrawableRes val iconId: Int,
// Resource ID of the label text for the menu item
@StringRes val labelId: Int,
// ID of a destination to navigate users
@IdRes val destinationId: Int
) {
object Home: MenuItem(
R.drawable.ic_baseline_home_24,
R.string.home,
R.id.SportsListFragment
)
object Favorites: MenuItem(
R.drawable.ic_baseline_favorite_24,
R.string.favorites,
R.id.FavoritesFragment
)
object Settings: MenuItem(
R.drawable.ic_baseline_settings_24,
R.string.settings,
R.id.SettingsFragment
)
}
하단 탐색 메뉴를 구성 가능한 함수로 구현
- 세 가지 매개변수를 사용하는 구성 가능한
BottomNavigationBar
함수를 정의합니다. 세 가지 매개변수에 해당하는 객체는List<MenuItem>
값으로 설정되는menuItems
객체와Modifier = Modifier
값으로 설정되는modifier
객체,(MenuItem) -> Unit = {}
람다 함수로 설정되는onMenuSelected
객체입니다. - 구성 가능한
BottomNavigationBar
함수의 본문에서modifier
매개변수를 사용하는NavigationBar()
함수를 호출합니다. NavigationBar()
함수에 전달된 람다 함수에서menuItems
매개변수에 관해forEach()
메서드를 호출한 다음,foreach()
메서드 호출에 설정된 람다 함수에서NavigationBarItem()
함수를 호출합니다.NavigationBarItem()
함수를false
값으로 설정된selected
매개변수에 전달합니다.onClick
매개변수는MenuItem
매개변수를 갖는onMenuSelected
함수가 포함된 람다 함수로 설정되고,icon
매개변수는painter = painterResource(id = menuItem.iconId)
매개변수와contentDescription = null
매개변수를 취하는Icon
함수가 포함된 람다 함수로 설정되며,label
매개변수는(text = stringResource(id = menuItem.labelId)
매개변수를 취하는Text
함수가 포함된 람다 함수로 설정되었습니다.
Navigation.kt
@Composable
fun BottomNavigationBar(
menuItems: List<MenuItem>,
modifier: Modifier = Modifier,
onMenuSelected: (MenuItem) -> Unit = {}
) {
NavigationBar(modifier = modifier) {
menuItems.forEach { menuItem ->
NavigationBarItem(
selected = false,
onClick = { onMenuSelected(menuItem) },
icon = {
Icon(
painter = painterResource(id = menuItem.iconId),
contentDescription = null)
},
label = { Text(text = stringResource(id = menuItem.labelId))}
)
}
}
}
BottomNavigationView
요소를 레이아웃 리소스 파일의 ComposeView
요소로 대체
activity_main.xml
파일에서BottomNavigationView
요소를ComposeView
요소로 바꿉니다. 이렇게 하면 Compose에서 하단 탐색 구성요소가 뷰 기반 UI 레이아웃에 삽입됩니다.
activity_main.xml
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment_content_main"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@id/top_navigation"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/nav_graph" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/navigation"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/top_navigation" />
</androidx.constraintlayout.widget.ConstraintLayout>
Compose 기반의 하단 탐색 메뉴를 뷰 기반 UI 레이아웃과 통합
MainActivity.kt
파일에서MenuItem
객체 목록으로 설정된navigationMenuItems
변수를 정의합니다.MenuItems
객체는 하단 탐색 메뉴에 목록 순서대로 표시됩니다.BottomNavigationBar()
함수를 호출하여ComposeView
객체에 하단 탐색 메뉴를 삽입합니다.BottomNavigationBar
함수에 전달된 콜백 함수에서 사용자가 선택한 항목과 연결된 대상으로 이동합니다.
MainActivity.kt
val navigationMenuItems = listOf(
MenuItem.Home,
MenuItem.Favorites,
MenuItem.Settings
)
binding.navigation.setContent {
MaterialTheme {
BottomNavigationBar(menuItems = navigationMenuItems){ menuItem ->
navController.navigate(screen.destinationId)
}
}
}
4. 가로 방향 지원
앱이 대형 화면 기기를 지원하면 가로 방향과 세로 방향을 지원해야 합니다. 현재 앱에는 MainActivity
활동 한 가지만 있습니다.
기기에서 활동의 디스플레이 방향은 AndroidManifest.xml
파일에 android:screenOrientation
속성(portrait
값으로 설정됨)과 함께 설정됩니다.
가로 방향을 지원하도록 앱을 만듭니다.
android:screenOrientation
속성을fullUser
값으로 설정합니다. 이 구성을 사용하면 사용자가 화면 방향을 잠글 수 있습니다. 기기 방향 센서가 4개 방향 중에서 결정합니다.
AndroidManifest.xml
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="fullUser">
그림 2. AndroidManifest.xml
파일을 업데이트하면 앱이 가로 방향으로 실행됩니다.
5. 창 크기 클래스
앱에 사용할 수 있는 원시 창 크기를 사용하여, 창 크기를 사전 정의된 크기 클래스(작게, 보통, 펼침)로 분류하는 데 도움이 되는 중단점 값입니다. 적응형 레이아웃을 디자인, 개발, 테스트할 때 이러한 크기 클래스를 사용할 수 있습니다.
사용 가능한 너비와 높이는 개별적으로 파티션이 나뉘므로 언제라도 앱에는 두 가지 창 크기 클래스(너비 창 크기 클래스, 높이 창 크기 클래스)가 연결되어 있습니다.
그림 3. 너비 창 크기 클래스와 연결된 중단점
그림 4. 높이 창 크기 클래스와 연결된 중단점
창 크기 클래스는 앱의 현재 창 크기를 나타냅니다. 즉, 실제 기기 크기로 창 크기 클래스를 결정할 수 없습니다. 앱이 같은 기기에서 실행되더라도 연결된 창 크기 클래스는 구성에 따라 변경됩니다(예: 화면 분할 모드에서 앱을 실행하는 경우). 이에 따른 중요한 두 가지 결과가 있습니다.
- 실제 기기는 특정 창 크기 클래스를 보장하지 않습니다.
- 창 크기 클래스는 앱의 전체 기간 동안 변경될 수 있습니다.
앱에 적응형 레이아웃을 추가한 후 작게, 보통, 펼침 창 크기 클래스를 대상으로 모든 범위의 창 크기에서 앱을 테스트합니다. 창 크기 클래스에 관한 테스트가 각각 필요하지만, 대부분의 경우 이것만으로는 충분하지 않습니다. 다양한 화면 크기로 앱을 테스트하여 UI 크기 조정이 제대로 이루어지도록 하는 것이 중요합니다. 자세한 내용은 대형 화면 레이아웃 및 대형 화면 앱 품질을 참고하세요.
6. 대형 화면에 목록 창과 세부정보 창을 나란히 배치
목록 세부정보 UI는 현재 너비 창 크기 클래스에 따라 다르게 작동해야 할 수 있습니다. 보통 또는 펼침 너비 창 크기 클래스가 앱과 연결되면 앱에 목록 창과 세부정보 창을 나란히 표시하기에 충분한 공간이 있다는 의미입니다. 따라서 사용자는 화면 전환 없이 항목 목록과 선택한 항목의 세부정보를 볼 수 있습니다. 하지만 작은 화면에서는 이렇게 하면 너무 복잡할 수 있습니다. 작은 화면에서는 처음에는 목록 창을 표시하고 한 번에 창 하나씩 표시하는 것이 더 나을 수 있습니다. 세부정보 창에는 사용자가 목록에서 항목을 탭할 때 선택한 항목의 세부정보가 표시됩니다. SlidingPaneLayout
클래스는 두 사용자 환경 중에서 현재 창 크기에 적절한 환경을 결정하는 로직을 관리합니다.
목록 창 레이아웃 구성
SlidingPaneLayout
클래스는 뷰 기반 UI 구성요소입니다. 이 Codelab에서는 목록 창의 레이아웃 리소스 파일인 fragment_sports_list.xml
파일을 수정합니다.
SlidingPaneLayout
클래스는 두 개의 하위 요소를 사용합니다. 각 하위 요소의 너비와 가중치 속성은 창이 두 뷰를 나란히 표시할 만큼 큰지 확인하는 SlidingPaneLayout
클래스의 핵심 요소입니다. 그만큼 크지 않으면 전체 화면 목록이 전체 화면 세부정보 UI로 대체됩니다. 창 크기가 창을 나란히 표시하기 위한 최소 요건보다 크면 가중치 값이 참조되어 두 창의 크기가 비례해서 조정됩니다.
SlidingPaneLayout
클래스가 fragment_sports_list.xml
파일에 적용되었습니다. 목록 창의 너비는 1280dp
가 되도록 구성됩니다. 이는 목록 창과 세부정보 창이 나란히 표시되지 않는 이유입니다.
화면 너비가 580dp
보다 클 경우 창이 나란히 표시되도록 다음과 같이 구성합니다.
RecyclerView
를280dp
너비로 설정합니다.
fragment_sports_list.xml
<androidx.slidingpanelayout.widget.SlidingPaneLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/sliding_pane_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".SportsListFragment">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="280dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:clipToPadding="false"
android:padding="8dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
<androidx.fragment.app.FragmentContainerView
android:layout_height="match_parent"
android:layout_width="300dp"
android:layout_weight="1"
android:id="@+id/detail_container"
android:name="com.example.android.sports.NewsDetailsFragment"/>
</androidx.slidingpanelayout.widget.SlidingPaneLayout>
그림 5. 레이아웃 리소스 파일을 업데이트하면 목록 창과 세부정보 창이 나란히 표시됩니다.
세부정보 창 변경
이제 보통 또는 펼침 너비 창 크기 클래스가 앱과 연결되면 목록 창과 세부정보 창이 나란히 표시됩니다. 하지만 사용자가 목록 창에서 항목을 선택하면 화면은 세부정보 창으로 완전히 전환됩니다.
그림 6. 목록에서 스포츠를 선택하면 화면이 세부정보 창으로 전환됩니다.
이 문제는 사용자가 목록 창에서 항목을 선택할 때 탐색이 트리거되어 발생합니다. 관련 코드는 SportsListFragment.kt
파일에서 확인할 수 있습니다.
SportsListFragment.kt
val adapter = SportsAdapter {
sportsViewModel.updateCurrentSport(it)
// Navigate to the details pane.
val action =
SportsListFragmentDirections.actionSportsListFragmentToNewsFragment()
this.findNavController().navigate(action)
}
목록 창과 세부정보 창을 나란히 표시할 공간이 충분하지 않은 경우에만 화면이 세부정보 창으로 완전히 전환되는지 확인합니다.
adapter
함수 변수에서SlidingPaneLayout
클래스의isSlidable
속성이 true이고SlidingPaneLayout
클래스의isOpen
속성이 false인지 확인하는if
문을 추가합니다.
SportsListFragment.kt
val adapter = SportsAdapter {
sportsViewModel.updateCurrentSport(it)
if(slidingPaneLayout.isSlidable && !slidingPaneLayout.isOpen){
// Navigate to the details pane.
val action =
SportsListFragmentDirections.actionSportsListFragmentToNewsFragment()
this.findNavController().navigate(action)
}
}
7. 너비 창 크기 클래스를 기준으로 올바른 탐색 구성요소 선택
Material Design에서는 앱이 상황에 따라 유연하게 구성요소를 선택하는 방식이 좋습니다. 이 섹션에서는 현재 너비 창 크기 클래스에 따라 상단 탐색 메뉴의 탐색 구성요소를 선택합니다. 다음 표에는 각 창 크기 클래스에 필요한 탐색 구성요소가 설명되어 있습니다.
너비 창 크기 클래스 | 탐색 구성요소 |
작게 | 하단 탐색 |
보통 | 탐색 레일 |
펼침 | 영구 탐색 창 |
탐색 레일 구현
- 세 가지 매개변수를 사용하는 구성 가능한
NavRail()
함수를 만듭니다. 그 세 가지 매개변수는List<MenuItem>
값으로 설정되는menuItems
객체와Modifier
값으로 설정되는modifier
객체, 그리고onMenuSelected
람다 함수입니다. - 함수 본문에서
modifier
객체를 매개변수로 사용하는NavigationRail()
함수를 호출합니다. BottomNavigationBar()
함수에NavigationBarItem
함수를 사용하여 한 것처럼,menuItems
객체의 각MenuItem
객체에 관해NavigationRailItem()
함수를 호출합니다.
영구 탐색 창 구현
- 세 가지 매개변수를 사용하는 구성 가능한
NavigationDrawer()
함수를 만듭니다. 그 세 가지 매개변수는List<MenuItem>
값으로 설정되는menuItems
객체와Modifier
값으로 설정되는modifier
객체, 그리고onMenuSelected
람다 함수입니다. - 함수 본문에서
modifier
객체를 매개변수로 사용하는Column()
함수를 호출합니다. menuItems
객체의 각MenuItem
객체에 관해Row()
함수를 호출합니다.Row()
함수의 본문에서BottomNavigationBar()
함수에NavigationBarItem
함수를 사용하여 한 것처럼,icon
및text
라벨을 추가합니다.
너비 창 크기 클래스를 기준으로 올바른 탐색 구성요소 선택
- 현재 너비 창 크기 클래스를 가져오려면, 구성 가능한 함수 내에서
ComposeView
객체의setContent()
메서드에 전달된rememberWidthSizeClass()
함수를 호출합니다. - 가져온 너비 창 크기 클래스에 따라 탐색 구성요소를 선택하고 선택한 탐색 구성요소를 호출하기 위한 조건부 브랜치를 만듭니다.
Modifier
객체를NavigationDrawer
함수에 전달하여 너비 값을256dp
로 지정합니다.
ActivityMain.kt:
binding.navigation.setContent {
MaterialTheme {
when(rememberWidthSizeClass()){
WidthSizeClass.COMPACT ->
BottomNavigationBar(menuItems = navigationMenuItems){ menuItem ->
navController.navigate(screen.destinationId)
}
WidthSizeClass.MEDIUM ->
NavRail(menuItems = navigationMenuItems){ menuItem ->
navController.navigate(screen.destinationId)
}
WidthSizeClass.EXPANDED ->
NavigationDrawer(
menuItems = navigationMenuItems,
modifier = Modifier.width(256.dp)
) { menuItem ->
navController.navigate(screen.destinationId)
}
}
}
}
탐색 구성요소를 적절한 위치에 배치
이제 앱은 현재 너비 창 크기 클래스에 따라 올바른 탐색 구성요소를 선택할 수 있지만 선택한 구성요소가 예상대로 배치되지는 않습니다. ComposeView
요소가 FragmentViewContainer
요소 아래에 배치되기 때문입니다.
MainActivity
클래스의 대체 레이아웃 리소스를 업데이트합니다.
layout-sw600dp/activity_main.xml
파일을 엽니다.ComposeView
요소와FragmentContainer
요소를 가로로 배치하도록 제약 조건을 업데이트합니다.
layout-sw600dp/activity_main.xml
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment_content_main"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintLeft_toRightOf="@+id/top_navigation"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:navGraph="@navigation/nav_graph" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/top_navigation"
android:layout_width="wrap_content"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/top_navigation" />
</androidx.constraintlayout.widget.ConstraintLayout>
수정 후 앱은 연결된 너비 창 크기 클래스에 따라 올바른 탐색 구성요소를 선택합니다. 첫 번째 스크린샷은 보통 너비 창 크기 클래스의 화면입니다. 두 번째 스크린샷은 펼침 너비 창 크기 클래스의 화면입니다.
그림 7. 보통 너비와 펼침 너비 창 크기 클래스의 화면.
8. 축하합니다
축하합니다. 이 Codelab을 완료하고 Compose를 사용하여 뷰 기반 Android 앱에 적응형 레이아웃을 추가하는 방법을 알아봤습니다. 그 과정에서 SlidingPaneLayout
클래스와 창 크기 클래스, 그리고 너비 창 크기 클래스에 따라 선택되는 탐색 구성요소도 알아봤습니다.