pathway1-2: MigrationCodelab 정리 (스터디)
기존 뷰 기반 레이아웃을 컴포즈 레이아웃으로 마이그레이션 하는 방법을 알아보자.
view-based → compose
컴포즈로 대체할 뷰 기반 레이아웃을 모두 주석 처리해주자. (xml 기준이다.)
주석으로 대체한 부분에는 아래와 같이 컴포즈 뷰를 삽입해주자. (xml 에 마이그레이션 하는 것이기 때문에 어쩔 수 없이 얘도 뷰가 되는 듯하다.)
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
안드로이드 뷰 에서 컴포즈 사용하기
composeView.setContent {
// You're in Compose world!
MaterialTheme {
PlantDetailDescription()
}
}
기존 방식의 안드로이드 뷰(액티비티 등) 에서 컴포즈 레이아웃을 사용하는 방법이다.
본격적인 migration
기존에 주석처리한 텍스트뷰를 대체할 컴포저블을 정의하자.
@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")
}
}
ui 마이그레이션이라 기존 뷰 속성과 상응하는 컴포저블을 작성해 주기만 하면된다.
@Composable
fun PlantDetailDescription(plantDetailViewModel: PlantDetailViewModel) {
기존에 뷰모델을 사용한 데이터바인딩을 해주고 있었으므로, 동일하게 컴포저블에서도 뷰모델을 받아준다. (이전 코드랩에서는 뷰모델을 직접 받지 말라고 권고하였는데, 요 코드랩은 마이그레이션 가이드이므로 일단 넘어가주자)
composeView.setContent {
// You're in Compose world!
MaterialTheme {
PlantDetailDescription(plantDetailViewModel)
}
}
동일하게 setContent 에서도 전달해준다.
observe livedata
@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)
}
뷰모델에 있는 라이브데이터를 observeAsState
로 관찰하여 컴포저블을 방출한다.
PlantWatering 컴포저블의 정의. 마찬가지로 기존 뷰와 동일하게 보이도록 해준다.
@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)
}
}
PlantDetailContent 에 기본 패딩을 추가하고, PlantWatering 를 포함시킨다.
@Composable
fun PlantDetailContent(plant: Plant) {
Surface {
Column(Modifier.padding(dimensionResource(R.dimen.margin_normal))) {
PlantName(plant.name)
PlantWatering(plant.wateringInterval)
}
}
}
Lifecycle of ComposeView
Compose는 ComposeView가 창에서 분리될 때마다 컴포지션을 삭제한다. 이것은 여러 가지 이유로 ComposeView가 프래그먼트에서 사용될 때 바람직하지 않다. 화면 전환이 일어나는 경우에는 유지하고, 실제 라이프사이클이 제거 이벤트인 경우에만 제거되도록 수정해야한다.
Migrating to Jetpack Compose | Android Developers
composeView.apply {
// Dispose the Composition when the view's LifecycleOwner
// is destroyed
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
// You're in Compose world!
MaterialTheme {
PlantDetailDescription(plantDetailViewModel)
}
}
}
setViewCompositionStrategy
으로 컴포즈뷰의 라이프사이클을 뷰 라이프사이클과 맞춰준다.
MdcTheme
setContent {
// You're in Compose world!
MdcTheme {
PlantDetailDescription(plantDetailViewModel)
}
}
MdcTheme 함수는 호스트 컨텍스트의 MDC 테마를 자동으로 읽고 사용자를 대신하여 밝은 테마와 어두운 테마 모두를 위해 MaterialTheme로 전달한다.
dark theme preview
@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)
}
}
PlantDescription 컴포저블 추가, html 을 보여주기 위해서는 html 을 string 으로 받아 이를 변환하여 보여주어야 한다.
@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")
}
}
테스트 코드 작성
기존에 작성되어있던 테스트 코드를 수정해보자.
@Rule
@JvmField
val activityTestRule = ActivityScenarioRule(GardenActivity::class.java)
→
@Rule
@JvmField
val composeTestRule = createAndroidComposeRule<GardenActivity>()
activityTestRule → composeTestRule
activityTestRule.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)
}
→
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)
}
activityTestRule.scenario.onActivity → composeTestRule.activityRule.
@Test
fun testPlantName() {
onView(ViewMatchers.withText("Apple"))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
}
→
@Test
fun testPlantName() { // 화면에 표시되는 식물의 이름을 확인합니다.
composeTestRule.onNodeWithText("Apple").assertIsDisplayed()
}
보너스 : 공유 버튼 클릭 동작을 테스트하는 방법
공유 버튼을 탭한 후 알맞은 인텐트가 트리거되는지 확인한다.
@Test
fun testShareTextIntent() { //
val shareText = activity.getString(R.string.share_text_plant, testPlant.name)
Intents.init()
onView(withId(R.id.action_share)).perform(click())
intended(
chooser(
allOf(
hasAction(Intent.ACTION_SEND),
hasType("text/plain"),
hasExtra(Intent.EXTRA_TEXT, shareText)
)
)
)
Intents.release()
// dismiss the Share Dialog
InstrumentationRegistry.getInstrumentation()
.uiAutomation
.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK)
}
// TODO: This workaround is needed due to the real database being used in tests.
// A fake database created with a Room.inMemoryDatabaseBuilder should be used instead.
// That's difficult to do in the current state of the project since there are no
// dependency injection best practices in place.
private fun populateDatabase() {
val request = TestListenableWorkerBuilder<SeedDatabaseWorker>(
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
).build()
runBlocking {
request.doWork()
}
}
Reference
2022.12.15 - [Android/Jetpack Compose] - [Jetpack Compose] 테마 Theming 기본 정리
2022.12.14 - [Android/Jetpack Compose] - [Android] compose basic state (상태의 기본 내용 정리)
2022.10.15 - [Android/Google Play] - 구글 플레이스토어 Android 11(target API 31) 으로 마이그레이션
'Android > Jetpack Compose' 카테고리의 다른 글
[Jetpack Compose] 상태 및 사이드이팩트 고급 내용 정리 (State, SideEffects) (0) | 2022.12.18 |
---|---|
[Jetpack Compose] 애니메이션 활용 및 기본 내용 정리 (0) | 2022.12.17 |
[Jetpack Compose] 테마 Theming 기본 정리 (0) | 2022.12.15 |
[Jetpack Compose] compose basic state (상태의 기본 내용 정리) (1) | 2022.12.14 |
[Android] This annotation should be used with the compiler argument '-opt-in=kotlin.RequiresOptIn' 해결하기 (0) | 2022.10.09 |