Jetpack Compose로 이전

컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요.

1. 소개

Compose와 뷰 시스템이 함께 작동할 수 있습니다.

이 Codelab에서는 Sunflower의 식물 세부정보 화면 일부를 Compose로 이전합니다. 실제 같은 앱을 Compose로 이전해 볼 수 있도록 프로젝트 사본을 만들었습니다.

Codelab을 마치고 난 후 원하는 경우 이전을 계속 진행하여 Sunflower의 화면 나머지를 전환할 수 있습니다.

이 Codelab을 진행하는 동안 추가 지원을 받으려면 다음 코드를 함께 체크아웃하세요.

학습할 내용

이 Codelab에서는 다음에 관해 알아봅니다.

  • 선택할 수 있는 다양한 이전 경로
  • 앱을 Compose로 점진적으로 이전하는 방법
  • 뷰를 사용하여 빌드된 기존 화면에 Compose를 추가하는 방법
  • Compose 내에서 뷰를 사용하는 방법
  • Compose에서 뷰 기반 테마를 사용하는 방법
  • 뷰와 Compose가 혼합되어 작성된 화면을 테스트하는 방법

기본 요건

필요한 항목

2. 이전 전략

Jetpack Compose는 처음부터 뷰 상호 운용성을 고려하여 설계되었습니다. Compose로 이전하려면 앱이 Compose로 완전히 이전될 때까지 Compose와 뷰가 코드베이스에 공존하는 증분 이전을 사용하는 것이 좋습니다.

권장되는 이전 전략은 다음과 같습니다.

  1. Compose를 사용하여 새로운 기능 빌드
  2. 기능을 빌드하면서 재사용 가능한 요소를 식별하고 공통 UI 구성요소의 라이브러리 만들기
  3. 기존 기능을 한 번에 한 화면씩 대체

Compose를 사용하여 새로운 기능 빌드

Compose를 사용하여 새로운 기능을 빌드하는 것은 Compose 채택을 유도하는 가장 좋은 방법입니다. 이렇게 하면 새로 추가하는 기능에 Compose의 이점을 활용할 수 있습니다.

새로운 기능에는 전체 화면이 포함될 수도 있으며, 이 경우 전체 화면이 Compose에 있습니다. 프래그먼트 기반 탐색을 사용 중인 경우에는 새 프래그먼트를 만들고 콘텐츠를 Compose에 포함합니다.

반면 빌드 중인 새 기능이 기존 화면의 일부인 경우에는 뷰와 Compose가 동일한 화면에 공존합니다. 예를 들어 추가하는 기능이 RecyclerView의 새로운 뷰 유형이라고 가정하겠습니다. 이러한 새 뷰 유형은 Compose에 있지만 다른 항목은 동일하게 유지됩니다.

공통 UI 구성요소의 라이브러리 빌드

Compose를 사용하여 기능을 빌드하다 보면 결국 구성요소 라이브러리를 빌드하게 됩니다. 공유 구성요소가 단일 정보 소스를 가지도록 앱 전체에서 재사용을 촉진할 수 있는 재사용 가능한 구성요소를 파악하는 것이 좋습니다. 그러면 빌드하는 새 기능이 이 라이브러리에 종속될 수 있습니다.

기존 기능을 Compose로 교체

새로운 기능을 빌드하는 것 외에도 앱의 기존 기능을 Compose로 점진적으로 이전하는 것이 좋습니다. 접근하는 방식은 개발자가 선택할 수 있지만, 적합한 옵션은 다음과 같습니다.

  1. 간단한 화면: 일부 UI 요소와 시작 화면, 확인 화면, 설정 화면 같은 역동성이 있는 앱의 간단한 화면입니다. 코드 몇 줄로 작성할 수 있으므로 Compose로 이전하는 데 적합한 방법입니다.
  2. 뷰와 Compose가 혼합된 화면: 이미 약간의 Compose 코드가 포함되어 있는 화면은 계속해서 이 화면에 있는 요소를 조금씩 이전할 수 있으므로 또 다른 적합한 옵션입니다. Compose에 하위 트리만 있는 화면이 있는 경우 전체 UI가 Compose에 속할 때까지 트리의 다른 부분을 계속 이전할 수 있습니다. 이를 상향식 이전 접근 방식이라고 합니다.

