Android

안드로이드 [Kotlin] - Jetpack Compose 코트랩 실습(3)

🤖 Play with Android 🤖 2023. 6. 18. 21:18
728x90


Compose UI 설계

단방향 데이터 흐름

State를 더 잘 이해하려면 기존의 View System의 흐름에 대해 알아야한다.


View System

  1. 사용자가 버튼을 클릭
  2. Click Event가 발생
  3. Event Handler가 상태를 업데이트
  4. 상태와 관련된 UI가 새로 업데이트

구조화 되지 않은 상태

  • 사용자가 이름을 입력하면 바로 화면에 "Hello 이름" 이 출력되도록 하려 한다.
  • 구현하는 한가지 방법은 TextView에 이벤트 콜백을 추가하는 것이다.
class HelloCodelabActivity : AppCompatActivity() {

   private lateinit var binding: ActivityHelloCodelabBinding // ViewBinding 사용
   var name = ""

   override fun onCreate(savedInstanceState: Bundle?) {
       binding.textInput.doAfterTextChanged {text ->
           name = text.toString()
           updateHello()
       }
   }

   private fun updateHello() {
       binding.helloText.text = "Hello, $name"
   }
}

하지만 몇가지 문제점들이 존재한다.

  1. 화면에 이벤트가 많아지면 관리가 힘듬
  2. 테스트의 어려움

기존의 구조화되지 않은 data flow를 개선하기 위해 AAC에서 제공되는 것이 ViewModel 이다.


단방햔 데이터 흐름

// ViewModel
class HelloCodelabViewModel: ViewModel() {

   //state를 갖고있는 LiveData 
   //UI 에서 이를 관찰한다.
   private val _name = MutableLiveData("")
   val name: LiveData<String> = _name

   //UI에서 호출하는 이벤트
   fun onNameChanged(newName: String) {
       _name.value = newName
   }
}

// Activity
class HelloCodeLabActivityWithViewModel : AppCompatActivity() {
   private val helloViewModel by viewModels<HelloCodelabViewModel>()

   override fun onCreate(savedInstanceState: Bundle?) {

       binding.textInput.doAfterTextChanged {
           helloViewModel.onNameChanged(it.toString()) 
       }

       helloViewModel.name.observe(this) { name ->
           binding.helloText.text = "Hello, $name"
       }
   }
}
  • VewModel은 UI에서 보관했던 State를 추출시켜준다.

구조화된 상태와 단방향 데이터 흐름의 차이는 무엇일까?

  • ViewModel을 사용하였더니 우리는 State를 Activity, 즉 View에서 분리시킬 수 있었다.
  • LiveData는 관찰가능한 상태를 담는 홀더(그릇)으로 state의 변화를 감지할 수 있게 해준다.

Observable : 관찰 가능한 상태를 담는 홀더로써 LiveData, Flow, StateFlow 등이 있다.


이렇게 구조화된 코드는

  • 이벤트는 UI에서 ViewModel로 위로 흐른다.
  • State는 ViewModel에서 UI로 아래로 흐른다.

이렇게 두 가지 특징이 있다.

image

Compose에서 이 패턴을 따를 때 몇 가지 이점이 존재한다.

  • 테스트 가능성 : UI로부터 state를 분리하는 것으로, ViewModel 및 Activity의 테스트가 더 쉬워진다.
  • State 캡슐화 : ViewModel 과 같은 하나의 장소에서만 state가 업데이트 될 수 있기 때문에, UI가 커짐에 따라 부분적인 state 업데이트 버그가 발생할 가능성이 줄어진다.
  • UI 일관성 : 모든 state 갱신은 state를 가지고 있는 observable을 사용하는 UI에서 즉시 반영된다

컴포저블 매개변수 정의

컴포저블에서 State 매개변수를 정의할 때는 몇 가지 사항을 고려해야 한다.

  • 컴포저블의 재사용 가능성
  • State 매개변수가 컴포저블에 미치는 영향

재사용을 쉽게 하기 위해서는 각 컴포저블에는 가능한 최소한의 정보만을 포함해야 한다. 예를 들어 뉴스 기사 헤더를 가져오는 경우 전체 뉴스가 아니라 필요한 정보들만 가져오는 것이 바람직하다.

