TEAM LICENSES: Save money and learn new skills through a Hacking with Swift+ team license >>

Hacking with SwiftData - Alternate sorting for separate models

Forums > Books

Hey all, I'm just finished up the beginner SwiftData tutorial and was experimenting with adding a .move modifier to the "sights".

Do I need to add a seperate SortDescriptor and initializer for the sights? Is it possible to have seperate SortDescriptors for just the sights of each destination?

When I drag/drop the sight to move/re-order a sight, it just resets back to ordering it by name. Here is my EditSightView:

import SwiftData
import SwiftUI

struct EditSight_View: View {
    @Environment(\.modelContext) var modelContext
    @Bindable var destination: Destination
    @State private var newSightName = ""

    var body: some View {
            List {
                ForEach(destination.sights) {sight in
                    Text(sight.name)
                } //: FOREACH
                .onDelete(perform: deleteSights)
                .onMove(perform: moveSights)
            } //: LIST
            HStack {
                TextField("Add a new sight in \(destination.name)", text: $newSightName)
                Button("Add", action: addSights)
            } //: HSTACK
    }

    // MARK: FUNCTIONS
    func addSights() {
        guard newSightName.isEmpty == false else { return }

        withAnimation {
            let sight = Sight(name: newSightName)
            destination.sights.append(sight)
            newSightName = ""
        }
    }

    func deleteSights(_ indexSet: IndexSet) {
        for index in indexSet {
            let sight = destination.sights[index]
            modelContext.delete(sight)
        }
    }

    func moveSights(_ from: IndexSet, to: Int) {
        destination.sights.move(fromOffsets: from, toOffset: to)
    }
}

#Preview {
    do {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(for: Destination.self, configurations: config)

        let example = Destination(name: "Example Destination", details: "Example details go here and will automatically expand vertically as they are edited.")
        return EditSight_View(destination: example)
            .modelContainer(container)
    } catch {
        fatalError("Failed to create model container.")
    }
}

3      

Hi @rheek!

I doubt it is feasible in this context. By context I mean, in your case you are using manual sorting by moving items around. As they say SwiftData rests on shoulders of Core Data, and in Core Data collections are stored in Set data type, meaning you cannot store it in partuculare order. I know, we all see Arrays in @Relationships and @Query but Core Data stores them as Set data type, by obvious reason it is more efficient. Well, maybe my deduction is wrong and community can help us in here.

As for filter, I suppose there is a way to filter that as for example I managed to make your manual sort to work in subview. But in below example I simply copy sights to local variable and use that for that particular view, but you can notice that as soon as you go back to previous view and return your manual sorting is gone. Definitely, you can apply some filter even to this local variable. But again as data behind it is Set you cannot sort that in the same way as you can in Array.

struct EditDestinationView: View {
    @Bindable var destination: Destination
    @State private var newSightName = ""

    // Create local variable to store your sights from a particular destination
    @State private var customSortSights: [Sight]

    // Init customSortSights by providing inital value from passed destination
    init(destination: Destination) {
        self.destination = destination
        _customSortSights = State(initialValue: destination.sights)
    }

    var body: some View {
        Form {
            TextField("Name", text: $destination.name)
            TextField("Details", text: $destination.details, axis: .vertical)
            DatePicker("Date", selection: $destination.date)

            Section("Priority") {
                Picker("Priority", selection: $destination.priority) {
                    Text("Meh").tag(1)
                    Text("Maybe").tag(2)
                    Text("Must").tag(3)
                }
                .pickerStyle(.segmented)
            }

            Section("Sights") {
                // Use local var to display manually sorted sights
                ForEach(customSortSights) { sight in
                    Text(sight.name)
                }
                .onDelete(perform: deleteSight)
                .onMove(perform: moveSights)

                HStack {
                    TextField("Add a new sight in \(destination.name)", text: $newSightName)
                    Button("Add", action: addSight)
                }
            }
        }
        .navigationTitle("Edit Destination")
        .navigationBarTitleDisplayMode(.inline)

    }

    func addSight() {
        guard newSightName.isEmpty == false else { return }

        withAnimation {
            let sight = Sight(name: newSightName)
            destination.sights.append(sight)
            // now it is also your responsibility to update customSortSights as well as adding it to destination
            customSortSights.append(sight)
            newSightName = ""
        }
    }

    func deleteSight(_ indexSet: IndexSet) {
        destination.sights.remove(atOffsets: indexSet)
        // now it is also your responsibility to remove sight from customSortSights as well as removing it from destination
        customSortSights.remove(atOffsets: indexSet)
    }

    func moveSights(_ from: IndexSet, to: Int) {
        customSortSights.move(fromOffsets: from, toOffset: to)
    }
}

