Reorder List rows containing text fields in SwiftUI on macOS

In SwiftUI there is a very useful onMove() modifier that we can use to provide easy drag and drop list reordering.

List {
    ForEach(values) {
        ...
    }
    .onMove { indices, newOffset in
        values.move(
            fromOffsets: indices,
            toOffset: newOffset
        )
    }
}

However, this modifier adds a drag gesture recogniser to each of the rows. This means that when we click on a text field within the row, it takes a long time before the text field is focused.

In AppKit there is a method to filter clicks to a subregion of the row (such as a drag handle on the leading edge), but SwiftUI does not provide this customization.

We do have access to moveDisabled() modifier, that can be used on rows to stop them from being draggable. What is great is that when this is applied, rows do not delay clicks, so focusing on text fields is immediate.

Integrating SwiftUI into UIKit Apps by Natalia Panferova book coverIntegrating SwiftUI into UIKit Apps by Natalia Panferova book cover

Check out our book!

Integrating SwiftUI into UIKit Apps

Integrating SwiftUI intoUIKit Apps

UPDATED FOR iOS 17!

A detailed guide on gradually adopting SwiftUI in UIKit projects.

  • Discover various ways to add SwiftUI views to existing UIKit projects
  • Use Xcode previews when designing and building UI
  • Update your UIKit apps with new features such as Swift Charts and Lock Screen widgets
  • Migrate larger parts of your apps to SwiftUI while reusing views and controllers built in UIKit

We can combine moveDisabled() with onHover(perform:) modifier to only enable the dragging of rows while the user is hovering over a given region of the row, such as a drag handle.

struct ContentView: View {
    @State var values = [
        "a", "b", "c", "d",
        "e", "f", "g", "h"
    ]
    
    @State var isHovering: Bool = false
    
    var body: some View {
        List {
            ForEach(0..<values.count, id: \.self) { index in
                HStack {
                    Image(systemName: "line.horizontal.3")
                        .onHover { hovering in
                            isHovering = hovering
                        }
                    
                    TextField("Value", text: $values[index])
                }
                .moveDisabled(!isHovering)
            }
            .onMove { indices, newOffset in
                values.move(
                    fromOffsets: indices,
                    toOffset: newOffset
                )
            }
        }
    }
}

This works since during the drag operation SwiftUI does not update onHover() call, so even when the mouse is dragged away, the item remains movable until the drag finishes.

With our solution focusing on text fields, clicking on buttons or navigation links within the rows is immediate. Also dragging to reorder can only start while the user mouse hovers over the drag handle. It means that you can even have other gesture based controls within your view (such a multi-line TextEditor) without the drag events being confused.