본문 바로가기

Android/Android Custom View

[Jetpack Compose] Reorderable LazyList 에 Drag&Drop to Remove 곁들이기

Unsplash, Blurrystock.

동기

DND 8기를 통해 알게 된 좋은 분들과 여전히 함께 이런 저런 작업을 하고 있습니다. 동아리 활동 이후 새롭게 시작한 프로젝트의 UI 중, 다소 복잡하게 설계되어 있는 부분을 담당하게 되었고, 이를 해결 및 구현한 과정에 대해 기록하고자 합니다.


요구 사항은 무엇이었나?

 

 

1. + 버튼을 누르면 화면 상단에 재료를 입력할 수 있는 박스가 생성된다.

2. 해당 박스 내의 InputField 에 특정 값을 입력하고 Done 버튼을 누르면 화면 하단의 그리드에 재료가 추가된다.

3. 화면 하단 그리드의 항목은 길게 누른 뒤 드래그하여 순서를 변경할 수 있다.

 

이게 요구 사항의 전부였다면, 구현하는 데에 큰 어려움이 없었을 것입니다. 관련 기능을 제공하는 유명한 라이브러리가 있기 때문입니다. 하지만 곤란하게도, 4. 항목을 길게 누르면 화면 하단에 삭제를 위한 영역이 생성되고, 해당 영역 내에 드래그된 아이템은 삭제되어야 한다는 요구 사항이 존재했습니다.

 

물론, 해당 요구 사항도 구현이 크게 어렵지 않습니다. 다만 문제점은, 3번과 4번 모두가 동작하도록 구현하여야 한다는 것이었습니다. 


구현 과정

 

GitHub - aclassen/ComposeReorderable: Enables reordering by drag and drop in Jetpack Compose (Desktop) LazyList & LazyGrid.

Enables reordering by drag and drop in Jetpack Compose (Desktop) LazyList & LazyGrid. - GitHub - aclassen/ComposeReorderable: Enables reordering by drag and drop in Jetpack Compose (Desktop) La...

github.com

 

먼저, 그리드의 순서 변경부터 구현하기로 결정했습니다. 유명한 라이브러리가 있으니, 이를 사용하여 빠르게 해결하면 되겠다고 생각했습니다. 해당 라이브러리는 LazyColumn, LazyRow, LazyVertical(Horizontal)Grid 의 항목을 LongClick 하여 순서를 변경할 수 있도록 도와줍니다. 

 

 

처음엔 순서 변경도 직접 구현할까 생각했었는데, 다양한 이유로 개발이 너무나 많이 지체되어 빠르게 해결해야 했기 때문에, 라이브러리를 활용하여 빠르게 구현했습니다. 그러나, 중요한 것은 항목 삭제였습니다.

쓰레기통 표시하기

먼저, 쓰레기통 UI 를 표시해주어야 합니다. 위 첨부한 영상에서 보이는 바와 같이, 항목을 길게 누르면 화면 하단에 작은 붉은 색 박스가 표시됩니다. 다음과 같이 간단하게 구현하였습니다.

 

@Composable
private fun BoxScope.TrashCan(
    isButtonRemovable: Boolean,
    uiEvent: (UiEvent) -> Unit
) {
    Box(
        modifier = Modifier
            .align(BottomCenter)
            .padding(bottom = 24.dp)
            .clip(shape = RoundedCornerShape(12.dp))
            .size(48.dp)
            .background(color = Color.Red.alpha(if (isButtonRemovable) 100 else 50))
            .onGloballyPositioned {
                uiEvent(
                    OnTrashCanMeasured(
                        trashCanSize = it.size.width,
                        trashCanPosition = it.positionInRoot()
                    )
                )
            }
    )
}

 

onGloballyPositioned { } 에서 해당 UI 의 크기와 위치를 ViewModel 의 UiState 에 저장합니다. 그래야 항목의 이동에 따라 반응하여 삭제 여부를 표시 및 처리할 수 있기 때문입니다. onGloballyPositioned { } 의 경우, 다른 Modifier 메서드보다 리소스를 많이 사용하므로, 가능하다면 최적화하는 편이 좋겠습니다.

 

LazyVerticalGrid(
    modifier = Modifier
        .fillMaxSize()
        .reorderable(reorderableLazyGridState)
        .detectReorderAfterLongPress(reorderableLazyGridState),
    state = reorderableLazyGridState.gridState,
    columns = GridCells.Fixed(4),
    contentPadding = PaddingValues(top = 4.dp),
    horizontalArrangement = spacedBy(2.dp),
    verticalArrangement = spacedBy(2.dp)
) {
    items(
        items = buttonList,
        key = { it }
    ) { item ->
        ReorderableItem(
            reorderableState = reorderableLazyGridState,
            key = item
        ) { isDragging ->
            IngredientButton(
                ingredient = item,
                isDragging = isDragging,
                uiEvent = uiEvent
            )
        }
    }
}

 

항목의 LongClick 에 대한 처리는 라이브러리에 포함된 Composable 인 ReorderableItem() 메서드를 이용해 처리해줬습니다. 해당 Composable 은 현재 드래그 상태를 나타내는 값을 제공하는데, 저는 이를 isDragging 이라는 이름으로 사용하고 있습니다. 드래그하고 있는 항목의 Composable 에는 해당 값이 true 로 전달되어, 이에 대한 다양한 처리가 가능합니다.

드래그 중인 항목의 위치 정보를 ViewModel 에 전달하기

