문제
TodoApp 을 만들던중 테이블뷰 섹션간의 cell이동이 필요했다.
그리고 예를 들어 한개의 tableView에 여러개의 섹션이 있다면 출발 섹션의 마지막 셀을 이동시키면 셀이 하나도 없게되므로 출발 섹션은 삭제되길 원했다.
그전에는 tableView의 cell 이동을 tableView(_:moveRowAt:to:) 메서드에서 문제 없이 했기에 이 메서드에서 섹션삭제만 추가해서 그대로 진행하면 될 줄 알았다.
//테이블뷰 셀 이동 메서드
func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
// allData에 모든 데이터 배열 넣기
guard var allData: [String: [TodoData]] = dicDataArray else { return }
let sectionKeys = Array(allData.keys).sorted() // 섹션키(날짜) 정렬
let sourceSectionKey = sectionKeys[sourceIndexPath.section] //출발 데이터 섹션 키
let destinationSectionKey = sectionKeys[destinationIndexPath.section] //도착 데이터 섹션키
// 이동할 데이터 임시 저장
guard var sourceSectionData = allData[sourceSectionKey] else { return }
let movedData = sourceSectionData[sourceIndexPath.row]
// 이동할 데이터 삭제
sourceSectionData.remove(at: sourceIndexPath.row)
if sourceSectionData.isEmpty {
allData.removeValue(forKey: sourceSectionKey)
} else {
allData[sourceSectionKey] = sourceSectionData
}
// 이동할 데이터 추가
guard var destinationSectionData = allData[destinationSectionKey] else { return }
destinationSectionData.insert(movedData, at: destinationIndexPath.row)
allData[destinationSectionKey] = destinationSectionData
// 데이터 변경 및 코어데이터 저장
dicDataArray = allData
self.updateCoreDataOrder(sourceSectionKey: sourceSectionKey, destinationSectionKey: destinationSectionKey)
//테이블뷰 업데이트 시작
tableView.beginUpdates()
tableView.moveRow(at: sourceIndexPath, to: destinationIndexPath)
if sourceSectionData.isEmpty {
tableView.deleteSections(IndexSet(integer: sourceIndexPath.section), with: .automatic)
}
tableView.endUpdates()
setupData()
tableView.reloadData()
}
private func updateCoreDataOrder(sourceSectionKey: String, destinationSectionKey: String) {
guard let allData = dicDataArray else { return }
//출발 섹션, 도착섹션 데이터만 인덱스 순서에 맞춰 저장
let sectionsToUpdate = Set([sourceSectionKey, destinationSectionKey])
for sectionKey in sectionsToUpdate {
guard let sectionData = allData[sectionKey] else { continue }
let date = DateHelper.dayStringToDate(text: sectionKey)
for (index, todoData) in sectionData.enumerated() {
todoData.order = Int64(index)
self.todoManager.todoOrderChangeUpdate(data: todoData, date: date, destinationOrder: Int64(index))
}
}
}
위 코드로 빌드를 하면 위 사진에서 2번 섹션에 있는 마지막 셀(3이라고 써있는)을 0번 또는 1번 섹션으로 이동시키면 2번 섹션이 사라지고 아무런 에러가 나오지 않는다. 하지만 0번이나 1번 섹션에서 2번 섹션으로 셀을 이동시킬때는 아래와 같은 에러가 나왔다.
에러메세지
1번 섹션의 마지막남은 셀(2라고 써진)을 2번 섹션 0번 인덱스로 옮기면서
1번 섹션은 사라져 2번 섹션이 자동으로 1번 섹션이 되었다.
그래서 에러에는 업데이트 후 2개의 섹션만 있는데, 존재하지 않는 2번 섹션으로 셀을 이동하려고 시도한다는 에러가 뜬다.
reason: 'attempt to move index path (<NSIndexPath: 0x99dbf7915109aad8> {length = 2, path = 1 - 0}) to index path (<NSIndexPath: 0x99dbf7915109b2d8> {length = 2, path = 2 - 0}) in section that does not exist - there are only 2 sections after the update'
도착하는 섹션 > 출발 섹션
도착하는 섹션이 출발 하는 섹션보다 클 경우, 출발 섹션의 마지막 셀을 이동시 출발 섹션은 사라지게 되고 도착섹션의 인덱스 번호가 -1이 되어버려 도착섹션과 맞지 않아 생기는 에러였다.
그래서 이번에는 위의 경우 도착섹션의 인덱스 번호에서 -1을 해주는 코드를 작성하니 이런 에러가 떴다.
에러 메세지
동일한 셀을 두개의 다른 위치로 동시에 이동하려고 시도한다는 에러 메세지다.
reason: 'attempt to move row at index path <NSIndexPath: 0xa79ddf87cb41c210> {length = 2, path = 1 - 0} to both <NSIndexPath: 0xa79ddf87cb41c210> {length = 2, path = 1 - 0} and <NSIndexPath: 0xa79ddf87cb41da10> {length = 2, path = 2 - 0}'
그 이후 여러가지 방법들을 시도해봤다. 그중에 그나마 작동이 됐던건 비동기로 처리하는 방법이다.
일단 섹션에 셀이 없어도 삭제하지 않고 비동기로 테이블뷰에서 섹션을 삭제하는 코드를 넣어줬다.
이방법은 에러없이 잘 동작 했지만 애니메이션이 매끄럽지않고 버벅거려 이방법도 쓸 수 없었다.
이 문제로 꽤 오랜 시간 구글링도 하고 오픈채팅방과 온라인 강의를 결제한 곳 등 도움을 받을 수 있는곳에 질문을 다 올렸지만 해결 되지 않다가 Drag & Drop API 를 써야 한다는걸 알게됐다. Drag & Drop API 는 처음 셀을 이동시킬때 구글링을 통해 알았지만 여러 앱간의 데이터를 이동하거나 복잡한 드래그 앤 드롭을 해야할 때 사용해야한다고 생각해서 tableView(_:moveRowAt:to:) 메서드로만 방법을 찾았는데 잘못된 것이었다.
tableView(_:moveRowAt:to:) 메서드는 단순한 셀 이동이나 삭제는 괜찮지만 섹션의 삭제처럼 구조의 변경이 생기면 에러가 생긴다.
단순한 리스트 재정렬이 아니고 내 경우처럼 섹션을 삭제해야 한다면 Drag & Drop API 를 이용하는게 맞는거 같다.
tableView(_:moveRowAt:to:) | Drag & Drop API |
|
|
해결
그래서 Drag & Drop API 로 해결한 방법!
1. drag, drop 델리게이트 채택
Drag & Drop API 를 이용하려면 dragInteractionEnabled = true 를 꼭 설정해 줘야 한다.
override func viewDidLoad() {
super.viewDidLoad()
//테이블 뷰의 드래그 활성화
tableView.dragInteractionEnabled = true
tableView.dragDelegate = self
tableView.dropDelegate = self
}
2. 메서드 구현
필수 메서드
tableView(_:itemsForBeginning:at:) | 드래그할 데이터를 지정하며, 사용자가 드래그를 시작할때 호출된다. 반환 값은 UIDragItem 배열이며, 각 UIDragItem은 드래그할 데이터를 포함하며, 데이터는 NSItemProvider를 통해 제공된다. |
tableView(_:performDropWith:) | 데이터를 특정 위치에 삽입하거나 이동하는 작업을 처리하고 용자가 데이터를 테이블 뷰에 드롭했을 때 호출된다. |
선택 메서드
tableView(_:dragSessionAllowsMoveOperation:) | 기본값은 true이며, 드래그된 셀이 이동 가능하도록 설정하고 필요에 따라 이동을 허용하거나 금지할 수 있다. |
tableView(_:dragSessionDidEnd:) | 드래그 세션이 종료될 때 호출된다. |
tableView(_:canHandle:) | 드롭을 허용할 데이터 형식을 제한할 때 사용 (기본값은 모든 데이터 형식 허용) |
tableView(_:dropSessionDidUpdate:withDestinationIndexPath:) | 드롭 세션이 업데이트될 때 호출되어 UI를 업데이트할 수 있음 |
tableView(_:itemsForBeginning:at:) 메서드
//나의 경우 - 출발 인덱스만 필요해서 데이터로 인덱스만 저장
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
let dragItem = UIDragItem(itemProvider: NSItemProvider())
dragItem.localObject = indexPath
return [dragItem]
}
//일반적인 예제
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
let item = todoItems[indexPath.row] // 드래그할 데이터
let itemProvider = NSItemProvider(object: item.title as NSString) // 데이터 전달
let dragItem = UIDragItem(itemProvider: itemProvider)
dragItem.localObject = item // 로컬 객체로 저장
return [dragItem]
}
tableView(_:performDropWith:) 메서드
실제 데이터를 이동시키고,
테이블뷰 업데이트(출발 섹션과 도착 섹션이 같을때와 다를때, 섹션이 비워질때, 안비워질 때 등등... 조건에 맞게 코드 작성)
func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
guard var destinationIndexPath = coordinator.destinationIndexPath,
let item = coordinator.items.first,
// localObject에서 출발 위치 가져오기
let sourceIndexPath = item.dragItem.localObject as? IndexPath else { return }
// 실제 데이터 이동
guard var allData = dicDataArray,
let sourceSectionKey = sectionTitles?[sourceIndexPath.section],
let destinationSectionKey = sectionTitles?[destinationIndexPath.section],
var sourceSectionData = allData[sourceSectionKey],
sourceSectionData.indices.contains(sourceIndexPath.row) else { return }
let movedItem = sourceSectionData.remove(at: sourceIndexPath.row)
if sourceSectionData.isEmpty {
allData.removeValue(forKey: sourceSectionKey)
} else {
allData[sourceSectionKey] = sourceSectionData
}
if var destinationSectionData = allData[destinationSectionKey] {
destinationSectionData.insert(movedItem, at: destinationIndexPath.row)
allData[destinationSectionKey] = destinationSectionData
}
self.dicDataArray = allData
sectionTitles = dicDataArray?.keys.sorted()
self.updateCoreDataOrder(sourceSectionKey: sourceSectionKey, destinationSectionKey: destinationSectionKey)
tableView.beginUpdates()
//출발섹션과 도착섹션이 다를때
if sourceSectionKey != destinationSectionKey {
if sourceSectionData.isEmpty { //출발 섹션이 비워질때 -> 테이블뷰에서 섹션 삭제
//도착섹션이 출발섹션보다 인덱스가 더 높을때 -> 도착섹션에서 -1
if destinationIndexPath.section > sourceIndexPath.section {
destinationIndexPath = IndexPath(row: destinationIndexPath.row, section: destinationIndexPath.section - 1)
}
tableView.deleteSections(IndexSet(integer: sourceIndexPath.section), with: .automatic)
} else { //출발 섹션이 안비워질떄 -> 테이블뷰에서 행 삭제
tableView.deleteRows(at: [sourceIndexPath], with: .automatic)
}
//테이블뷰 도착섹션에 행 추가
tableView.insertRows(at: [destinationIndexPath], with: .automatic)
// 출발섹션과 도착섹션이 같을때
} else if sourceSectionKey == destinationSectionKey {
tableView.moveRow(at: sourceIndexPath, to: destinationIndexPath)
}
tableView.endUpdates()
self.setupData()
let totalSection = tableView.numberOfSections
tableView.reloadSections(IndexSet(integersIn: 0..<totalSection ), with: .automatic)
}
tableView(_:dropSessionDidUpdate:withDestinationIndexPath:) 메서드
func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
let dropProposal = UITableViewDropProposal(operation: .cancel)
if isSearchBarActive {
return dropProposal
} else {
guard session.items.count == 1,
session.localDragSession != nil else { return dropProposal } //드래그가 같은 테이블뷰 내에서 발생하는지 확인, 다른 앱이나 외부 드래그 차단
return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
}
}