리스트 외부까지 가능한 드래그 앤 드롭 구현하기

K_Gs·2025년 3월 16일
7
post-thumbnail

폴더 구조 만들기
이전 글에서 이어집니다.

폴더는 완성!

이전 글에서 리스트에 폴더 구조를 적용하는 작업은 완료하였습니다.

글에 적혀있지만 간단하게 요약하자면 이 리스트는 컴포넌트를 재귀적으로 호출하여 폴더 구조를 만들어냅니다. 깊이 0의 리스트에서 폴더를 누르면 다시 리스트를 호출하여 그 폴더의 아이템으로 구성된 깊이 1의 리스트를 만들어냅니다.

오늘 글에서는 해당 리스트 구조에 아래의 5, 6, 7번 기능을 추가하는 작업을 다룹니다.

  1. 리스트에 폴더가 일반 파일과 동등하게 표시된다.
  2. 폴더 선택시 해당 폴더 하위에 있는 파일이 보여진다.
  3. 폴더 선택시 해당 폴더의 이름이 탑 앱바에 보여진다.
  4. 폴더 선택시 설명 텍스트가 사라진다.
  5. 파일(폴더, 일반파일)은 꾹 눌러 드래그하여 순서 조정이 가능하다.
  6. 파일은 꾹 눌러 폴더안에 집어 넣을 수 있다.
  7. 파일은 꾹 눌러 탑앱바에 닿게 하여 폴더 밖으로 빼낼 수 있다.

사전 조사

시작에 앞서 드래그 앤 드롭이 꽤 난이도 있는 작업이 될 것이라 판단하여 먼저 Android에서 기본 제공해주는 기능이나 라이브러리가 있는지 찾아보았습니다.

Android-Drag And Drop

Android 공식 문서에 해당 글이 있기는 하였지만 다루는 내용은 이미지를 드래그하여 특정 컴포넌트에 두었을 때 그 이미지를 보여지게 하는 이미지 드래그앤 드롭의 기능이였습니다.

다른 여러 라이브러리도 찾아보았지만 현재 구현하고자 하는 리스트 내외부 드래그는 존재하지 않아 직접 구현하기로 결정하였습니다.

기본 드래그

처음 조사를 한뒤 기본 드래그 로직을 아래와 같이 구상해보았습니다.

  • 각 아이템의 Rect(화면상 좌표)를 구한다.
  • 각 아이템에 제스쳐 인식을 달아둔다.
  • 만약 아이템을 드래그 한다면 해당 아이템의 Offset을 변경시킨다.
  • 드래그가 완료되었을때 Rect를 돌며 인접한 아이템을 찾는다.
  • 해당 아이템들 사이에 이동한 아이템을 배치 시킨다.

이 코드는 아래와 같이 적용하여 쉽게 만들 수 있습니다.

var draggedItem by remember { mutableStateOf<Int?>(null) }
var draggedOffset by remember { mutableStateOf(Offset.Zero) }

