Drag and Drop Issue

Panther·2021년 4월 16일
1

이 글은 두 Table View 사이에 cell의 이동 시 이동이 아닌 복사가 되는 현상을 해결하는 데 초점을 맞춘 글입니다. 정확하지 않은 부분이 있을 수 있습니다.

Drag and Drop Delegate

둘 이 상의 Table View 사이에서 cell 이동을 하려면, 애플이 WWDC17에서 소개한 Drag Delegate와 Drop Delegate의 사용이 필요합니다. 둘 이상의 Table View 이동뿐만 아니라 다른 앱으로 이동할 수 있도록 해주기도 합니다. 단지 두 가지 메소드를 작성하는 것만으로 Drag and Drop이 가능합니다.

// Drag Delegate
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
    return [UIDragItem] 
}

//Drop Delegate
func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
}

아래 메소드는 앞서 말씀드린 Drag Delegate 메소드입니다. 저는 두 Table View를 만들고 각 Table View가 두 Array로부터 데이터를 나타내도록 했고, Array에 있는 데이터는 모두 String입니다.

func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
    let item = tableView == firstTableView ? firstData.data[indexPath.row] : secondData.data[indexPath.row]
    guard let data = item.data(using: .utf8) else { return [] }
    let itemProvider = NSItemProvider(item: data as NSData, typeIdentifier: kUTTypePlainText as String)
    return [UIDragItem(itemProvider: itemProvider)]
}

다른 내용보다 우선 NSItemProvider가 필요하다는 점을 기억해야 합니다. NSItemProvider가 되려면 NSItemProviderWriting, NSItemProviderReading 프로토콜을 준수하고 있어야 하며, NSString, NSAttributedString, NSURL, UIColor, UIImage는 이미 두 가지 프로토콜을 준수하고 있기 때문에 바로 Drag and Drop이 가능합니다.

그런데 앞서 언급한 타입이 아니라 생성한 객체가 타입일 수 있습니다. 생성한 객체가 NSItemProvider가 되려면 NSItemProviderWriting, NSItemProviderReading 프로토콜을 준수하도록 해야 합니다. 아래 코드를 보면, NSObject, Codable, NSItemProviderWriting, NSItemProviderReading을 채택하고 준수하고 있음을 보여줍니다. 참고한 링크 역시 남기겠습니다.

https://medium.com/@foffer/drag-and-drop-with-custom-classes-using-codable-in-ios-11-77f20fe812eb

import MobileCoreServices

final class Model: NSObject, Codable, NSItemProviderReading, NSItemProviderWriting {
    
    let name: String
    let character: String
    let favorite: String
    
    init(name: String, character: String, favorite: String) {
        self.name = name
        self.character = character
        self.favorite = favorite

    }
    
    static var readableTypeIdentifiersForItemProvider: [String] {
        return [(kUTTypeData) as String]
    }
    
    static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> Model {
        let decoder = JSONDecoder()
        do {
            let model = try decoder.decode(Model.self, from: data)
            return model
        } catch {
            fatalError()
        }
    }
    
    static var writableTypeIdentifiersForItemProvider: [String] {
        return [(kUTTypeData) as String]
    }
    
    func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? {
        let progress = Progress(totalUnitCount: 100)
        do {
            let data = try JSONEncoder().encode(self)
            progress.completedUnitCount = 100
            completionHandler(data, nil)
        } catch {
            completionHandler(nil, error)
        }
        return progress
    }
    
}

두 Table View 사이의 이동

우선 String만을 담는 두 Table View 사이 이동을 시도하면 원활하게 작동합니다. 아래 주석처리 된 데이터 이동 부분 아래를 보면 Drop이 발생하는 Table View가 첫 번째일 때, 즉 두 번째 Table View의 데이터를 첫 번째 TableView로 이동시키려고 할 때 firstData Array에 추가하고 secondData Array에 데이터를 삭제하며 첫 번째 Table View를 reloadData() 하고 있음을 알 수 있습니다. 그 반대도 마찬가지로 똑같이 구현했습니다. contains(string) 부분은 지우고 removeData(target: string)하는 것만으로도 작동합니다.

