본문 바로가기

Android/Jetpack Compose

[Compose] LazyList에서 스크롤을 커스텀하기 위해 FlingBehavior를 사용해보자.

728x90

필자가 실무를 진행하다, LazyColumn의 스크롤 애니메이션을 직접 커스텀해야 하는 이슈가 발생했다.

지금까지 LazyColumn을 사용할 때는 많은 양의 item List를 보여줄 때 사용하는 케이스가 많아서, 직접 애니메이션에 대해서 커스텀을 하기보다는 그냥 사용자가 스크롤하는 대로 보여주면 됐다.

따라서, 스크롤에 대해서 별도로 건들지 않고 필요 시 State만 컨트롤해 주는 정도로 사용했다.

 

하지만 이번에 직접 스크롤을 커스텀하다보니 생각보다 자료도 많이 없었고, 검색하기 위한 키워드를 찾는 것조차 생각보다 시간이 오래 걸렸다.

 

따라서, 이번 글에서는 그렇게 깊지는 않지만 간단하게 스크롤을 커스텀할 수 있는 코드를 설명하고자 한다.


우선,

기본적으로 LazyColumn을 사용하면 다음과 같이 사용하게 된다.

 

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LazyColumnFlingBehaviorExample() {
    val lazyListState = rememberLazyListState()

    LazyColumn(
        state = lazyListState,
        modifier = Modifier
            .fillMaxSize()
            .background(color = Color.White),
    ) {
        stickyHeader {
            // Header Item
        }

        item {
            // Content Item
        }
    }
}

 

lazyListState를 사용하여 LazyColumn에 대한 Scroll을 직접 수행하거나 하며,

stickyHeader를 통해 상단에 고정될 Content를 선언하고, item 이하 스크롤되는 영역에 많은 데이터를 넣곤 한다.

 

이렇게만 선언해서 사용하는 경우, 아이템이 많을 때 사용자의 스크롤에 따라 LazyColumn에 있는 데이터들이 스크롤돼서 보이게 된다.

 

 

 

이처럼 말이다.

 

하지만, 필자가 이번에 필요했던 것은 스크롤이 완료되었을 때 아이템이 전체가 다 보여야 했다.

즉, 위의 스크롤되는 영상에서 스크롤이 끝났을 때, 가장 상단의 아이템이 가려지면 다시 스크롤이 위로 올라가서 가려짐 없이 첫 번째 아이템을 보여야 했다.

 

이와 같은 기능을 찾아야 하는데, 검색을 뭐라고 해야 할지 정말 막막했다.

LazyColumn에서의 애니메이션을 이것저것 찾아보다가 발견했던 것이

flingBehavior

 

라는 것이다.

 

FlingBehavior은 LazyList에서 사용되는 스크롤 애니메이션과 관련된 속성 값이다.

그리고 해당 속성 값에 넣을 수 있는 가장 기본적인 값은 다음과 같다.

 

flingBehavior = rememberSnapFlingBehavior(lazyListState = lazyListState)

 

여기서 rememberSnapFlingBehavior의 함수 설명을 보면,

 

Create and remember a FlingBehavior for decayed snapping in Lazy Lists.
This will snap the item's center to the center of the viewport.

Params: lazyListState - The LazyListState from the LazyList where this FlingBehavior will be used.

 

이처럼 나와있다.

 

This will snap the item's center to the center of the viewport.

 

라는 부분만 봐도 알 수 있듯이 필자가 원하는 동작을 시켜주는 함수라는 것을 알 수 있다.따라서, 해당 값을 flingBehavior에 넣고 동작을 확인해 보았다.

 

 

 

설명에서 볼 수 있던 대로, 첫 번째로 보이는 아이템이 가려지게 되면 그 정도에 따라서 다음 아이템으로 넘어가거나, 다시 스크롤되어 현재 아이템이 전체가 다 보이도록 움직이게 된다.

 

여기까지 적용했으면, 대부분의 경우에서 사용하는 것에 문제는 없을 것이다.

하지만, 필자는 가려짐 없이 첫 번째 아이템이 보인다. 는 조건 외에도 추가적으로 한 번에 너무 많은 데이터가 스크롤되면 안 된다.라는 조건도 만족해야 했다.

 

그래서 이 FlingBehavior를 사용하면서 스크롤의 속도를 조절할 수 있는 방법에 대해서 찾아보았다.

 

LazyColumn에서 스크롤되는 속도를 조절하는 방법을 찾아보면 대부분, 사용자의 터치 Gesture와 그 속도를 체크해서 절대 값이 일정 이상이 되면 특정 값으로 고정하는 방식을 사용했다.

즉, 

 

modifier = Modifier
    .pointerInput(Unit) {
        ...

        detectDragGestures { change, dragAmount ->
            ...
        }
        
        detectTransformGestures { _, pan, _, _ ->
            ...
        }
    }

 

LazyColumn의 modifier에 이와 같은 옵션을 추가하여 사용자의 드래그를 탐지해서 데이터를 제한한다.

