Reading time: 3 min

This recipe shows how to implement drag & drop to reorder items in SwiftUI. It offers a recipe for a generic ReorderableForEach which can then be plugged into any layout, such as VStack, LazyVGrid, etc.

The end result looks like this:

Grid Stack

This code works starting with SwiftUI 3 (iOS 15, macOS 12).

This component is available as a Swift Package in this repo.

Implementing drop to reorder functionality requires us to build on the drag & drop recipe. The action is pretty much the same:

  • Use onDrag on each ForEach item to make it draggable. The resulting NSItemProvider content isn't that important, but it should be unique for the list. Since we'll support ony Hashable data, each item should have a unique hashValue.
  • Trigger the reordering using a DropDelegate in onDrop:
    • We'll show a preview of the drop using dropEnter. This also reorders the data binding automatically, meaning any reorder is automatically reflected in the data array.
    • When a drop happens (in performDrop), we just clear the current state.

On top of this, we can easily add a binding to dynamically toggle reorder functionality. We can also pass an extra argument in the content ViewBuilder to indicate if an item is the one being dragged, so that it can be rendered differently than other items.

Here's the code for ReoderableForEach:

import SwiftUI
import UniformTypeIdentifiers

public struct ReorderableForEach<Data, Content>: View
where Data : Hashable, Content : View {
  @Binding var data: [Data]
  @Binding var allowReordering: Bool
  private let content: (Data, Bool) -> Content

  @State private var draggedItem: Data?
  @State private var hasChangedLocation: Bool = false

  public init(_ data: Binding<[Data]>,
              allowReordering: Binding<Bool>,
              @ViewBuilder content: @escaping (Data, Bool) -> Content) {
    _data = data
    _allowReordering = allowReordering
    self.content = content
  }

  public var body: some View {
    ForEach(data, id: \.self) { item in
      if allowReordering {
        content(item, hasChangedLocation && draggedItem == item)
          .onDrag {
            draggedItem = item
            return NSItemProvider(object: "\(item.hashValue)" as NSString)
          }
          .onDrop(of: [UTType.plainText], delegate: ReorderDropDelegate(
            item: item,
            data: $data,
            draggedItem: $draggedItem,
            hasChangedLocation: $hasChangedLocation))
      } else {
        content(item, false)
      }
    }
  }

  struct ReorderDropDelegate<Data>: DropDelegate
  where Data : Equatable {
    let item: Data
    @Binding var data: [Data]
    @Binding var draggedItem: Data?
    @Binding var hasChangedLocation: Bool

    func dropEntered(info: DropInfo) {
      guard item != draggedItem,
            let current = draggedItem,
            let from = data.firstIndex(of: current),
            let to = data.firstIndex(of: item)
      else {
        return
      }
      hasChangedLocation = true
      if data[to] != current {
        withAnimation {
          data.move(fromOffsets: IndexSet(integer: from),
                    toOffset: (to > from) ? to + 1 : to)
        }
      }
    }

    func dropUpdated(info: DropInfo) -> DropProposal? {
      DropProposal(operation: .move)
    }

    func performDrop(info: DropInfo) -> Bool {
      hasChangedLocation = false
      draggedItem = nil
      return true
    }
  }
}

Then, you can easily plug it in into any layout. Here's an example with VStack:

struct ReorderingVStackTest: View {
  @State private var data = ["Apple", "Orange", "Banana", "Lemon", "Tangerine"]
  @State private var allowReordering = false

  var body: some View {
    VStack {
      Toggle("Allow reordering", isOn: $allowReordering)
        .frame(width: 200)
        .padding(.bottom, 30)
      VStack {
        ReorderableForEach($data, allowReordering: $allowReordering) { item, isDragged in
          Text(item)
            .font(.title)
            .padding()
            .frame(minWidth: 200, minHeight: 50)
            .border(Color.blue)
            .background(Color.red.opacity(0.9))
            .overlay(isDragged ? Color.white.opacity(0.6) : Color.clear)
        }
      }
    }
  }
}

Or, you can use it with a LazyVGrid to immitate UIKit UICollectionView:

struct ReorderingVGridTest: View {
  @State private var data = ["Apple", "Orange", "Banana", "Lemon", "Tangerine"]
  @State private var allowReordering = false

  var body: some View {
    VStack {
      Toggle("Allow reordering", isOn: $allowReordering)
        .frame(width: 200)
        .padding(.bottom, 30)
      LazyVGrid(columns: [
        GridItem(.flexible()),
        GridItem(.flexible())
      ]) {
        ReorderableForEach($data, allowReordering: $allowReordering) { item, isDragged in
          Text(item)
            .font(.title)
            .padding()
            .frame(minWidth: 150, minHeight: 50)
            .border(Color.blue)
            .background(Color.red.opacity(0.9))
            .overlay(isDragged ? Color.white.opacity(0.6) : Color.clear)
        }
      }
    }
    .padding()
  }
}

Next Post Previous Post