Hanbit the Developer

[Compose] SwipeToAction 구현(AnchoredDraggableState, Layout) 본문

Mobile/Android

[Compose] SwipeToAction 구현(AnchoredDraggableState, Layout)

hanbikan 2024. 2. 16. 19:04

Overview

예시 코드

SwipeToAction(
    swipeActions = SwipeActions.withSameActions(
        action = SwipeAction.deleteAction { /* TODO: onClickDeleteActionButton */ }
    )
) { modifier ->
    Row(
        modifier = modifier // YOU MUST ADD IT!
            .fillMaxWidth()
            .background(Color.White, RoundedCornerShape(8.dp))
            .padding(8.dp),
    ) {
        Text(text = "Sample")
    }
}

위처럼 swipeActions를 지정해준 뒤 내부 컨텐츠에 modifier를 지정해주면 곧바로 swipe-to-action이 적용되는 컴포넌트를 구현하였습니다.

구현 과정

ActionButton

재사용성을 고려했을 때 스와이프 시 나타나는 액션 버튼이 변경될 여지가 크기 때문에 추상화가 필요했습니다:

/**
 * [ActionButton]의 UI에 필요한 데이터 클래스
 */
data class SwipeAction(
    val backgroundColor: Color,
    val iconImageVector: ImageVector,
    val iconTint: Color,
    val onClick: () -> Unit,
)

상응하는 뷰는 다음과 같습니다:

/**
 * [SwipeToAction] 양쪽에 나타나는 액션 버튼입니다. 클릭되었을 때 [moveToCenter], [SwipeAction.onClick]를 호출합니다.
 */
@Composable
fun ActionButton(
    action: SwipeAction,
    moveToCenter: () -> Unit,
    dragThresholdsDp: Float,
    margin: Dp,
) {
    val interactionSource = remember { MutableInteractionSource() }

    Box(
        modifier = Modifier
            .width(dragThresholdsDp.dp - margin)
            .background(
                action.backgroundColor,
                RoundedCornerShape(Dimens.SpacingMedium)
            )
            .clickable(
                onClick = {
                    moveToCenter()
                    action.onClick()
                },
                interactionSource = interactionSource,
                indication = null
            ),
    ) {
        Icon(
            modifier = Modifier.align(Alignment.Center),
            imageVector = action.iconImageVector,
            contentDescription = null,
            tint = action.iconTint
        )
    }
}

이렇게 함으로써 아래 코드를 통해 ‘액션 버튼’을 생성할 수 있게 되었습니다:

val action = SwipeAction(
    backgroundColor = Color.Red,
    iconImageVector = Icons.Default.Delete,
    iconTint = Color.White,
    onClick = { /* Click event */ }
)

ActionButton(
    action = action,
    // ...
)

이후에 아래 코드처럼 자주 사용될 것으로 보이는 SwipeAction을 생성하는 함수를 작성하였습니다:

/**
 * [ActionButton]의 UI에 필요한 데이터 클래스
 */
data class SwipeAction(
    val backgroundColor: Color,
    val iconImageVector: ImageVector,
    val iconTint: Color,
    val onClick: () -> Unit,
) {
    companion object {
        fun deleteAction(onClick: () -> Unit) = SwipeAction(
            backgroundColor = Color.Red,
            iconImageVector = Icons.Default.Delete,
            iconTint = Color.White,
            onClick = onClick
        )
    }
}

이에 따라 코드가 다음과 같이 간소화됩니다:

ActionButton(
    action = SwipeAction.deleteAction { /* Click events */ },
    // ...
)

SwipeActions

지금까지의 앱 사용 경험을 돌이켜보면 swipe to action은 항상 양쪽 방향 모두 지원했던 것 같습니다. 따라서 방금 작성한 SwipeAction을 항상 세트로 묶어주는 SwipeActions를 작성하였습니다. 따라서 컴포넌트 호출 시 startSwipeAction, endSwipeAction을 둘 다 지정하는 대신 swipeActions만을 설정하면 됩니다.

/**
 * @param startAction 좌측으로 드래그했을 때 우측에 표시되는 [SwipeAction]
 * @param endAction 우측으로 드래그했을 때 좌측에 표시되는 [SwipeAction]
 */
data class SwipeActions(
    val startAction: SwipeAction,
    val endAction: SwipeAction,
)

또한 양 사이드의 동작이 같은 경우가 많을 것이라고 생각하여 다음과 같은 함수를 추가하였습니다:

