프로그래밍/Android

[안드로이드] 회전목마(Carousel) 애니메이션 구현하기

Lou Park 2023. 4. 27. 23:44

회전목마 애니메이션

게임에서 아이템이나 캐릭터 선택을 할때 회전 목마처럼 돌아가는 선택 애니메이션을 자주 볼 수 있는데, 이것을 안드로이드에서 구현해 볼 수 있는 기회가 생겼다. 사실 노가다를 하면 어떻게든 구현할 수 있지만, 이번에는 문제를 분석하고 쪼개보는 연습을 겸해봤다.

💡 요구사항: 3가지 종류의 상자가 있고, 이 상자들을 돌려가면서 열 상자를 선택하게 해주세요.

 

1. 상자 유형 데이터화

첫번째로 해야할 일은 상자를 데이터화하는거다. enum 클래스로 상자의 이미지, 가격, 이름이 담긴 LuckyBoxType을 만들어 주었다. enum을 사용한 이유는 순차적 접근이 sealed class보다 훨씬 쉽기 때문이다.

enum class LuckyBoxType(
    val image: Int = 0,
    val cost: Int = 0,
    @StringRes val nameResId: Int = 0,
) {
    // 나무 상자
    WOODEN_BOX(
        image = R.drawable._,
        cost = 200,
        nameResId = R.string._
    ),
    // 은 상자
    SILVER_BOX(
        image = R.drawable._,
        cost = 500,
        nameResId = R.string._
    ),
    // 금 상자
    GOLDEN_BOX(
        image = R.drawable._,
        cost = 1000,
        nameResId = R.string._
    ),
}

2. 회전하는 상자들을 위한 자료구조 선택

상자는 계속해서 돌아야한다. 이를 구현하는 방법은 3가지 정도가 있다.

  • 일반 배열에 커서를 하나 두고 돌린다. (index + 1 % array.size)
  • 유저 입장에서는 거의 무한인 (Int.MAX_SIZE) 배열을 만들어서 커서를 중간에다 두고, index를 ++, —한다.
  • Circular Array를 이용한다.

"바퀴를 다시 만들지 말라"는 말을따라...이미 있는 CircularArray를 사용하기로 했다.
이를 이용하면 금/은/나무 상자가 돌지만 CircularArray.first()로 손쉽게 현재 활성화 된 상자 데이터를 가져올 수 있다.

circular array로 표현한 상자 데이터 상태

추가적으로, Kotlin의 확장 함수로 “다음”버튼을 눌렀을때와 “이전” 버튼을 눌렀을때 배열에 변화를 쉽게 줄 수 있도록 했다.

fun <T> CircularArray<T>.next(): T {
    val pop = this.popFirst()
    this.addLast(pop)
    return pop
}

fun <T> CircularArray<T>.previous(): T {
    val pop = this.popLast()
    this.addFirst(pop)
    return pop
}

 

이제 CircularArray.next()CircularArray.previous()를 호출하면 각각 이렇게 변할 것이다.

[나무, 은, 금].next()

>>> [은, 금, 나무]

[나무, 은, 금].previous()

>>> [금, 나무, 은]

 

3. 상자들의 애니메이션 값을 일반화 시키기

모든 상자들의 좌표는 가운데 상자의 위치로 겹쳐있게 한다.
하지만 가장 좌측과 가장 우측상자는 translateX 값을 다르게 주어서 펼쳐진 것처럼 보이게 한다. 이렇게하면 가운데 상자를 기준으로 양측의 translateX 값은 절대값은 같고 양수/음수 부호만 다르게되어 계산하기 깔끔한 숫자가 나온다.

  • 나무상자의 translateX: 0
  • 금상자의 translateX: -100 
  • 은상자의 translateX: 100
x값을 이용하는게 아니라 translateX 값을 사용하는 이유?
x값은 View의 왼쪽 상단 모서리를 기준으로하는데, 이 경우 View의 가운데 위치를 알기위해 View의 가로 길이 절반을 더 해야해서 계산이 더 복잡해지기 때문이다.

 

하지만 문제가 발생했다.

앞서 말했듯이 CircularArray.first()로 현재 활성화된 데이터를 가져와야지! 라고 생각했기 때문에…

처음에는 배열의 Index와 View의 위치를 이렇게 구현하려고 했다.

위에서 바라본 (TopView) 상자들의 모습. 번호는 CircularArray의 Index다.

유저가 눈으로 보는 상자의 위치
[금] --- [나무] --- [은]

실제 배열의 상태
[나무] --- [은] --- [금]

하지만 시각적인 위치와 논리적인 위치가 달라서 이미지를 표시하는 View인 ImageView를 다시 논리적인 위치에 맞게 맵핑을 해야했고, 코딩하면서도 계속해서 헷갈렸다.

