swiftuiswiftui-listswiftdata

Some List Items not editable using .move in SwiftUI List


I modified the default Items example from xcode to include two models, a view with a query and a detail view. On this detail view I cannot properly move all items, only the top two, please see the attached video.

Especially strange is that some dragging does work and some does not. It changes with the number of sub-items on a todo. The models are SwiftData and it is have a one-to-many relationship. On the many relationship the problem exists.

How should the code be adjusted to make sure all items are draggable?

https://imgur.com/a/n1y7iXX

Below is all code necessary for the minimal example. I target iOS 17.5 and this shows on both preview, simulator and my iPhone.

Models

@Model
final class ToDo {
    var timestamp: Date
    var items: [Item]
    
    init(timestamp: Date) {
        self.timestamp = timestamp
        self.items = []
    }
}

@Model
final class Item {
    var timestamp: Date
    var done: Bool
    
    init(timestamp: Date, done: Bool) {
        self.timestamp = timestamp
        self.done = done
    }
}

ItemListView (Here is the problem!)

struct ItemListView: View {
    @Bindable var todo: ToDo
    
    var body: some View {
        List {
            ForEach($todo.items) { $item in
                Text(item.timestamp.description)

            }
            .onMove { indexSet, offset in
                todo.items.move(fromOffsets: indexSet, toOffset: offset)
            }
        }
        
    }
}

ContentView

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query var items: [ToDo]

    var body: some View {
        NavigationSplitView {
            List {
                ForEach(items) { item in
                    NavigationLink {
                        ItemListView(todo: item)
                    } label: {
                        Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
                ToolbarItem {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
        } detail: {
            Text("Select an item")
        }
    }

    private func addItem() {
        withAnimation {
            let newItem = ToDo(timestamp: Date())
            modelContext.insert(newItem)
        }
    }

    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            for index in offsets {
                modelContext.delete(items[index])
            }
        }
    }
}

APP

@main
struct ListProjectApp: App {

    var sharedModelContainer: ModelContainer = {
        
        let schema = Schema([
            ToDo.self,
        ])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)

        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

    var body: some Scene {
        
        WindowGroup {
            ContentView()
        }
        .modelContainer(ToDoContainer.create())
    }
}

actor ToDoContainer {
    
    @MainActor
    static func create() -> ModelContainer {
        let schema = Schema([
            ToDo.self,
            Item.self
        ])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)

        let container = try! ModelContainer(for: schema, configurations: [modelConfiguration])
                
        
        let todo = ToDo(timestamp: Date())
        
        container.mainContext.insert(todo)

        let item1 = Item(timestamp: Date(), done: false)
        let item2 = Item(timestamp: Date(), done: false)
        let item3 = Item(timestamp: Date(), done: false)
        
        todo.items.append(item1)
        todo.items.append(item2)
        todo.items.append(item3)

        
        return container
    }
}

Solution

  • If you just want the re-ordering to "work", without persisting the order, you can use a separate @State to track the order.

    struct ItemListView: View {
        let toDo: ToDo
        
        @State var items: [Item] = []
        
        var body: some View {
            List {
                ForEach(items) { item in
                    Text(item.timestamp.description)
    
                }
                .onMove { indexSet, offset in
                    items.move(fromOffsets: indexSet, toOffset: offset)
                }
            }
            .onAppear {
                items = toDo.items
            }
        }
    }
    

    If you want to persist the order, you should add a order property to Item, which you can sort by. Also add an inverse relationship so it's easy to get the items that belong to a ToDo in a Query.

    @Model
    final class ToDo {
        var timestamp: Date
        
        @Relationship(inverse: \Item.toDo)
        var items: [Item]
        
        init(timestamp: Date) {
            self.timestamp = timestamp
            self.items = []
        }
    }
    
    @Model
    final class Item {
        var timestamp: Date
        var done: Bool
        var order = 0
        var toDo: ToDo?
        
        init(timestamp: Date, done: Bool) {
            self.timestamp = timestamp
            self.done = done
        }
    }
    

    In onMove, calculate the new order and update the order properties.

    struct ItemListView: View {
        let toDo: ToDo
        
        @Query var items: [Item]
        
        init(toDo: ToDo) {
            self.toDo = toDo
            let id = toDo.persistentModelID
            self._items = Query(filter: #Predicate<Item> {
                $0.toDo?.persistentModelID == id
            }, sort: \.order)
        }
        
        var body: some View {
            List {
                ForEach(items) { item in
                    Text(item.timestamp.description)
    
                }
                .onMove { indexSet, offset in
                    // I'm setting the order properties of all the Items,
                    // but there is probably a faster way to calculate the new order
                    // that only sets the properties that needs to be set
                    var copy = items
                    copy.move(fromOffsets: indexSet, toOffset: offset)
                    for (item, order) in zip(copy, 0..<copy.count) {
                        item.order = order
                    }
                    // context.save() here if auto save is off
                }
            }
        }
    }