#Preview {
    do {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let containter = try ModelContainer(for: Destination.self, configurations: config)
        let example = Destination(name: "Example Destination", details: "Example details go here and will automatically expand vertically as they are edited.")
        return EditDestinationView(destination: example)
            .modelContainer(containter)
    } catch {
        fatalError("Failed to created model container.")
    }

}

3      

Thanks for the reply!. After reading through your reply, I'm assuming that the way to solve this issue is to add an attruibute l"sortvalue", and then each time a .move function happens, map the entire customSortSghts array with the existing order then replace the entire array, forcing a refresh. I really don't remember how to do that, but I'll go back through some of the other courses to get a refresher and see if I can get that to work.

Thanks again!

3      

I finally got it working, but I'm not sure the solution is the most elegant or clean.

I added a new attribute to SIght called sortOrder:

import SwiftData
import Foundation

@Model
class Sight {
    var name: String
    var sortOrder: Int

    init(name: String = "", sortOrder: Int = 0) {
        self.name = name
        self.sortOrder = sortOrder
    }
}

Now I can store a custom sortorder. I also added a function at the end to loop through the currently displayed sight and renumber the order starting at 0 and then replace that destination's Sites with the re-ordered temporarty array. I'd love anyone's input on more efficient ways of writing out this function or any other critique. When re-launching the data displays correctly, and I can't find any functional issues that cauase the code to function incorrectly. (I also added in a display text to show the "custom sort order".)


import SwiftData
import SwiftUI

struct EditSight_View: View {
    @Environment(\.modelContext) var modelContext
    @Bindable var destination: Destination
    @State private var newSightName = ""

    // Create local variable to store your sights from a particular destination
    @State private var customSortedSights: [Sight]

    // Init customSortSights by providing inital value from passed destination
    init(destination: Destination) {
        self.destination = destination
        _customSortedSights = State(initialValue: destination.sights.sorted(by: { $0.sortOrder < $1.sortOrder }))
    }

    var body: some View {
        List {
            ForEach(customSortedSights) { sight in
                HStack {
                    Text(sight.name)
                    Spacer()
                    Text("Sorting \(sight.sortOrder)")
                }
            } //: FOREACH
            .onDelete(perform: deleteSights)
            .onMove(perform: moveSights)
        } //: LIST
        HStack {
            TextField("Add a new sight in \(destination.name)", text: $newSightName)
            Button("Add", action: addSights)
        } //: HSTACK
    }

    // MARK: FUNCTIONS
    func addSights() {
        guard newSightName.isEmpty == false else { return }

        withAnimation {
            let newSortNumber = customSortedSights.count + 1
            let sight = Sight(name: newSightName, sortOrder: newSortNumber)
            customSortedSights.append(sight)
            replaceSight()
            newSightName = ""

        }
    }

    func deleteSights(_ indexSet: IndexSet) {
        customSortedSights.remove(atOffsets: indexSet)
        replaceSight()
    }

    func moveSights(_ from: IndexSet, to: Int) {
        customSortedSights.move(fromOffsets: from, toOffset: to)
        replaceSight()
    }

    func replaceSight() {
        var tempSortOrder = 0
        var tempSortedSights = [Sight]()
        var tempSight = Sight()
        for i in 0 ..< customSortedSights.count {
            tempSight = customSortedSights[i]
            tempSight.sortOrder = tempSortOrder
            tempSortOrder += 1
            tempSortedSights.append(tempSight)
        }
        let sortedSights = tempSortedSights.sorted { $0.sortOrder < $1.sortOrder }
        destination.sights.removeAll()
        destination.sights = sortedSights
    }
}

#Preview {
    do {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(for: Destination.self, configurations: config)

        let example = Destination(name: "Example Destination", details: "Example details go here and will automatically expand vertically as they are edited.")
        return EditSight_View(destination: example)
            .modelContainer(container)
    } catch {
        fatalError("Failed to create model container.")
    }
}

3      

Hello. I am running into the exact same problem.

Also curious if there is a more elegant solution.

2      

hi,

@rheek found a basic solution in which you store a sortOrder value for each model, but as you saw, it rewrites the sort order value for each model upon a move.

i've used this myself (see my ShoppingList project, when aranging the order of Locations), and although it's somewhat inefficient, it gets the job done especially when the number of models you are arranging by moving them about in a list is somewhat small -- don't do this for a few thousand items!

there are two alternatives you can look at.

(1) if you make @rheek's sortOrder a double, rathen than an Int, it's easy to change the sort order after a move of an item: just get the sort order of what will precede it and the sort order of what will follow it, and set the item's sort order to the average of these two. (appropriate modifications for end-cases of move to beginning and move to end of list, and moving more that one item at once).

it's quite efficient: only the sort order of the item moved needs to be rewritten.