@Composable
fun Header(news: News) {
    // Header 컴포저블 함수에 뉴스 전체를 가져오고 있다. 
    // 이는 컴포저블에는 최소한의 정보만을 포함해야 한다는 규칙에 위배된다.
}

@Composable
fun Header(title: String, subtitle: String) {
    // 바람직한 Header 컴포저블 함수
}
  • 하지만 너무 많은 매개변수는 가독성 및 함수의 효과를 떨어트리므로 공통된 부분을 묶어 그룹화 하는 것이 바람직하다.

UI State 생성 파이프라인

최신 UI는 동적이다. 사용자가 UI와 상호작용할 때 실시간으로 UI 상태가 변경된다.
기본적으로 State, 즉 상태는 항상 존재하며 이벤트로 인해 변경되게 된다.

image
이벤트 상태
일시적이고 예측할 수 없으며 일정 기간 존재한다. 항상 존재한다.
상태 생성의 입력 역할 상태 생성의 출력
  • 위 내용을 요약하면 상태는 기존에 존재하고 있고 이벤트는 불시에 발생한다는 것이다.
  • 각 이벤트는 적절한 State Holder 에 의해 처리되고 따라서 상태가 변경되게 된다.

코드랩에서 다운받은 앱은 현재 항공편 목적지 목록이 비어있는 상태이다. 우리가 할 일은 다음과 같다.

  • ViewModel에 로직을 추가하여 UI State를 생성한다. 이 앱에서는 추천 목적지 목록이 될 것이다.
  • View에서 UI State를 사용해서 UI를 표시한다.

MainViewModel

private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
  • 추천 목적지 목록(State) 을 저장하는 MutableStateFlow 타입의 변수 _suggestedDestinations를 정의하고 초기값으로 empyList() 를 넣어준다.

  • StateFlow 타입, 즉 불변 타입의 변수 suggestedDestinations 을 다시 정의한다.


Koltin Backing Property

  • MutableStateFlowViewModel 안에서만 데이터가 수정될 수 있다.
  • StateFlow 는 외부에서 데이터를 읽을 수는 있지만, 수정할 수는 없다.

이렇게 하는 이유는 외부에서 데이터를 변경하지 못하게 하고, 내부에서만 변경이 가능하게 하는 캡슐화를 하기 위해서다. 따라서 View는 ViewModel을 통해서만 UI 상태를 수정할 수 있게 된다.


init {
    _suggestedDestinations.value = destinationsRepository.destinations
}
  • 이후에는 init 블록, 즉 ViewModel이 생성되고 바로 실행되는 블록에서 destinationsRepository 호출을 추가하여 Data Layer에서 목적지 목록을 가지고 온다.

   fun updatePeople(people: Int) {
        viewModelScope.launch {
            if (people > MAX_PEOPLE) {
              _suggestedDestinations.value = emptyList()
            } else {
                val newDestinations = withContext(defaultDispatcher) {
                    destinationsRepository.destinations
                        .shuffled(Random(people * (1..100).shuffled().first()))
                }
                  _suggestedDestinations.value = newDestinations
            }
        }
    }

    fun toDestinationChanged(newDestination: String) {
        viewModelScope.launch {
            val newDestinations = withContext(defaultDispatcher) {
                destinationsRepository.destinations
                    .filter { it.city.nameToDisplay.contains(newDestination) }
            }
              _suggestedDestinations.value = newDestinations
        }
    }
}
  • 추가로 UI에서 발생한 이벤트로 상태를 정상적으로 업데이트하기 위한 코드를 작성해준다.

ViewModel에서 안전하게 Flow 및 State 사용하기

  • UI와 ViewModel을 연결하지 않았으니 당연히 목적지 목록은 여전히 보이지 않는다.
  • 이제 ViewModel의 StateFlow로 선언되어 있는 suggestedDestinations 의 데이터가 변경될 때마다, 즉 상태가 변할 때마다 CraneContent 컴포저블의 UI가 업데이트 되도록 해야한다.
  • 이 때 사용하는 것이 collectAsStateWithLifecycle() 함수이다.

