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/android/codelab-android-compose

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

샘플 앱 실행

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

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

9b53216a27f911f2.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 '1.3.2'
    }
}

dependencies {
    //...
    // Compose
    def composeBom = platform('androidx.compose:compose-bom:2022.10.00')
    implementation(composeBom)
    androidTestImplementation(composeBom)

    implementation "androidx.compose.runtime:runtime"
    implementation "androidx.compose.ui:ui"
    implementation "androidx.compose.foundation:foundation"
    implementation "androidx.compose.foundation:foundation-layout"
    implementation "androidx.compose.material:material"
    implementation "androidx.compose.runtime:runtime-livedata"
    implementation "androidx.compose.ui:ui-tooling"
    //...
}

이러한 종속 항목의 버전은 프로젝트 수준 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'가 화면에 표시됩니다.

a3be172fdfe6efcb.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")
    }
}

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

db91b149ddbc3613.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는 직접 업데이트할 필요 없이 변경사항을 반영합니다.

4ae8fb531c2ede85.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)
    }
}

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

e506690d1024be88.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)
        }
    }
}

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

311e08a065f58cd3.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")
    }
}

미리보기:

12928a361edc390e.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)
    }
}

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

38f43bf79290a9d7.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. Material Theming

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

올바른 테마 색상을 사용하려면 자체 테마를 정의하고 테마의 색상을 제공하여 MaterialTheme를 맞춤설정해야 합니다.

MaterialTheme 맞춤설정

나만의 테마를 만들려면 theme 패키지에서 Theme.kt 파일을 엽니다. Theme.kt는 콘텐츠 람다를 허용하고 이를 MaterialTheme에 전달하는 SunflowerTheme라는 컴포저블을 정의합니다.

아직 흥미로운 동작이 실행된 것은 없습니다. 다음에 맞춤설정하게 됩니다.

Theme.kt

import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable

@Composable
fun SunflowerTheme(
    content: @Composable () -> Unit
) {
    MaterialTheme(content = content)
}

MaterialTheme를 사용하면 색상, 서체, 도형을 맞춤설정할 수 있습니다. 지금은 Sunflower View의 테마에 동일한 색상을 제공하여 색상을 맞춤설정합니다. SunflowerTheme는 시스템이 어두운 모드이면 기본값이 true이고 그렇지 않으면 falsedarkTheme라는 불리언 매개변수를 허용할 수도 있습니다. 이 매개변수를 사용할 경우 올바른 색상 값을 MaterialTheme에 전달하여 현재 설정된 시스템 테마와 일치시킬 수 있습니다.

Theme.kt

@Composable
fun SunflowerTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val lightColors  = lightColors(
        primary = colorResource(id = R.color.sunflower_green_500),
        primaryVariant = colorResource(id = R.color.sunflower_green_700),
        secondary = colorResource(id = R.color.sunflower_yellow_500),
        background = colorResource(id = R.color.sunflower_green_500),
        onPrimary = colorResource(id = R.color.sunflower_black),
        onSecondary = colorResource(id = R.color.sunflower_black),
    )
    val darkColors  = darkColors(
        primary = colorResource(id = R.color.sunflower_green_100),
        primaryVariant = colorResource(id = R.color.sunflower_green_200),
        secondary = colorResource(id = R.color.sunflower_yellow_300),
        onPrimary = colorResource(id = R.color.sunflower_black),
        onSecondary = colorResource(id = R.color.sunflower_black),
        onBackground = colorResource(id = R.color.sunflower_black),
        surface = colorResource(id = R.color.sunflower_green_100_8pc_over_surface),
        onSurface = colorResource(id = R.color.sunflower_white),
    )
    val colors = if (darkTheme) darkColors else lightColors
    MaterialTheme(
        colors = colors,
        content = content
    )
}

이 속성을 사용하려면 SunflowerTheme에 관한 MaterialTheme 사용을 대체합니다. 예를 들어 PlantDetailFragment에서 다음을 실행합니다.

PlantDetailFragment.kt

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

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

PlantDetailDescription.kt

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

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

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

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

미리보기에서 볼 수 있듯이 색상은 이제 Sunflower 테마의 색상과 일치합니다.

9b0953b7bb00a63d.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, "")
    SunflowerTheme {
        PlantDetailContent(plant)
    }
}

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

51e24f4b9a7caf1.png

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

438d2dd9f8acac39.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()
}

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

B743660b5e840b06.png

13. 축하합니다

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

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

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

다음 단계

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

추가 자료