뷰와 Compose가 혼합된 UI를 Compose로 이전하는 상향식 접근 방식

이 Codelab의 접근 방식

이 Codelab에서는 Sunflower의 식물 세부정보 화면을 Compose로 점진적으로 이전하여 Compose와 뷰가 공존하게 할 것입니다. 이후 원하면 이전을 계속 진행할 수 있습니다.

3. 설정

코드 가져오기

GitHub에서 Codelab 코드를 가져옵니다.

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

또는 저장소를 ZIP 파일로 다운로드할 수 있습니다.

샘플 앱 실행

다운로드한 코드에는 사용 가능한 모든 Compose Codelab용 코드가 포함되어 있습니다. 이 Codelab을 완료하려면 Android 스튜디오 내에서 MigrationCodelab 프로젝트를 엽니다.

이 Codelab에서는 Sunflower의 식물 세부정보 화면을 Compose로 이전합니다. 식물 목록 화면에서 식물 하나를 탭하여 식물 세부정보 화면을 열 수 있습니다.

bb6fcf50b2899894.png

프로젝트 설정

프로젝트는 여러 git 브랜치로 빌드됩니다.

  • main 브랜치는 Codelab의 시작점입니다.
  • end 브랜치에는 이 Codelab의 솔루션이 포함되어 있습니다.

main 브랜치의 코드로 시작하고 각자의 속도에 맞게 Codelab을 단계별로 따라하는 것이 좋습니다.

Codelab을 진행하는 중에 프로젝트에 추가해야 하는 코드 스니펫이 제공됩니다. 코드 스니펫의 댓글에 명시된 코드를 삭제해야 하는 경우도 있을 수 있습니다.

git을 사용하여 end 브랜치를 가져오려면 cdMigrationCodelab 프로젝트의 디렉터리에 넣고 다음 명령어를 사용합니다.

$ git checkout end

또는 다음 위치에서 솔루션 코드를 다운로드합니다.

자주 묻는 질문(FAQ)

4. Sunflower의 Compose

main 브랜치에서 다운로드한 코드에 Compose가 이미 추가되어 있습니다. 하지만 제대로 작동하기 위해 필요한 사항을 살펴보겠습니다.

앱 수준 build.gradle 파일을 연 경우 Compose 종속 항목을 가져오는 방법을 확인하고 buildFeatures { compose true } 플래그를 사용하여 Android 스튜디오가 Compose와 함께 작동하도록 설정합니다.

app/build.gradle

android {
    //...
    kotlinOptions {
        jvmTarget = '1.8'
    }
    buildFeatures {
        //...
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion rootProject.composeVersion
    }
}

dependencies {
    //...
    // Compose
    implementation "androidx.compose.runtime:runtime:$rootProject.composeVersion"
    implementation "androidx.compose.ui:ui:$rootProject.composeVersion"
    implementation "androidx.compose.foundation:foundation:$rootProject.composeVersion"
    implementation "androidx.compose.foundation:foundation-layout:$rootProject.composeVersion"
    implementation "androidx.compose.material:material:$rootProject.composeVersion"
    implementation "androidx.compose.runtime:runtime-livedata:$rootProject.composeVersion"
    implementation "androidx.compose.ui:ui-tooling:$rootProject.composeVersion"
    implementation "com.google.accompanist:accompanist-themeadapter-material:$rootProject.accompanistVersion"
    //...
}

이러한 종속 항목의 버전은 프로젝트 수준 build.gradle 파일에 정의되어 있습니다.

5. Compose 시작

식물 세부정보 화면에서는 식물의 설명을 Compose로 이전하되 화면의 전체 구조는 그대로 둡니다.

Compose에서 UI를 렌더링하려면 호스트 활동 또는 프래그먼트가 필요합니다. Sunflower에서는 모든 화면이 프래그먼트를 사용하므로 setContent 메서드를 사용하여 Compose UI 콘텐츠를 호스팅할 수 있는 Android 뷰인 ComposeView를 사용하게 됩니다.

XML 코드 삭제

