Enabling drag reordering in SwiftUI lazy grids and stacks

Aug 30, 2023 · Follow on Twitter and Mastodon swiftui

While SwiftUI’s List supports drag to reorder, the LazyVGrid, LazyHGrid, LazyVStack and LazyHStack components lack this functionality. Let’s see how to implement it from scratch.

The solution in this post builds upon this amazing post by ramzesenok. I’ve modularized it a bit and added the possibility to provide a custom preview.

What to expect

To give you an idea of what to expect, we will implement something that will let us add drag reordering to a LazyVGrid and LazyHGrid:

A demo of a grid with drag to reordering

as well as to LazyVStack and LazyHStack, using the same component and configuration:

A demo of a grid with drag to reordering

We will be able to use any content views for the list items and customize the preview. The solution will also handle ending the drag gesture outside of the list.

Building the component

Before we start building, we need to specify the capabilities that a reorderable item needs. Let’s call it Reorderable and require it to be both Identifiable and Equatable:

public typealias Reorderable = Identifiable & Equatable

In this post, let’s use a very basic model for our list items, that just has a numeric identifier:

struct GridData: Identifiable, Equatable {
    let id: Int
}

Since we want to be able to use this component in all kind of collection views, let’s create it as a replacement to the regular ForEach view. Let’s call it ReorderableForEach:

public struct ReorderableForEach<Item: Reorderable, Content: View, Preview: View>: View {
    
    ...
}

We want the view to take a binding to the collection items so we can change the order, a binding to the active item so we can edit it, a content view builder for the list items, a preview builder to customize the drag preview and a moveAction to perform the move:

public struct ReorderableForEach<Item: Reorderable, Content: View, Preview: View>: View {
    
    public init(
        _ items: [Item],
        active: Binding<Item?>,
        @ViewBuilder content: @escaping (Item) -> Content,
        @ViewBuilder preview: @escaping (Item) -> Preview,
        moveAction: @escaping (IndexSet, Int) -> Void
    ) {
        self.items = items
        self._active = active
        self.content = content
        self.preview = preview
        self.moveAction = moveAction
    }
    
    public init(
        _ items: [Item],
        active: Binding<Item?>,
        @ViewBuilder content: @escaping (Item) -> Content,
        moveAction: @escaping (IndexSet, Int) -> Void
    ) where Preview == EmptyView {
        self.items = items
        self._active = active
        self.content = content
        self.preview = nil
        self.moveAction = moveAction
    }
    
    @Binding 
    private var active: Item?

    @State
    private var hasChangedLocation = false
    
    private let items: [Item]
    private let content: (Item) -> Content
    private let preview: ((Item) -> Preview)?
    private let moveAction: (IndexSet, Int) -> Void

    public var body: some View {
        ... // Coming up
    }
}

We can now create a view with and without a custom preview. Let’s now iterate over the collection and render a content view for each item in the collection:

public struct ReorderableForEach<Item: Reorderable, Content: View, Preview: View>: View {
    
    ...

    public var body: some View {
        ForEach(items) { item in
            if let preview {
                contentView(for: item)
                    .onDrag {
                        dragData(for: item)
                    } preview: {
                        preview(item)
                    }
            } else {
                contentView(for: item)
                    .onDrag {
                        dragData(for: item)
                    }
            }
        }
    }

    private func contentView(for item: Item) -> some View {
        content(item)
            .opacity(active == item && hasChangedLocation ? 0.5 : 1)
            .onDrop(
                of: [.text],
                delegate: ReorderableDragRelocateDelegate(
                    item: item,
                    items: items,
                    active: $active,
                    hasChangedLocation: $hasChangedLocation
                ) { from, to in
                    withAnimation {
                        moveAction(from, to)
                    }
                }
            )
    }
    
    private func dragData(for item: Item) -> NSItemProvider {
        active = item
        return NSItemProvider(object: "\(item.id)" as NSString)
    }
}