(2) in a slightly different direction, store a separate item in SwiftData/Core Data whose only purpose is to track the order of the items you present in the list. show items in a list in the order specified by this array.

it's easy to do in SwiftData by keeping an array of, say, item UUIDs and using that array and indirection to lay out the list. (in Core Data, but you'll need to specify a value transformer for [UUID].) moving items in the list is the same as moving their corresponding UUIDs in the array.

this code seems to work:

import SwiftUI
import SwiftData

@Model
class Person {
    var name: String
    let referenceID = UUID() // something unique to identify this person

    init(name: String) {
        self.name = name
    }
}

@Model
class PersonOrder {
    var uuidOrder: [UUID] // an array to show the order of people by UUID

    init() { 
        uuidOrder = []
    }
}

@main
struct TestApp: App {

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Person.self, PersonOrder.self])
    }
}

struct ContentView: View {
    @Environment(\.modelContext) var modelContext
    @Query() var people: [Person]
    @Query() var personOrders: [PersonOrder]    // the query brings back an array

    // this gives the people in the order that you determine
    var orderedPeople: [Person] {
        // break out people into arrays of people who have the same 
        // referenceID -- but, of course, there's only one for each referenceID
        let uuidLookup = Dictionary(grouping: people, by: { $0.referenceID })
        // personOrders.first is, what should be, the only PersonOrder of
        // interest, so use its uuidOrder and, for each element, find
        // the Person with that UUID
        return personOrders.first?.uuidOrder.compactMap({ uuidLookup[$0]?.first }) ?? []
    }

    var body: some View {
        List {
            ForEach(orderedPeople) { person in
                Text(person.name)
            }
            .onMove(perform: handleMove)
        }
        Button("Add New") {
            let person = Person(name: "Person \(Int.random(in: 1...1000))")
            // add to the model context, but also add its referenceID to the PersonOrder
            personOrders.first?.uuidOrder.append(person.referenceID)
            modelContext.insert(person)
        }
        .onAppear { // establish the one and only PersonOrder object if we need to
            if personOrders.count == 0 {
                let firstPersonOrder = PersonOrder()
                modelContext.insert(firstPersonOrder)
            }
        }
    }

    func handleMove(indices: IndexSet, newOffset: Int) {
        // moving people around in this list is represented by
        // just moving their referenceIDs in the PersonOrder
        personOrders.first?.uuidOrder.move(fromOffsets: indices, toOffset: newOffset)
    }

}

hope this helps,

DMG

2      

I put together a sample project illustrating the cleanest workaround I could come up.

Rather than paste all the code here you can see it all in a repo I made: https://github.com/hekuli/swiftdata-test/tree/main

I'd be thrilled to get any feedback on how this could be improved.

2      

@rheek In my opinion you got to the root of the problem when you added the sortOrder property as an Int, but most of the rest was not needed...or at least I did not need them. Approx 5 minutes of reading the initial response and updating my code got me the correct reults very quickly.

Like Delaware MathGuy, I also created a like and similar project where I like to list records with the latest at the top; or in your case ...order:.reverse. My default query for the item table is on itemNum,order:.reverse. itemNum ==> sortOrder.

As a result, all I needed to do was add the custom sort order declaration and to the init()

` // Create local variable to store your sights from a particular destination @State private var customSortSights: [Sight]

// Init customSortSights by providing inital value from passed destination
init(destination: Destination) {
    self.destination = destination
    _customSortSights = State(initialValue: destination.sights)
}

`
The addItem(your addSight) is basically as Paul had it, however I did change the name property to a computed property of the itemNum and itemName
`
func addItem() {
    guard !newInspItem.isEmpty == false else {return}
    withAnimation{
        let newItemNumber = lastItemNum + 1
        let item = InspItem(itemNum: newItemNumber ,itemType: Type.obs, itemName: newInspItem, itemStatus: Status.rec)
        item.itemName = item.viewItemNumName
        insp.items.append(item)
        newInspItem = ""
    }

}

`
In this way I have the records sorted by number whether I use the name field or the number field.  I did use the subview procedure without any other changes.  

Caveat...I am leveraging MANY years of Oracle Relational DB programming to get similar results out of SQLite/SwiftData. BTW...Mark Moeykens covers refreshing views easily in Mastering SwiftData

   

@jmb  

Late to this but was looking to solve a similar problem.

I added a var sortOrder: Int to my model and then use this as the the function called in onMove:

    @Query(sort: \MyModel.sortOrder) private var modelItems: [MyModel]

    private func move(from indices: IndexSet, to newOffset: Int) {
        var orderedList = modelItems
        orderedList.move(fromOffsets: indices, toOffset: newOffset)
        for (index, item) in orderedList.enumerated() {
            item.sortOrder = index
        }
    }

When I create a new item, I set sortOrder = modelItems.count to put it at the end.

   

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.