Android Compose
업데이트:
카테고리: Android
/태그: Compose, Saver, Side Effect, snapshotFlow, State Hosting, UI Test, 재구성
recomposition(재구성)
View - Tool Window - layout Inspector 화면에 보이는 레이아웃이 얼마나 recompose 되었는지 확인가능하다.
컴포져블을 다시 호출할 때 함수 내부의 변수도 호출하여 내부의 선언된 함수가 제대로 변경되지 않을 수 있음
@Composable
fun LoginScreen(showError: Boolean) {
if (showError) {
LoginError()
}
LoginInput() // This call site affects where LoginInput is placed in Composition
}
@Composable
fun LoginInput() { /* ... */ }
위의 예제와 같이 조건부에 따라 LoginError 가 있을 수 도 있고, 없을 수도 있다.
그래서 이때 Recomposition이 발생하여 Composable에 들어있는 변수가 다시 초기화된다.
그래서 Compose 내부에서는 아래와 같이 remember 와 mutableStateOf 를 같이 사용해야된다.
remember는 recomposition이 일어나도 변경되지 않고 유지된다.
val expanded = remember { mutableStateOf(false) }
remeber는 구성변경, Lazy에서 스크롤에서 벗어나고 다시 돌아올 때 내부의 상태값이 소실된다.
그래서 Acivity가 소실되고 다시 생성되도 이전에 값을 유지하고 싶으면 rememberSaveable 을 사용한다.
ViewModel
android kotlin과 다르게 compose는 선언형으로 구성되어 있어 MainActivity에서 모든 컴포넌트를 관리하는 것과 다르게 한 컴포넌트가 하나의 함수로 구성되어 있다.
그렇기 때문에 viewModel를 사용하기 위해서는 각 함수마다 의존성을 추가해주어야 한다.
-
의존성 추가
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:{latest_version}" -
함수의 인자로 viewModel 추가
@Composable fun WellnessScreen( modifier: Modifier = Modifier, viewModel: WellnessViewModel = viewModel(), ) { Column{ StatefulCounter() Spacer(modifier = modifier.height(16.dp)) WellnessTaskList(list = viewModel.tasks, onCloseTask ={task->viewModel.remove(task)}) } }
MutableList 타입은 리스트 내부도 var로 값이 변경되면 반영되지 않는다.
내부의 값도 바로 반영되고 싶으면 MutableState 타입으로 지정하여 값을 변경 시켜준다.
State
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count = 0
Text("You've had $count glasses.")
Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
변수를 var로 지정하면 아무리 Button을 클릭해도 숫자가 올라가지 않는다.
var count by remember { mutableStateOf(0) }
val count: MutableState<Int> = remember **{** *mutableStateOf*(0) **}**
변수를 mutableState로 지정하면 정상적으로 반영된다.
by 를 사용하여 var로 지정해도 되고, = 를 사용하여 val 로 할당해도 된다.
단, val 형태는 .value 로 값을 접근해야된다.
Side Effect
-
LaunchedEffect
composable 함수 내부에서 상태변경에 따른 비동기 작업을 실행하기 위한 side Effect
Coroutine LifeCycle 과 Compose LifeCycle이 함께 묶여있다.
@Composable fun MyScreen( state: UiState<List<Movie>>, snackbarHostState: SnackbarHostState ) { if (state.hasError) { // 에러가 true일 때만 실행 // hasError가 변경되면 이전의 coroutine이 취소되고 재시작 LaunchedEffect(snackbarHostState) { snackbarHostState.showSnackbar( message = "Error message", actionLabel = "Retry message" ) } } Scaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) } ) { contentPadding -> // ... } }LaunchedEffect블록 내부에 작성하는 이유는 error 여부가 나도, 메세지가 아직 준비되지 않았다면, if 문으로 처리한 경우 메세지가 정상적으로 나오지 않을수도 있지만,LaunchedEffect()내부의 상태를 주시하고 있다가 변경이 발생하면 블록 내부의 로직을 실행한다.@Composable fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { val currentOnTimeout by rememberUpdatedState(onTimeout) // 처음 로딩될 때 로고를 2초동안 보여주고 끝남 LaunchedEffect(Unit) { delay(SplashWaitTime) currentOnTimeout() } Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null) } }LaunchedEffect의 값을 한번만 처리하고 싶으면
true,Unit을 인자로 넘겨준다.Suspend 함수나 시간적으로 delay가 되는 함수를 넘겨준다면
remeberUpdatedState를 이용하여 람다식을 기억해야 한다. -
rememberCoroutineScope
composable 함수에서 비동기 작업을 실행할 때 사용
-
DisposableEffect
키가 변경되거나 Composable이 Compose에서 종료되었을 때 남은 동작을 정리하기 위한 것, 함수가 종료되었을 때 메모리 낭비를 하지 않기 위한 사이드 이펙트
fun rememberMapViewWithLifecycle(): MapView { val context =LocalContext.current return remember{ MapView(context).apply{ id= R.id.map onCreate(Bundle()) } } }어떤 lifeCycle과도 연결되어 있지 않음, mapView만 기억하고, onCreate()를 호출하는 로직임
제대로 lifeCycle과 연결시켜주기 위해서는 하나하나 마다 일일히 mapView의 상태를 연결시켜줘야한다.
private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle()) Lifecycle.Event.ON_START -> mapView.onStart() Lifecycle.Event.ON_RESUME -> mapView.onResume() Lifecycle.Event.ON_PAUSE -> mapView.onPause() Lifecycle.Event.ON_STOP -> mapView.onStop() Lifecycle.Event.ON_DESTROY -> mapView.onDestroy() else -> throw IllegalStateException() } }@Composable fun rememberMapViewWithLifecycle(): MapView { val context = LocalContext.current val mapView = remember { MapView(context).apply { id = R.id.map } } val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(key1 = lifecycle, key2 = mapView) { val lifecycleObeserver = getMapLifecycleObserver(mapView) lifecycle.addObserver(lifecycleObeserver) onDispose { lifecycle.removeObserver(lifecycleObeserver) } } return mapView }LaunchedEffect와 하는 동작은 비슷하나, 재시작이나 종료되었을 때 작동할
onDispose구문이 필요하다. -
produceState
ComposeState가 아닌 값 State 로 반환시킨다.
비동기적으로 값을 가져오며, 상태가 변경되면 ui에 반영한다.
@Composable
fun DetailsScreen(
onErrorLoading: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DetailsViewModel = viewModel()
) {
// uiState 초기값 지정
val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
// 도시의 상세정보의 결과에 따라 초기값이 달라짐
val cityDetailsResult = viewModel.cityDetails
value = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
}
// 도시 상세정보가 들어있다면, 지도를 랜더링하고 아니면 로딩화면을 보여준다.
when {
(uiState.cityDetails != null) -> {
DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
}
uiState.isLoading -> {
Box(modifier.fillMaxSize()) {
CircularProgressIndicator(
color = MaterialTheme.colors.onSurface,
modifier = Modifier.align(Alignment.Center)
)
}
}
else -> { onErrorLoading() }
}
}
-
derivedStateOf
!https://developer.android.com/static/codelabs/jetpack-compose-advanced-state-side-effects/img/2c112d73f48335e0.gif
스크롤이 특정위치에 도달하면 하단에 상단으로 바로가는 버튼이 보여진다.
처음 아이템이 지났는지 확인해야 한다.
→
LazyColumn-LazyListStateval showButton = listState.firstIndex > 0와 같은식으로 하면 firstIndex가 바뀔 때 마다 recompose가 발생하게 된다.derivedStateOf를 사용하여 Compose의 상태를 다른 곳으로 전달을 원할 때만 요청한다?
위와같이 계산로직으로 진행하면 값이 변경되는 매순간마다 호출해야되지만, 후자의 경우는 recomposition 마다 실행되지는 않으므로, 함수의 호출횟수를 최소한으로 줄일 수 있다.
조건부에 따라 함수 상태를 변경해야되는 경우에는 derivedStateOf 사용을 고려해볼 수 있다.
State Hosting
- State는 내려보내고, event는 올려보낸다 → 실제 데이터를 사용하는 함수에서는 Stateless 하게 된다.
- 재사용성이 높고, 테스트가 편해진다.
- State는 공유될 수도, 가로챌수도 있다.
- 단일 소스에서 오기(SSOT)때문에 신뢰성 ⬆️
@Composable
fun Cart(viewModel: CartViewModel = viewModel()) {
// ViewModel에서 데이터를 총괄하여 관리함
val cartItems by viewModel.cartItems
LazyColumn {
items(cartItems) { item ->
// 각각의 데이터를 하나의 아이템에 뿌려준다.
CartItem(
quantitiy = item.quantitiy,
incrementQuantity = {
viewModel.incrementQuantity(item)
},
decrementQuantity = {
viewModel.decrementQuantitiy(item)
},
)
}
}
}
자식에 있는 State를 부모 Composable(viewModel)이 호출하여 저장하는 방식이다.
- Scaffold -
scaffoldState - navHost -
navController
위의 두 사례도 StateHosting 이다.
compose 파일 내부에서 class를 만들어서 state holder를 제작하기 보다는 viewModel를 통해서 만드는 것이 좋다.
Saver
rememberSaveable 에서 상태의 직렬화 및 역직렬화를 담당하는 인터페이스
- save : 저장 가능한 상태로 변환
- restore : 원본 값을 복원 가능한 값으로 변환
Compose에서는 listSaver, mapSaver 를 지원한다.
remember라는 상태저장타입이 있는데, Saver까지 추가로 설정해서 rememberSaveable 를 사용하는 것일까
remember 은 Compose 함수 내부에서 상태를 보존하기에, 구성변경과 같은 상황에서는 상태가 제대로 보존되지 않을 수 있음
remember 는 해당 함수의 LifeCycle를 따르고 rememberSaveable 은 하나의 Compose(Fragment) LifeCycle를 따른다.
기존
@Composable
fun CraneEditableUserInput(
hint: String,
caption: String? = null,
@DrawableRes vectorImageId: Int? = null,
onInputChanged: (String) -> Unit
) { ... }
Hint와 입력 로직을 Composable함수에서 인자로 받아서 사용하고 있었음
변경
class EditableUserInputState(private val hint: String, initialText: String) {
var text by mutableStateOf(initialText)
val isHint: Boolean
get() = text == hint
companion object {
val saver: Saver<EditableUserInputState, *> = listSaver(
save = { listOf(it.hint, it.text) },
restore = {
EditableUserInputState(
hint = it[0],
initialText = it[1]
)
}
)
}
}
@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
// 인자로 들어가는 값은 초기의 값
rememberSaveable(hint, saver = EditableUserInputState.saver) {
EditableUserInputState(hint, hint)
}
@Composable
fun CraneEditableUserInput(
state: EditableUserInputState = rememberEditableUserInputState(""),
caption: String? = null,
@DrawableRes vectorImageId: Int? = null
) { /* ... */ }
함수 외부에 class로 입력 text와 hint를 State로 관리
함수에서 입력값을 관리하는 것이 아니라 최상위에서 class로 변수들을 관리함
snapshotFlow
Compose의 상태를 flow의 형태로 내보낸다.
val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
LaunchedEffect(currentOnDestinationChanged) {
snapshotFlow { editableUserInputState.text }
.filter { !editableUserInputState.isHint }
.collect {
currentOnDestinationChanged(editableUserInputState.text)
}
}
LaunchedEffect 와 같이 사용하여 관찰자가 변경될 때 Flow를 실행시키는 것으로 활용할 수 있다.
위의 코드는 DestinationChanged 가 변경되면 즉, 새로운 목적지 값이 입력된 경우에 isHint가 아니면 StateInput의 값을 변경 시키는 로직이다.
Navigation
React의 router 설정하는 것과 유사함
-
의존성 추가
implementation "androidx.navigation:navigation-compose:{latest_version}" -
NavController 설정
rememberNavController()를 사용해서 구성변경이 일어나도 값이 유지되도록 한다.→ 유저가 이동했던 경로가 포함되어 있는 정보이기에
항상 composable 함수의 최상단에 위치해야함
@Composable fun RallyApp() { RallyTheme { var currentScreen: RallyDestination by remember { mutableStateOf(Overview) } val navController = rememberNavController() Scaffold() {...} } } -
Navigation graph에
NavHost추가Navigation의 중요부분 →
NavController,NavGraph,NavHostNavController는 항상 하나의NavHost와 연관되어있음NavHost는NavGraph의 현재 목적지를 보여주고, 컨테이너 역할을 한다.composable 사이로 이동을 할 때
NavHost의 내용은 자동으로 recompose 된다.-
router 목록
interface RallyDestination { val icon: ImageVector val route: String val screen: @Composable () -> Unit } object Overview : RallyDestination { override val icon = Icons.Filled.PieChart override val route = "overview" override val screen: @Composable () -> Unit ={OverviewScreen()} } object Accounts : RallyDestination { override val icon = Icons.Filled.AttachMoney override val route = "accounts" override val screen: @Composable () -> Unit ={AccountsScreen()} } object Bills : RallyDestination { override val icon = Icons.Filled.MoneyOff override val route = "bills" override val screen: @Composable () -> Unit ={BillsScreen()} }
NavHost( navController = navController, // 처음 시작 startDestination = Overview.route, modifier = Modifier.padding(innerPadding) ) { // 이동할 화면 설정 composable(route = Overview.route) { Overview.screen() } composable(route = Accounts.route) { Accounts.screen() } composable(route = Bills.route) { Bills.screen() } }→ 위의 탭한 Icon은 정상적으로 바뀌지만 화면은 바뀌지 않음
왜나면 탭의 클릭이벤트에 지정을 하지 않았기 때문에
RallyTheme { var currentScreen: RallyDestination by remember { mutableStateOf(Overview) } val navController = rememberNavController() Scaffold( topBar = { RallyTabRow( allScreens = rallyTabRowScreens, onTabSelected = { newScreen -> navController.navigate(newScreen.route) }, currentScreen = currentScreen ) } ) { innerPadding -> NavHost( navController = navController, startDestination = Overview.route, modifier = Modifier.padding(innerPadding) ) { composable(route = Overview.route) { Overview.screen() } composable(route = Accounts.route) { Accounts.screen() } composable(route = Bills.route) { Bills.screen() } } } }상단의 탭선택 시
NavController.navigate()로 클릭한 탭의 route를 넘겨주고 아래의NavHost를 통해 맞는 route의 값에 따라서 화면을 보여준다. -
-
navigate에 추가적인 옵션을 부여하여 사용하고 싶다면, 확장함수 형태로 제작후 원하는 옵션을 입력하고
navController.navigate대신 작성한 확장함수를 사용한다.// 확장 함수 제작 fun NavHostController.navigateSingleTopTo(route: String) = this.navigate(route) { popUpTo ( this@navigateSingleTopTo.graph.findStartDestination().id ) { saveState = true } launchSingleTop = true restoreState = true }launchSingleTop: 여러번 같은 탭을 클릭해도, 최상단에 동일한 탭이 존재한다면 스택이 쌓이지 않고 하나만 존재popUpTo: 백스택에서 탐색하기 전에 지정한 목적지로 이동, 해당 목적지를 찾을 때 까지 일치하지 않는 대상을 모두 꺼냄- 뒤로가기를 클릭하면, 뒤에 아무리 다른 화면의 이동이 많아도, 내부에 지정된 화면으로만 이동된다.
restoreState: 이전의 저장된 경로를 복원해야 되는 여부
-
탭 UI 적용
var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }이전에는
onTapSelected에서 currentScreen의 값을 변경하지 않아 UI에 반영되지 않음val navController = rememberNavController() val currentBAckStack by navController.currentBackStackEntryAsState() val currentDestination = currentBAckStack?.destination val currentScreen = rallyTabRowScreens.find { it.route == currentDestination?.route } ?: OverviewnavController 에서 현재 화면의 id를 받아와 상단 화면과 비교 후 맞는 화면으로 반환한다.
이러면 이전에는 currentScreen이 반영되지 않아서 상단 탭 UI가 변경되지 않았는데, 화면이 변경됨에 따라 반영을 하여 정상적으로 탭 UI가 변경되는 것을 볼 수 있다.
6 . 한가지 경로로 여러가지 데이터 보여주기
“route/{argument}”→ 특정 경로의 아이디에 따라서 보여주는 데이터가 달라짐NavHost() { composable( route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}", arguments = listOf( navArgument(SingleAccount.accountTypeArg) { type = NavType.StringType} ) ) { SingAccountScreen() } }route- 경로 형식을 작성arguments-route에{argument}로 들어가는 것에 대한 정보를 작성한다.-
위의 arguments 에 대한 정보는 NavHost 가 아닌,
SingleAccount에 변수로 작성해도 된다.object SingleAccount : RallyDestination { ... const val accountTypeArg = "account_type" val arguments = listOf( navArgument(accountTypeArg) { type = NavType.StringType } ) }
그럼 이렇게 경로로 넘겨준 정보는 어디에서 확인하는가?
→ NavHost에서 argument에 대한 정보를 같이 넘겨줘 전달해주어야한다.
composable( route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}", arguments = SingleAccount.arguments ) { navBackStackEntry -> val accountType = navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg) SingleAccountScreen(accountType) }- 해당 경로에 작성된 composable 블록 내부에서 argument를 가져와 compose 함수에 넘겨주면, route의 경로에 작성되있는 값을 가져올 수 있다.
-
route 경로 설정 대신 딥링크 설정
manifest에 추가설정<activity> <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="rally" android:host="single_account" /> </intent-filter> </activity>data 필드 덕분에
url : rally://single_account로 변경되었다.그리고 더이상 composable에 argument를 작성할 필요가 없다.
composable( route = SingleAccount.routeWithArgs, // arguments = SingleAccount.arguments deepLinks = listOf(navDeepLink { uriPattern = " relly://${SingleAccount.route}/{${SingleAccount.accountTypeArg}}" }) ) -
Compose Navigation Test
Navigation 을 최상위 Compose에서 전달받아 사용했기 때문에 Test할 때 NavController 인스턴스가 필요하지 않다.
의존성 추가 :
androidTestImplementation "androidx.navigation:navigation-testing:$rootProject.composeNavigationVersion"테스트 케이스 작성
class NavigationTest { @get:Rule val composeTestRule = createComposeRule() lateinit var navController: NavHostController @Before fun setupRallyNavHost() { // Navigation에 필요한 Controller 설정 composeTestRule.setContent { navController = TestNavHostController(LocalContext.current) // NavController의 navigation을 직접 설정함 navController.navigatorProvider.addNavigator( ComposeNavigator() ) RallyNavHost(navController = navController) } } @Test fun rallyNavHost_verifyOverviewStartDestination() { composeTestRule // 실제로 이동해서 해당 Content를 클릭 .onNodeWithContentDescription("Overview Screen") .assertIsDisplayed() } @Test fun rallyNavHost_clickAllAccount_navigatesToAccounts() { composeTestRule .onNodeWithContentDescription("All Accounts") .performClick() composeTestRule .onNodeWithContentDescription("Accounts Screen") .assertIsDisplayed() } }onNodeWithContentDescription에 인자로 들어간 부분은Modifier.semantics { contentDescription = "" }으로 Modifier의 속성으로 들어가있다.
UI test
class ExampleTest {
// UI 테스트 실행을 위해 필요한 인스턴스
@get:Rule
val rule = createComposeRule()
// 실제 테스트를 실행하는 코드
@Test
fun enterFormula_showFormula() {
// 테스트 실행에 필요한 화면 설정
rule.setContent { MyCalculator() }
rule.onNodeWithText("1").performClick()
// 확인
rule.assert("", "")
}
}
compose에서 요소들은 Node 로 표현된다.
- onNodeWith… : view의 findViewById 와 같이 해당 요소를 찾는다.
onNodeWithText: 해당 글씨가 포함된 요소를 선택한다. 작성된 text 기준으로 찾기에, 원치않는 요소가 선택될 수 있다.onNode(): 인자안에 원하는 조건을 설정하여 요소를 선택할 수 있다.onNodeWithContentDescription: Compose에는 따로 요소에 ID를 지정할 수 없기때문에 고유한 값을 modifier.semantic { contentDescription = “” } 으로 설정한다.
- perform… : 선택된 요소로 어떤 동작을 할지 정의한다.
- assert : 원하는 결과가 도출되었는지 확인
이것을 무한애니메이션 대신, 반복가능한 애니메이션으로 변경한다.
// **이전**
val animatedElevation = animateDpAsState(
targetValue = currentTargetElevation,
animationSpec = **tween(durationMillis = 500)**,
finishedListener = {
currentTargetElevation = if (currentTargetElevation > 4.dp) {
1.dp
} else {
8.dp
}
}
)
// **변경**
val infiniteElevationAnimation = rememberInfiniteTransition()
val animatedElevation: Dp by infiniteElevationAnimation.animateValue(
initialValue = 1.dp,
targetValue = 8.dp,
typeConverter = Dp.VectorConverter,
animationSpec = **infiniteRepeatable**(
animation = tween(500),
repeatMode = RepeatMode.Reverse
)
)
접근성
- 화면에 터치하기 위한 최소 사이즈는
48dpIcon을 사용하면 일일히 사이즈를 지정해줘야하는데IconButton을 사용하면 그럴필요 없이 사이즈가 자동으로 점유된다.
- 레이블 클릭
Modifier.clickable(onClickLabel)에 클릭했을 때의 출력할 내용을 ㅅ설정modifier.semantic()안에 contextDescription 이나 label을 집어넣어 음성비서를 쓸 때 적혀질 단어를 작성
- Image 나 Icon - contentDescription
- TalkBack을 작동시, 해당 버튼의 이름
- Headings - semantic { heading() }
- 사용자 지정으로 합치기
- 게시물의 저자의 정보에 대한 부분은, 프로필 / 이름 / 작성일자 로 쪼개져 있기 때문에 일기 매우 불편하다.
- 전체 컨텐츠를 감싸고 있는
Row/Column-Modifier.semantics(mergeDescendants = ture)로 설정