collectAsStateWithLifecycle

  • collectAsStateWithLifecycle은 flow 에서 값을 수집하고 최신 값을 Compose 상태로 나타내는 라이프사이클 인식 방식의 composable 함수이다.
  • 새로운 flow 방출이 발생할 때마다 이 상태 객체의 값이 업데이트 된다.
  • 이렇게 하면 컴포지션의 모든 State.value 가 사용되는 부분에서 재구성이 일어난다.

기본적으로 collectAsStateWithLifecycleLifecycle.State.STARTED 를 사용하여 flow에서 값 수집을 시작하거나 중지한다. 이것이 collectAsState 와의 차이점이다.

image
  • 또한 collectAsStateWithLifecycle 은 내부적으로 Android에서 Flow를 수집할 때 권장하는 repeatOnLifecycle API를 사용하고 있다고 한다.

CraneHome

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun CraneHomeContent(
    onExploreItemClicked: OnExploreItemClicked,
    openDrawer: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: MainViewModel = viewModel(),
) {

    val suggestedDestinations by viewModel.suggestedDestinations.collectAsStateWithLifecycle()
    ...
}
  • collectAsStateWithLifecycle() 을 통해 ViewModel의 데이터를 컴포저블에 연결해주었다.
  • 이제 정상적으로 목적지 목록이 보이는 것을 확인할 수 있다.
  • collectAsStateWithLifecycle() 을 사용했으므로 해당 View가 백그라운드로 들어갔을 때는 Flow가 수집을 중지한다.

LaunchedEffect 및 rememberUpdatedState

  • 코드랩에서 말하는 랜딩 스크린은 우리가 흔히 아는 스플래시 화면과 동일한 것 같다.
  • onTimeout 콜백으로 스크린을 적절하게 사라지게 해주자
  • 안드로이드에서 일반적으로 비동기, 혹은 백그라운드 작업을 위해 코루틴을 많이 사용하는데, Compose는 UI 레이어에서 코루틴을 안전하게 사용할 수 있는 API를 제공한다.
  • 여기서는 백엔드와의 통신을 가정하여 delay 함수를 사용한다.

Landing Screen

@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        val currentOnTimeout by rememberUpdatedState(onTimeout)

        // Make LandingScreen disappear after loading data
        LaunchedEffect(Unit) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

여기서 LanchedEffect 라는 함수가 나오는데 이를 위해서는 Compose에서의 Side Effect 를 알아야 한다.


Side Effect

컴포저블 범위 밖에서 발생하는 앱 상태에 대한 변경

  • Composable을 사용할 때 여러 Composable을 겹쳐서 사용한다.
  • 이 경우 시스템은 각 Composable에 대한 LifeCycle을 만들게 된다.
  • 또한, 기본적으로 Composable은 바깥쪽에서 안쪽으로 State를 내려준다.

하지만

  • 안쪽 컴포저블에서 바깥쪽에 있는 컴포저블의 상태에 대한 변경을 하는 경우
  • 컴포저블에서 컴포저블이 아닌 앱 상태에 대해 변화를 주는 경우

이 경우, 단뱡향이 아닌 양방향 의존성으로 Effect가 생기며 이를 Side Effect라고 부른다.


Side Effect 처리 방법

  • 컴포저블은 Side Effect가 없는 것이 좋으나, 앱 상태를 변경해야 하는 경우 Side Effect를 예측 가능한 방식으로 실행되도록 Effect API를 사용 해야 한다.
  • 이 때 사용하는 API 중 하나가 LanchedEffect 이다.

LanchedEffect

  • LanchedEffect는 Composable에서 컴포지션이 일어날 때, suspend fun을 실행해주는 컴포저블이다.
@Composable
fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutinScope.() -> Unit
) {
    ...
}
  • 재구성은 컴포저블의 상태가 바뀔 때마다 일어나므로, 만약 매번 리컴포지션이 일어날 때마다 이전 LaunchedEffect 가 취소되고 다시 수행된다면 매우 비효율적일 것이다.
  • 따라서 LaunchedEffect는 key라고 불리는 기준값을 두어 이 기준값이 변경될 때만 suspend fun을 취소하고 재실행한다.
image

예를 들어 TextField에 입력되는 문자를 스낵바로 보이게 만드는 상황을 생각해보자. 만약 문자가 바뀐다면 이전의 스낵바는 취소되고 새로운 스낵바가 보여야 할 것이다,