func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
    let destinationIndexPath: IndexPath
    if let indexPath = coordinator.destinationIndexPath {
        destinationIndexPath = indexPath
    } else {
        let section = tableView.numberOfSections - 1
        let row = tableView.numberOfRows(inSection: section)
        destinationIndexPath = IndexPath(row: row, section: section)
    }
    coordinator.session.loadObjects(ofClass: NSString.self) { items in
        guard let strings = items as? [String] else { return }
        var indexPaths = [IndexPath]()
        for (index, string) in strings.enumerated() {
            let indexPath = IndexPath(row: destinationIndexPath.row + index, section: destinationIndexPath.section)
            // 데이터 이동
            if tableView == self.firstTableView {
                self.firstData.addItem(string, at: indexPath.row)
                if self.secondData.data.contains(string) {
                    self.secondData.removeData(target: string)
                    self.firstTableView.reloadData()
                }
            } else {
                self.secondData.addItem(string, at: indexPath.row)
                if self.firstData.data.contains(string) {
                    self.firstData.removeData(target: string)
                    self.secondTableView.reloadData()
                }
            }
            indexPaths.append(indexPath)
        }
        tableView.insertRows(at: indexPaths, with: .automatic)
    }
}

문제는 생성한 객체의 이동입니다.

생성한 객체의 이동에서 발생한 문제

앞서 언급한 NSItemProviderWriting, NSItemProviderReading 프로토콜을 채택하고 있는 객체의 이동과 관련해 위 performDropWith가 담긴 메소드를 똑같이 적용했다고 했을 때, 이동시키는 데이터가 한 쪽에 추가되고 source가 되는 Table View에서 제거되지 않는 문제가 발생합니다. 즉 복사가 된다는 의미입니다. 지금까지 내용은 예전부터 있었던 하나의 Table View 내에서 reordering이 적용되어 있었기 때문에 아래 delegate 메소드를 사용해 Table View 내 reordering과 Table View 사이의 이동이 가능하도록 구현한 내용이었습니다.

func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
    return true
}
    
func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
    data.moveItem(at: sourceIndexPath.row, to: destinationIndexPath.row)
}

아래 내용은 WWDC17 "Drag and Drop with Collection and Table View"의 Transcript를 인용한 내용입니다. Drag and Drop을 사용하는 동시에 예전부터 있었던 위 메소드를 사용해 reordering을 할 수 있다는 내용을 언급하고 있습니다.

https://developer.apple.com/videos/play/wwdc2017/223/

Table view moveRowAt IndexPath to IndexPath.

You can continue to implement this, if you like, to support reordering, using Drag and Drop.

Because table view is actually going to call this instead of calling through perform drop with coordinator, if you've returned that magic drop proposal and a single row is actually being reordered.

This makes it really easy, because you can use the same code to ship on an iPhone. For example, maybe where you don't have Drag and Drop using the existing style of reordering. And, if you are on an iPad, you can use the new style of reordering here.

다시 Table View 사이의 이동과 관련한 내용으로 넘어와서, 복사가 되지 않고 source Table View의 데이터가 삭제되는 방법에 대해서 고민했습니다. 첫 번째 시도했던 것은 Drag Delegate에서 itemsForBeginning이 포함된 메소드에 해당 데이터를 삭제하는, 즉 드래그 시작 시점에서 데이터를 삭제하는 방법을 사용해봤습니다. 이렇게 했을 때 알게된 내용은 다음과 같습니다.

  1. Drag 시작 시점에서 데이터가 삭제되므로 Table View의 row를 다룰 때 이상한 현상들이 발견됩니다. 그렇기 때문에 사용할 수 없습니다.
  2. 기억이 정확하지 않습니다만 Table View 내 이동을 시도할 때 index 오류가 발생합니다. Drag 시작 시점에 print를 하면 Drag가 작동되는 상황, 즉 순서를 바꾸려는 row가 삭제되면서 이를 이동시키려고 할 때 해당 Table View의 행 하나는 삭제가 되어 비어있는 행이 추가되지 않아 오류가 발생하는 것으로 이해하고 있습니다.

먼저 결정해줘야 하는 것