private val mappedImageViews by lazy {
    arrayOf(binding.ivLuckyBoxCenter, binding.ivLuckyBoxRight, binding.ivLuckyBoxLeft)
}

mappedImageViews[0]은 그래서....뭐였더라? ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ

 

그래서 first를 쓰자는 생각은 폐기하고, 가운데 값을 써버리기로 했다.

마찬가지로 확장 함수를 만들어서 쉽게 가운데 값을 가져올 수 있도록 변경해서, CircularArray.center()를 통해 지금 활성화된 상자에 대한 정보를 가져오게 했다.

fun <T> CircularArray<T>.center(): T {
    return this.get(size() / 2)
}

이제 실제 배열의 상태와 ImageView들간의 시각적 배치가 동일 해져서 훨씬 이해하기 간단해졌다.

위에서 바라본 (TopView) 상자들의 모습. 번호는 CircularArray의 Index다.

유저가 눈으로 보는 상자의 위치
[금] --- [나무] --- [은]

실제 배열의 상태
[금] --- [나무] --- [은]

 

자 그럼, 회전하는 애니메이션은 어떻게 할 것인가?

 

이해를 간단히 하기 위해서, 나무상자가 금상자의 위치로 가는 것만 생각해보자.

나무상자의 입장에서 변하는것은 위치(TranslationX), 크기(Scale), 투명도(Alpha) 3가지다.

실제 수치로 보게되면, 이런 식이 된다.

  • 왼쪽으로 밀리면서 translationX: 0 → -100
  • 작아지고 scale: 1.5 → 1
  • 흐려진다 alpha: 1 → 0.8

나무상자는 금 상자의 위치로가면서 작아지고, 흐려진다.

 

 

그럼 조금 더 복잡한 상황, 금 상자가 은 상자의 위치로 가는것도 생각해보자.


위에서 생각했던것처럼 보면 오히려 더욱 간단하게 느껴지기도 한다.

  • 오른쪽으로 밀리면서 translationX: -100 → 100
  • 크기는 그대로 scale: 1 → 1
  • 흐림정도도 그대로 alpha: 0.8 → 0.8

금상자는 은 상자의 위치로 가면서, 크기 변화도, 흐림변화도 없다.

 

하지만 이대로만 구현한다면 금 상자가 이동하는 도중 나무상자를 가려버릴 것이다.

이를 해결하기 위해서는 상자들의 Z축을 고려해야한다. 금 상자의 z-index는 어떠한 상자들보다도 낮아야 자연스럽게 보인다.

그래서 애니메이션을 하기전에, 각 상자의 포지션에 맞게 Z축 정리를 해주는 것이 중요하다.

 

어쨌든 금 상자쪽이 z-index가 가장 낮고, 나머지는 일단 금 상자보다 크다면 어떻게 되도 상관이 없다. 그래서 실제 배열의 index로 설정해도 된다.

  • 금 상자 z-index: 0
  • 나무 상자 z-index: 1
  • 은 상자 z-index: 2

하지만 만약에 반대 방향으로 가게되면, 은 상자 쪽이 가장 낮은 z-index를 가져야 할 것이다. 뒤집어서만 생각해두면 된다.

  • 금 상자 z-index: 2
  • 나무 상자 z-index: 1
  • 은 상자 z-index: 0

 

또 한가지.

실제 View의 위치를 영구적으로 바꿔버리는 것이 아니라, 애니메이션이 끝나고난 후 표시되는 이미지뷰를 바꿔쳐버리는 작업이 필요하다.

아래는 애니메이션과 함께 실제로 View가 이동하는 모습이다.

첫번째 줄 ~ 마지막줄 시간순이다.

  • 왜 이름을 Left, Center, Right로 지어가지고 헷갈리게 하느냐?
  • 그냥 두는게 낫지 않느냐? 라는 생각이 들수도 있지만…

상자를 열때 상자 이미지뷰를 가리고, 그 위에 애니메이션을 재생해야 했다.

만약에 위치를 영구적으로 변형할 경우에, 현재 가운데에 있는 ImageView를 따로 찾아서 가린 후 그 위에 Lottie Animation을 재생해야 했겠지만, ImageView들의 위치를 그대로 둠으로서 원래 가운데에 있는 ImageView를 집어서 바로 가리면 된다.

 

 

정리해서 현재 위치를 fromIndex, 나중에 상자가 도착할 위치를 toIndex라고 해보자.

  • 애니메이션 시작~중간에는 toIndex를 기준으로 변형을 일으키고,
  • 끝에는 fromIndex 대로 바꿔치기를 해야한다.
[금] -- [나무] -- [은]

next() 

[나무] -- [은] -- [금]

 

next를 하게되면 금 상자는 fromIndex = 0, toIndex = 2 를 가지게 된다.

 