이전을 시작해 보겠습니다. fragment_plant_detail.xml을 열고 다음 안내를 따르세요.

  1. 코드 보기로 전환합니다.
  2. NestedScrollView 내에서 ConstraintLayout 코드 및 중첩된 4개의 TextView를 삭제합니다(Codelab에서는 개별 항목을 이전할 때 XML 코드를 비교하고 참조하므로 코드를 주석 처리하면 유용함).
  3. 대신 Compose 코드를 호스팅하는 ComposeView를 추가하고 뷰 ID로 compose_view를 사용합니다.

fragment_plant_detail.xml

<androidx.core.widget.NestedScrollView
    android:id="@+id/plant_detail_scrollview"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipToPadding="false"
    android:paddingBottom="@dimen/fab_bottom_padding"
    app:layout_behavior="@string/appbar_scrolling_view_behavior">

    <!-- Step 2) Comment out ConstraintLayout and its children –->
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_margin="@dimen/margin_normal">

        <TextView
            android:id="@+id/plant_detail_name"
        ...

    </androidx.constraintlayout.widget.ConstraintLayout>
    <!-- End Step 2) Comment out until here –->

    <!-- Step 3) Add a ComposeView to host Compose code –->
    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</androidx.core.widget.NestedScrollView>

Compose 코드 추가

이제 식물 세부정보 화면을 Compose로 이전할 준비가 되었습니다.

Codelab 전반에 걸쳐 plantdetail 폴더 아래 PlantDetailDescription.kt 파일에 Compose 코드를 추가합니다. 파일을 열어 자리표시자 "Hello Compose" 텍스트를 프로젝트에서 바로 사용할 수 있는 것을 확인할 수 있습니다.

PlantDetailDescription.kt

@Composable
fun PlantDetailDescription() {
    Surface {
        Text("Hello Compose")
    }
}

이전 단계에서 추가한 ComposeView에서 이 컴포저블을 호출하여 화면에 표시해 보겠습니다. PlantDetailFragment.kt를 엽니다.

화면에서 데이터 결합을 사용하므로 composeView에 직접 액세스하고 setContent를 호출하여 화면에 Compose 코드를 표시할 수 있습니다. Sunflower에서 Material Design을 사용하므로 MaterialTheme 내에서 PlantDetailDescription 컴포저블을 호출합니다.

PlantDetailFragment.kt

class PlantDetailFragment : Fragment() {
    // ...
    override fun onCreateView(...): View? {
        val binding = DataBindingUtil.inflate<FragmentPlantDetailBinding>(
            inflater, R.layout.fragment_plant_detail, container, false
        ).apply {
            // ...
            composeView.setContent {
                // You're in Compose world!
                MaterialTheme {
                    PlantDetailDescription()
                }
            }
        }
        // ...
    }
}

앱을 실행하면 'Hello Compose'가 화면에 표시됩니다.

66f3525ecf6669e0.png

6. XML을 사용하여 컴포저블 만들기

먼저 식물 이름을 이전합니다. 더 정확하게는 fragment_plant_detail.xml에서 삭제한 ID가 @+id/plant_detail_nameTextView입니다. 다음은 XML 코드입니다.

<TextView
    android:id="@+id/plant_detail_name"
    ...
    android:layout_marginStart="@dimen/margin_small"
    android:layout_marginEnd="@dimen/margin_small"
    android:gravity="center_horizontal"
    android:text="@{viewModel.plant.name}"
    android:textAppearance="?attr/textAppearanceHeadline5"
    ... />

textAppearanceHeadline5 스타일이 있고, 가로 여백이 8.dp이며, 가로로 화면 가운데 표시되는 것을 확인할 수 있습니다. 그러나 표시할 제목은 저장소 레이어에서 가져온 PlantDetailViewModel에 의해 노출된 LiveData에서 관찰됩니다.

LiveData 관찰에 대해서는 나중에 다룰 예정입니다. 일단 이름을 사용할 수 있고 PlantDetailDescription.kt 파일에서 만드는 PlantName 컴포저블에 매개변수로 전달된다고 가정해 보겠습니다. 이 컴포저블은 나중에 PlantDetailDescription 컴포저블에서 호출됩니다.

PlantDetailDescription.kt