이를 구현하기 위해서는 TextField의 Text가 바뀔때마다, 즉 State가 변경될 때마다 스낵바가 보이도록 하는 suspend fun이 취소되고 재수행되어야한다.

@Composable
fun SampleScreen() {
    val scaffoldState = remeberScaffoldState()
    val text by rememberSaveable { mutableStateOf("") } // text 변수(상태)

    LaunchedEffect(text) { // text 변수를 LaunchedEffect의 key로 설정
        // 이 블록은 text가 바뀔 때마다 취소대고 재수행된다.
        scaffoldState.snackbarHostState.showSnackbar(
            message = "Current Text is $text"
        )
    }
}

이를 위해 위 코드에서는 다음과 같이 구현하였다,

  • TextField의 Text값이 변화하는 값을 저장하는 text 변수를 만들었다.
  • text 변수를 LaunchedEffect의 key값으로 주어 text가 바뀔 때마다 LaunchedEffect가 재수행되게 하게 하였다.
  • LaunchedEffect의 suspend fun block 에서 스낵바 생성하였다.

Key값을 두 개 이상 만들기

만약 LaunchedEffect의 block을 두 개 이상의 변수에 의해 재실행되어야 할 때는 key를 하나 더 명시하면 된다/

fun LaunchedEffect(
    key1: Any?,
    key2: Any?,
    block: suspend CoroutineScope.() -> Unit
)
LaunchedEffect(text, number) {
    scaffoldState.snackbarHostState.showSnackbar(
        message = "Current Text is $text And Number is $number"
    )
}

예를 들어 number라는 새로운 변수도 LaunchedEffect를 발생시키는 값이라면 단순히 text 뒤에 number을 명시해주는 것만으로 가능하다.


rememberCoroutineScope

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun CraneHomeContent(
    onExploreItemClicked: OnExploreItemClicked,
    openDrawer: () -> Unit, // 이 곳에서 수행
    modifier: Modifier = Modifier,
    viewModel: MainViewModel = viewModel(),
)
  • 위 코드에서 scaffoldState.drawerState.open() 는 suspend 함수이기 때문에 코루틴 스코프 안에서 호출되어야 한다.
  • 이 때 어떤 코루틴 스코프를 사용해야 좋은지 고민할 필요가 있다.

컴포저블에서 올바른 Coroutine Scope를 선택하는 것이 중요한 이유

  • 컴포저블 내부에서 코루틴을 수행할 경우, 컴포저블에 대한 재구성이 일어날 때 정리되어야 하는 코루틴이 정리되지 않아 코루틴이 쌓일 수 있다.
  • 재구성은 자주 일어날 수 있는 동작이므로 재구성마다 이렇게 코루틴이 쌓이게 된다면 오버헤드가 쌓여 앱 크래시가 발생할 수도 있다.

따라서 대부분의 경우 컴포저블에서 코루틴을 생성한다면 재구성 시 파괴되어야 한다.

일반 lifecycleScope을 사용했을 경우 문제점

  • 스낵바를 누르면 스낵바가 띄워지는 화면이 있다고 해보자.
  • 이 때 이 스낵바를 출력하는데 사용하는 코루틴 스코프로 lifecycleScope 를 사용하면 어떻게 될까?

lifecycleScope은 Activity의 생명주기를 따르다보니, 스낵바를 띄우자마자 다른 화면으로 이동, 즉 컴포저블이 재구성될 때도 Coroutine Job이 살아있어 스낵바가 보이게 된다.

image

즉 다른 스크린으로 넘어가서 정지되어야 하는 코루틴 작업임에도 불구하고 정리가 되지 않는 상황이다.


rememberCoroutineScope

  • rememberCoroutineScope 은 컴포저블의 생명주기를 따르는 코루틴 스코프이다.
  • 따라서 컴포저블이 파괴될 때 파괴되어야하는 코루틴은 rememberCoroutineScope 안에서 실행시켜 컴포저블이 파괴될 때 파괴시켜야 한다.
image