DragGestures와 TransformGestures를 동시에 사용하지는 않지만, 이와 같이 사용자의 제스처를 탐지해서 데이터를 반환해주는 함수가 여러개 존재하기 때문에 이와 같은 함수를 사용한다.

 

하지만, 이와 같이 사용자 제스처를 탐지해서 사용하게 되면 우리가 생각하는 것 처럼 flingBehavior 특성이 적용되면서 제스쳐 데이터를 통해 속도를 제한한다.라는 것이 되지 않고, 제스처만 탐지하게 된다.

즉, 쉽게 생각해서 pointerInput를 사용해서 위와 같은 사용자 입력을 탐지하게 되면 가장 상단에 투명한 view가 깔리고 그곳에서 사용자 입력을 감지한다.라고 생각 하면 된다.

최상단에 사용자 입력을 감지하는 view가 깔려있기 때문에, 우리가 원래 적용했던 이벤트들이 하나도 동작하지 않게 되는 것이다.

 

따라서, flingBehavior를 커스텀해서 적용하는 방법을 찾거나, 사용자 제스처를 받아서 완전히 스크롤 전체를 커스텀하는 방식을 사용해야 한다.

 

필자는 여기서 flingBehavior를 커스텀하는 방식을 찾아보았고, 이 방식을 찾는 것도 생각보다 오랜 시간이 걸렸다.

 

우선 Custom 한 FlingBehavior의 함수 전체는 다음과 같다.

@Composable
fun maxScrollSpeedFlingBehavior(): FlingBehavior {
    val splineBasedDecay = rememberSplineBasedDecay<Float>()
    return remember(splineBasedDecay) {
        MaxScrollSpeedFlingBehavior(splineBasedDecay)
    }
}

private class MaxScrollSpeedFlingBehavior(
    private val splineBasedDecay: DecayAnimationSpec<Float>,
) : FlingBehavior {
    override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
        val setVelocity = if (initialVelocity > 0F) initialVelocity.coerceAtMost(2_000F)
        else initialVelocity.coerceAtLeast(-2_000F)

        return if (abs(setVelocity) > 0f) {
            var velocityLeft = setVelocity
            var lastValue = 0f
            AnimationState(
                initialValue = 0f,
                initialVelocity = setVelocity,
            ).animateDecay(splineBasedDecay) {
                val delta = value - lastValue
                val consumed = scrollBy(delta)
                lastValue = value
                velocityLeft = this.velocity
                if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
            }
            velocityLeft
        } else setVelocity
    }
}

 

 

이 함수들을 처음부터 설명하면 다음과 같다.

 

@Composable
fun maxScrollSpeedFlingBehavior(): FlingBehavior {
    val splineBasedDecay = rememberSplineBasedDecay<Float>()
    return remember(splineBasedDecay) {
        MaxScrollSpeedFlingBehavior(splineBasedDecay)
    }
}

 

우선, splineBasedDecay 데이터는 Fling 관련한 Animation을 설정할 때 사용하는 데이터이며 애니메이션 속도를 감속하기 위해 사용되는 값이다.

해당 값 자체가 어떤 형태로 사용되는지는 정확하게 파악하지 못하였으나, 해당 값을 토대로 애니메이션 감속 속도를 계산하고 자연스럽게 이 속도 변화를 연결해 주는 역할을 한다고 한다.

즉, 감속 애니메이션을 생성하는데 반드시 필요한 값이며, 스크롤을 수행했을 때 스크롤의 가속도가 사용자 제스처에서부터 0으로 변환될 때 이를 자연스럽게 애니메이션으로 연결해 주고, remember를 사용하여 감속 시마다 데이터가 재 랜더링되는 것을 방지해 준다.

 

private class MaxScrollSpeedFlingBehavior(
    private val splineBasedDecay: DecayAnimationSpec<Float>,
) : FlingBehavior {
    override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
        val setVelocity = if (initialVelocity > 0F) initialVelocity.coerceAtMost(2_000F)
        else initialVelocity.coerceAtLeast(-2_000F)

        return if (abs(setVelocity) > 0f) {
            var velocityLeft = setVelocity
            var lastValue = 0f
            AnimationState(
                initialValue = 0f,
                initialVelocity = setVelocity,
            ).animateDecay(splineBasedDecay) {
                val delta = value - lastValue
                val consumed = scrollBy(delta)
                lastValue = value
                velocityLeft = this.velocity
                if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
            }
            velocityLeft
        } else setVelocity
    }
}

 

FlingBehavior를 상속받는 해당 클래스는, performFling을 override 하여 원하는 대로 커스텀할 수 있다.

 

performFling부분은 사용자가 스크롤을 했을 대, 스크롤되는 속도를 가져올 수 있는 함수로 사용되는 initialVelocity는 사용자가 스크롤하는 속도 값을 나타낸다.

즉, 사용자가 스크롤했을 때의 초기 속도를 조절하고 splineBasedDecay를 통해서 감속시켜 필자가 원하는 동작을 시켜주는 함수라고 생각하면 된다.

 