[금] -- [나무] -- [은]

prev()

[은] -- [금] -- [나무]

 

prev를 하게되면 은 상자는 fromIndex = 2, toIndex = 0을 가지게 된다.

 

4. 실제로 구현

class LuckyBoxAnimator() {

        fun next(mappedImageViews: Array<ImageView>) {
        mappedImageViews.forEachIndexed { fromIndex, imageView ->
            val toIndex = (circularArray.size() + fromIndex - 1) % circularArray.size()
            imageView.z = fromIndex.toFloat()
            imageView.animate(fromIndex, toIndex)
        }
    }

        fun prev(mappedImageViews: Array<ImageView>) {
        mappedImageViews.forEachIndexed { fromIndex, imageView ->
            val toIndex = (fromIndex + 1) % circularArray.size()
            imageView.z = toIndex.toFloat()
            imageView.animate(fromIndex, toIndex)
        }
    }

}

실질적인 애니메이션의 구현은 뒤로두고, z-index 문제부터 풀어볼 수 있다.

  • next는 z-index를 fromIndex
  • prev는 z-index를 toIndex

앞서서, z-index는 배열의 순서대로 부여하면되고 방향을 바꾸면 그대로 뒤집으면 된다 했으므로 이렇게 z-index를 설정 해주면 딱 맞다.

 

z-index 문제는 해결되었으니, 애니메이션을 보자.

애니메이션은 시간에 따라 값이 변하는 것이다.

 

우리는 애니메이션의 끝에 도달할때의 값만 제대로 넣어주면된다.

따라서 toIndex에 해당하는 값들로 채워주면된다.

class LuckyBoxAnimator(
    private val circularArray: CircularArray<LuckyBoxType>,
  private val scales: Array<Float>,
  private val alphas: Array<Float>,
  private val translations: Array<Float>
) {
    private fun ImageView.animate(fromIndex: Int, toIndex: Int) {
        this.animate()
            .alpha(alphas[toIndex])
            .scaleX(scales[toIndex])
            .scaleY(scales[toIndex])
            .translationX(translations[toIndex])
            .setDuration(ANIMATION_DURATION)
            .doOnEnd {
                setByIndex(this, fromIndex)
            }
    }
}

scales, alphas, translations는 각각 해당 위치에 있는 상자들의 크기, 불투명도, translateX 값을 담고있는 배열이다.

translations = [-100, 0, 100]
alphas = [0.8F, 1F, 0.8F]
scales = [1, 1.5, 1]

 

애니메이션이 끝나면 바꿔치기를 해야한다고 했다.

애니메이션 이후에는 원래 위치에서 표시해줘야 했던 그대로 표시한다.

따라서 fromIndex를 가지고 설정해주면된다.

doOnEnd {
    setByIndex(this, fromIndex)
}
fun setByIndex(imageView: ImageView, index: Int) {
    imageView.setImageResource(circularArray.get(index).image)
    imageView.alpha = alphas[index]
    imageView.translationX = translations[index]
    imageView.scaleX = scales[index]
    imageView.scaleY = scales[index]
}

이렇게 해서 단 50줄만으로 회전목마 애니메이션 처리를 가능하게하는 LuckyBoxAnimator 를 만들 수 있었다.

 

private val mappedImageViews by lazy {
    arrayOf(
            binding.ivLuckyBoxLeft, 
            binding.ivLuckyBoxCenter, 
            binding.ivLuckyBoxRight
        )
}

private lateinit var luckyBoxAnimator: LuckyBoxAnimator

private fun init() {
        // enum class를 가격 낮은 순으로 circularArray에 넣는다.
    LuckyBoxType.values()
        .sortedBy { it.cost }
        .forEach {
            circularArray.addLast(it)
        }
        // 나무상자에서 부터 시작해야하므로 한칸뒤로 땡김
    circularArray.previous() 

        luckyBoxAnimator = LuckyBoxAnimator(
            circularArray,
            scales = arrayOf(1F, CENTER_BOX_SIZE / BOX_SIZE, 1F),
            alphas = arrayOf(BOX_ALPHA, 1F, BOX_ALPHA),
            translations = arrayOf(TRANSLATE_X_LEFT, 0F, TRANSLATE_X_RIGHT)
        )
        // 처음에 circular array 상태에 따라 이미지 세팅
        mappedImageViews.mapIndexed { index, imageView ->
            luckyBoxAnimator.setByIndex(imageView, index)
        }
}
binding.btnNextBox.setOnSingleClickListener {
      circularArray.next()
    luckyBoxAnimator.next(mappedImageViews)
    setBoxInformation()
}

binding.btnPrevBox.setOnSingleClickListener {
        circularArray.previous()
    luckyBoxAnimator.prev(mappedImageViews)
    setBoxInformation()
}