관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] DragDropCollectionView 구현 방법 (dragDelegate, dropDelegate, 드래그앤 드랍) 본문

UI 컴포넌트 (swift)

[iOS - swift] DragDropCollectionView 구현 방법 (dragDelegate, dropDelegate, 드래그앤 드랍)

jake-kim 2023. 7. 4. 01:49

시스템에서 제공된 것으로 구현한 Drag, Drop UI

주의사항

  • 애플에서 제공하는 기본 Drag, Drop을 사용하면, UITextField에서 becomeFirstResponder 상태에서 resignFirstResponder로 변경되므로 셀을 이동시킬때 키보드가 내려가므로 주의
  • 키보드가 내려가지 않게 하려면 Drag, Drop을 제공하는 커스텀 CollectionView 구현이 필요
    • 키보드가 내려가지 않게 하려면 UILongPressGestureRecognizer를 사용하여 직접 커스텀 필요한데, 이 부분은 다음 포스팅 글참고

ex) 애플에서 기본으로 제공하는 drag, drop을 사용한 경우, 셀 drag를 하는 순간 키보드가 내려가는 문제

DragDropCollectionView 구현 아이디어

  • collectionView의 drag, drop 델릴게이트를 준수

https://developer.apple.com/documentation/uikit/uicollectionviewdragdelegate
https://developer.apple.com/documentation/uikit/uicollectionviewdropdelegate

  • UICollectionViewDragDelegate와 UICollectionViewDropDelegate에서 제공하는 메소드에서 drag, drop 이벤트 캐치
    • collectionView의 dataSource 수정
    • 해당 셀 업데이트

drag drop 구현 전, 수평 스크롤이 있는 UICollectionView 준비

  • UICollectionViewFlowLayout의 scrollDirection을 .horizontal로 설정하고, UICollectionView의 heightAnchor와 itemSize의 height값을 동일하게 준것
import UIKit

final class MyCell: UICollectionViewCell {
    override func prepareForReuse() {
        super.prepareForReuse()
        prepare(color: nil)
    }
    
    func prepare(color: UIColor?) {
        contentView.backgroundColor = color
    }
}

class ViewController: UIViewController {
    private enum Metric {
        static let cellWidth = 80.0
        static let cellHeight = 120.0
        static let horizontalInset = 20.0
    }
    
    private lazy var collectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        let mainSize = UIScreen.main.bounds
        layout.itemSize = .init(width: Metric.cellWidth, height: Metric.cellHeight)
        layout.scrollDirection = .horizontal
        
        let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
        view.contentInset = .init(top: 0, left: Metric.horizontalInset, bottom: 0, right: Metric.horizontalInset)
        view.register(MyCell.self, forCellWithReuseIdentifier: "cell")
        view.dataSource = self
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    private var dataSource = (0...10).map { _ in UIColor.randomColor }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(collectionView)
        NSLayoutConstraint.activate([
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            collectionView.heightAnchor.constraint(equalToConstant: Metric.cellHeight)
        ])
    }
}

extension ViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool {
        true
    }
}

extension ViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        dataSource.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as? MyCell
        else { return UICollectionViewCell() }
        
        cell.prepare(color: dataSource[indexPath.row])
        return cell
    }
}

drag, drop 기능 구현

  • dragDelegate, dropDelegate 준수 및 drag 활성화
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
...

view.dragDelegate = self
view.dropDelegate = self
view.dragInteractionEnabled = true

...
  • UICollectionViewDragDelegate, UICollectionViewDropDelegate 준수
extension ViewController: UICollectionViewDragDelegate {
    func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
        print("drag>", indexPath)
        return []
    }
}

extension ViewController: UICollectionViewDropDelegate {
    func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
        print("drop>", coordinator.destinationIndexPath)
    }
}
  • itemsForBeginning의 구현은 단순히 아래처럼 반환해주면 완료
extension ViewController: UICollectionViewDragDelegate {
    func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
        [UIDragItem(itemProvider: NSItemProvider())]
    }
}
  • 핵심은 DropDelegate 구현부