드래그 중인 항목의 위치 정보를 ViewModel 의 UiState 에 적용해야 합니다. 쓰레기통이 차지하는 영역에 해당 항목이 접근하게 되었을 때 삭제가 가능하도록 구현하기 위함입니다. 

 

항목의 위치 정보는 쓰레기통의 크기 및 위치 정보를 처리했던 것처럼, Modifier 의 onGloballyPositioned { } 를 통해 획득하고 처리할 수 있습니다. 다만, 문제는 isDragging 이 true 인 경우에만 처리해줘야 합니다.

 

Modifier 는 메서드 체이닝을 통해 사용하므로, 이에 대한 조건문을 적용하면 코드가 복잡해져 가독성이 저하됩니다. 이를 위해 branchedModifier() 라는 Composable 메서드를 구현했습니다.

 

@Composable
inline fun branchedModifier(
    condition: Boolean,
    crossinline onDefault: @Composable () -> Modifier,
    noinline onTrue: (@Composable (Modifier) -> Modifier)? = null,
    noinline onFalse: (@Composable (Modifier) -> Modifier)? = null,
): Modifier {
    val defaultModifier: Modifier = onDefault()
    var modifier: Modifier = onDefault()
    modifier = if (condition) {
        onTrue?.let {
            onTrue(modifier)
        } ?: defaultModifier
    } else {
        onFalse?.let {
            onFalse(modifier)
        } ?: defaultModifier
    }

    return modifier
}

 

branchedModifier() 메서드는 condition 에 따라 특정 메서드를 추가로 적용하거나 적용하지 않도록 하기 위해 사용됩니다. onTrue 와 onFalse 는 함수 타입 파라미터이며, nullable 로 구현하여 적용이 필요없는 경우에는 구현을 생략할 수 있도록 작성하였습니다.

 

Box(
    modifier = branchedModifier(
        condition = isDragging,
        onDefault = {
            Modifier
                .clip(shape = RoundedCornerShape(12.dp))
                .aspectRatio(1f)
                .animateItemPlacement()
                .background(color = Grey0)
                .clickableWithoutRipple { }
                .pointerInteropFilter {
                    startingXPosition = it.x
                    startingYPosition = it.y
                    false
                }
        },
        onTrue = { modifier ->
            modifier.onGloballyPositioned {
                uiEvent(OnButtonDragged(it.positionInRoot() + Offset(startingXPosition, startingYPosition)))
            }
        },
    )
) {
    Column(modifier = Modifier.align(Center)) {
        StyledText(
            text = ingredient,
            style = Typography.bodyMedium,
            fontSize = 13
        )
    }
}

 

이와 같이 사용할 수 있으며, condition 에 isDragging 을 적용하여 현재 드래그 중인 경우에만 onGloballyPositioned { } 를 적용합니다. 해당 메서드에서 ViewModel 의 uiState 로 해당 항목의 드래그 위치 정보와 원본 위치 정보를 혼합하여 전달합니다. 즉, 현재 화면에서의 드래그 위치 정보를 활용하여 쓰레기통의 영역과 비교하게 됩니다.

 

Modifier 커스터마이징과 관련해서는 다양한 제약 사항이 존재하는 것으로 알고 있습니다. 현재는 큰 문제가 발견되지 않아 이와 같이 사용하고 있긴 합니다만, 추후에 추가로 학습하여 수정할 부분이 있으면 수정할 계획입니다.

삭제 기능 구현하기

여기서부터는 너무나 간단해집니다. 쓰레기통이 차지하는 영역 내에 드래그 위치가 포함되게 되면 삭제가 가능한 상태임을 UiState 에 업데이트하고, 드래그가 끝날 때 삭제 가능 여부를 판별하여 항목의 삭제를 진행하면 되겠습니다. 

 

private fun onDraggingEnded(deletableItemIndex: Int) {
    if (uiState.value.isButtonRemovable) {
        _uiState.update {
            it.copy(
                buttonList = it.buttonList.toMutableList().apply {
                    removeAt(deletableItemIndex)
                }
            )
        }
    }
    
    _uiState.update {
        it.copy(
            buttonDraggingPosition = Offset.Zero,
            isButtonDragging = false,
            isButtonRemovable = false
        )
    }
}

 

이로써 모든 구현이 끝납니다.

구현 과정 중 곤란했던 것은, '어떤 Composable 의 Modifier 에 onGloballyPositioned { } 를 적용하여 항목의 위치를 계산할 것인가' 였습니다. 외부 라이브러리의 액션을 해치지 않으면서 원하는 기능을 구현하는 것이 관건이었기 때문입니다. 


구현 결과

 

 

항목 순서 변경, 항목 삭제가 모두 가능하도록 구현되었습니다. 

처음에는 항목 영역이 조금이라도 쓰레기통 영역과 겹치게 되면 삭제가 가능하도록 구현했었는데, 그렇게 하니 화면 하단부로의 항목 위치 변경에도 삭제가 반응하게 되어 사용성이 떨어지는 문제가 있었습니다. 이를 해결하기 위해 현재 터치되고 있는 부분에만 삭제가 트리거되도록 변경하였습니다.

 

자세한 코드는 여기에서 확인하실 수 있습니다.


Jetpack Compose 의 아주 큰 장점은, 다양한 UI 요구 사항을 해결하는 데에 리소스가 적게 소모된다는 것입니다. 약간의 논리 구조를 통해 아주 간단하게 다양한 UI 및 인터랙션을 구현할 수 있어, UI 를 개발하는 이들에게 아주 큰 효용을 가져다 줍니다. 파도 파도 어렵긴 하지만 말입니다.