[UICollectionView] Drag & Drop cell 위치 변경, Delegate로 구현하기

2024. 2. 1. 10:44UIKit, SwiftUI, H.I.G

1️⃣ 이번 글에서 구현하고자 하는 기능은?

이번 글에서 구현할 기능은
예전에도 한번 구현해 본 경험이 있던 컬렉션 뷰에서 특정 Cell을 꾹 눌러 드래그할 때, Cell의 순서를 바꿀 수 있는 Drag & Drop 기능이다.

사실 이 내용은 예전에도 한번 구현해본적이 있다.
당시에는 테이블 뷰의 "편집 모드"에 한정되는 기능 지원으로 인해 UILongPressGestureRecognizer부터 시작해 Snapshot을 찍고, 상태를 저장하고 머시기.... 어렵게 구현했었다.

하지만, 2년이 지난 지금.
그때는 없던 UICollectionViewDragDelegate, UICollectionViewDropDelegate라는 좋은 방식을 찾을 수 있었고,
Apple 공식문서에서도 Supporting Drag and Drop in Collection VIews라는 Article도 제공해주고 있어, 예전만큼 이 기능을 구현하는 데 있어 여려움이 있지는 않았다. 오늘 글도 해당 Article의 전반적인 내용을 바탕으로 작성되었다!

오늘 글에서 다루는 드래그앤드롭 기능 영상!

오늘 기능은 UICollectionView의 DragDelegate와 DropDelegate 프로토콜을 채택해서, 그 메서드를 사용하는 방식으로 구현한다.

✔️ UICollectionViewDragDelegate : 컬렉션 뷰 아이템의 "드래그"하는 동작을 관리할 때 사용 (드래그 작업 시작, 드래그 아이템 정보를 받아오는 작업 등을 해당 프로토콜 메서드에서 구현할 수 있다.)
✔️ UICollectionViewDropDelegate : 컬렉션 뷰 아이템의 "드롭"하는 동작을 관리할 때 사용(드래그 동작, 드랍할 때의 액션, 드랍할 때 아이템을 수신하는 컬렉션 뷰에 대한 정보들을 해당 프로토콜 메서드에서 받아올 수 있다)
editClipCollectionView.dragDelegate = self
editClipCollectionView.dropDelegate = self

위의 내용에 따르면 채택하는 Delegate 프로토콜은 2개(DragDelegate, DropDelegate)이다.
하지만, 아래에서 실질적으로 구현해야 하는 메서드는 세 부분(Drag 1개, Drop 2개)으로 나눠서 구현하게 될 것이다. 차근차근 배워보자!

✔️ collectionView(_:itemsForBeginning:at:) : 처음 드래그가 시작될 때 호출되는 부분
✔️ collectionView(_:dropSessionDidUpdate:withDestinationIndexPath:) : 드래그 동작 중에 호출되는 부분
✔️ collectionView(_:performDropWith:) : 드래그를 마치고 드롭 작업이 발생했을 때 호출되는 부분

왼쪽부터 차례대로 itemsForBeginning, dropSessionDidUpdate, performDropWith에 해당하는 액션이다.

 

2️⃣ itemsForBeginning: 처음 드래그가 시작될 때 호출되는 부분

처음 드래그가 시작될 때 호출되는 부분에서는 어떤 아이템을 드래그해야할지 지정해야 하는 부분이 필요할 것이다.
이를 해당 메서드에서는 UIDragItem의 배열로 반환하면서 지정할 수 있고, 만약 드래그를 하고 싶지 않은 경우에는 빈 배열을 반환해 주면 된다고 한다.

공식 문서를 기반으로 내가 작성한 코드에 대해서 자세하게 설명해보겠다.

1. 하나 이상의 NSItemProvider 객체를 생성해야 한다.
*NSItemProvider는 데이터를 제공하고 전달하는 데 사용되는 클래스이며, 드래그 앤 드롭 작업에서 데이터를 제공하고 전달하는 데에 활용된다.
**NSItemProvider는 다양한 유형의 데이터를 제공할 수 있는데, 이를 통해 드랍 대상에게 전달할 수 있는 다양한 데이터(예를 들어, 이미지, 텍스트, URL 등)를 포장하고 관리할 수도 있다.

2. NSItemProvider 객체를 UIDragItem 객체로 래핑하고, 이를 배열에 넣어 반환한다.

3. 파라미터로 제공되는 indexPath값으로 드래그할 값을 특정할 수 있는데, 나 같은 경우에는 0번째 아이템을 고정하기 위해 조건을 추가했다. (빈배열을 반환하면, 드래그를 할 수 없도록 만든다.)
// MARK: - CollectionView Drag Delegate

extension EditClipViewController: UICollectionViewDragDelegate {
    /// 처음 드래그가 시작될 때 호출되는 함수
    func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
        return indexPath.item != 0 ? [UIDragItem(itemProvider: NSItemProvider())] : []
    }
}

 

3️⃣ dropSessionDidUpdate : 드래그 동작 중에 호출되는 부분

