(Cocoa | Swift ) 테이블 뷰에서 드래그하여 재정렬하기

도전과제 – 테이블 뷰에서 row를 드래그하여 재정렬해보자.

드래그 앤 드롭의 매커니즘과 구현방법에 대해서 살펴보았었는데, 그렇다면 테이블 뷰에서 드래그 앤 드롭으로 데이터의 순서를 임의의 순서대로 바꿀 수 있는가에 대해서 살펴보자.

우선 NSTableViewDataSource에서는 다음과 같은 드래그 앤 드롭 관련 메소드들이 정의되어 있다.

  • tableView(_: NSTableView, acceptDrop: NSDraggingInfo, row: Int, dropOperation: NSTableViewDropOperation) -> Bool 드랍이 발생하는 시점에 호출된다.
  • tableView(_: NSTableView, namesOfPromisedFilesDroppedAtDestination: URL, forDraggedRowsWith: IndexSet)
  • tableView(_:NSTableView, valiateDrop: NSDraggingInfo, proposedRow: Int, proposedDropOperation: NSTableViewDropOperation) -> Bool : 특정 위치로 드랍할 수 있는지를 판단한다.
  • tableView(NSTableView, writeRowsWith: IndexSet, to: NSPasteboard) 드래그할 때 각 셀의 값들을 페이스트보드에 쓴다.
  • tableView(NSTableView, draggingSession: NSDraggingSession, willBeginAt: NSPoint, forRowIndexes: IndexSet) -> 드래그가 시작될 때 동작을 정의한다.
  • tableView(NSTableView, draggingSession: NSDraggingSession, endedAt: NSPoint, operation: NSDragOperation)

테이블 Row의 드래그 작업 진행 과정

NSTableView 클래스는 일반적으로 서브클래싱하지 않고 사용하는 것을 목적으로 디자인되었다. 기본적으로 NSDraggingDestination, NSDraggingSource를 따르도록 만들어져 있고, 관련한 기능들을 dataSource객체에게 위임하고 있다. 따라서 NSTableViewDataSource 프로토콜의 레퍼런스 문서를 참고하면 된다.

여기서는 동일 테이블 뷰 내에서 드래그 앤 드롭을 통해 row들을 재정렬하는 부분을 만들어보겠다. 두 개 이상의 테이블 뷰를 두고 상호 간에 드래그 앤 드롭으로 이동/복사하는 부분도 이 내용을 활용하면 어렵지 않게 구현할 수 있을 것이다.

  1. 테이블 뷰는 드래그 목적지가 되어야 하므로 register(forDraggedTypes:)는 반드시 한 번 호출해주어야 한다. 이 메소드는 다음 중 한 곳에서 호출하면 되겠다.
    1. viewDidMoveToWindow()
    2. viewDidLoad()
    3. awakeFromNib()
  2. 드래그가 시작되려 할 때 테이블 뷰는 데이터소스에게 드래그하려는 위치들에 대해서 클립보드에 데이터를 써 둘 것을 요청한다. 이 때 tableView(_:writeRowsWith:to:)가 호출된다. 여기서 클립보드에 데이터를 넣고 true를 리턴하면 드래그가 시작된다.
  3. 테이블 row의 드래그는 두 가지 경우가 있을 수 있는데, 한가지는 특정한 row에 드롭하는 것이고, 다른 한가지는 row 와 row 사이에 드롭하는 것이다. 재정렬의 경우에는 row 와 row 사이에 선택한 값들을 끼워넣는 것이다. 드롭을 허용할 것인지 여부는 데이터 소스의 tableview(_:validateDrop:proposedRow:proposedDropOperation)을 호출하여 확인하게 된다.
  4. 드롭을 허용한다면 드래그하는 사이에 넣을 수 있는 row 와 row 사이에는 파란색 라인이 드롭이 가능하다는 것을 시각적으로 알려준다. 사용자가 마우스 버튼을 놓게되면 해당 위치에 대해서 드롭이 발생하는데, 이때 데이터 소스는 tableView(_:acceptDrop:info:dropOperation:)이 호출된다. 이 시점에서 클립보드의 데이터를 이용해서 원본 데이터 리스트를 재정렬하면 된다.

재정렬

우선 재정렬을 위해 Array를 확장해보자. Array 타입에서 원소 하나 혹은 여러 원소들을 특정 위치로 옮기는 기능을 추가한다.

배열 내에서 특정 원소를 다른 지정한 위치로 옮길 때 주의해야 할 점은 원소를 붙이거나 제거하면서 각 원소의 인덱스가 달라진다는 점이다. 예를 들어 한 개의 원소를 옮길 때는 앞으로 옮기느냐 뒤로 옮기느냐에 따라서 제거 -> 삽입 혹은 삽입 -> 제거 해야하는 순서가 다르다.

여러 개의 원소를 옮기는 경우에는 상황이 보다 복잡할 수 있다. 이 때는 옮겨야 하는 데이터를 따로 복사해두고, 타깃이 되는 위치를 재계산해서 수행하도록 한다.