/**
 * @param startAction 좌측으로 드래그했을 때 우측에 표시되는 [SwipeAction]
 * @param endAction 우측으로 드래그했을 때 좌측에 표시되는 [SwipeAction]
 */
data class SwipeActions(
    val startAction: SwipeAction,
    val endAction: SwipeAction,
) {
    companion object {
        fun withSameActions(action: SwipeAction) = SwipeActions(
            startAction = action,
            endAction = action
        )
    }
}

지금까지의 내용을 코드로 정리하면 다음과 같습니다:

val swipeActions = SwipeActions.withSameActions(
    action = SwipeAction.deleteAction { /* Click event */ }
)
ActionButton(
    action = swipeActions.startAction,
    // ...
)
ActionButton(
    action = swipeActions.endAction,
    // ...
)

SwipeToAction

안드로이드 공식 문서에 따라 swipe를 구현해주었습니다.

DragValue

anchored draggable state는 총 3개입니다.(왼쪽으로 스와이프한 상태, 평소 상태, 그리고 오른쪽으로 스와이프한 상태)

enum class DragValue { Start, Center, End }

createAnchoredDraggableState

AnchoredDraggableState를 처음 접하였어서 새로운 개념이 가장 많았던 부분입니다. 먼저 AnchoredDraggableState가 적용되는 방식을 대략적으로 살펴보면 다음과 같습니다:

val anchoredDraggableState = remember { AnchoredDraggableState ( /* ... */ ) }
Row(
    modifier = Modifier
        .offset {
            IntOffset(
                x = anchoredDraggableState
                    .requireOffset()
                    .roundToInt(),
                y = 0
            )
        }
        .anchoredDraggable(anchoredDraggableState, Orientation.Horizontal)
) {
    Text(text = "Sample")
}

 

  1. AnchoredDraggableState를 생성한다.
  2. 스와이프 하고자 하는 곳의 Modifier에 offset, anchoredDraggable을 적용한다. 이때 anchoredDraggableState로부터 얼마나 드래그 했는지를 가져와서(requireOffset()) offset으로 적용한다.

여기까지는 간단해보이지만 AnchoredDraggableState를 생성하는 부분이 조금 까다롭습니다. 코드가 길기 때문에 createAnchoredDraggableState() 함수를 작성하여 따로 빼주었습니다:

/**
 * @param dragThresholdsDp 드래그되는 최대 가동 범위입니다.
 * @param velocityThresholdDp 1초에 [velocityThresholdDp] 이상 움직이면 드래그 처리가 됩니다.(스냅)
 * @param positionalThresholdWeight [dragThresholdsDp] * [positionalThresholdWeight] 이상 움직이면 드래그
 * 처리가 됩니다.
 */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun createAnchoredDraggableState(
    dragThresholdsDp: Float,
    velocityThresholdDp: Float,
    positionalThresholdWeight: Float,
): AnchoredDraggableState<DragValue> {
    val density = LocalDensity.current
    val dragThresholdsPx = with(density) { dragThresholdsDp.dp.toPx() }
    return remember {
        AnchoredDraggableState(
            anchors = DraggableAnchors {
                DragValue.Start at -dragThresholdsPx
                DragValue.Center at 0f
                DragValue.End at dragThresholdsPx
            },
            initialValue = DragValue.Center,
            positionalThreshold = { distance: Float -> distance * positionalThresholdWeight },
            velocityThreshold = { with(density) { velocityThresholdDp.dp.toPx() } },
            animationSpec = tween(),
        )
    }
}
  1. thresholds 인자: 주석에 기본적인 설명을 달아놓았으며 추가로 예시를 들어보겠습니다. dragThresholdsDp = 100.dp, velocityThresholdDp = 60.dp, positionalThresholdWeight = 0.5f라고 가정해보겠습니다. 먼저 아이템을 좌측으로 최대 100dp, 우측으로 최대 100dp 만큼 드래그할 수 있습니다.(dragThresholdsDp) 그리고 좌측 또는 우측으로 50dp(100dp * 0.5f) 이상 움직인 뒤 손을 놓으면 해당 방향으로 스와이프 됩니다.(velocityThresholdDp) 마지막으로 ‘1초에 60dp 이상 움직일 수 있는 속도’에 도달하면 그 방향으로 스와이프 됩니다.(positionalThresholdWeight)
  2. AnchoredDraggableState 생성자: anchors에 각 앵커의 좌표를 정의해줍니다. 생성자에서 쓰이는 값들은 단위가 px이므로 주의해야 합니다.