@Composable
private fun PlantName(name: String) {
    Text(
        text = name,
        style = MaterialTheme.typography.h5,
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = dimensionResource(R.dimen.margin_small))
            .wrapContentWidth(Alignment.CenterHorizontally)
    )
}

@Preview
@Composable
private fun PlantNamePreview() {
    MaterialTheme {
        PlantName("Apple")
    }
}

미리보기는 다음과 같습니다.

d09fe886b98bde91.png

여기서

  • Text의 스타일은 MaterialTheme.typography.h5로, XML 코드의 textAppearanceHeadline5와 유사합니다.
  • 수정자는 XML 버전처럼 보이도록 텍스트를 장식합니다.
  • fillMaxWidth 수정자는 사용 가능한 최대 너비를 차지하도록 사용됩니다. 이 수정자는 XML 코드에서 layout_width 속성의 match_parent 값에 해당합니다.
  • padding 수정자를 사용하여 가로 패딩 값 margin_small을 적용합니다. 이 값은 XML의 marginStartmarginEnd 선언에 해당합니다. margin_small 값은 dimensionResource 도우미 함수를 사용하여 가져오는 기존 크기 리소스이기도 합니다.
  • wrapContentWidth 수정자는 텍스트가 가로로 화면 가운데 표시되도록 하는 데 사용됩니다. XML에서 gravitycenter_horizontal인 것과 유사합니다.

7. ViewModel 및 LiveData

이제 제목을 화면에 연결해 보겠습니다. 이렇게 하려면 PlantDetailViewModel을 사용하여 데이터를 로드해야 합니다. 이를 위해 Compose는 ViewModelLiveData 통합 기능을 지원합니다.

ViewModel

PlantDetailViewModel의 인스턴스가 프래그먼트에서 사용되므로 PlantDetailDescription에 매개변수로 전달하기만 하면 됩니다.

PlantDetailDescription.kt 파일을 열고 PlantDetailViewModel 매개변수를 PlantDetailDescription에 추가합니다.

PlantDetailDescription.kt

@Composable
fun PlantDetailDescription(plantDetailViewModel: PlantDetailViewModel) {
    //...
}

이제 프래그먼트에서 이 컴포저블을 호출할 때 ViewModel의 인스턴스를 전달합니다.

PlantDetailFragment.kt

class PlantDetailFragment : Fragment() {
    ...
    override fun onCreateView(...): View? {
        ...
        composeView.setContent {
            MaterialTheme {
                PlantDetailDescription(plantDetailViewModel)
            }
        }
    }
}

LiveData

이를 사용하면 이미 PlantDetailViewModelLiveData<Plant> 필드에 액세스하여 식물 이름을 가져올 수 있습니다.

컴포저블에서 LiveData를 관찰하려면 LiveData.observeAsState() 함수를 사용합니다.

LiveData에서 내보낸 값은 null일 수 있으므로, 사용을 null 검사로 래핑해야 합니다. 이 때문에 재사용을 위해 LiveData 소비를 분할하고 여러 컴포저블에서 리슨하는 것이 좋습니다. Plant 정보를 표시하는 PlantDetailContent라는 새 컴포저블을 만듭니다.

이러한 업데이트 과정을 거쳐 이제 PlantDetailDescription.kt 파일이 다음과 같이 표시됩니다.

PlantDetailDescription.kt

@Composable
fun PlantDetailDescription(plantDetailViewModel: PlantDetailViewModel) {
    // Observes values coming from the VM's LiveData<Plant> field
    val plant by plantDetailViewModel.plant.observeAsState()

    // If plant is not null, display the content
    plant?.let {
        PlantDetailContent(it)
    }
}

@Composable
fun PlantDetailContent(plant: Plant) {
    PlantName(plant.name)
}

@Preview
@Composable
private fun PlantDetailContentPreview() {
    val plant = Plant("id", "Apple", "description", 3, 30, "")
    MaterialTheme {
        PlantDetailContent(plant)
    }
}

PlantDetailContentPlantName을 호출하기 때문에 PlantNamePreview는 직접 업데이트할 필요 없이 변경사항을 반영합니다.

3e47e682cf518c71.png