rememberCoroutineScope 은 첫번째 스크린의 생명주기를 따르다 보니, 첫번째 스크린이 파괴되고 다른 스크린으로 넘어갈 때 실행되는 코루틴 Job 또한 취소 된다. 즉 수행되고 있던 스낵바도 사라지게 된다.

정리하면 Composable이 파괴될 때 파괴되어야 하는 코루틴을 생성해야될 때는 rememberCoroutineScope을 사용해야 한다.


State Holder

상태의 수가 적다면 Composable 안에서 관리하는 것이 편할 수 있다. 하지만 그 수가 많아지면 어떻게 해야 할까?


State Holder란

State holder 는 복잡한 UI 의 상태와 그것과 관련된 로직을 담고있는 객체이다. 단일 UI 위젯에 관련된 것일 수도, 전체 화면에 관련된 것일 수도 있다.

image
  • 하나의 UI 위젯은 0 ~ N개의 State Holder 에 의존할 수 있다.
  • 어떤 State Holder 는 비즈니스 로직이나 화면의 상태(screen state)에 접근해야할 경우 ViewModel 에 의존할 수 있다.
  • ViewModel 은 데이터 레이어에 의존한다.

Disposable Effect

  • Dissposable Effect란 컴포저블이 배치된 후에 정리해야 할 Side Effect가 있는 경우에 쓰인다.
  • 즉 컴포저블의 Lifecycle에 맞춰 정리해야 하는 리스너나 작업이 있는 경우에 그 리스너나 작업을 제거하기 위해 사용된다.

안드로이드에서는 Lifecycle에 따라 Side Effect를 발생시킨 다음 정리해야 하는 경우가 있다. 이런 부분에서 제대로 Side Effect를 정리하지 않으면 메모리 누수 혹은 의도치 않은 결과가 나올 수 있다.

Disposable Effect 사용 방법

@Composable
fun DisposableEffect(
    key1: Any?,
    effect: DisposableEffectScope.() -> DisposableEffectResult
) {
    remember(key1) { DisposableEffectImpl }
}
  • key값은 Disposable Effect가 재수행되는 것을 결정하는 파리미터이다.
  • effect 람다식은 DisposableEffectResult를 반환하는 식이다.

DisposableEffect(key) {
    // 컴포저블이 제거될 때 Dispose 되어야 하는 효과 초기화
    onDispose {
        // Dispose 되어야 하는 효과 제거 (컴포저블이 Dispose 될 때 호출된다.)
    }
}
  • 위 코드에서 effect 블록은 처음에는 초기화 로직만 수행하고 이후에는 key값이 바뀔 때마다 onDispose 블록을 호출한 후 초기화 로직을 다시 호출한다.
  • onDispose 블록의 리턴 값이 바로 DisposableEffect여서onDispose 블록은 effect 람다식의 맨 마지막에 꼭 와야 한다.

사용 예시

로깅을 한다고 가정해보자. 분석을 위한 로깅은 Activity의 onStart에서 시작되어 onStop에서 끝나야 한다. 그렇지 않으면 앱이 백그라운드에 내려가서도 로깅 작업이 지속될 것이며, 이는 로깅의 신뢰도를 저하시킨다. 따라서 로깅을 위해서는 다음의 LifecycleEventObserver을 Screen Composable의 lifecycle에 붙여야 한다.

val observer = LifecycleEventObserver { _, event ->
    if (event == Lifecycle.Event.ON_START) {
        startLoggingOnStart()
    } else if (event == Lifecycle.Event.ON_STOP) {
        stopLoggingOnStop()
    }
}
  • 위의 observer을 Lifecycle에 붙이기 위해서 LaunchedEffect를 사용하였다.
  • 하지만 이 코드는 문제를 가지고 있다.

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    _onStartLogging: () -> Unit,
    _onStopLogging: () -> Unit
) {
    val startLoggingOnStart by rememberUpdatedState(_onStartLogging)
    val stopLoggingOnStop by rememberUpdatedState(_onStopLogging)

    LaunchedEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                startLoggingOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                stopLoggingOnStop()
            }
        }

        lifecycleOwner.lifecycle.addObserver(observer)
        println("KotlinWorld >> Observer Attached")
    }
}
  • 위 코드에서 문제는 바로 observerlifecycleOwner 가 바뀔 때마다 lifecycleOwnerlifecycle 에 붙는데 이 observer 가 정리되는 부분이 없다.
  • 만약 observer 가 정리되지 않는다면 저 observer 은 계속해서 이전 lifecycleOwner 에 붙어 있을 것이다.

