[Kotlin/Compose] Swipe Action 기능 만들기 with. AnchoredDraggable

AnchoredDraggable

포스팅에 앞서 Anchored draggable이 무엇인지 간단하게 설명해보고자 합니다.

AnchoredDraggableState

AnchoredDraggableState는 드래그 가능한 요소를 관리하고 제어하는 데 사용합니다.
간단하게 설명하자면, 특정 앵커 포인트 사이에서 드래그를 관리하는 데 사용되는 상태 객체 입니다.

class AnchoredDraggableState<T>(  
initialValue: T,  
internal val positionalThreshold: (totalDistance: Float) -> Float,  
internal val velocityThreshold: () -> Float,  
val animationSpec: AnimationSpec<Float>,  
internal val confirmValueChange: (newValue: T) -> Boolean = { true }  
)

AnchoredDraggalbeState class는 다음과 같이 정의되어 있습니다.

가장 먼저 인자에 대해 간단하게 설명하면,

  1. intitialValue : 초기 위치 입니다. 드래그 요소의 시작 위치를 설정할 수 있습니다.
  2. Threshold : 임계값으로 두 가지의 임계값을 정의할 수 있습니다.
    1. positionThreshold : 드래그 동작 중 컴포넌트가 이동해야 하는 최소 거리를 정의한다. 이게 무슨 소리인지 의문이 들 수 있지만, 쉽게 설명하자면 positionThreshold값을 30dp로 설정하였다면, 해당 값 만큼 컴포넌트를 이동시켜야 드래그가 인식된다는 뜻입니다.
    2. velocityThreshold : 드래그 동작을 끝내는 데 필요한 최소 속도의 정의입니다. 드래그 동작을 끝낼 때, 설정된 값의 임계점을 넘어야 드래그 동작이 완료되는 것으로 간주합니다.
  3. animationSpec : 드래그 동작이 끝나고 애니메이션 동작을 정의하는 객체입니다.
  4. confirmValueChange : 드래그 동작으로 값이 변경될 때, 변경을 확인하는 함수입니다.

anchoredDraggable

anchoredDraggable함수는 AnchoredDraggableState 를 사용하여, 드래그 동작 상태를 관리합니다.

@ExperimentalFoundationApi  
fun <T> Modifier.anchoredDraggable(  
state: AnchoredDraggableState<T>,  
orientation: Orientation,  
enabled: Boolean = true,  
reverseDirection: Boolean = false,  
interactionSource: MutableInteractionSource? = null  
) = draggable(  
    state = state.draggableState,  
    orientation = orientation,  
    enabled = enabled,  
    interactionSource = interactionSource,  
    reverseDirection = reverseDirection,  
    startDragImmediately = state.isAnimationRunning,  
    onDragStopped = { velocity -> launch { state.settle(velocity) } }  
)

각 매개변수에 대해 설명해보겠습니다.

  1. state : 상태로 achoredDraggableState를 받아 상태를 관리합니다.
  2. orientation : 방향으로 드래그 동작의 방향을 정의합니다. vertical, horizontal 중 하나를 선택하여 진행 방향을 결정합니다.
  3. enabled : 드래그 기능을 활성화/비활성화를 결정하는 변수로 bool을 통해 지정이 가능합니다.
  4. reverseDirection : 드래그의 경우 기본적으로 수평의 경우에는 왼 -> 오 수직의 경우에는 위 -> 아래 방향으로 이동을 합니다. 하지만, 반대 방향으로 적용을 하고 싶다면, true값을 지정하면 반대로 이동이 가능합니다.

Swipe Action 기능 만들기

오늘의 결과물

How to


다음과 같은 형태의 모양을 완성하기 위해

Box를 사용해서 보라색 배경을 만들어주고, 수정/삭제/즐겨찾기 기능을 각각 배치하여, 스와이프에 따라 화면에 보여지도록 구현하였습니다.

ActionItem

먼저 수정/삭제/즐겨찾기각각의 기능을 담당하는, item를 만들어보도록 하겠습니다.
3가지 기능은 동일한 크기를 가지고, 아이콘/이름/색상값만 각각 다르게 적용되므로, ActionItem 이라는 함수를 통해 생성하도록 하겠습니다.

