업데이트:

카테고리:

/

태그: , , , , , ,

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 내부에서는 아래와 같이 remembermutableStateOf 를 같이 사용해야된다.

remember는 recomposition이 일어나도 변경되지 않고 유지된다.

val expanded = remember { mutableStateOf(false) }

remeber는 구성변경, Lazy에서 스크롤에서 벗어나고 다시 돌아올 때 내부의 상태값이 소실된다.

그래서 Acivity가 소실되고 다시 생성되도 이전에 값을 유지하고 싶으면 rememberSaveable 을 사용한다.

ViewModel

android kotlin과 다르게 compose는 선언형으로 구성되어 있어 MainActivity에서 모든 컴포넌트를 관리하는 것과 다르게 한 컴포넌트가 하나의 함수로 구성되어 있다.

그렇기 때문에 viewModel를 사용하기 위해서는 각 함수마다 의존성을 추가해주어야 한다.

  1. 의존성 추가

    implementation "androidx.lifecycle:lifecycle-viewmodel-compose:{latest_version}"

  2. 함수의 인자로 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

  1. 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 를 이용하여 람다식을 기억해야 한다.

  2. rememberCoroutineScope

    composable 함수에서 비동기 작업을 실행할 때 사용

  3. 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 구문이 필요하다.

  4. 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() }
    }
}
  1. derivedStateOf

    !https://developer.android.com/static/codelabs/jetpack-compose-advanced-state-side-effects/img/2c112d73f48335e0.gif

    스크롤이 특정위치에 도달하면 하단에 상단으로 바로가는 버튼이 보여진다.

    처음 아이템이 지났는지 확인해야 한다.

    LazyColumn - LazyListState

    val 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의 값을 변경 시키는 로직이다.

React의 router 설정하는 것과 유사함

  1. 의존성 추가

     implementation "androidx.navigation:navigation-compose:{latest_version}"
    
  2. NavController 설정

    rememberNavController() 를 사용해서 구성변경이 일어나도 값이 유지되도록 한다.

    → 유저가 이동했던 경로가 포함되어 있는 정보이기에

    항상 composable 함수의 최상단에 위치해야함

     @Composable
     fun RallyApp() {
         RallyTheme {
             var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
             val navController = rememberNavController()
             Scaffold() {...}
         }
     }
    
  3. Navigation graph에 NavHost 추가

    Navigation의 중요부분 → NavController, NavGraph, NavHost

    NavController 는 항상 하나의 NavHost 와 연관되어있음

    NavHostNavGraph 의 현재 목적지를 보여주고, 컨테이너 역할을 한다.

    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의 값에 따라서 화면을 보여준다.

  1. navigate 옵션 사용

    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 : 이전의 저장된 경로를 복원해야 되는 여부
    1. 탭 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 } ?: Overview
      

      navController 에서 현재 화면의 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의 경로에 작성되있는 값을 가져올 수 있다.
  2. 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}}"
     })
     )
    
  3. 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 로 표현된다.

  1. onNodeWith… : view의 findViewById 와 같이 해당 요소를 찾는다.
    • onNodeWithText : 해당 글씨가 포함된 요소를 선택한다. 작성된 text 기준으로 찾기에, 원치않는 요소가 선택될 수 있다.
    • onNode() : 인자안에 원하는 조건을 설정하여 요소를 선택할 수 있다.
    • onNodeWithContentDescription : Compose에는 따로 요소에 ID를 지정할 수 없기때문에 고유한 값을 modifier.semantic { contentDescription = “” } 으로 설정한다.
  2. perform… : 선택된 요소로 어떤 동작을 할지 정의한다.
  3. 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
        )
    )

접근성

  1. 화면에 터치하기 위한 최소 사이즈는 48dp
    • Icon 을 사용하면 일일히 사이즈를 지정해줘야하는데 IconButton 을 사용하면 그럴필요 없이 사이즈가 자동으로 점유된다.
  2. 레이블 클릭
    • Modifier.clickable(onClickLabel) 에 클릭했을 때의 출력할 내용을 ㅅ설정
    • modifier.semantic() 안에 contextDescription 이나 label을 집어넣어 음성비서를 쓸 때 적혀질 단어를 작성
  3. Image 나 Icon - contentDescription
    • TalkBack을 작동시, 해당 버튼의 이름
  4. Headings - semantic { heading() }
  5. 사용자 지정으로 합치기
    • 게시물의 저자의 정보에 대한 부분은, 프로필 / 이름 / 작성일자 로 쪼개져 있기 때문에 일기 매우 불편하다.
    • 전체 컨텐츠를 감싸고 있는 Row / Column - Modifier.semantics(mergeDescendants = ture) 로 설정