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?
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
}
}
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
}
}
}
}