@Composable  
private fun ActionItem(  
    modifier: Modifier,  
    color: Color,  
    imageVector: ImageVector,  
    content :String  
){  
    Box(  
        modifier = modifier.background(color = color.copy(alpha = 0.3f)),  
        contentAlignment = Alignment.Center  
    ) {  
        Column(  
            verticalArrangement = Arrangement.Center,  
            horizontalAlignment = Alignment.CenterHorizontally  
        ) {  
            Icon(  
                modifier = Modifier  
                    .padding(top = 10.dp, bottom = 4.dp)  
                    .padding(horizontal = 20.dp)  
                    .size(30.dp),  
                imageVector = imageVector,  
                contentDescription = null,  
                tint = Color.White  
            )  

            Text(  
                text = content,  
                color = Color.Black,  
                style = MaterialTheme.typography.bodySmall  
            )  
        }  
    }  
}

DraggableItem

다음은 보라색 배경과, 아이템이 적용된 컴포저블입니다.

@OptIn(ExperimentalFoundationApi::class)  
@Composable  
fun DraggableItem(  
    state: AnchoredDraggableState<DragAnchors>,  
    content: @Composable BoxScope.() -> Unit,  
    startAction: @Composable (BoxScope.() -> Unit) = {},  
    endAction: @Composable (BoxScope.() -> Unit) = {}  
) {  
    Box(  
        modifier = Modifier  
            .padding(16.dp)  
            .fillMaxWidth()  
            .height(100.dp)  
            .clip(RectangleShape)  
    ) {  

        endAction()  
        startAction()  

        Box(  
            modifier = Modifier  
                .fillMaxWidth()  
                .align(Alignment.CenterStart)  
                .offset {  
                    IntOffset(  
                        x = -state  
                            .requireOffset()  
                            .roundToInt(),  
                        y = 0,  
                    )  
                }  
                .anchoredDraggable(state, Orientation.Horizontal, reverseDirection = true),  
            content = content  
        )  
    }  
}

 

높이100dp너비maxBox를 생성해주었습니다.

매개변수로 들어가는 값의 경우에는

  1. state : 드래그 상태입니다.
  2. content : 보라색 영역을 의미하며, 스와이프 전 사용자에게 보여지는 컴포저블입니다.
  3. startAction : 가장 좌측에 위치하는 action으로 본 포스팅에서는 즐겨찾기가 정의된 컴포저블입니다.
  4. endAction : 가장 우측에 위치하는 action으로 수정/삭제가 정의된 컴포저블입니다.

다음은 코드 설명입니다.

 

Box의 경우에는 코드 상 작성된 순서대로 랜더링 되므로, 나중에 작성된 코드가 가장 상단에 위치하게 됩니다.이러한 점을 생각하여, endActionstartAction을 먼저 배치하고 그 위에 content를 배치하여 스와이프 이전에는 안보이다가 스와이프를 통해 content가 안보이게 되면 화면에 나타나도록 하였습니다.

 

ModifierachoredDraggable에 매개변수로 받은 state를 적용하였습니다.


offset을 이용하여 box위치를 동적으로 이동시켜 주었습니다. state 객체의 requireOffset을 이용하여 계산을 합니다. requireOffset은 수평 이동거리를 반환하기 때문에, 해당 값을 이용하여 Box를 이동시켜 줍니다.

적용하기

작성한 두 가지 함수를 이용하여, 기능을 구현하였습니다.

가장 먼저 각 상태를 enum class로 정의했습니다.

enum class DragAnchors {  
    Start,  
    Center,  
    End,  
}

시작 위치는 기본으로 Center가 적용이 됩니다.