In this code, we use a plain ForEach to iterate over the items, then render a contentView for each item, with an onDrag modifier applied to let us drag the items.

The drag modifier differs a bit depending on if we want to use a custom preview or not. We thus perform an if check and render the same content view with two different modifiers.

In the contentView builder, we change the opacity when an item is active. We also apply an onDrop modifier with a custom delegate that looks like this:

struct ReorderableDragRelocateDelegate<Item: Reorderable>: DropDelegate {
    
    let item: Item
    var items: [Item]
    
    @Binding var active: Item?
    @Binding var hasChangedLocation: Bool

    var moveAction: (IndexSet, Int) -> Void

    func dropEntered(info: DropInfo) {
        guard item != active, let current = active else { return }
        guard let from = items.firstIndex(of: current) else { return }
        guard let to = items.firstIndex(of: item) else { return }
        hasChangedLocation = true
        if items[to] != current {
            moveAction(IndexSet(integer: from), to > from ? to + 1 : to)
        }
    }
    
    func dropUpdated(info: DropInfo) -> DropProposal? {
        DropProposal(operation: .move)
    }
    
    func performDrop(info: DropInfo) -> Bool {
        hasChangedLocation = false
        active = nil
        return true
    }
}

This delegate will actually try to perform the move as soon as dropEntered is triggered. This is why the items reorder as we drag, as you can see in the gifs above.

This is actually all we need, but we do want some additional handling as well. For instance, we want to be able to terminate the move operation if an item is dropped outside the list.

To do this, let’s create another delegate:

struct ReorderableDropOutsideDelegate<Item: Reorderable>: DropDelegate {
    
    @Binding
    var active: Item?
        
    func dropUpdated(info: DropInfo) -> DropProposal? {
        DropProposal(operation: .move)
    }
    
    func performDrop(info: DropInfo) -> Bool {
        active = nil
        return true
    }
}

All this does is to reset the active state whenever it performs a drop. We can now create a view extension to make it easy to bind this delegate to any view:

public extension View {
    
    func reorderableForEachContainer<Item: Reorderable>(
        active: Binding<Item?>
    ) -> some View {
        onDrop(of: [.text], delegate: ReorderableDropOutsideDelegate(active: active))
    }
}

This modifier should be applied to the outermost container, to make sure that ending the drag gesture outside the list will still reset the active state.

And with that, we’re done. To use this, just apply the reorderableForEachContainer to the container view, then use a ReorderableForEach instead of a ForEach:

struct ContentView: View {
    
    @State
    private var items = (1...100).map { GridData(id: $0) }
    
    @State
    private var active: GridData?
     
    var body: some View {
        ScrollView(.vertical) {
            LazyVGrid(columns: .adaptive(minimum: 100, maximum: 200)) {
                ReorderableForEach(items, active: $active) { item in
                    shape
                        .fill(.white.opacity(0.5))
                        .frame(height: 100)
                        .overlay(Text("\(item.id)"))
                        .contentShape(.dragPreview, shape)
                } preview: { item in
                    Color.white
                        .frame(height: 150)
                        .frame(minWidth: 250)
                        .overlay(Text("\(item.id)"))
                        .contentShape(.dragPreview, shape)
                } moveAction: { from, to in
                    items.move(fromOffsets: from, toOffset: to)
                }
            }.padding()
        }
        .background(Color.blue.gradient)
        .scrollContentBackground(.hidden)
        .reorderableForEachContainer(active: $active)
    }
    
    var shape: some Shape {
        RoundedRectangle(cornerRadius: 20)
    }
}

Remember to apply a .contentShape(.dragPreview, ...) modifier if your list items need a shape when they’re being lifted up, as well as to any custom preview you may want to use.

Happy reordering!

Discussions & More

Please share any ideas, feedback or comments you may have in the Disqus section below, or by replying to this tweet or this toot.

If you found this text interesting, make sure to follow me on Twitter and Mastodon for more content like this, and to be notified when new content is published.

If you like & want to support my work, please consider sponsoring me on GitHub Sponsors.