정확히 이해하고 있지 않아 자신있게 말씀드리기 어렵지만, 경험상 예전 방식으로 Table View 내 reordering을 할 수 있게 하고, Table View 사이 이동은 Drag and Drop으로 구현하려면 이를 명확히 해줘야 합니다. Table View 내 reordering하는 것을 예전 방식대로 추가하고 Drag and Drop에도 reordering하는 코드를 추가하면 Simulator에서 여러번 이동 시 index 오류가 발생합니다.

해결 방법

우선 말씀드릴 부분은 어쩌면 오류가 남아있을 수도 있다는 것입니다. 이 글의 가장 밑에 내용을 적겠습니다. 계속 찾아본 결과 Raywenderlich의 Tutorial을 참고했고, 필요한 내용만 적용했을 때 해결했습니다. 아래 링크를 남기겠습니다.

https://www.raywenderlich.com/3121851-drag-and-drop-tutorial-for-ios

먼저 저는 예전 방식의 메소드, 즉 canMoveRowAtmoveRowAt이 담긴 메소드를 삭제하고 reordering과 Table View 사이 이동을 모두 Drag and Drop에서 이뤄지도록 하려고 했습니다. 그리고 Raywenderlich의 Tutorial에 나온 것처럼 Drag Delegate 내용도 수정했습니다. 먼저 Drag Delegate는 아래와 같습니다. 내용을 살펴보면 dragSessionDidEnd가 담긴 메소드에서 데이터가 담긴 Array에서 해당 데이터를 삭제하고 그에 맞는 Table View의 row를 삭제하고 있습니다.

func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
    let dragCoordinator = DragCoordinator(sourceIndexPath: indexPath)
    session.localContext = dragCoordinator
    let result = tableView == firstTableView ? firstData.dragItems(for: indexPath) : secondData.dragItems(for: indexPath)
    return result
}
    
func tableView(_ tableView: UITableView, dragSessionDidEnd session: UIDragSession) {
    guard let dragCoordinator = session.localContext as? DragCoordinator, dragCoordinator.dragCompleted == true, dragCoordinator.isReordering == false
    else {
        return
    }
    let sourceIndexPath = dragCoordinator.sourceIndexPath
    if tableView == firstTableView {
         firstTableView.performBatchUpdates( {
            firstData.removeAt(index: sourceIndexPath.item)
            firstTableView.deleteRows(at: [sourceIndexPath], with: .automatic)
        } )
    } else {
        secondTableView.performBatchUpdates( {
            secondData.removeAt(index: sourceIndexPath.item)
            secondTableView.deleteRows(at: [sourceIndexPath], with: .automatic)
        } )
    }
}

중간에 dragItems(for: indexPath)를 사용하고 있는데, 데이터를 갖고 있는 클래스 각각에 아래 메소드를 추가했습니다. 클래스를 강조한 이유는 이 글 가장 아래에 있습니다.

func dragItems(for indexPath: IndexPath) -> [UIDragItem] {
    let movingCard = firstData[indexPath.item]
    let itemProvider = NSItemProvider(object: movingCard)
    let dragItem = UIDragItem(itemProvider: itemProvider)
    dragItem.localObject = movingCard
    return [dragItem]
}

Drop Delegate 내 메소드는 아래와 같습니다.

func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
    return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
}

func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
    let destinationIndexPath: IndexPath
    if let indexPath = coordinator.destinationIndexPath {
        destinationIndexPath = indexPath
    } else {
        destinationIndexPath = IndexPath(item: tableView.numberOfRows(inSection: 0), section: 0)
    }
    if coordinator.proposal.operation == .move {
        guard let dragCoordinator = coordinator.session.localDragSession?.localContext as? DragCoordinator else { return }
        for dropItem in coordinator.items {
            if let sourceIndexPath = dropItem.sourceIndexPath {
                dragCoordinator.isReordering = true
                if tableView == firstTableView {
                    firstData.moveCard(at: sourceIndexPath, to: destinationIndexPath, in: tableView)
                } else {
                    secondData.moveCard(at: sourceIndexPath, to: destinationIndexPath, in: tableView)
                }
                coordinator.drop(dropItem.dragItem, toRowAt: destinationIndexPath)
            } else {
                dragCoordinator.isReordering = false
                if let movingCard = dropItem.dragItem.localObject as? Model {
                    tableView.performBatchUpdates( {
                        if tableView == firstTableView {
                                firstTableView.insertRows(at: [destinationIndexPath], with: .automatic)
                                firstData.addItem(movingCard, at: destinationIndexPath.item)
                        } else {
                            secondTableView.insertRows(at: [destinationIndexPath], with: .automatic)
                            secondData.addItem(movingCard, at: destinationIndexPath.item)
                        }
                    } )
                }
            }
            dragCoordinator.dragCompleted = true
        }
    }
}