위와 같이 정리되어야 하는 Effect(observer)가 있는 경우에는 바로 Disposable Effect를 사용할 수 있다. lifecycle이 바뀔 때 새로운 옵저버가 lifecycle에 붙어 변화를 구독하고, composable이 제거될 때 observer 또한 정리된다.

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    _onStartLogging: () -> Unit,
    _onStopLogging: () -> Unit
) {
    val startLoggingOnStart by rememberUpdatedState(_onStartLogging)
    val stopLoggingOnStop by rememberUpdatedState(_onStopLogging)

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                startLoggingOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                stopLoggingOnStop()
            }
        }

        lifecycleOwner.lifecycle.addObserver(observer)
        println("KotlinWorld >> Observer Attached")

        // onDispose 블록은 effect 람다식의 맨 마지막에 꼭 와야 한다.
        onDispose {
            // Composable이 dispose될 때 observer을 제거함
            lifecycleOwner.lifecycle.removeObserver(observer) 
            println("KotlinWorld >> Observer Removed")
        }
    }
}

앱 실행
image

  • onCreate후 Composition Start되어 observer가 lifecycle에 Attach되고 logging이 시작되었다.

홈 버튼 누르기
image

  • onStop 수행 되어 logging이 중지되었다.

다시 앱 실행

image
  • onStart가 수행되어 logging이 다시 시작되었다.

백 버튼으로 앱 종료

image
  • onStop이 수행되어 로깅이 종료되고 onDestroy가 호출되기 전 컴포저블이 onDispose되어 Observer가 제거되었다.

derivedStateOf

image

  • 위 그림과 같이 스크롤을 내려 첫번째 요소를 넘기면 가장 상단으로 가게 하는 버튼을 만들 것이다.

사용자가 첫 번째 항목을 넘겼는지 계산하려면 LazyColumn의 LazyListState를 사용하고 listState.firstVisibleItemIndex > 0인지 확인하면 된다.

val showButton = listState.firstVisibleItemIndex > 0
  • 위 코드는 효율적이지 않다.
  • showButton을 읽는 컴포저블 함수는 firstVisibleItemIndex가 변화할 때마다 재구성을 시도할 것이기 때문이다.
  • 매번 firstVisibleItemIndex를 확인하는 것이 아니라 true와 false 간에 조건이 변경될 때만 함수를 재구성하면 효율적일 것이다.

이를 가능하게 해주는 것이 derivedStateOf 이다.

val showButton by remember {
    derivedStateOf {
        // 해당 블록안의 값이 변경될 때만 재굿어을 시도함
        listState.firstVisibleItemIndex > 0
    }
}

애니메이션

간단한 값 변경 애니메이션

  • Compose에서 가장 간단한 Animation API 중 하나인 animate*AsState API부터 시작해보자
  • 이 API는 상태 변경에 애니메이션을 적용하고 싶을 때 사용한다.
image
  • 콘텐츠 내용이 바뀌지는 않지만 콘텐츠의 배경 색상이 변경되는 것을 확인할 수 있다.
val backgroundColor = if (tabPage == TabPage.Home) Purple100 else Green300
  • 여기서 tabPage 는 값에 따라 배경 색상은 보라색과 녹색 간에 전환된다.
  • 이 값을 애니메이션으로 처리해보자.

간단한 값 변경을 애니메이션으로 처리하려면 animate*AsState API를 사용하면 된다. 이 경우에는 animateColorAsState 으로 간단히 래핑하여 애니메이션 값을 생성할 수 있다.

// 색 변화를 애니메이션 처리
val backgroundColor by animateColorAsState(if (tabPage == TabPage.Home) Purple100 else Green300)

반환된 값은 State 객체이므로 by 선언과 함께 local delegated property 즉 위임패턴을 사용하여 일반 변수처럼 처리할 수 있다.


가시성 애니메이션

  • 앱의 콘텐츠를 스크롤하면 스크롤 방향에 따라 플로팅 작업 버튼이 확장되거나 축소시켜보자.