이제 Compose에 식물 이름이 표시되도록 ViewModel에 연결했습니다. 다음 몇 개의 섹션에서 나머지 컴포저블을 빌드하고 비슷한 방식으로 ViewModel에 연결할 것입니다.

8. 추가 XML 코드 이전

이제 UI에서 누락된 정보, 즉 물주기 정보와 식물 설명을 더 쉽게 완성할 수 있습니다. 이전과 비슷한 접근 방식을 따르면 화면의 나머지 부분도 이전할 수 있습니다.

이전에 fragment_plant_detail.xml에서 삭제한 물주기 정보 XML 코드는 ID가 plant_watering_headerplant_watering인 두 개의 TextView로 구성됩니다.

<TextView
    android:id="@+id/plant_watering_header"
    ...
    android:layout_marginStart="@dimen/margin_small"
    android:layout_marginTop="@dimen/margin_normal"
    android:layout_marginEnd="@dimen/margin_small"
    android:gravity="center_horizontal"
    android:text="@string/watering_needs_prefix"
    android:textColor="?attr/colorAccent"
    android:textStyle="bold"
    ... />

<TextView
    android:id="@+id/plant_watering"
    ...
    android:layout_marginStart="@dimen/margin_small"
    android:layout_marginEnd="@dimen/margin_small"
    android:gravity="center_horizontal"
    app:wateringText="@{viewModel.plant.wateringInterval}"
    .../>

이전에 했던 것과 마찬가지로 PlantWatering이라는 새 컴포저블을 만들고 Text 컴포저블을 추가하여 화면에 물주기 정보를 표시합니다.

PlantDetailDescription.kt

@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun PlantWatering(wateringInterval: Int) {
    Column(Modifier.fillMaxWidth()) {
        // Same modifier used by both Texts
        val centerWithPaddingModifier = Modifier
            .padding(horizontal = dimensionResource(R.dimen.margin_small))
            .align(Alignment.CenterHorizontally)

        val normalPadding = dimensionResource(R.dimen.margin_normal)

        Text(
            text = stringResource(R.string.watering_needs_prefix),
            color = MaterialTheme.colors.primaryVariant,
            fontWeight = FontWeight.Bold,
            modifier = centerWithPaddingModifier.padding(top = normalPadding)
        )

        val wateringIntervalText = pluralStringResource(
            R.plurals.watering_needs_suffix, wateringInterval, wateringInterval
        )
        Text(
            text = wateringIntervalText,
            modifier = centerWithPaddingModifier.padding(bottom = normalPadding)
        )
    }
}

@Preview
@Composable
private fun PlantWateringPreview() {
    MaterialTheme {
        PlantWatering(7)
    }
}

미리보기는 다음과 같습니다.

6f6c17085801a518.png

참고해야 할 사항:

  • 가로 패딩과 정렬 장식은 Text 컴포저블에서 공유하므로 로컬 변수(예: centerWithPaddingModifier)에 할당하여 수정자를 재사용할 수 있습니다. 수정자는 일반 Kotlin 객체이므로 가능합니다.
  • Compose의 MaterialThemeplant_watering_header에 사용되는 colorAccent와 정확하게 일치하지 않습니다. 일단은 MaterialTheme.colors.primaryVariant를 사용하고 상호 운용성 테마 설정 섹션에서 개선하도록 하겠습니다.
  • Compose 1. 2.1에서 pluralStringResource를 사용하려면 ExperimentalComposeUiApi를 선택해야 합니다. Compose의 향후 버전에서는 이 작업이 더 이상 필요하지 않을 수 있습니다.

모두 서로 연결하고 PlantDetailContent에서 PlantWatering도 호출합니다. 처음에 삭제한 ConstraintLayout XML 코드는 Compose 코드에 포함해야 하는 16.dp의 여백을 갖고 있었습니다.

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_margin="@dimen/margin_normal">

PlantDetailContent에서 이름과 물주기 정보를 함께 표시하고 이를 패딩으로 포함하는 Column을 만듭니다. 또한 사용되는 배경 색상과 텍스트 색상이 적절하도록 하려면 이를 처리하는 Surface를 추가합니다.

PlantDetailDescription.kt

