Sort It Out

Sort Menu for SwiftUI

Sort It Out

Every app contains data that is shown to the user, and in most cases, users like to have at least a little control over how the data is displayed to them. The easiest way to give users some control is to allow them to choose by which property and in what order they want to sort the information. Most applications have more than one screen with some form of records, so following the DRY principle, we should at least try to create a somewhat generic solution. Let's try to do it!

First Things First

Before we dive into creating a sort menu, we need to have something to sort. First, we'll create a simple SwiftData model and some helpers. (I've written a separate article on how I tend to work with SwiftData in previews, so I'll skip most of the details.)

import Foundation
import SwiftData
import UIKit

@Model
final class ToDoItem: Codable {
    private enum CodingKeys: CodingKey {
        case name
        case done
        case dueDate
        case creationDate
    }

    var name = ""
    var done = false
    var dueDate: Date?
    let creationDate = Date.now

    init(name: String = "", done: Bool = false, dueDate: Date? = nil) {
        self.name = name
        self.done = done
        self.dueDate = dueDate
    }

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        done = try container.decode(Bool.self, forKey: .done)
        dueDate = try container.decodeIfPresent(Date.self, forKey: .dueDate)
        creationDate = try container.decode(Date.self, forKey: .creationDate)
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(name, forKey: .name)
        try container.encode(done, forKey: .done)
        if let dueDate {
            try container.encode(dueDate, forKey: .dueDate)
        }
        try container.encode(creationDate, forKey: .creationDate)
    }
}

@MainActor
final class PreviewModelContainerProvider {
    static let shared = PreviewModelContainerProvider()

    private let toDoItems: [ToDoItem]
    let modelContainer: ModelContainer

    var modelContext: ModelContext {
        modelContainer.mainContext
    }

    var toDoItem: ToDoItem {
        guard let item = toDoItems.first else {
            fatalError("There is no data loaded")
        }
        return item
    }

    private init() {
        let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
        do {
            modelContainer = try ModelContainer(for: ToDoItem.self, configurations: configuration)
        } catch {
            fatalError("Could not create ModelContainer")
        }

        guard let previewData = NSDataAsset(name: "ToDoItemPreviewsData")?.data else {
            fatalError("Preview data not found")
        }

        do {
            toDoItems = try JSONDecoder().decode([ToDoItem].self, from: previewData)
            for toDoItem in toDoItems {
                modelContext.insert(toDoItem)
            }
        } catch {
            fatalError("Could not decode preview data")
        }
    }
}

Here, we have a JSON file with the stub data that we need to add to our Preview Assets.

[
    {
        "done": false,
        "name": "Buy groceries",
        "creationDate": 746172000
    },
    {
        "dueDate": 746258400,
        "name": "Walk the dog",
        "done": true,
        "creationDate": 746085600
    },
    {
        "dueDate": 746344800,
        "creationDate": 745999200,
        "done": false,
        "name": "Read a book"
    },
    {
        "name": "Complete homework",
        "done": true,
        "creationDate": 745912800
    },
    {
        "done": false,
        "dueDate": 746517600,
        "name": "Call mom",
        "creationDate": 745826400
    },
    {
        "dueDate": 746604000,
        "done": true,
        "name": "Pay bills",
        "creationDate": 745740000
    },
    {
        "done": false,
        "creationDate": 745653600,
        "name": "Schedule dentist appointment"
    },
    {
        "dueDate": 746776800,
        "name": "Clean the house",
        "creationDate": 745567200,
        "done": true
    },
    {
        "creationDate": 745480800,
        "done": false,
        "name": "Exercise",
        "dueDate": 746863200
    },
    {
        "name": "Prepare dinner",
        "done": true,
        "creationDate": 745394400
    }
]

The last thing we need to do is create a simple UI to display our data. Let's create a list with rows presenting all the information that each ToDoItem contains.

import SwiftData
import SwiftUI

struct ToDoItemRow: View {
    private let toDoItem: ToDoItem

    init(for toDoItem: ToDoItem) {
        self.toDoItem = toDoItem
    }