if (extended) {
    Text(
        text = stringResource(R.string.edit),
        modifier = Modifier
            .padding(start = 8.dp, top = 3.dp)
    )
}
  • 현재 HomeFloatingActionButton 컴포저블 안에 있다.
  • “EDIT” 라는 텍스트는 if 문을 사용하여 표시하거나 숨긴다.

AnimatedVisibility(extended) {
    Text(
        text = stringResource(R.string.edit),
        modifier = Modifier
            .padding(start = 8.dp, top = 3.dp)
    )
}
  • 이 작업에 애니메이션을 적용하는 것은 if 를 AnimatedVisibility 컴포저블로 간단히 바꾸어 주기만 하면 된다.

AnimatedVisibility

  • AnimatedVisibility는 지정된 Boolean 값이 변경될 때마다 애니메이션을 실행한다.
  • 기본적으로 AnimatedVisibility는 페이드 인 및 확장하여 요소를 표시하고 페이드 아웃 및 축소하여 요소를 숨긴다.

FAB 버튼을 클릭하면 편집 기능이 지원되지 않는다라는 메세지가 표시된다. 또한 AnimatedVisibility를 사용하여 나타났다가 사라지는 애니메이션이 실행된다. 이 애니메이션을 커스터마이징하여 요소가 위에서 안으로 들어가고, 위로 미끄러지도록 하는 방법을 살펴보자.
AnimatedVisibility(
    visible = shown,
    enter = slideInVertically(),
    exit = slideOutVertically()
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colors.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

애니메이션을 커스터마이징 하려면 AnimatedVisibility 컴포저블에 enterexit 매개변수를 추가해야 한다.

  • 내려오는 애니메이션은 정상이지만 올라가는 애니메이션이 부자연스럽다.
  • 이는 slideInVertically 및 slideOutVertically의 기본 동작이 항목 높이의 절반을 사용하기 때문이다.

enter 매개변수와 exit 매개변수에 대해 좀 더 구체적으로 알아보자

  • enter 매개변수는 EnterTransition 의 인스턴스여야 한다.
  • 이 예제에서는 SlideInVertically 함수를 사용하여 EnterTransition 을 만들 수 있다.
  • 이 함수를 사용하면 initialOffsetYanimationSpec 매개변수로 추가하여 커스터마이징이 가능하다.
  • initialOffsetY 는 초기 위치를 반환하는 람다식이여야 한다.
  • 람다는 요소의 높이에 해당하는 인자 하나를 수신하므로, 간단히 음수를 반환할 수 있다.

SlideInVertically 를 사용할 때 슬라이드 인 후 대상 오프셋은 항상 0(픽셀)이다. initialOffsetY 는 절대값 또는 람다 함수를 통해 요소 전체 높이의 백분율로 지정할 수 있다.

animationSpec 은 EnterTransition 및 ExitTransition을 포함한 많은 애니메이션 API의 공통 매개변수다. 다양한 AnimationSpec 타입 중 하나를 전달하여, 시간 경과에 따라 애니메이션 값이 어떻게 변경되어야 하는지 지정할 수 있다. 이 예제에서는 간단히 시간(duration) 기반의 AnimationSpec 을 사용한다. 지속 시간은 150ms이고 easingLinearOutSlowInEasing 이다.

여기서 이징(Easing)은 애니메이션의 분수를 조정하는 방법이다. 이징을 사용하면 전환 요소가 일정한 속도로 이동하는 대신 속도를 높이거나 낮출 수 있다.

  • exit 매개변수에 대해 slideOutVertically 함수를 사용할 수 있다.
  • SlideOutVertically 는 초기 오프셋이 0이라고 가정하므로 targetOffsetY 만 지정하면 된다.
  • animationSpec 매개변수에 대해 동일한 tween 함수를 사용하지만 지속 시간은 250ms이고 FastOutLinearInEasing 의 easing이 있다.
image

콘텐츠 크기 변경 애니메이션

  • 앱 콘텐츠에는 여러 주제가 표시된다.
  • 이 중 하나를 클릭해 보면 해당 주제의 본문 텍스트가 열려 표시되는 것을 확인할 수 있다.
  • 텍스트가 포함된 카드는 본문이 표시되거나 숨겨질 때 확장 및 축소된다.
image
Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
) {
    // ... 제목과 본문
}
  • 여기서 Column 컴포저블은 내용이 변경되면 크기가 변경된다.
Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
        .animateContentSize()
) {
    // ... 제목과 본문
}
  • animateContentSize 수정자를 추가하여 크기 변경을 애니메이션할 수 있다.