우선, 필자는 2000f라는 값을 제한된 속도로 설정하였다. 이 속도의 max 값 자체는 각자 데이터를 확인해 보면서 설정하면 좋을 것 같다.

setVelocity 값으로 설정되는 값은, 2000F 보다 큰 값을 2000F로 제한하는 역할을 하며, 위와 아래로 스크롤할 때 데이터가 음수 양수로 나타내므로 양수일 때는 min 값을, 음수일 때는 max 값을 사용하여 제한된 속도를 설정해 주었다.

return 되는 값은 제대로 스크롤이 됐다면 setVelocity는 호출되지 않지만, 터치했을 때도 감지가 되는 케이스가 있으므로 위와 같이 설정을 해두었다.

 

if문이 true일 때는, 감속 애니메이션을 설정해 두었다. setVelocity가 초기 속도이며, 이 속도에서부터 0으로 감속하는 애니메이션을 설정하였으며 반환되는 값을 이후에 남은 스크롤의 속도를 반환하게 된다.

실제 스크롤되지 않은 정도가 0.5f보다 크다면, 비정상적인 동작으로 판단하고 스크롤 자체를 멈춘다.

 

즉, 스크롤을 수행했을 때 해당 함수가 반복적으로 수행되며, splineBasedDecay에 정의된 감속 스펙을 기반으로 속도가 줄어든다. 속도가 줄어들 때마다 남은 속도를 반환하여 재 호출 시 사용하며, 이 남은 속도가 0이 될 때까지 반복하며 자연스러운 감속 애니메이션을 만들어준다.

 

이렇게까지 선언을 한 다음에, 적용해 보면 다음과 같은 결과를 볼 수 있다.

 

 

 

 

영상을 보면 알 수 있듯이, 스크롤을 아무리 강하게 수행해도 크게 스크롤되지 않는 것을 볼 수 있다.

하지만, 이와 같이 커스텀을 진행했을 때 스크롤 속도는 제한할 수 있지만 원래의 flingBehavior의 기능인 첫 번째 index가 가려졌을 때 가려지지 않도록 다시 스크롤되는 기능이 사라졌음을 알 수 있다.

flingBehavior = rememberSnapFlingBehavior(lazyListState = lazyListState)

 

해당 기능은 위의 snapFlingBehavior에서 제공하는 기능이었기 때문에, flingBehavior를 커스텀한 지금은 해당 기능이 없는 것이다.

 

따라서, 필자는 여기서 한 가지 SideEffect를 추가하였다.

 

LaunchedEffect(key1 = lazyListState.isScrollInProgress) {
    if (!lazyListState.isScrollInProgress) {
        lazyListState.animateScrollToItem(lazyListState.firstVisibleItemIndex)
    }
}

 

lazyListState의 isScrillInProgress 값을 사용하였는데, 해당 값은 스크롤 중이냐 아니냐를 반환해 주는 값이다.

즉, 스크롤 데이터가 변경되고, 변경된 데이터가 멈춤을 나타낼 때, 보이고 있는 첫 번째 index로 animateScroll 하도록 해두었다.

 

이와 같은 sideEfffect를 넣어주면 다음과 같이 보인다.

 

 

 

스크롤이 애매할 때 알아서 첫 번째 인덱스로 되며 넘어가게 된다.


이것으로 flingBehavior를 커스텀하여 스크롤을 원하는 대로 변경해 보았다.

결과적으로 스크롤 속도를 제한하면서, 첫 번째 아이템을 반드시 전체가 보이도록 설정을 해보았는데 snapFlingBehavior를 사용할 때만큼 자연스럽지는 않지만, 원하는 결과가 나오긴 해서 다행이라고 생각했다.

 

물론 flingBehavior를 커스텀한 부분에서 첫 번째 index가 가려졌을 때 스크롤되도록도 추가할 수 있을 것 같지만, 해당 부분을 더 깊게 파고들고, 이해해야 사용할 수 있을 것 같다는 생각이 들어서 추후에 적용해보고자 한다.

 

해당 부분을 커스텀하면서

기본적으로 제공하는 기능만 가지고 사용하는 것에는 전혀 문제가 없었지만,

조금만 커스텀이 들어가게 되면 상당히 난이도도 올라가고 검색하는 것조차 키워드를 알 수 없어 오래 걸린다는 사실이 좀 충격적으로 다가왔다.

 

따라서 기본 컴포넌트를 사용하면서 커스텀이 들어갈 때, 시간이 좀 걸리거나 검색하는데 어려움을 느끼는 부분이 발생하면

이처럼 정리하여 추후에도 쉽게 사용할 수 있도록 글을 남겨야겠다.

 

해당 게시글에 사용한 예제는 Github에 올려두었다.

https://github.com/HeeGyeong/ComposeSample

 

GitHub - HeeGyeong/ComposeSample

Contribute to HeeGyeong/ComposeSample development by creating an account on GitHub.

github.com

 

 

728x90