How to Move Cells in Your iOS App by Dragging
ShowNote mimicked the page management of Power Point, offering a Screen to handle pages. This feature included functionalities such as hiding, removing, and reordering.
In this article, we will delve into the process of reordering cells within a UICollectionViewController.
Start
Typically, we kick off the reordering process with a long press. However, I won’t delve extensively into the UILongPressGesture here.
Begin
When the gesture begins, we need to determine the item index under our finger to identify the cell that should start moving.
switch(gesture.state){
case .began:
let pos = gesture.location(in: self.collectionView);
guard let indexPath = collectionView.indexPathForItem(at: pos) else{
return;
}
Now that we know which cell is under our finger, inform the collection view to start the interactive movement.
collectionView.beginInteractiveMovementForItem(at: indexPath);
Moving
Now that we know which cell was pressed. But how can we moving it? Should we use a pan gesture also? nope! we can detect the moving by .changed
and get new position.
case .changed:
let pos = gesture.location(in: self.collectionView);
Pass the new position to the collection view to update the interactive movement target position.
self.collectionView.updateInteractiveMovementTargetPosition(pos);
The method name contains update
and movement
. Therefore I thought it would move the cell following my finger. But nothing was happened. I had to implement protocols for movement.
func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
//
}
Cell is being moved to a new location. So, is everything done? No! The data is not changed!
Reordering
The collection view moves cells, but the data’s order is not changed. This can lead to issues.
So, how can we reorder the data? You may remember I implemented moveItemAt:to:
above. It is empty now. We should swap data in the method.
You may think it would work by just swapping them. But it will not work.
func collectionView(_ collectionView: UICollectionView,
moveItemAt sourceIndexPath: IndexPath,
to destinationIndexPath: IndexPath) {
datas.swapAt(sourceIndexPath.item, destinationIndexPath.item)
}
Let’s analyze the video. Data 6
is moved to the position of Data 3
. Then Data 3
will be moved to the position of Data 4
, not Data 6
.
To solve this problem, first, we determine if the source item is before the destination.
let sourceIndex = sourceIndexPath.row;
let destIndex = destinationIndexPath.row;
if (sourceIndex < destIndex) {
Secondly, obtain a reference to the source data.
let source = datas[sourceIndex]
Third, insert the source data at the position of the destination data.
if (sourceIndex < destIndex) {
datas.insert(source, at: destIndex)
Lastly, remove the source data from the original position.
datas.remove(at: sourceIndex)
If the source data is later than the destination, remove it first and then insert it at the destination.
else {
datas.remove(at: sourceIndex)
datas.insert(source, at: destIndex)
}
Transparent
It is good to make the dragging cell partially transparent for better visibility.
case .began:
...
guard let cell = collectionView.cellForItem(at: indexPath) else {
return
}
cell.alpha = 0.5
To restore the alpha of the moved cell, remember which cell is being moved.
var movingCell: UICollectionViewCell!
...
case .began:
movingCell = cell
case .ended:
movingCell.alpha = 1
Prevent to Dragging
To prevent dragging specific cells, implement the canMoveItemAt
method. Unfortunately, it can't disable dropping onto specific cells.
func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool {
return indexPath.item != 2
}
The full source is in this example repository.
SwiftUI
There is no Collection View for SwiftUI, but there is Grid. I used LazyVGrid, but I won’t go into the details of how to use it here.
onDrag
To use .onDrag
with a custom data type, you need to make sure that your data type conforms to NSItemProviderWriting
. Here's an example of how you might do it:
With the onDrag
modifier, we can detect when a long press is started. In a LazyVGrid
, it might looks something like this:
LazyVGrid(columns: columns) {
ForEach(datas.indices, id: \.self) { dataIndex in
let data = datas[dataIndex]
Text(data.name)
...
.onDrag({ ... })
}
}.gridCellColumns(4)
.onDrag
requires an NSItemProvider
. Therefore, I attempted to create it with my CellData
. However, it was not working.
.onDrag({ .init(object: data ) })
There are two ways to solve this problem. In this case, I didn’t conform to NSItemProviderWriting
and used the name.
.onDrag({ .init(object: data.name as NSString ) })
onDrop
Now that dragging has begun, the next step is to implement the drop functionality. The onDrop modifier, which comes with a closure, is the key to achieving this. So, I gave it a try.
I assumed that the grid cell would be moved to a new position if I returned true; however, nothing happened.
.onDrop(of: [.text], isTargeted: nil, perform: { providers in
return true
})
There is also onDrop with Delegate. It can control more detail.
Delegate
To use the modifier, I had to implement DropDelegate
.
@MainActor public protocol DropDelegate {
@MainActor func validateDrop(info: DropInfo) -> Bool
@MainActor func performDrop(info: DropInfo) -> Bool
@MainActor func dropEntered(info: DropInfo)
@MainActor func dropUpdated(info: DropInfo) -> DropProposal?
@MainActor func dropExited(info: DropInfo)
}
struct DataDropController: DropDelegate {
func performDrop(info: DropInfo) -> Bool {
return true
}
}
dropUpdated
The Delegate has dropUpdated, and I can return .move
as the operation. Therefore, I thought it would move the cell to a new position.
func dropUpdated(info: DropInfo) -> DropProposal? {
return .init(operation: .move)
}
However it was not working as well, But plus icon is disappeared.
The remaining method to use is only dropEntered
.
I checked it first to see whether it is called when dragging over.
func dropEntered(info: DropInfo) {
debugPrint("dropEntered")
}
Maybe DropInfo
would have information for the cell under the cursor? I attempted to get the target cell, but I couldn't, and I won't talk about it here. So I had to bind all datas and current dragging data to the delegate.
struct ContentView: View {
@State var draggingData: CellData?
...
ForEach(datas.indices, id: \.self) { dataIndex in
...
.onDrag({
draggingData = data
return .init(object: data.name as NSString )
})
.onDrop(of: [.text], delegate: DataDropController(data: data,
datas: $datas,
draggingData: $draggingData)
)
}
}
struct DataDropController: DropDelegate {
let data: CellData
@Binding var datas: [CellData]
@Binding var draggingData: CellData?
}
Move data
Now we can know which cell is under the dragging cell. Let’s move it.
First, I get the index of dragging and target cell.
func dropEntered(info: DropInfo) {
guard let draggingData else {
return
}
guard let sourceIndex = datas.firstIndex(where: { $0 == draggingData }),
let destIndex = datas.firstIndex(where: { $0 == data }) else {
return
}
Second, I moved the data to the new position like UIKit.
if (sourceIndex < destIndex) {
datas.insert(draggingData, at: destIndex)
datas.remove(at: sourceIndex)
}else {
datas.remove(at: sourceIndex)
datas.insert(draggingData, at: destIndex)
}
Animation
The cells were moved, but they weren’t animated, even with withAnimation
.
withAnimation(.default){
if (sourceIndex < destIndex) {
...
}
The reason was the approach with ForEach(datas.indices)
. So, I bound datas.
ForEach(datas, id: \.name) { data in
Now the animation is working, but there is something weird. 🧐
When the destination is after the source, I insert first. However, it will create duplicate data, and iOS can’t determine which cell should be moved
if (sourceIndex < destIndex) {
datas.insert(draggingData, at: destIndex)
datas.remove(at: sourceIndex)
So I removed the insert-first code.
Looks good!
Conclusion
There may be more ways to animate cells, but in this article, I focused on a method I have used for a long time. I would appreciate your comments if you know better ways.
Thanks!
The full source is in this example repository.
If you found this post helpful, please give it a round of applause 👏. Explore more iOS-related content in my other posts.