다양한 값 애니메이션

  • 이번에는 탭 인디케이터를 커스터마이징해보자.
  • 현재 선택된 탭에 표시되는 사각형이다.
image
@Composable
private fun HomeTabIndicator(
    tabPositions: List<TabPosition>,
    tabPage: TabPage
) {
    val indicatorLeft = tabPositions[tabPage.ordinal].left
    val indicatorRight = tabPositions[tabPage.ordinal].right
    val color = if (tabPage == TabPage.Home) Purple700 else Green800
    ...
  • 여기서 IndicatorLeft는 탭 행(tab row)에서 인디케이터의 왼쪽 테두리고, IndicatorRight는 인디케이터의 오른쪽 테두리다.
  • color는 테두리 색상이다. 색상 역시 보래색과 녹색끼리 변경된다.
val transition = updateTransition(tabPage)
val indicatorLeft by transition.animateDp { page ->
    tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp { page ->
    tabPositions[page.ordinal].right
}
val color by transition.animateColor { page ->
    if (page == TabPage.Home) Purple700 else Green800
}
  • 여러 값을 동시에 애니메이션하기 위해 Transition을 사용할 수 있다. updateTransition 함수로 Transition 을 생성할 수 있다.
  • targetState 매개변수로 현재 선택된 탭의 인덱스를 전달한다.

인디케이터 전환이 애니메이션으로 변하면서 훨씬 부드러워진 것을 확인할 수 있다.

여기서 transitionSpec 매개변수를 지정하여 애니메이션 동작을 커스터마이징 할 수 있다. 예를 들어, 목적지에 더 가까운 가장자리가 다른 가장자리보다 빠르게 움직이도록 함으로써 인디케이터에 대한 탄성 효과를 얻을 수 있다.


Android Studio는 컴포즈 Preview에서 전환 검사를 지원한다. 애니메이션 미리보기를 사용하려면, 미리보기에서 컴포저블의 오른쪽 상단 모서리에 있는 “Start interactive mode” 아이콘을 클릭하여 인터렉티브 모드를 시작한다.

“Play” 아이콘 버튼을 클릭하여 애니메이션을 실행할 수 있다. seekbar를 드래그해 각 애니메이션 프레임을 볼 수도 있다.

image

반복 애니메이션

image

현재 온도 옆에 있는 새로 고침 아이콘 버튼을 클릭해 보자. 앱이 최신 날씨 정보를 로드를 시작(하는 척) 한다. 로딩이 완료될 때까지 회색 원과 막대인 로딩 인디케이터가 나타난다. 이 인디케이터 알파 값(투명도) 애니메이션을 적용하여 프로세스가 진행 중임을 더 명확하게 표시해 보자.

val alpha = 1f

이 값을 0f와 1f 사이에서 반복적으로 움직이게 만들고 싶다. 이를 위해 InfiniteTransition을 사용할 수 있다.

이 API는 이전 섹션의 Transition API와 유사하다. 둘 다 여러 값에 애니메이션을 적용하지만,

  • Transition 은 상태 변경에 따라 값에 애니메이션을 적용하는 반면,
  • InfiniteTransition 은 값에 무기한 애니메이션을 적용한다.

val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = keyframes {
            durationMillis = 1000
            0.7f at 500
        },
        repeatMode = RepeatMode.Reverse
    )
)

InfiniteTransition 을 생성하려면 RememberInfiniteTransition 함수를 사용하자. 그런 다음 각 애니메이션 값 변경은 InfiniteTransition의 animate* 확장 함수 중 하나로 선언될 수 있다. 이 경우 알파 값에 애니메이션을 적용하므로 animationFloat 를 사용해보겠다. initialValue 매개변수는 0f 여야 하고, targetValue1f 이여야 한다.

(바탕화면 영상 시청)