Jetpack Compose로 이전

1. 소개

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

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

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

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

학습할 내용

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

  • 선택할 수 있는 다양한 이전 경로
  • 앱을 Compose로 점진적으로 이전하는 방법
  • Android 뷰를 사용하여 빌드된 기존 화면에 Compose를 추가하는 방법
  • Compose 내에서 Android 뷰를 사용하는 방법
  • Compose에서 뷰 시스템의 테마를 사용하는 방법
  • 뷰 시스템과 Compose 코드로 화면을 테스트하는 방법

기본 요건

준비 사항

2. 이전 계획 세우기

Compose로의 이전은 개발자와 팀에 따라 다를 수 있습니다. Jetpack Compose를 기존 Android 앱으로 이전하는 방법에는 여러 가지가 있습니다. 두 가지 일반적인 이전 전략은 다음과 같습니다.

  • Compose를 사용하여 완전히 새로운 화면 개발
  • 기존 화면을 가져와 구성요소를 점진적으로 이전

새 화면에 Compose

앱을 새로운 기술로 리팩터링할 때 일반적인 접근방식은 앱에 빌드하는 새 기능에 새로운 기술을 채택하는 것입니다. 이 경우 새 기능은 새 화면입니다. 앱의 새 UI 화면을 빌드해야 한다면 앱의 나머지 부분에는 뷰 시스템을 사용하는 상태에서 Compose 사용을 고려합니다.

이 경우 이전된 기능의 가장자리에서 Compose 상호 운용성을 사용하게 됩니다.

Compose와 뷰의 공존

한 화면에서 일부는 Compose로 이전하고 나머지는 View 시스템에 그대로 둘 수 있습니다. 예를 들어 화면의 나머지 부분은 뷰 시스템에 남겨두고 RecyclerView만 이전할 수 있습니다.

또는 반대로 Compose를 외부 레이아웃으로 사용하고 MapView 또는 AdView와 같이 Compose에서 사용할 수 없는 기존 뷰를 사용합니다.

이전 완료

한번에 하나씩 전체 프래그먼트 또는 화면을 Compose로 이전합니다. 가장 간단하지만 조잡해질 수 있습니다.

그렇다면 이 Codelab에서는?

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

3. 설정

코드 가져오기

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

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

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

Android 스튜디오 열기

이 Codelab에는 Android 스튜디오 Bumblebee가 필요합니다.

샘플 앱 실행

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

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

bb6fcf50b2899894.png

프로젝트 설정

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

  • main은 개발자가 확인하거나 다운로드한 브랜치로 Codelab의 시작점입니다.
  • end에는 이 Codelab의 솔루션이 포함되어 있습니다.

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

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

git을 사용하여 end 브랜치를 가져오려면 다음 명령어를 사용합니다.

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

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

자주 묻는 질문(FAQ)

4. Sunflower의 Compose

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

app/build.gradle(또는 build.gradle (Module: compose-migration.app)) 파일을 열어 Compose 종속 항목을 가져오는 방법을 확인하고 buildFeatures { compose true } 플래그를 사용하여 Android 스튜디오가 Compose와 함께 작동하도록 설정합니다.

app/build.gradle

android {
    ...
    kotlinOptions {
        jvmTarget = '1.8'
        useIR = true
    }
    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.android.material:compose-theme-adapter:$rootProject.composeVersion"
    ...
}

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

5. Compose 시작

식물 세부정보 화면에서는 식물의 설명을 Compose로 이전하되 화면의 전체 구조는 그대로 둡니다. 이렇게 하면 이전 계획 세우기 섹션에서 언급한 Compose와 뷰의 공존 이전 전략을 따르게 됩니다.

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

XML 코드 삭제

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

  1. 코드 보기로 전환합니다.
  2. NestedScrollView 내에서 ConstraintLayout 코드 및 중첩된 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!" 텍스트를 프로젝트에서 바로 사용할 수 있는 것을 확인할 수 있습니다.

plantdetail/PlantDetailDescription.kt

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

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

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

plantdetail/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의 스타일은 XML 코드에서 textAppearanceHeadline5로 매핑되는 MaterialTheme.typography.h5입니다.
  • 수정자는 XML 버전처럼 보이도록 텍스트를 장식합니다.
  • fillMaxWidth 수정자는 XML 코드의 android:layout_width="match_parent"에 해당합니다.
  • dimensionResource 도우미 함수를 사용하는 뷰 시스템의 값인 margin_small의 가로 padding입니다.
  • wrapContentWidthText를 가로로 정렬합니다.

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라는 새 컴포저블을 만듭니다.

위와 같이 LiveData 관찰을 추가한 후의 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)
    }
}

PlantDetailContent가 바로 그 순간의 PlantName을 호출하기 때문에 PlantNamePreview와 동일한 미리보기입니다.

3e47e682cf518c71.png

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

8. 추가 XML 코드 이전

이제 UI에서 누락된 정보, 즉 물주기 정보와 식물 설명을 더 쉽게 완성할 수 있습니다. 이전에 했던 것과 동일한 XML 코드 접근 방식을 살펴보면 화면의 나머지 부분도 이전할 수 있습니다.

이전에 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

@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 = LocalContext.current.resources.getQuantityString(
            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를 사용하고 테마 설정 섹션에서 개선하도록 하겠습니다.

모두 서로 연결하고 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를 사용하여 정확히 렌더링을 해야 합니다.

AndroidViewView를 매개변수로 사용하고 뷰가 확장되었을 때 콜백을 제공합니다.

PlantDescription 컴포저블을 만들어 보겠습니다. 이 컴포저블은 람다에서 방금 기억한 TextViewAndroidView를 호출합니다. factory 콜백에서 주어진 Context를 사용하여 HTML 상호작용에 반응하는 TextView를 초기화합니다. 그리고 update 콜백에서 저장된 HTML 형식의 설명으로 텍스트를 설정합니다.

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 업데이트 콜백이 재구성됩니다. 콜백 내에서 상태를 읽게 되면 재구성됩니다.

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 유형을 위한 프래그먼트의 뷰 수명 주기에 따라 상태를 저장해야 합니다.
  • 전환 또는 창 전환이 발생할 때 화면에 Compose UI 요소를 유지합니다. 전환 중에는 ComposeView가 창에서 분리된 후에도 계속 표시됩니다.

AbstractComposeView.disposeComposition 메서드를 수동으로 호출하여 컴포지션을 수동으로 삭제할 수 있습니다. 대안으로 더 이상 필요하지 않은 컴포지션을 자동으로 삭제하려면 다른 전략을 설정하거나 setViewCompositionStrategy 메서드를 호출하여 전략을 직접 만듭니다.

DisposeOnViewTreeLifecycleDestroyed 전략을 사용하여 프래그먼트의 LifecycleOwner가 소멸되면 컴포지션을 삭제합니다.

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

plantdetail/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에서 고유 머티리얼 테마를 처음부터 재작성하는 대신 뷰 시스템에 사용할 수 있는 테마를 Compose에서 상속할 수 있습니다. 머티리얼 테마는 Compose와 함께 제공되는 모든 머티리얼 디자인 구성요소와 완벽하게 호환됩니다.

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

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

...
dependencies {
    ...
    implementation "com.google.android.material:compose-theme-adapter:$rootProject.composeVersion"
    ...
}

이 속성을 사용하려면 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을 확인하세요.

추가 자료