@OptIn(ExperimentalFoundationApi::class)  
@Composable  
private fun swipeAction() {  
    val density = LocalDensity.current  

    val defaultActionSize = 80.dp  
    val actionSizePx = with(density) { defaultActionSize.toPx() }  
    val endActionSizePx = with(density) { (defaultActionSize * 2).toPx() }  

    val state = remember {  
        AnchoredDraggableState(  
            initialValue = DragAnchors.Center,  
            anchors = DraggableAnchors {  
                DragAnchors.Start at -actionSizePx  
                DragAnchors.Center at 0f  
                DragAnchors.End at endActionSizePx  
            },  
            positionalThreshold = { distance: Float -> distance * 0.5f },  
            velocityThreshold = { with(density) { 100.dp.toPx() } },  
            animationSpec = tween(),  
        )  
    }  
    DraggableItem(state = state,  
        startAction = {  
            Box(  
                modifier = Modifier  
                    .fillMaxHeight()  
                    .align(Alignment.CenterStart),  
            ) {  
                ActionItem(  
                    modifier = Modifier  
                        .width(defaultActionSize)  
                        .fillMaxHeight()  
                        .offset {  
                            IntOffset(  
                                ((-state  
                                    .requireOffset() - actionSizePx))  
                                    .roundToInt(), 0  
                            )  
                        }  
                    ,  
                    color = Color.Yellow,  
                    imageVector = Icons.Outlined.Star,  
                    content = "즐겨찾기"  
                )  
            }  
        },  
        endAction = {  
            Row(  
                modifier = Modifier  
                    .fillMaxHeight()  
                    .align(Alignment.CenterEnd)  
                    .offset {  
                        IntOffset(  
                            ((-state  
                                .requireOffset()) + endActionSizePx)  
                                .roundToInt(), 0  
                        )  
                    }  
            )  
            {  
                ActionItem(  
                    modifier = Modifier  
                        .width(defaultActionSize)  
                        .fillMaxHeight(),  
                    color = Color.Green,  
                    imageVector = Icons.Default.Edit,  
                    content = "수정"  

                )  
                ActionItem(  
                    modifier = Modifier  
                        .width(defaultActionSize)  
                        .fillMaxHeight(),  
                    color = Color.Red,  
                    imageVector = Icons.Default.Delete,  
                    content = "삭제"  
                )  
            }  
        }, content = {  
            Box(  
                modifier = Modifier  
                    .fillMaxSize()  
                    .background(Color.Blue.copy(alpha = 0.3f)), contentAlignment = Alignment.Center  
            ) {  
                Text(text = "Swipe", color = Color.White, style = MaterialTheme.typography.bodyLarge)  
            }  
        })  
}  

AnchoredDrragableState

스와이프의 상태를 관리하기 위해 state변수를 생성하였습니다.

  1. initialValue : 엥커의 중앙에서 시작하기 위해 초기 위치를 중앙으로 설정하였습니다.
  2. anchors : 스와이프시 사용할 엥커의 포인트를 지정하였습니다. 시작/중앙/끝을 각각 지정해주었습니다. 예를 들어 Start로 가기 위해서는 사용자가 -actionSizePx만큼 이동해야 Start 앵커 포인트에 도달하게 됩니다.
  3. positionalThreshold : 스와이프 동작이 앵커 포인트를 변경하기 위한 최소 값을 지정하였습니다.
  4. velocityThreshold : 스와이프 동작이 멈추기 위해 넘어야 하는 속도입니다.

DraggableItem

처음에 만들어둔 함수의 매개변수에 state와 컴포저블을 사용하여 구현하였습니다.

IntOffset(  
                                ((-state  
                                    .requireOffset() - actionSizePx))  
                                    .roundToInt(), 0  
                            )  

startend를 구현할 때 각 Boxoffset을 조정하도록 하였습니다.


state의 경우에는 requireOffset을 포함하는데, 이는 스와이프 이동 거리입니다.

사용자가 오른쪽으로 드래그를 한다면, 양수의 값을 반환하게 됩니다. 하지만, 오른쪽으로 스와이프를 하게 되면, UI는 왼쪽으로 이동이 되어져야 합니다.

간단하게 설명하면, 가장 좌측에 있는 즐겨찾기 버튼을 보고 싶다면, 오른쪽으로 스와이프를 하여, 해당 UI를 좌측으로 이동시켜야 화면에 보이게 됩니다. 결국 requireOffset의 계산 결과의 반대방향으로 이동해야 사용자에게 보이게 됩니다.

또한 아이템의 크기만큼 추가적으로 화면에 보여져야 하므로 actionSizePx의 경우에 아이템의 크기를 계산한 값을 이동시켜주어야 합니다.

 

 

github

https://github.com/Myeongcheol-shin/slideUnlock

 

GitHub - Myeongcheol-shin/slideUnlock: slideUnlock

slideUnlock. Contribute to Myeongcheol-shin/slideUnlock development by creating an account on GitHub.

github.com