드래그 동작 중(=드롭 세션의 업데이트가 발생될 때)에 호출되는 dropSessionDidUpdate 메서드는 총 3개의 파라미터를 갖고 있다.
*작업이 발생하는 컬렉션 뷰(collectionView), 현재 드롭 세션을 나타내는 UIDropSession 객체(session), 그리고 드롭 작업의 결과로 도착하는(=변하는) indexPath값(destinationIndexPath, 해당 매개변수는 옵셔널

이 3개의 파라미터 값을 가지고 올바른 동작을 처리해서, 최종적으로는 UICollectionViewDropProposal을 반환시켜줘야 한다.

*UICollectionViewDropProposal은 Drop의 동작과 의도를 나타내는 클래스이며, operation과 intent를 속성으로 지정한다.

✔️ operation : "드롭 작업의 동작"을 나타내는 UICollctionViewDropOperation 열거형 값이다.
UICollctionViewDropOperation 열거형 항목 : .cancel (취소) .forbidden (드롭 작업을 막음) .copy (복사) .move (이동)

✔️ intent
: "드롭 작업의 의도"를 나타내는 UICollectionViewDropIntent 열거형 값이다.
UICollectionViewDropIntent 열거형 항목 : .insertAtDestinationIndexPath (destinationIndexPath에 새로운 아이템을 삽입하고자 할 때 사용) .insertIntoDestinationIndexPath (destinationIndexPath에 있는 아이템과의 관계(병합, 복사 등)를 고려할 때 사용)


위를 바탕으로 내가 구현한 코드에 대해서 설명해보겠다.

1. collectionView의 hasActiveDrag를 이용하여 현재 드래그 작업이 활성화되어 있는지 확인한다. -> 활성화되지 않았다면 (=현재 유저가 드래그 작업 중이 아니라면) .forbidden 속성을 반환하여 드롭 작업을 막도록 설정한다.

2. 0번째 item은 드래그(다른 아이템의 삽입도 이뤄질 수 없다)도 고정이어야 하므로, 해당 경우(else 부분)에 대해서도 .forbidden 속성을 반환하여 드롭 작업을 막도록 설정한다.

3. 설정한 조건을 만족하는 경우에는 .move 액션(드롭 작업을 하는 동안 다른 아이템들은 움직여야 한다)과 .insertAtDestinationIndexPath(목적지 인덱스에 삽입되어야 한다)을 만족하는 DropProposal 객체를 반환시킨다.
// MARK: - CollectionView Drop Delegate

extension EditClipViewController: UICollectionViewDropDelegate {
    /// 드래그 하는 동안 호출되는 함수
    func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
        guard collectionView.hasActiveDrag else { return UICollectionViewDropProposal(operation: .forbidden) }
        if destinationIndexPath?.item != 0 {
            return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
        } else {
            return UICollectionViewDropProposal(operation: .forbidden)
        }
    }

 

4️⃣ performDropWith : 드래그를 마치고 드롭 작업이 발생했을 때 호출되는 부분

마지막으로 Drop 작업이 발생했을 때 호출되는 performDropWith 메서드를 살펴보겠다.
해당 메서드에서는 파라미터로 주어지는 드롭 작업의 대상인 컬렉션 뷰(collectionView)와 드롭 작업에 대한 세부 정부와 제어를 제공하는 coordinator 속성을 활용해서 자세한 내용을 구현해주게 될 것이다.

우선, 어떤 위치에 drop 작업이 이루어지는지를 확인하기 위해서 coordinator.destinationIndexPath 값을 먼저 확인한다.

이후, 0번째 인덱스 아이템을 제외한 경우에 대해 drop 아이템을 item에, sourceIndexPath를 통해 원래 인덱스 경로를 저장해 둔다.
이후, performBatchUpdates 블록 내에서 컬렉션 뷰의 업데이트 작업을 진행시켰다.
컬렉션 뷰에서 사용하는 dataList 항목의 remove, insert 작업 / 컬렉션 뷰 아이템의 delete, insert 작업, 그리고 coordinator.drop 코드를 이용한 자연스러운 애니메이션까지 추가해주면 된다.

*clipList indexPath.item에 -1을 해준 이유는, 데이터 리스트의 첫 번째 값은 고정값으로 박아두었기에 실제 컬뷰에서 데이터가 반영되는 부분은 1번째 index부터였다. 이를 대비하기 위한 -1을 처리해준 것이다.

extension EditClipViewController: UICollectionViewDropDelegate {
     /// 드래그가 끝나고 드랍할 때 호출되는 함수
    func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
        var destinationIndexPath: IndexPath
        if let indexPath = coordinator.destinationIndexPath {
            destinationIndexPath = indexPath
        } else {
            let item = collectionView.numberOfItems(inSection: 0)
            destinationIndexPath = IndexPath(item: item-1, section: 0)
        }
        // 0번째 인덱스 드랍이 아닌 경우, 배열과 컬뷰 아이템 삭제, 삽입, reload까지 진행
        if destinationIndexPath.item != 0 {
            guard let item = coordinator.items.first, let sourceIndexPath = item.sourceIndexPath else { return }
            collectionView.performBatchUpdates {
                let sourceItem = clipList.clips.remove(at: sourceIndexPath.item - 1)
                clipList.clips.insert(sourceItem, at: destinationIndexPath.item-1)
                collectionView.deleteItems(at: [sourceIndexPath])
                collectionView.insertItems(at: [destinationIndexPath])
                coordinator.drop(item.dragItem, toItemAt: destinationIndexPath)                
            }
        }
    }
}