Drag Delegatea와 Drop Delegate 모두 DragCoordinator가 등장하는데, 아래와 같습니다.

class DragCoordinator {

    let sourceIndexPath: IndexPath
    var dragCompleted = false
    var isReordering = false
    
    init(sourceIndexPath: IndexPath) {
        self.sourceIndexPath = sourceIndexPath
    }
    
}

Table View 사이 이동에서 performDropWith 메소드에 DragCoordinator 관련 부분을 삭제하면 여전히 복사가 되는 것으로 기억하고 있습니다. Table View 내의 reordering은 정상적으로 작동합니다. 그런데 DragCoordinator를 추가하면 Table View 사이 이동도 원활하게 작동합니다.

짚고 넘어가야 하는 부분

Drag가 시작되는 시점과 Drop이 되는 시점에 두 데이터의 개수를 print 해봤습니다. 두 Table View가 각각 7개의 row를 갖고 있는데, Table View 사이 이동 시, 예를 들어 왼쪽에서 오른쪽으로 하나를 넘길 때 print되는 개수가 각각 6, 8개여야 하지만 7, 8개로 print됩니다. 그런데 다시 어떤 하나를 Drag하려고 할 때는 정상적인 개수, 즉 6, 8개로 print됩니다. 이 부분은 아직 정확히 파악하지 못한 부분으로 더 생각해봐야 합니다.

앞서 데이터를 갖고 있는 클래스를 말씀드렸습니다. 구조체로 해보니 Table View 내 reordering에서 thread 에러가 발생하기 때문입니다. 클래스로 하면 아래처럼 정상적으로 작동합니다.

더 알아봐야 하는 내용

Core Foundation?

간단히 String만을 담는 두 Table View의 데이터 이동에서 Drag Delegate 메소드는 아래와 같습니다. 내용을 살펴보면 typeIdentifier: kUTTypePlainText as String가 등장합니다.

func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
    let item = tableView == memberNameTableView ? memberNameData.data[indexPath.row] : memberNameSecondData.data[indexPath.row]
    guard let data = item.data(using: .utf8) else { return [] }
    let itemProvider = NSItemProvider(item: data as NSData, typeIdentifier: kUTTypePlainText as String)
    return [UIDragItem(itemProvider: itemProvider)]
}

위에서 String을 세 개 담는 생성한 객체가 타입인 경우 모델에 다음과 같은 내용이 작성되어 있습니다.

static var readableTypeIdentifiersForItemProvider: [String] {
    return [(kUTTypeData) as String]
}
 
static var writableTypeIdentifiersForItemProvider: [String] {
    return [(kUTTypeData) as String]
}

kUTType으로 시작하는 것이 있는데, alt 클릭하면 let kUTType~: CFString라고 나옵니다. CF는 Core Foundation을 의미하는데, 찾아보면 아래 링크와 같습니다.

https://developer.apple.com/documentation/corefoundation

Access low-level functions, primitive data types, and various collection types that are bridged seamlessly with the Foundation framework.

low-level function, primitive data types, 그리고 다양한 collection 타입에 접근할 수 있도록 하는 모양입니다. Foundation framework와 매끄럽게 연결되도록 한다고 합니다. Drag and Drop은 시스템, 운영체제 내에서 영향을 받는 모양입니다.

NSItemProvider?

https://developer.apple.com/documentation/foundation/nsitemprovider

An item provider for conveying data or a file between processes during drag and drop or copy/paste activities, or from a host app to an app extension.

데이터 혹은 파일을 Drag and Drop, copy/paste, host 앱에서 앱 extension 사이 이동하는 동안 전달할 수 있게 하는 객체로 보입니다. 아래 다음과 같은 내용이 있습니다. 비동기로 작동하는 모양입니다.

DispatchQueue.main.async {
    // work that impacts the user interface
}

0개의 댓글