@Composable
fun PlantDetailContent(plant: Plant) {
    Surface {
        Column(Modifier.padding(dimensionResource(R.dimen.margin_normal))) {
            PlantName(plant.name)
            PlantWatering(plant.wateringInterval)
        }
    }
}

미리보기를 새로고침하면 다음과 같이 표시됩니다.

56626a7118ce075c.png

9. Compose 코드의 뷰

이제 식물 설명을 이전해 보겠습니다. fragment_plant_detail.xml의 코드에는 app:renderHtml="@{viewModel.plant.description}"을 포함하는 TextView가 있어 화면에 표시할 텍스트를 XML에 알려줍니다. renderHtmlPlantDetailBindingAdapters.kt 파일에서 찾을 수 있는 결합 어댑터입니다. 구현은 HtmlCompat.fromHtml을 사용하여 TextView에 텍스트를 설정합니다.

그러나 Compose는 현재 Spanned 클래스를 지원하지 않으며 HTML 형식 텍스트도 표시하지 않습니다. 따라서 이 제한을 우회하려면 Compose 코드에서 뷰 시스템의 TextView를 사용해야 합니다.

Compose는 아직 HTML 코드를 렌더링할 수 없으므로 프로그래매틱 방식으로 TextView를 만들어 AndroidView API를 사용하여 정확히 렌더링을 해야 합니다.

AndroidView를 사용하면 factory 람다에 View를 구성할 수 있습니다. 또한 뷰가 확장되었을 때 및 후속 재구성 시 호출될 때 update 람다를 제공합니다.

PlantDescription 컴포저블을 만들어 보겠습니다. 이 컴포저블은 factory 람다에 TextView를 구성하는 AndroidView를 호출합니다. factory 람다에서 HTML 형식의 텍스트를 표시하는 TextView를 초기화한 뒤에 movementMethodLinkMovementMethod 인스턴스로 설정합니다. 마지막으로 update 람다에서 TextView의 텍스트를 htmlDescription으로 설정합니다.

PlantDetailDescription.kt

@Composable
private fun PlantDescription(description: String) {
    // Remembers the HTML formatted description. Re-executes on a new description
    val htmlDescription = remember(description) {
        HtmlCompat.fromHtml(description, HtmlCompat.FROM_HTML_MODE_COMPACT)
    }

    // Displays the TextView on the screen and updates with the HTML description when inflated
    // Updates to htmlDescription will make AndroidView recompose and update the text
    AndroidView(
        factory = { context ->
            TextView(context).apply {
                movementMethod = LinkMovementMethod.getInstance()
            }
        },
        update = {
            it.text = htmlDescription
        }
    )
}

@Preview
@Composable
private fun PlantDescriptionPreview() {
    MaterialTheme {
        PlantDescription("HTML<br><br>description")
    }
}

미리보기:

deea1d191e9087b4.png

htmlDescription은 매개변수로 전달된 특정 description의 HTML 설명을 기억합니다. description 매개변수가 변경되면 rememberhtmlDescription 코드가 다시 실행됩니다.

따라서 htmlDescription이 변경되면 AndroidView 업데이트 콜백이 재구성됩니다. update 람다 내에서 상태를 읽게 되면 재구성됩니다.

PlantDetailContent 컴포저블에 PlantDescription을 추가하고 HTML 설명도 표시하도록 미리보기 코드를 변경해 보겠습니다.

PlantDetailDescription.kt

@Composable
fun PlantDetailContent(plant: Plant) {
    Surface {
        Column(Modifier.padding(dimensionResource(R.dimen.margin_normal))) {
            PlantName(plant.name)
            PlantWatering(plant.wateringInterval)
            PlantDescription(plant.description)
        }
    }
}

@Preview
@Composable
private fun PlantDetailContentPreview() {
    val plant = Plant("id", "Apple", "HTML<br><br>description", 3, 30, "")
    MaterialTheme {
        PlantDetailContent(plant)
    }
}

미리보기는 다음과 같습니다.

7843a8d6c781c244.png

이제 원래 ConstraintLayout의 모든 콘텐츠를 Compose로 이전했습니다. 앱을 실행하여 제대로 작동하는지 확인할 수 있습니다.

c7021c18eb8b4d4e.gif

10. ViewCompositionStrategy