UICollectionViewDropDelegate 구현

  • drag하는 동안 계속 호출되는 메소드 dropSessionDidUpdate 구현
    • drag가 활성화 되어 있는경우에만 drop이 동작하도록 구현
    • drag없이 drop이 동작할 수 없도록 하는 메소드
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
    guard collectionView.hasActiveDrag else {
        return UICollectionViewDropProposal(operation: .forbidden)
    }
    return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
}
  • performDropWith 구현
    • coordinator 프로퍼티에서 destinationIndexPath를 얻어낼 수 있는데, 만약 이 값이 없다면 destinationPath가 마지막 indexPath임을 이용
      • coordinator를 사용하면 또 sourceIndexPath를 얻어낼 수 있는데 이를 이용하여 move를 구현
func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
    var destinationIndexPath: IndexPath
    if let indexPath = coordinator.destinationIndexPath {
        destinationIndexPath = indexPath
    } else {
        let row = collectionView.numberOfItems(inSection: 0)
        destinationIndexPath = IndexPath(item: row - 1, section: 0)
    }
    
    guard coordinator.proposal.operation == .move else { return }
    move(coordinator: coordinator, destinationIndexPath: destinationIndexPath, collectionView: collectionView)
}

private func move(coordinator: UICollectionViewDropCoordinator, destinationIndexPath: IndexPath, collectionView: UICollectionView) {
  // TODO
}
  • coordinator로부터 sourceIndexPath를 얻어내고 performBatchUpdates 블락 안에서 dataSource와 UI 업데이트
    • performBatchUpdates가 끝나면 cooridnator.drop()을 사용하여 drop 애니메이션 효과 주기
private func move(coordinator: UICollectionViewDropCoordinator, destinationIndexPath: IndexPath, collectionView: UICollectionView) {
    guard
        let sourceItem = coordinator.items.first,
        let sourceIndexPath = sourceItem.sourceIndexPath
    else { return }
    
    collectionView.performBatchUpdates { [weak self] in
        self?.move(sourceIndexPath: sourceIndexPath, destinationIndexPath: destinationIndexPath)
    } completion: { finish in
        print("finish:", finish)
        coordinator.drop(sourceItem.dragItem, toItemAt: destinationIndexPath)
    }
}

private func move(sourceIndexPath: IndexPath, destinationIndexPath: IndexPath) {
  // TODO
}
  • 주의) 애니메이션 효과를 주는 cooridnator.drop() 호출은 반드시 performBatchUpdates 이후에 수행해야함

반드시 performBatch 안에서 dataSource 업데이트와 UI 업데이트 후 호출해야함

  • performBatchUpdates 안에서 호출되는 코드
    • dataSource 업데이트
    • UI 업데이트
private func move(sourceIndexPath: IndexPath, destinationIndexPath: IndexPath) {
    let sourceItem = dataSource[sourceIndexPath.item]
    
    // dataSource 이동
    dataSource.remove(at: sourceIndexPath.item)
    dataSource.insert(sourceItem, at: destinationIndexPath.item)
    
    // UI에 반영
    collectionView.deleteItems(at: [sourceIndexPath])
    collectionView.insertItems(at: [destinationIndexPath])
}

(완료)

구현된 Drag, Drop UI

(move했을때 dataSource가 변경되지 않은 케이스가 있으므로 정확히 하려면 아래처럼 move 메소드를 구현)

    private func move(sourceIndexPath: IndexPath, destinationIndexPath: IndexPath) {
        let sourceItem = dataSource[sourceIndexPath.item]
        
        // dataSource 이동
        DispatchQueue.main.async {
            self.dataSource.remove(at: sourceIndexPath.item)
            self.dataSource.insert(sourceItem, at: destinationIndexPath.item)
            let indexPaths = self.dataSource
                .enumerated()
                .map(\.offset)
                .map { IndexPath(row: $0, section: 0) }
            UIView.performWithoutAnimation {
                self.collectionView.reloadItems(at: indexPaths)
            }
        }
    }

 

cf) 원하는 뷰 영역만 드래그 할 때 보이도록 하는 방법은 다음 포스팅 글에서 계속

 

* 전체 코드: https://github.com/JK0369/DragDropCollectionView_Example

* 참고

https://developer.apple.com/documentation/uikit/uicollectionviewdropdelegate/2897375-collectionview

https://developer.apple.com/documentation/uikit/uicollectionviewdragdelegate

https://developer.apple.com/documentation/uikit/uicollectionviewdropdelegate

https://developer.apple.com/documentation/uikit/uicollectionviewdropdelegate/2897304-collectionview

Comments