최근 Glide와 함께 애니메이션 처리에 Coroutines Suspend를 적용해 본 사례를 소개합니다.
만들어 볼 스펙의 모습은 아래와 같습니다.
위의 샘플은 사실 Coroutines Suspend를 사용하지 않더라도 손쉽게 작성할 수 있습니다. 간단하게 작업한다면 아래처럼 작성할 수 있습니다.
다른 기술을 사용하더라도 기본적인 기능 호출/콜백/애니메이션/Handler 등 여러 작업이 필수로 요구됩니다. 특히 콜백과 Handler를 동시 사용하면, 코드가 분산되어 가독성이 낮습니다.
이럴 때 Coroutines Suspend
을 사용하면 하나의 블록 안에서 순차적으로 선언할 수 있습니다.
UI 동작을 Coroutines Suspend로 응용하기 위해서는 각 작업의 종료 시점이 필요합니다. 해당 설명은 Chris Banes의 블로그에서 상세하게 설명하고 있습니다. 먼저 읽으시면, 이후의 내용이 더 이해가 잘됩니다.
Animator는 직접 취소/종료 등을 탐지할 수 있는 Animation 객체입니다. 일시 중지는 Animator#AnimatorListenerAdapter를 사용하여 조정할 수 있습니다.
suspend fun Animator.awaitEnd() = suspendCancellableCoroutine<Unit> { cont ->
// coroutine이 취소된 경우, 애니메이션도 취소
cont.invokeOnCancellation { cancel() }
addListener(object : AnimatorListenerAdapter() {
private var endedSuccessfully = true
override fun onAnimationCancel(animation: Animator) {
endedSuccessfully = false
}
override fun onAnimationEnd(animation: Animator) {
// coroutine continuation이 계속 호출되지 않도록 리스너를 제거
animation.removeListener(this)
if (cont.isActive) {
// continuation이 활성화일 때 continuation을 resume/cancel한다
if (endedSuccessfully) {
cont.resume(Unit)
} else {
cont.cancel()
}
}
}
})
}
노출/사라지는 효과는 간단하게 Android에서 기본 제공하는 것으로도 사용할 수 있습니다. 앞서 Animator의 일시 중지와 종료 시점을 확인했으니 이 작업은 매우 간단합니다. 간단하게 Alpha/TranslationY를 사용하여 Animator를 생성합니다.
// TranslationY 수치는 임의의 값
private fun generateFadeIn(target: View): Animator {
return AnimatorSet().apply {
interpolator = FastOutSlowInInterpolator()
playTogether(
ObjectAnimator.ofFloat(target, View.ALPHA, 0f, 1f),
ObjectAnimator.ofFloat(target, View.TRANSLATION_Y, 50f, 0f)
)
}
}
private fun generateFadeOut(target: View): Animator {
return AnimatorSet().apply {
interpolator = FastOutSlowInInterpolator()
playTogether(
ObjectAnimator.ofFloat(target, View.ALPHA, 1f, 0f),
ObjectAnimator.ofFloat(target, View.TRANSLATION_Y, 0f, 50f)
)
}
}
private suspend fun View.startAwaitEnd(animator: Animator) {
animator.setTarget(this)
animator.start()
animator.awaitEnd()
}
private suspend fun playStep(url: String) {
// TODO: 이미지 로드
binding.notiText.run {
text = "Show ${Date()}"
// Fade-in 효과
startAwaitEnd(generateFadeIn(this))
// 2초 대기
delay(2.seconds)
// Fade-out 효과
startAwaitEnd(generateFadeOut(this))
}
}
ViewPropertyAnimator도 사용할 수 있겠지만, Animator의 동작을 트래킹하는 ViewPropertyAnimator#setListener의 경우 단일 리스너만 설정가능합니다. 그래서 ViewPropertyAnimator를 정의하는 경우를 고려한다면 ViewPropertyAnimator로 Coroutine 일시 중지를 사용에는 적합하지 않습니다.
이제 Glide로 이미지를 로드하는 동안, Coroutine Scope내의 작업을 일시 중지할 수 있습니다. 앞서 일시 중지를 위해서는 원하는 작업의 종료 시점이 필요하다고 설명했습니다. Glide는 RequestListener
를 통해서 시점을 처리할 수 있습니다. 해당 함수들은 Glide를 사용하는 사용자라면 익숙한 리스너일 것입니다.
이제 해당 리스너의 구현체에서 일시 중지된 작업을 Continuation#resume
으로 재개할 수 있습니다.
suspend fun ImageView.awaitLoad(url: String) = suspendCoroutine { cont ->
Glide.with(this)
.load(url)
.addListener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
): Boolean {
cont.resume(Unit)
return false
}
override fun onResourceReady(
resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean
): Boolean {
cont.resume(Unit)
return false
}
})
.into(this)
}
suspendCancellableCoroutine를 사용한다면 View가 Detach되는 시점 등을 사용하여 CancellableContinuation#cancel을 하는 방법도 있습니다.
private suspend fun playStep(url: String) {
// 이미지 로드
binding.imageView.awaitLoad(url)
binding.notiText.run {
text = "Show ${Date()}"
// Fade-in 효과
startAwaitEnd(generateFadeIn(this))
// 2초 대기
delay(2.seconds)
// Fade-out 효과
startAwaitEnd(generateFadeOut(this))
}
}
기본적인 동작은 이것으로 끝났습니다.
이미지들을 노출과 애니메이션을 반복하기 위해서는 while/for/재귀호출을 사용할 수 있습니다. 여기서는 while 사용하며 Coroutine이 유효(isActive)할 때까지 반복하도록 합니다.
binding.root.findViewTreeLifecycleOwner()?.lifecycleScope?.launch {
while (isActive) {
playStep(/** Image Url */)
}
}
private suspend fun playStep(url: String) {
// 이미지 로드
binding.imageView.awaitLoad(url)
binding.notiText.run {
text = "Show ${Date()}"
// Fade-in 효과
startAwaitEnd(generateFadeIn(this))
// 2초 대기
delay(2.seconds)
// Fade-out 효과
startAwaitEnd(generateFadeOut(this))
}
}
이것으로 기대하는 효과를 얻을 수 있습니다.
모든 애니메이션과 동작들이 Coroutines Suspend를 적용하기 편리하다고는 볼 수 없습니다. 아래 항목들을 참고하여 적용 전에 조건에 맞는지 체크한 후 도입하면 좋습니다.
샘플 코드 : https://github.com/Pluu/SuspendGlideSample
comments powered by Disqus
Subscribe to this blog via RSS.
LazyColumn/Row에서 동일한 Key를 사용하면 크래시가 발생하는 이유
Posted on 30 Nov 2024