    var body: some View {
        Button {
            toDoItem.done.toggle()
        } label: {
            HStack {
                Image(systemName: toDoItem.done ? "checkmark.circle" : "circle")
                    .font(.title)
                VStack(alignment: .leading) {
                    Text(toDoItem.name)
                        .font(.headline)
                        .strikethrough(toDoItem.done)
                    HStack {
                        LabeledContent("Creation date") {
                            Text(toDoItem.creationDate.formatted(date: .long, time: .omitted))
                        }
                        LabeledContent("Due date") {
                            Text(toDoItem.dueDate?.formatted(date: .long, time: .omitted) ?? "-")
                        }
                    }
                    .font(.caption2)
                }
            }
        }
        .buttonStyle(.plain)
    }
}

struct ContentView: View {
    @Query
    private var items: [ToDoItem]

    var body: some View {
        NavigationStack {
            List {
                ForEach(items) { item in
                    ToDoItemRow(for: item)
                }
            }
            .navigationTitle("To Do")
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(PreviewModelContainerProvider.shared.modelContainer)
}

With all that in place, we have a solid foundation to continue our work. Let's dive in!

Brace Yourselves - Sorting Is Coming

As you may have noticed, every time our app (or rather the preview) reloads, the data is presented in a different order. This is because each time we load our data in the preview helper, it creates new entries in memory, and we haven't specified any sorting for our @Query. We can fix that really quickly.

@Query(sort: [SortDescriptor(\ToDoItem.name)])
private var items: [ToDoItem]

Much better, we no longer have a random element, even in our previews. However, we want to allow users to change the sort parameter and order dynamically. This is a bit trickier, as we can't just create a @State with our sort descriptors. We need to extract the @Query to a separate View and feed it with our dynamic sort descriptors.

struct ToDoItemsListing: View {
    @Query
    private var items: [ToDoItem]

    init(sort sortDescriptors: [SortDescriptor<ToDoItem>]) {
        _items = Query(
            sort: sortDescriptors,
            animation: .default
        )
    }

    var body: some View {
        List {
            ForEach(items) { item in
                ToDoItemRow(for: item)
            }
        }
    }
}

struct ContentView: View {
    var body: some View {
        NavigationStack {
            ToDoItemsListing(sort: [SortDescriptor(\.name)])
                .navigationTitle("To Do")
        }
    }
}

That's it! We can now easily and dynamically change the sorting. However, there is one caveat: we can't sort by the done property of our ToDoItem. This is because Bool doesn't conform to the Comparable protocol. Luckily, that's a really simple fix.

import Foundation

extension Bool: Comparable {
    public static func < (lhs: Bool, rhs: Bool) -> Bool {
        !lhs && rhs
    }
}

Now, all properties of our model can be used for sorting. We can dynamically change SortDescriptors, so we are ready to give users control over the order in which the application presents data.

Trust Me, I'm a User

Before we give users control, we need to make sure all the gears are ready for that. We want users to be able to choose between the properties of our model. This sounds like a task for some enum. Let's create one for our ToDoItem. Of course, a simple enum is not enough; we need something more, like some form of SortDescriptor provider.

extension ToDoItem {
    enum ToDoItemSortDescriptorProvider {
        case name
        case done
        case dueDate
        case creationDate

        var sortDescriptors: [SortDescriptor<ToDoItem>] {
            switch self {
            case .name:
                [SortDescriptor(\.name)]
            case .done:
                [SortDescriptor(\.done)]
            case .dueDate:
                [SortDescriptor(\.dueDate)]
            case .creationDate:
                [SortDescriptor(\.creationDate)]
            }
        }
    }
}

Nice! But… let's think about what happens if we use those single-item SortDescriptor lists. Take, for example, the descriptor for the done property. It sorts items as it should, with false items on top and true items on the bottom, but all other properties are not considered.

Fortunately, we return a list of sort descriptors, so we just need to add more descriptors for each case. For that, we need to arbitrarily choose an order for them, so let's use this as the default order: done, dueDate, name, and creationDate. This seems to make the most sense.

var sortDescriptors: [SortDescriptor<ToDoItem>] {
    let doneDescriptor = SortDescriptor(\ToDoItem.done)
    let dueDateDescriptor = SortDescriptor(\ToDoItem.dueDate)
    let nameDescriptor = SortDescriptor(\ToDoItem.name)
    let creationDateDescriptor = SortDescriptor(\ToDoItem.creationDate)
    switch self {
    case .name:
        return [nameDescriptor, doneDescriptor, dueDateDescriptor, creationDateDescriptor]
    case .done:
        return [doneDescriptor, dueDateDescriptor, nameDescriptor, creationDateDescriptor]
    case .dueDate:
        return [dueDateDescriptor, doneDescriptor, nameDescriptor, creationDateDescriptor]
    case .creationDate:
        return [creationDateDescriptor, doneDescriptor, dueDateDescriptor, nameDescriptor]
    }
}

Much better! Next, we need to actually use it. Since we want it to be changeable by the user, we need to add some @State to our view.

@State
private var sortDescriptorProvider = ToDoItem.ToDoItemSortDescriptorProvider.done

var body: some View {
    NavigationStack {
        ToDoItemsListing(sort: sortDescriptorProvider.sortDescriptors)
            .navigationTitle("To Do")
    }
}

The easy part is behind us. Now, we need to create a UI that allows the user to change sorting parameters. There are many possible ways to implement such an interaction, but we'll create a menu accessible from the toolbar.

Of course, we could create this menu manually, but a large part of the code would be the same for each sort property. To avoid that, let's first make our enum conform to the CaseIterable protocol, especially since this change is trivial. However, that's not all we need to do before we can use it. It would be nice to have a conversion between our enum and a user-readable string. This is also painless; we just need to add a raw value for it. The last thing we need to add is yet another protocol conformance, this time to Identifiable, as we want to use it in a ForEach view builder.

enum ToDoItemSortDescriptorProvider: String, CaseIterable, Identifiable {
    case name = "Name"
    case done = "Done"
    case dueDate = "Due Date"
    case creationDate = "Creation Date"

    var id: Self {
        self
    }
    ...
}

With that in place, we can create our Menu pretty straightforwardly.

ToDoItemsListing(sort: sortDescriptorProvider.sortDescriptors)
    .navigationTitle("To Do")
    .toolbar {
        Menu {
            ForEach(ToDoItem.ToDoItemSortDescriptorProvider.allCases) { provider in
                Button {
                    withAnimation {
                        sortDescriptorProvider = provider
                    }
                } label: {
                    if provider == sortDescriptorProvider {
                        Label(provider.rawValue, systemImage: "checkmark")
                    } else {
                        Text(provider.rawValue)
                    }
                }
            }
        } label: {
            Label("Sort", systemImage: "arrow.left.arrow.right")
        }
    }

Finally, we have a menu that the user can use to choose which property the application should use to sort and present data. However, there is a flaw in the current solution: the user can choose the property but not the sort order (ascending or descending). Luckily, our code is easily modifiable to handle that as well. All we need to do is change the provider's sortDescriptors property into a method that accepts SortOrder and modify the sort menu a little.

enum ToDoItemSortDescriptorProvider: String, CaseIterable, Identifiable {
    ...
    func sortDescriptors(order: SortOrder) -> [SortDescriptor<ToDoItem>] {
        let doneDescriptor = SortDescriptor(\ToDoItem.done, order: order)
        let dueDateDescriptor = SortDescriptor(\ToDoItem.dueDate, order: order)
        let nameDescriptor = SortDescriptor(\ToDoItem.name, order: order)
        let creationDateDescriptor = SortDescriptor(\ToDoItem.creationDate, order: order)
        switch self {
        case .name:
            return [nameDescriptor, doneDescriptor, dueDateDescriptor, creationDateDescriptor]
        case .done:
            return [doneDescriptor, dueDateDescriptor, nameDescriptor, creationDateDescriptor]
        case .dueDate:
            return [dueDateDescriptor, doneDescriptor, nameDescriptor, creationDateDescriptor]
        case .creationDate:
            return [creationDateDescriptor, doneDescriptor, dueDateDescriptor, nameDescriptor]
        }
    }
}

struct ContentView: View {
    @State
    private var sortDescriptorProvider = ToDoItem.ToDoItemSortDescriptorProvider.done
    @State
    private var sortOrder = SortOrder.forward

    var body: some View {
        NavigationStack {
            ToDoItemsListing(sort: sortDescriptorProvider.sortDescriptors(order: sortOrder))
                .navigationTitle("To Do")
                .toolbar {
                    Menu {
                        ForEach(ToDoItem.ToDoItemSortDescriptorProvider.allCases) { provider in
                            Button {
                                withAnimation {
                                    if provider == sortDescriptorProvider {
                                        sortOrder = sortOrder == .forward ? .reverse : .forward
                                    } else {
                                        sortDescriptorProvider = provider
                                        sortOrder = .forward
                                    }
                                }
                            } label: {
                                if provider == sortDescriptorProvider {
                                    Label(
                                        provider.rawValue,
                                        systemImage: sortOrder == .forward ? "arrow.up" : "arrow.down"
                                    )
                                } else {
                                    Text(provider.rawValue)
                                }
                            }
                        }
                    } label: {
                        Label("Sort", systemImage: "arrow.left.arrow.right")
                    }
                }
        }
    }
}

We created a nice solution that allows the user to select not only the sort property but also the sort order. Moreover, the code is simple and maintainable, but it is not portable. Let's say we have a second model and a view presenting it. We would need to create almost all of the code again, just this time for the new model and its properties. We need to change that and stick to the DRY principle.

Prepare for More

Think a bit about what we can make generic in our case. The ToDoItemSortDescriptorProvider seems to be a good candidate, despite the fact that it needs to be different for each model. The sort menu we built operates directly on the enum for the ToDoItem provider, but it doesn't care about what information it provides, just how it provides it. That's an excellent candidate to abstract as a protocol that provider enums should conform to. We can even make it a bit more readable and add a helper property for our user-readable value, so we'll start with that.

import Foundation
import SwiftData

protocol SortDescriptorProvider: CaseIterable, Identifiable, RawRepresentable where RawValue == String, AllCases: RandomAccessCollection {
    associatedtype Model: PersistentModel

    func sortDescriptors(order: SortOrder) -> [SortDescriptor<Model>]
}

extension SortDescriptorProvider {
    var id: Self {
        self
    }

    var name: String {
        rawValue
    }
}

There is one limitation to this approach. Even though our protocol implements the RawRepresentable protocol with the associatedtype forced to String, when implementing concrete providers (considering we implement it as an enum), we need to repeat the raw value type that the provider supplies. Let's make our ToDoItemSortDescriptorProvider conform to our new protocol.

enum ToDoItemSortDescriptorProvider: String, SortDescriptorProvider {
    case name = "Name"
    case done = "Done"
    case dueDate = "Due Date"
    case creationDate = "Creation Date"

    func sortDescriptors(order: SortOrder) -> [SortDescriptor<ToDoItem>] {
            ...
    }
}

We could even remove some code here, which is always nice.

Now we need to make use of this new protocol and have the sort menu use it. For that, we need to extract it into another view. Doing that is also a good opportunity to clean up and refactor the code a bit.

struct SortMenu<Provider>: View where Provider: SortDescriptorProvider {
    @Binding
    private var sortProvider: Provider
    @Binding
    private var sortOrder: SortOrder

    init(sortProvider: Binding<Provider>, sortOrder: Binding<SortOrder>) {
        _sortProvider = sortProvider
        _sortOrder = sortOrder
    }

    var body: some View {
        Menu {
            ForEach(Provider.allCases) { provider in
                if provider == sortProvider {
                    currentProviderButton
                } else {
                    button(for: provider)
                }
            }
        } label: {
            Label("Sort", systemImage: "arrow.left.arrow.right")
        }
    }

    private var currentProviderButton: some View {
        Button {
            sortOrder = sortOrder == .forward ? .reverse : .forward
        } label: {
            Label(
                sortProvider.name,
                systemImage: sortOrder == .forward ? "arrow.up" : "arrow.down"
            )
        }
    }

    private func button(for provider: Provider) -> some View {
        Button {
            sortProvider = provider
            sortOrder = .forward
        } label: {
            Text(provider.name)
        }
    }
}

struct ContentView: View {
    ...
    var body: some View {
        ...
            .toolbar {
                SortMenu(sortProvider: $sortDescriptorProvider.animation(), sortOrder: $sortOrder.animation())
            }
    }
}

That's it; we are ready for more models and more views with a sort menu to come!

What's Next?

Right now, it might seem like we didn't save much code and even had to write more to achieve the same simple result. That's true in this particular case, but think about more realistic examples, such as models with relationships where the application presents a list of entities that links to another list of different entities. With this approach, for each such list, we need a provider enum (or struct, or class, depending on what you choose), two state variables, and one line with the SortMenu view.

One might also ask if this approach is suitable for other persistent data frameworks, and the answer is yes, it is! I chose SwiftData because I've been working with it the most lately and came up with this idea specifically for that framework, but nothing prevents you from using it for something else. All you need to do is change the SortDescriptor type used by SwiftData to some other type (e.g., SortComparator for Core Data), and you can basically use the same code.

The whole solution can be found on my GitHub, and each section of this article is a separate commit in the repository.