File(modifier = Modifier.
	.onGloballyPositioned {//각 아이템 Rect 구하기
    	indexToRect[index] = it.boundInWindow()
    }.pointerInput(Unit) {//제스쳐 인식
        detectDragGesturesAfterLongPress(
            onDragStart = { offset ->
            	draggedItem = index
                draggedOffset = offset
            },
            onDrag = { change, dragAmount ->
                draggedOffset += dragAmount
            },
            onDragEnd = {
            	draggedItem = null
                draggedOffset = Offset.Zero
                //indexToRect를 통해 돌며 가까운 아이템 판별 및 이동
            },
            onDragCancel = {
            	draggedItem = null
                draggedOffset = Offset.Zero
            }
        )//드래그 중인 아이템이라면 Offset 적용
	}.offset( if(index == draggedItem) draggedOffset else Offset.Zero)
    .

하지만 저의 경우 이렇게 할 수 없었습니다.

아이템을 폴더 내외부로 이동 시켜야 했지만, 가장 중요한 역할을 하는 pointerInput이 해당 Composable에 결합되어 있었기 때문입니다. 즉, 만약 폴더 이동으로 인해 아이템이 사라지면 제스쳐 인식또한 받을 수 없어집니다.

또한 추가로 이 방식은 아이템이 리스트 내부에 존재하기에 Offset을 어떻게 적용하더라도 리스트 내부에만 아이템이 보여집니다. 탑앱바로도 이동 할 수 있어야하는 지금 상태에선 사용할 수 없었습니다.

오버레이

이에 대한 대안으로 저는 제스쳐 인식 및 Offset, 아이템의 표시 등 드래그와 관련된 모든 작업을 전체 화면을 덮는 Box에서 인식하도록 하기로 하였습니다.

이렇게 하게 되면 위에서 언급한 PointerInput이 끊기는 문제와 탑 앱바까지 드래그를 하지 못하는 문제 모두를 해결 할 수 있었습니다.

//HomeScreent.kt
Box(modifier = Modifier
	.fillMaxSize()
    .pointerInput(Unit) {
        detectDragGesturesAfterLongPress(
            onDragStart = { offset ->
                isDragging = true
                draggedOffset = offset
            },
            onDrag = { change, dragAmount ->
                draggedOffset += dragAmount
            },
            onDragEnd = {
                draggedOffset = Offset.Zero
                isDragging = false
            },
            onDragCancel = {
                draggedOffset = Offset.Zero
                isDragging = false
            }
        )
    }
) {
    Row(modifier = Modifier.fillMaxSize()) {
        Scaffold(
            modifier = Modifier.weight(1f),
            topBar = {
                HomeTopAppBar(
                ///....

바로 기존 Scaffold를 감싸는 Box를 만들어주고, 제스쳐 인식을 달았습니다.

처음 구상과 달리 드래그의 종료를 각 드래그의 대상이 되는 targetItem에서 알지 못하기에 isDraging이라는 플래그를 두고 전달하여 판단할 수 있게 하였습니다.

//FileList 컴포넌트에 매개변수로 전달
FileList(
	//...
	isDragging = isDragging,
	dragOffset = draggedOffset,
	draggedItem = draggedItem,
	setDraggedItem = {
    	draggedItem = it
	},
)

그리고 FileList 컴포넌트에 인자를 추가하고 전달하도록 하였습니다.

이를 전달 받은 List에서는 만약 자신이 활성 List라면(FocusedFolder가 없다면) 미리 Item에서 onGloballyPositioned를 통해 만들어준 IndexToRect를 통해 TargetItem을 찾아 setDraggedItem을 호출하도록 하였습니다.

if (focusedFolder != null) return@LaunchedEffect
// 활성 리스트이고
if (isDragging) {//드래그를 시작하였으며
    if (draggedItem != null) return@LaunchedEffect
    //드래그 대상 아이템이 없다면
    //RectList를 돌며 판단
    indexToRectForTarget.map { item ->
        if (item.value.contains(dragOffset)) {
            setDraggedItem(fileList[item.key])
            insertIndex = item.key + 1
            return@LaunchedEffect
        }
    }
    setDraggedItem(null)
}

이제 draggedItem이 set되면 오버레이를 표시하게 해주면 드래그의 시작은 완성됩니다.

var overlaySize by remember { mutableStateOf(IntSize.Zero) }
if (draggedItem != null && isDragging) {
    File(
        modifier = Modifier
            .onSizeChanged { size ->
                overlaySize = size
            }
            .offset {
                IntOffset(
                    (draggedOffset.x - overlaySize.width / 2f).roundToInt(),
                    (draggedOffset.y - overlaySize.height / 2f).roundToInt()
                )
           	}
           	.zIndex(1f)
            .alpha(0.5f),
        fileName = draggedItem!!.title,
        onFileClick = {},
        isFolder = draggedItem!! is FileEntity.Folder
    )
}

삽입 지점 활성화

움직여지는건 좋지만 지금 상태에서는 어디 지점에 삽입되는지 명확하게 보이지 않았습니다. 이를 위해 가까운 아이템 사이에 삽입되는 것이 아닌 삽입지점을 별도의 아이템으로 만들고 파랑색으로 활성화되도록 구현하기로 하였습니다.

삽입지점 생성

이번에 구현하는 기능의 삽입지점은 아래와 같이 세가지 종류입니다.

  1. 탑앱바(상위폴더의 최상위 위치로 삽입)
  2. 폴더(해당 폴더 내의 최상위 위치로 삽입)
  3. 아이템 사이

해당 삽입지점을 한번에 다루기 위해 각각에 index를 매겼습니다. 단, 아래와 같이 적용할 경우 0번 아이템이 삽입 대상이라면 탑앱바와 번호가 겹치기에 index에 +1 을 하여 판단합니다.

  • 탑앱바 : 0
  • 폴더 : -(아이템 index+1)
  • 아이템 사이 : 아이템 Index+1

다음으로는 아이템 사이의 삽입지점을 표시할 컴포넌트인 InsertTargetDivider를 만들었습니다. 코드가 간단하여 따로 적지는 않지만, index현재 삽입 지점을 받아 같다면 색을 가지는 divider입니다.

이제 해당 컴포넌트를 각 File 아이템 아래에 같이 생성 되도록 넣어주고, 삽입지점 확인을 위한 ItemToRectForInsert를 만들어 각 삽입지점의 Rect를 저장하도록 만들었습니다.

또한 만약 File아이템이 폴더라면 그 또한 삽입 대상이기에 Rect를 저장했습니다.

코드는 아래와 같습니다.

if (draggedItem?.documentId == file.documentId) return@itemsIndexed
//만약 현재 아이템이 드래그 중이라면 표시 X
val alpha by animateFloatAsState(
     targetValue = if (insertIndex == index.toFolderTargetNum()) FOLDER_TARGET_ALPHA else 0f,
     animationSpec = tween(TARGET_ANIMATION_SPEED)
)
File(modifier = Modifier
    .onGloballyPositioned {
    	indexToRectForTarget[index] = it.boundsInWindow()
        //폴더 라면 삽입지점 Rect에 추가
        if (file is FileEntity.Folder)
        	indexToRectForInsert[index.toFolderTargetNum()] =
                                it.boundsInWindow()
        }
   .drawWithContent {
   		//만약 삽입대상이라면 drawRect로 색 덮입힘
        drawContent()
        drawRect(
            color = Color(
            	context.getColor(
                	R.color.secondary_strong
                )
            ), alpha = alpha
       )
   }
   .animateItem()
   //...
}

//아이템 사이 삽입지점
InsertTargetDivider(
	index = index.toTargetNum(),
    nowInsertTarget = insertIndex,
    onGloballyPositionChange = {
    	//Rect 계산
    	indexToRectForInsert[index.toTargetNum()] = it.boundsInWindow()
    }
)

이렇게 구현하면 모든 아이템 하단에는 삽입지점이 생기게 됩니다. 하지만 최상단, 즉 0번 아이템 위에 삽입하고자 할 경우 divider가 없기에 별도로 한개의 divider를 생성해주었습니다.

LazyColumn(
	modifier = Modifier.fillMaxSize(), 
    state = fileListState
) {
	item {//1번 삽입지점
    	InsertTargetDivider( //...)
    }
    itemsIndexed(fileList) { index, file ->
    	//....

최적화

위와 같이 구현하게 되면 아이템이 늘어나면 늘어날수록 IndexRect를 매칭시키는 map의 크기가 늘어납니다.

이는 다음단계에서 진행할 실제 삽입지점을 map을 돌며 찾는데 점점 부담이 될 것이란 생각이 들었습니다.

그러던 중 LazyColumn은 화면에 보여지지 않게된 컴포넌트를 재사용한다는 점과 화면상에 보이는 Rect만 삽입지점 및 드래그 대상 아이템이 될 수 있다는 것을 떠올렸습니다.

그렇기에 DisposableEffect를 도입하여 화면에서 아이템이 사라지면, 즉 재사용이 되면 Rect도 제거하여 연산의 속도를 높였습니다.

DisposableEffect(Unit) {
    onDispose {
        indexToRectForTarget.remove(index) 
        // 드래그 Target 목록에서 제거
        if (file is FileEntity.Folder)
        	indexToRectForInsert.remove(index.toFolderTargetNum())
        // 삽입지점 목록에서 제거
    }
}

삽입 지점 판단

이제 모든 삽입지점은 IndexToRectForInsert에 저장되어 있습니다.

LaunchedEffect를 통해 Offset이 바뀌면 가장 가까운 삽입 지점을 찾도록 간단하게 구현하였습니다.

LaunchedEffect(dragOffset){
	 val newInsertTarget = indexToRectForInsert.minByOrNull {
         val itemRect = it.value
         val center = 
         	 Offset((itemRect.left + itemRect.right) / 2f, (itemRect.top + itemRect.bottom) / 2f)
         if (isFolderTarget(it.key)) {
             if (itemRect.contains(dragOffset)) hypot(
                 center.x - dragOffset.x, center.y - dragOffset.y
             )
         	 else Float.MAX_VALUE
    	 } else hypot(center.x - dragOffset.x, center.y - dragOffset.y)
    }?.key ?: return@LaunchedEffect

    if (newInsertTarget != insertIndex && isDragging) insertIndex = newInsertTarget
}

단, 폴더의 경우 Offset이 폴더 위에 있을때만 삽입 대상이 되어야 하기에 그 부분을 확인 후, 폴더 위에 있지 않다면 최대값을 적용하였습니다.

탑앱바 삽입 지점 판단

비슷하게 탑앱바의 삽입지점 판단도 적용하였습니다.

FileList의 인자에 topAppBarArea를 추가하여 탑앱바의 Rect를 넘겨줍니다.

이후 Offset 판단시에 아래와 같이 탑앱바 여부를 가장 먼저 판단하도록 하였습니다.

LaunchedEffect(dragOffset) {
    if (focusedFolder != null || draggedItem == null) return@LaunchedEffect
    if (parentFolderId != null && topAppBarArea?.contains(dragOffset) == true) {
        insertIndex = TOP_APPBAR_TARGET_NUM
        return@LaunchedEffect
    }
    //.....

이제 insertIndex에 삽입 지점이 저장되게 됩니다.

폴더 및 상위 폴더로 이동

삽입 지점을 알 수 있게 되었기에, 다음으로는 삽입 지점에 탑앱바 혹은 폴더에 1초간 머문다면 상위 폴더, 하위 폴더로 이동하게 하는 기능을 구현하고자 하였습니다.

InsertIndex가 바뀌는 것을 LunchedEffect로 감지 할 수 있게 하여 이를 구현하였습니다.

LaunchedEffect(insertIndex) {
    insertIndex?.let {
        if (isFolderTarget(it)) {
            delay(TIME_INTERACTION_DRAGGING)
            val targetItem = fileList[it.toFolderTargetNum()]
            if (targetItem is FileEntity.Folder) {
                onChangeSelectFolder(targetItem.title)
                focusedFolder = targetItem
                clearFileDragState()
            }
        }

        if (isTopAppBarTarget(it)) {
            while (parentFolderId != null) {
                delay(TIME_INTERACTION_DRAGGING)
                backDispatcher?.onBackPressed()
                //폴더 구조로 인해 뒤로가기는 상위 폴더로 이동과 동일하게 작동함
            }
        }
    }
}

삽입 대상이 폴더일때 해당 대상이 1초간 유지 되면 현재 리스트의 드래그 관련 상태를 제거하고 해당 폴더로 들어가도록 하였고,

삽입 대상이 탑 앱바라면 유지되는 1초마다 더 이상 올라갈 수 없을때 까지 상위 폴더로 이동하도록 하였습니다.

리스트 위 아래로 이동

요구 사항에 있던 모든 기능은 완료하였지만, 마지막으로 편리한 삽입을 위해 리스트의 위, 아래에 아이템이 닿으면 리스트가 슬라이드 되도록 하는 추가기능을 넣기로 했습니다.

먼저 리스트 위와 아래에 Box를 추가하고 Rect를 구하였습니다.

var lazyColumnTopMoveArea by remember { mutableStateOf<Rect?>(null) }
var isInsideTopMoveArea by remember { mutableStateOf(false) }
var lazyColumnBottomMoveArea by remember { mutableStateOf<Rect?>(null) }
var isInsideBottomMoveArea by remember { mutableStateOf(false) }

//BOX를 외부로 빼낸 컴포넌트
LazyColumnMoveArea(Modifier.align(Alignment.TopStart)) {
    lazyColumnTopMoveArea = it.boundsInWindow()
}

LazyColumnMoveArea(Modifier.align(Alignment.BottomStart)) {
    lazyColumnBottomMoveArea = it.boundsInWindow()
}

이는 탑앱바, 폴더와 마찬가지로 Rect를 구한 다음 Offset이 닿았는지 판단하도록 하기 위함입니다.

Offset을 감지하는 LunchedEffect에 탑앱바 다음 순서로 인식하도록 추가합니다.

LaunchedEffect(dragOffset) {
    if (focusedFolder != null || draggedItem == null) return@LaunchedEffect
    if (parentFolderId != null && topAppBarArea?.contains(dragOffset) == true) {
        insertIndex = TOP_APPBAR_TARGET_NUM
        return@LaunchedEffect
    }

    if (lazyColumnTopMoveArea?.contains(dragOffset) == true) {
        if (!isInsideTopMoveArea) isInsideTopMoveArea = true
        return@LaunchedEffect
    }
    if (lazyColumnBottomMoveArea?.contains(dragOffset) == true) {
        if (!isInsideBottomMoveArea) isInsideBottomMoveArea = true
        return@LaunchedEffect
    }
    isInsideBottomMoveArea = false
    isInsideTopMoveArea = false
    ///...

그리고 만약 isInside~~~~~MoveArea가 true가 되면 리스트를 슬라이드 하기 위해 LunchedEffect로 이를 감지하고 움직입니다.

이와 동시에 리스트가 변경되기에 삽입지점을 새롭게 판단해줍니다.

LaunchedEffect(isInsideTopMoveArea) {
    if (!isInsideTopMoveArea || draggedItem == null) return@LaunchedEffect
    while (true) {
        fileListState.scrollBy(
            -LIST_MOVE_SPEED
        )

        val newInsertTarget = indexToRectForInsert.minByOrNull {
            val temp = it.value
            val center = Offset((temp.left + temp.right) / 2f, (temp.top + temp.bottom) / 2f)
            if (isFolderTarget(it.key)) Float.MAX_VALUE
            else hypot(center.x - dragOffset.x, center.y - dragOffset.y)
        }?.key ?: return@LaunchedEffect

        if (newInsertTarget != insertIndex && isDragging) insertIndex = newInsertTarget
        delay(LIST_MOVE_INTERVAL)
    }
}

단, 이때 폴더는 1초간 머무름으로 인해 예상치 못하게 폴더 안으로 들어가 버릴 수 있기에 삽입 대상에서 제외합니다.

여기까지 함으로서 모든 구현이 아래와 같이 동작하며 마무리 됩니다.

드래그 종료

드래그 종료시엔 insertIndex를 바탕으로 다음과 같은 5개의 정보를 전달해야했습니다.

  • 삽입 대상 폴더
  • 삽입 할 아이템 id
  • 삽입 할 아이템의 폴더 여부
  • 삽입 할 아이템의 새로운 정렬 넘버
  • 이 아이템을 삽입함으로서 정렬 넘버를 새롭게 매겨야하는지

아래 2개의 경우 BE에서 정렬기준으로 order라는 수를 이용하기에 생기는 정보입니다.

우리가 order가 100인 아이템과 300인 아이템 사이에 집어넣게 된다면 이 아이템의 새로운 정렬 넘버는 200이 됩니다. 이를 계산해서 BE로 넘겨 업데이트 하는 방식입니다.

이럴 경우 order간 간격이 너무 좁아지면 문제가 생길 수 있기에 적당히 좁아졌을때 새롭게 번호를 매겨야한다는 플래그를 넘겨 번호를 재지정하는 방식입니다.

지금은 단순하게 삽입 대상 지점과 삽입할 아이템만을 알고 있기에 order를 실제로 계산해 주어야했습니다.

먼저 order를 구하기 위해 삽입 대상이 되는 폴더의 FileList 먼저 구해줍니다.

val insertIndexNonNull: Int = requireNotNull(insertIndex)
val (targetList, pureInsertIndex) = when {
	isTopAppBarTarget(insertIndexNonNull) -> {
        Pair(parentFolderFileList, TOP_NUM)
    }
	isSpaceTarget(insertIndexNonNull) -> {
        Pair(fileList, insertIndexNonNull-1)
    }
    else -> {
        Pair(
        	fileList[insertIndexNonNull.toFolderTargetNum()] as FileEntity.Folder).documents,
      		TOP_NUM
       	)
    }
}

이후 해당 List를 바탕으로 order를 구합니다.
이는 List의 상태에 따라 다르게 구하였습니다.

  • 비어있다 -> 100
  • 가장 마지막에 넣는다 -> 마지막 아이템 order + 50
  • 가장 처음에 넣는다 -> 가장 처음 아이템 order / 2
  • 그 외 -> 삽입 지점 위아래 order 합 / 2
val (order, isReallocate) = when {
    targetList.isEmpty() -> {
        Pair(100,false)
    }

    pureInsertIndex == 0 -> {
        Pair(targetList[0].order/2,targetList[0].order <= ORDER_MIN_GAP_TO_REALLOCATE)
    }

    pureInsertIndex == targetList.size -> {
         Pair(targetList.last().order + ORDER_ADD_VALUE_LAST_INSERT, false)
    }

     else -> {
         Pair(
             (targetList[pureInsertIndex].order + targetList[pureInsertIndex-1].order)/2,
  			 targetList[pureInsertIndex].order - targetList[pureInsertIndex-1].order <= ORDER_MIN_GAP_TO_REALLOCATE
         ) 
     }
}

또한 동시에 새로운 order와 상,하 아이템의 order 차이가 20 미만이라면 재할당 flag를 세팅해주었습니다.

이제 해당 값들을 바탕으로 onDragEnd를 호출해줍니다.

when {
    isTopAppBarTarget(insertIndexNonNull) -> onDragEnd(-parentFolderId!!, order, isReallocate )
    isSpaceTarget(insertIndexNonNull) -> onDragEnd(-nowFolderId, order, isReallocate)
    else -> onDragEnd((fileList[insertIndexNonNull.toFolderTargetNum()] as FileEntity.Folder).folderId, order, isReallocate)
}
clearFileDragState()

이로써 모든 구현이 완료되었습니다.

마무리하며

상당히 난이도 있는 구현이였습니다.

각 기능을 잘 쪼개서 개발하는데도 다 구현하고 버그 잡는데 2~3일 정도 걸린 것 같습니다.

하나의 큰 구현 문제를 푼 것 같아 기분이 좋고 뭐든 할 수 있을 것 같은 기분이네요!

profile
아직도 모르는게 많으니, 알아가고 싶은 것도 많다

0개의 댓글