이제 본격적으로 컴포넌트를 구현해야 합니다. 지금까지 작성했던 모든 코드가 SwipeToAction에 쓰입니다:

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SwipeToAction(
    swipeActions: SwipeActions,
    dragThresholdsDp: Float = 72f,
    velocityThresholdDp: Float = 100f,
    positionalThresholdWeight: Float = 0.5f,
    margin: Dp = Dimens.SpacingSmall,
    contentToSwipe: @Composable (Modifier) -> Unit,
) {
    val anchoredDraggableState = createAnchoredDraggableState(
        dragThresholdsDp = dragThresholdsDp,
        velocityThresholdDp = velocityThresholdDp,
        positionalThresholdWeight = positionalThresholdWeight
    )
    val draggableModifier = Modifier
        .offset {
            IntOffset(
                x = anchoredDraggableState
                    .requireOffset()
                    .roundToInt(),
                y = 0
            )
        }
        .anchoredDraggable(anchoredDraggableState, Orientation.Horizontal)

    Box {
        ActionButton(
            modifier = Modifier
                .align(Alignment.CenterStart)
                .fillMaxHeight(),
            action = swipeActions.endAction,
            moveToCenter = { anchoredDraggableState.moveToCenter() },
            dragThresholdsDp = dragThresholdsDp,
            margin = margin,
        )
        ActionButton(
            modifier = Modifier
                .align(Alignment.CenterEnd)
                .fillMaxHeight(),
            action = swipeActions.startAction,
            moveToCenter = { anchoredDraggableState.moveToCenter() },
            dragThresholdsDp = dragThresholdsDp,
            margin = margin,
        )
        contentToSwipe(draggableModifier)
    }
}
  1. AnchoredDraggableState 생성
  2. contentToSwipe에 Modifier 적용
  3. 뷰 그리기: 뒤에 ActionButton 2개를 양쪽에 넣고 앞쪽을 contentToSwipe로 채워줍니다. 이렇게 하면 스와이프 시 뒤에 숨겨져 있던 버튼이 노출됩니다. 앞서 설명하지 않았지만 ActionButton의 사이즈는 dragThresholdsDp에 의존합니다. dragThresholdsDp = 100.dp, margin = 10.dp일 경우 ActionButton의 사이즈는 90dp가 됩니다.

하지만 UI 문제를 직면하였습니다. ActionButton가 contentToSwipe 만큼 컴포넌트를 꽉 채우기를 원했으나 화면은 다음 그림과 같이 표시됩니다.

이때 해결 방법은 2가지로 나뉩니다.

  1. SwipeToAction의 높이를 고정으로 두고 ActionButton에서 fillMaxHeight() 사용하기
  2. Layout을 통해 contentToSwipe의 높이를 measure한 뒤 높이 적용하기

1번이 훨씬 쉬운 방법이었으나 제 앱에서 높이를 동적으로 두어야 했기 때문에 2번 방법을 사용하였습니다(또한 높이에 대한 제약이 없어지기 때문에 컴포넌트의 사용성이 좋아집니다.):

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SwipeToAction(
    swipeActions: SwipeActions,
    dragThresholdsDp: Float = 72f,
    velocityThresholdDp: Float = 100f,
    positionalThresholdWeight: Float = 0.5f,
    margin: Dp = Dimens.SpacingSmall,
    contentToSwipe: @Composable (Modifier) -> Unit,
) {
    val anchoredDraggableState = createAnchoredDraggableState(
        dragThresholdsDp = dragThresholdsDp,
        velocityThresholdDp = velocityThresholdDp,
        positionalThresholdWeight = positionalThresholdWeight
    )
    val draggableModifier = Modifier
        .offset {
            IntOffset(
                x = anchoredDraggableState
                    .requireOffset()
                    .roundToInt(),
                y = 0
            )
        }
        .anchoredDraggable(anchoredDraggableState, Orientation.Horizontal)

    Layout(
        content = {
            contentToSwipe(draggableModifier)
            ActionButton(
                action = swipeActions.endAction,
                moveToCenter = { anchoredDraggableState.moveToCenter() },
                dragThresholdsDp = dragThresholdsDp,
                margin = margin,
            )
            ActionButton(
                action = swipeActions.startAction,
                moveToCenter = { anchoredDraggableState.moveToCenter() },
                dragThresholdsDp = dragThresholdsDp,
                margin = margin,
            )
        }
    ) { measurableList, constraints ->
        // ActionButton의 높이를 content의 높이로 맞춥니다.
        val contentPlaceable = measurableList[0].measure(constraints)
        val contentHeightConstraints = constraints.copy(
            minHeight = contentPlaceable.height,
            maxHeight = contentPlaceable.height
        )
        val endActionPlaceable = measurableList[1].measure(contentHeightConstraints)
        val startActionPlaceable = measurableList[2].measure(contentHeightConstraints)

        val startActionX = constraints.maxWidth - startActionPlaceable.width // End에 버튼 배치

        layout(constraints.maxWidth, contentPlaceable.height) {
            endActionPlaceable.place(0, 0)
            startActionPlaceable.place(startActionX, 0)
            contentPlaceable.placeRelative(0, 0)
        }
    }
}
  1. Layout 인자 content 블럭: measure 또는 place(컴포넌트 배치)에 필요한 컴포넌트를 배치합니다.
  2. 코드 블럭(measurePolicy): content에 컴포넌트가 3개이므로 measurableList에는 3개의 Measurable이 들어갑니다. Measurable은 measure() 함수를 통해 사이즈를 측정한 뒤, layout 함수의 placementBlock 코드 블럭 내에서 place() 함수를 호출함으로써 배치됩니다. 이때 ActionButton의 사이즈를 결정하기 위해서, contentToSwipe를 측정해 얻은 높이를 적용한 Constraints를 생성하였고, 이를 ActionButton의 measure의 인자로 넘김으로써 크기를 결정하였습니다.