Compose는 ComposeView가 창에서 분리될 때마다 컴포지션을 삭제합니다. 다음 두 가지 이유로 프래그먼트에서 ComposeView가 사용될 때에는 바람직하지 않습니다.

  • 컴포지션은 Compose UI View 유형을 위한 프래그먼트의 뷰 수명 주기에 따라 상태를 저장해야 합니다.
  • 전환이 발생할 때 기본 ComposeView가 분리된 상태가 됩니다. 그러나 이러한 전환 중에도 Compose UI 요소는 계속 표시됩니다.

이 동작을 수정하려면 프래그먼트의 뷰 수명 주기를 따르도록 적절한 ViewCompositionStrategysetViewCompositionStrategy를 호출합니다. 특히 DisposeOnViewTreeLifecycleDestroyed 전략을 사용하여 프래그먼트의 LifecycleOwner가 소멸되면 컴포지션을 삭제하려고 합니다.

이렇게 PlantDetailFragment가 전환에 들어가고 나오고(자세한 내용은 nav_garden.xml 확인) 나중에 Compose 내에서 View 유형을 사용할 것이므로 ComposeViewDisposeOnViewTreeLifecycleDestroyed 전략을 사용하는지 확인해야 합니다. 그럼에도 불구하고 프래그먼트에서 ComposeView를 사용할 때는 항상 이 전략을 설정하는 것이 좋습니다.

PlantDetailFragment.kt

import androidx.compose.ui.platform.ViewCompositionStrategy
...

class PlantDetailFragment : Fragment() {
    ...
    override fun onCreateView(...): View? {
        val binding = DataBindingUtil.inflate<FragmentPlantDetailBinding>(
            inflater, R.layout.fragment_plant_detail, container, false
        ).apply {
            ...
            composeView.apply {
                // Dispose the Composition when the view's LifecycleOwner
                // is destroyed
                setViewCompositionStrategy(
                    ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
                )
                setContent {
                    MaterialTheme {
                        PlantDetailDescription(plantDetailViewModel)
                    }
                }
            }
        }
        ...
    }
}

11. 상호 운용성 테마 설정

식물 세부정보의 텍스트 콘텐츠가 Compose로 이전되었습니다. 그러나 Compose가 알맞은 테마 색상을 사용하고 있지 않을 수도 있습니다. 식물 이름에는 녹색을 사용해야 하는데 보라색을 사용하고 있습니다.

이 초기 이전 단계에서는 Compose에서 고유 Material 테마를 처음부터 재작성하는 대신 뷰 시스템에 사용할 수 있는 테마를 Compose에서 상속할 수 있습니다. Material 테마는 Compose와 함께 제공되는 모든 Material Design 구성요소와 완벽하게 호환됩니다.

Compose에서 뷰 시스템 Material Design 구성요소(MDC) 테마를 재사용하려면 Accompanist Material 테마 어댑터 라이브러리를 사용하면 됩니다. MdcTheme 함수는 호스트 컨텍스트의 MDC 테마를 자동으로 읽고 사용자를 대신하여 밝은 테마와 어두운 테마 모두를 위해 MaterialTheme으로 전달합니다. 이 Codelab에서는 테마 색상만 필요하지만 라이브러리는 뷰 시스템의 도형과 서체도 읽습니다.

라이브러리는 다음과 같이 app/build.gradle 파일에 이미 포함되어 있습니다.

...
dependencies {
    ...
    implementation "com.google.accompanist:accompanist-themeadapter-material:$rootProject.accompanistVersion"
    ...
}

이 속성을 사용하려면 MdcTheme에 대한 MaterialTheme 사용을 대체합니다. 예를 들어 PlantDetailFragment에서는 다음과 같습니다.

PlantDetailFragment.kt

class PlantDetailFragment : Fragment() {
    ...
    composeView.apply {
        ...
        setContent {
            MdcTheme {
                PlantDetailDescription(plantDetailViewModel)
            }
        }
    }
}

PlantDetailDescription.kt 파일의 모든 미리보기 컴포저블은 다음과 같습니다.

PlantDetailDescription.kt