extension Array {
    mutating func move(from fromIndex: Index, to toIndex: Index) {
        if fromIndex == toIndex { return }
        else if toIndex < fromIndex {
            // 앞쪽으로 옮기는 경우
            let x = remove(at: fromIndex)
            insert(x, at: toIndex)
        } else {
            // 뒤쪽으로 옮기는 경우
            insert(self[fromIndex], at: toIndex)
            remove(at: fromIndex)
        }
    }

    mutating func move(with indexes: IndexSet, to toIndex: Index) {
        // 선택된 위치에 있는 데이터들
        let movingData = indexes.map{ self[$0] }
        // 데이터들을 삭제 후 다시 삽입할 위치를 계산한다. 
        // 앞으로 이동할 위치보다 앞선 곳에 이동할 대상이 포함되지 않았다면
        // toIndex 를 그대로 쓰지만, 그렇지 않은 개수만큼은 앞으로 밀어주어야 한다.
        let targetIndex = toIndex - (indexes.filter{ $0 < toIndex }.count)

        // 이동할 데이터들을 제거한후 다시 삽입
        for (i, e) in indexes.enumerated() {
            remove(at: e - i)
        }
        insert(contentsOf: movingData, at: targetIndex)
    }
}

드래그 앤 드롭 재정렬 구현

뷰 컨트롤러에서 코드를 작성할 것이다. 뷰 컨트롤러의 주요 뷰 내에 별도의 테이블 뷰가 있다고 가정한다. 이 테이블 뷰는 인터페이스 빌더로부터 설치되었으며 뷰 컨트롤러에서는 nameTableView라는 이름으로 참조된다고 치자.

override func viewDidLoad() {
    super.viewDidLoad()
    nameTableView.register(forDraggedTypes: ["public.data"])
}

드래그 앤 드롭으로 재정렬하는 과정에서 복사되는 데이터는 문자열이나 특정 객체가 아닌 IndexSet 타입의 데이터이다. 다행히 해당 타입은 NSCodinig을 지원하므로, 인코딩된 이진 데이터를"public.data" 포맷으로 사용할 것이다.

그리고 간단한 과일 이름들을 뿌려주도록 한다. 과일 이름들은 names 라는 프로퍼티로 접근한다고 친다. 참고로 테이블 뷰는 뷰 기반이 아닌 셀 기반으로 사용한다고 가정한다.

extension ViewController : NSTableViewDataSource {
    func numberOfRows(in tableView: NSTableView) -> Int {
        return names.count
    }

    func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? {
        return names[row]
    }
}

드래그 허용

드래그를 허용하는 것은 데이터 소스가 선택된 row에 대한 정보를 클립보드에 복사할 수 있느냐하는 여부에 따라 결정될 것이다.

extension ViewController {
    func tableView(_ tableView: NSTableView, writeRowsWith rowIndexes: IndexSet, to pboard: NSPasteboard) -> Bool {
        let data = NSKeyedArchiver.archivedData(withRootObject: rowIndexes)
        let item = NSPasteboardItem()
        item.setData(data, forType: "public.data")
        pboard.writeObjects([item])
        return true
    }
}

드롭을 허용할 것인지 결정

드롭을 허용할 것인지를 결정하자. 두 가지 조건을 검사해야 하는데 첫째, 같은 테이블 뷰 내에서 드래그된 데이터인지 그리고 둘째, 행과 행사이로 드롭하려고 하는 것인지를 검사한다.

행과 행 사이로 드롭하려는 것인지 특정 행으로 드롭하려는 것인지는 테이블 뷰가 드래그 위치를 계속 파악해서 알려준다. 이는 NSTableViewDropOperation이라는 타입으로 정의되어 있는데 각각 .on, .above로 구분한다.

func tableView(_ tableView: NSTableView,
     validateDrop info: NSDraggingInfo,
     proposedRow row: Int,
     proposedDropOperation dropOperation: NSTableViewDropOperation) -> NSDragOperation
{
    if let source = info.draggingSource() as? NSTableView,
       source === self,
       dropOperation == .above
    {
        return .move
    }
    return []
}

드롭

validateDrop*이 적절한 타입의 드래그 작업값을 리턴한 뒤 마우스 버튼을 놓으면 해당 위치로 드롭이 시작된다. 이 때 작업을 처리한다. 해야하는 작업은 두 가지 이다.

  • 클립보드의 내용을 바탕으로 옮겨야 하는 위치의 데이터를 옮긴다.
  • 옮겨진 결과에 대해서 선택영역을 바꿔준다.
func tableView(_ tableView: NSTableView,
     acceptDrop info: NSDraggingInfo,
     row: Int,
     dropOperation: NSTableViewDropOperation) -> Bool
{
    let pb = info.draggingPasteboard()
    if let itemData = pb.pasteboardItems?.first?.data(forType: "public.data"),
       let indexes = NSKeyedUnarchiver.unarchiveObject(with: itemData) as? IndexSet
    {
        names.move(with: indexes, to: row)
        let targetIndex = row - indexes.fitler{ $0 < row }.count
        tableView.selectRowIndexes(IndexSet(targetIndex..<targetIndex + indexes.count),
                                byExtendingSelection: false)
        return true
    }
    return false
}