이렇게 함으로써 구현을 모두 마칠 수 있었습니다.

Remind

중요한 내용들을 복기하자면 다음과 같습니다:

  1. ActionButton를 위한 추상화: SwipeAction, SwipeActions
  2. AnchoredDraggableState: DragValue 정의, 생성자(dragThresholdsDp, velocityThresholdDp, positionalThresholdWeight, DraggableAnchors), Modifier에 적용(offset, anchoredDraggable)
  3. SwipeToAction에 Layout 적용: content 블럭에 컴포넌트 정의, measurePolicy 코드 블럭에서 Measurable.measure(), Placeable.place() 호출(place()는 layout() 함수의 인자인 placementBlock 코드 블럭 내에서 호출)

최종 코드

/**
 * [contentToSwipe]에 드래그를 적용하고 드래그 시 남는 공간에 [SwipeAction]을 표시하는 컴포넌트입니다.
 *
 * 사용 사례:
 *
 * ```
 * SwipeToAction(
 *     swipeActions = SwipeActions.withSameActions(
 *         action = SwipeAction.deleteAction { /* TODO: onClickDeleteActionButton */ }
 *     )
 * ) { modifier ->
 *     Row(
 *         modifier = modifier // YOU MUST ADD IT!
 *             .fillMaxWidth()
 *             .background(Color.White, RoundedCornerShape(8.dp))
 *             .padding(8.dp),
 *     ) {
 *         Text(text = "Sample")
 *     }
 * }
 * ```
 *
 * @param swipeActions [SwipeActions]
 * @param dragThresholdsDp 드래그되는 최대 가동 범위입니다.
 * @param velocityThresholdDp 1초에 [velocityThresholdDp] 이상 움직이면 드래그 처리가 됩니다.(스냅)
 * @param positionalThresholdWeight [dragThresholdsDp] * [positionalThresholdWeight] 이상 움직이면 드래그
 * 처리가 됩니다.
 * @param margin 드래그 되었을 때 [SwipeAction] 버튼과 [contentToSwipe] 사이의 간격입니다.
 * @param contentToSwipe 드래그가 적용될 메인 컨텐츠입니다. 반드시 인자로 넘어가는 [Modifier]를 적용시켜야 합니다.
 */

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SwipeToAction(
    swipeActions: SwipeActions,
    dragThresholdsDp: Float = 72f,
    velocityThresholdDp: Float = 100f,
    positionalThresholdWeight: Float = 0.5f,
    margin: Dp = Dimens.SpacingSmall,
    contentToSwipe: @Composable (Modifier) -> Unit,
) {
    val anchoredDraggableState = createAnchoredDraggableState(
        dragThresholdsDp = dragThresholdsDp,
        velocityThresholdDp = velocityThresholdDp,
        positionalThresholdWeight = positionalThresholdWeight
    )
    val draggableModifier = Modifier
        .offset {
            IntOffset(
                x = anchoredDraggableState
                    .requireOffset()
                    .roundToInt(),
                y = 0
            )
        }
        .anchoredDraggable(anchoredDraggableState, Orientation.Horizontal)

    Layout(
        content = {
            contentToSwipe(draggableModifier)
            ActionButton(
                action = swipeActions.endAction,
                moveToCenter = { anchoredDraggableState.moveToCenter() },
                dragThresholdsDp = dragThresholdsDp,
                margin = margin,
            )
            ActionButton(
                action = swipeActions.startAction,
                moveToCenter = { anchoredDraggableState.moveToCenter() },
                dragThresholdsDp = dragThresholdsDp,
                margin = margin,
            )
        }
    ) { measurableList, constraints ->
        // ActionButton의 높이를 content의 높이로 맞춥니다.
        val contentPlaceable = measurableList[0].measure(constraints)
        val contentHeightConstraints = constraints.copy(
            minHeight = contentPlaceable.height,
            maxHeight = contentPlaceable.height
        )
        val endActionPlaceable = measurableList[1].measure(contentHeightConstraints)
        val startActionPlaceable = measurableList[2].measure(contentHeightConstraints)

        val startActionX = constraints.maxWidth - startActionPlaceable.width // End에 버튼 배치

        layout(constraints.maxWidth, contentPlaceable.height) {
            endActionPlaceable.place(0, 0)
            startActionPlaceable.place(startActionX, 0)
            contentPlaceable.placeRelative(0, 0)
        }
    }
}

/**
 * [SwipeToAction] 양쪽에 나타나는 액션 버튼입니다. 클릭되었을 때 [moveToCenter], [SwipeAction.onClick]를 호출합니다.
 */
@Composable
fun ActionButton(
    action: SwipeAction,
    moveToCenter: () -> Unit,
    dragThresholdsDp: Float,
    margin: Dp,
) {
    val interactionSource = remember { MutableInteractionSource() }

    Box(
        modifier = Modifier
            .width(dragThresholdsDp.dp - margin)
            .background(
                action.backgroundColor,
                RoundedCornerShape(Dimens.SpacingMedium)
            )
            .clickable(
                onClick = {
                    moveToCenter()
                    action.onClick()
                },
                interactionSource = interactionSource,
                indication = null
            ),
    ) {
        Icon(
            modifier = Modifier.align(Alignment.Center),
            imageVector = action.iconImageVector,
            contentDescription = null,
            tint = action.iconTint
        )
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun createAnchoredDraggableState(
    dragThresholdsDp: Float,
    velocityThresholdDp: Float,
    positionalThresholdWeight: Float,
): AnchoredDraggableState<DragValue> {
    val density = LocalDensity.current
    val dragThresholdsPx = with(density) { dragThresholdsDp.dp.toPx() }
    return remember {
        AnchoredDraggableState(
            anchors = DraggableAnchors {
                DragValue.Start at -dragThresholdsPx
                DragValue.Center at 0f
                DragValue.End at dragThresholdsPx
            },
            initialValue = DragValue.Center,
            positionalThreshold = { distance: Float -> distance * positionalThresholdWeight },
            velocityThreshold = { with(density) { velocityThresholdDp.dp.toPx() } },
            animationSpec = tween()
        )
    }
}

enum class DragValue { Start, Center, End }

/**
 * @param startAction 좌측으로 드래그했을 때 우측에 표시되는 [SwipeAction]
 * @param endAction 우측으로 드래그했을 때 좌측에 표시되는 [SwipeAction]
 */
data class SwipeActions(
    val startAction: SwipeAction,
    val endAction: SwipeAction,
) {
    companion object {
        fun withSameActions(action: SwipeAction) = SwipeActions(
            startAction = action,
            endAction = action
        )
    }
}

/**
 * [ActionButton]의 UI에 필요한 데이터 클래스
 */
data class SwipeAction(
    val backgroundColor: Color,
    val iconImageVector: ImageVector,
    val iconTint: Color,
    val onClick: () -> Unit,
) {
    companion object {
        fun deleteAction(onClick: () -> Unit) = SwipeAction(
            backgroundColor = Color.Red,
            iconImageVector = Icons.Default.Delete,
            iconTint = Color.White,
            onClick = onClick
        )
    }
}

@OptIn(ExperimentalFoundationApi::class)
fun <T> AnchoredDraggableState<T>.moveToCenter() {
    dispatchRawDelta(-offset)
}