@Preview
@Composable
private fun PlantDetailContentPreview() {
    val plant = Plant("id", "Apple", "HTML<br><br>description", 3, 30, "")
    MdcTheme {
        PlantDetailContent(plant)
    }
}

@Preview
@Composable
private fun PlantNamePreview() {
    MdcTheme {
        PlantName("Apple")
    }
}

@Preview
@Composable
private fun PlantWateringPreview() {
    MdcTheme {
        PlantWatering(7)
    }
}

@Preview
@Composable
private fun PlantDescriptionPreview() {
    MdcTheme {
        PlantDescription("HTML<br><br>description")
    }
}

미리보기에서 볼 수 있듯이 MdcThemestyles.xml 파일의 테마에서 색상을 선택합니다.

886d7eaea611f4eb.png

새 함수를 만들고 미리보기의 uiModeConfiguration.UI_MODE_NIGHT_YES를 전달하여 어두운 테마 UI를 미리 볼 수도 있습니다.

import android.content.res.Configuration
...

@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlantDetailContentDarkPreview() {
    val plant = Plant("id", "Apple", "HTML<br><br>description", 3, 30, "")
    MdcTheme {
        PlantDetailContent(plant)
    }
}

미리보기는 다음과 같습니다.

cfe11c109ff19eeb.png

앱을 실행하면 밝은 테마와 어두운 테마 모두에서 이전하기 전과 동일하게 동작합니다.

c99216fc77699dd7.gif

12. 테스트

식물 세부정보 화면의 일부를 Compose로 이전한 후 오류가 발생하지 않았는지 테스트해야 합니다.

Sunflower에서 androidTest 폴더에 있는 PlantDetailFragmentTest는 앱의 일부 기능을 테스트합니다. 파일을 열고 현재 코드를 살펴봅니다.

  • testPlantName은 화면에 표시되는 식물의 이름을 확인합니다.
  • testShareTextIntent는 공유 버튼을 탭한 후 알맞은 인텐트가 트리거되는지 확인합니다.

활동 또는 프래그먼트에 Compose를 사용한다면 ActivityScenarioRule을 사용하는 대신 ComposeTestRuleActivityScenarioRule과 통합하는 createAndroidComposeRule을 사용해야 합니다. 그러면 Compose 코드를 테스트할 수 있습니다.

PlantDetailFragmentTest에서 ActivityScenarioRule 사용을 createAndroidComposeRule로 바꿉니다. 테스트를 구성하는 데 활동 규칙이 필요한 경우 다음과 같이 createAndroidComposeRuleactivityRule 속성을 사용합니다.

@RunWith(AndroidJUnit4::class)
class PlantDetailFragmentTest {

    @Rule
    @JvmField
    val composeTestRule = createAndroidComposeRule<GardenActivity>()

    ...

    @Before
    fun jumpToPlantDetailFragment() {
        populateDatabase()

        composeTestRule.activityRule.scenario.onActivity { gardenActivity ->
            activity = gardenActivity

            val bundle = Bundle().apply { putString("plantId", "malus-pumila") }
            findNavController(activity, R.id.nav_host).navigate(R.id.plant_detail_fragment, bundle)
        }
    }

    ...
}

테스트를 실행하면 testPlantName이 실패합니다. testPlantName은 TextView가 화면에 표시되는지 확인합니다. 그러나 UI 일부만 Compose로 이전했습니다. 따라서 Compose 어설션을 사용해야 합니다.

@Test
fun testPlantName() {
    composeTestRule.onNodeWithText("Apple").assertIsDisplayed()
}

테스트를 실행하면 모든 테스트에 통과합니다.

dd59138fac1740e4.png

13. 축하합니다

축하합니다. 이 Codelab을 완료했습니다.

원본 Sunflower github 프로젝트의 compose 브랜치가 식물 세부정보 화면을 Compose로 완전히 이전했습니다. 이 Codelab에서 실행한 작업과 별도로 CollapsingToolbarLayout의 동작을 시뮬레이션하기도 합니다. 여기에는 다음 항목이 포함됩니다.

  • Compose로 이미지 로드
  • 애니메이션
  • 치수 처리 개선
  • 그 외 다양한 항목

다음 단계

Compose 개발자 과정의 다른 Codelab을 확인하세요.

추가 자료