Smart Searchable List in SwiftUI

While working on some applications, I found myself a few times in the position where I needed to give the user a choice from data that already existed in the application. However, I didn't want to constrain the choice only to already existing data but to give a simple mechanism to create a new entry if the preferable one didn't exist. It sounds really simple, but it might be much more complicated than one can think of! Let's dive in and see what choices we have and what problems we can encounter.

Oh, just before we begin, let me point out that we'll be using SwiftUI and SwiftData.

Let's Start with Data

To create a searchable list, we need some data we can search through. For this example, a single model class is more than enough.

import SwiftData

@Model
final class Product {
    var name: String = ""
    var checked: Bool = false

    init(name: String, checked: Bool = false) {
        self.name = name
        self.checked = checked
    }
}

I prefer to work with Previews, so let's create a simple helper and add some data we can work with.

import SwiftData

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

    private(set) var modelContainer: ModelContainer

    private init() {
        do {
            let config = ModelConfiguration(isStoredInMemoryOnly: true)
            modelContainer = try ModelContainer(for: Product.self, configurations: config)
            for productIdx in 1 ... 10 {
                modelContainer.mainContext.insert(Product(name: "Product \(productIdx)"))
            }
        } catch {
            fatalError("Couldn't create model container for Previews")
        }
    }
}

That's all we need to start working!

Basic View

Before we add smart functionality to our search, we need to build the most simple view with a basic search. To do that, we need two views so we can make use of SwiftData's @Query, and #Predicate functionality.

struct ProductsListing: View {
    @Query
    private var filteredProducts: [Product]

    init(withName searchText: String) {
        let sanitizedSearchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)

        _filteredProducts = Query(
            filter: #Predicate {
                sanitizedSearchText.isEmpty || $0.name.localizedStandardContains(sanitizedSearchText)
            },
            sort: [SortDescriptor(\.name)]
        )
    }

    var body: some View {
        List {
            ForEach(filteredProducts) {
                Text($0.name)
            }
        }
    }
}

struct ContentView: View {
    @State
    private var searchText = ""

    var body: some View {
        NavigationStack {
            ProductsListing(withName: searchText)
                .searchable(text: $searchText)
        }
    }
}

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

Now we'll make our list clickable, so the user can change the state of the Product object. We should go ahead and create a new view that will be used inside ForEach.

struct ProductTile: View {
    private let product: Product

    init(_ product: Product) {
        self.product = product
    }

    var body: some View {
        Button {
            product.checked.toggle()
        } label: {
            Label(product.name, systemImage: product.checked ? "checkmark.circle" : "circle")
        }
    }
}

Don't forget to actually use is in our view.

ForEach(filteredProducts) {
    ProductTile($0)
}

The last thing we have to do is add animations. Normally, we'd simply add animation: .default to the Query constructor and call it a day. However, that would solve only half of our problems. We need to explicitly add the animation modifier to our view, as we dynamically create the Query based on the search text passed by the user.

Update our Query constructor call to animate on changes.

_filteredProducts = Query(
    filter: #Predicate {
                sanitizedSearchText.isEmpty || $0.name.localizedStandardContains(sanitizedSearchText)
    },
    sort: [SortDescriptor(\.name)],
    animation: .default
)

Then add the animation modifier to the List view.

 List {
    ForEach(filteredProducts) {
        ProductTile($0)
    }
}
.animation(.default, value: filteredProducts)

All that gives us a really simple searchable list of Products.

Give Users More Choices

To make our list smart, we should add an additional choice at the top of the list when the Product the user is looking for doesn't exist yet. Begin with some helpers and a new section containing a nonexistent product at the top of the list. To do so, we need to modify the ProductsListing view.

struct ProductsListing: View {
    private let nonexistentProduct: Product
    @Query
    private var filteredProducts: [Product]

    private var showNewProduct: Bool {
        !nonexistentProduct.name.isEmpty && !filteredProducts.contains(where: { $0.name.localizedCaseInsensitiveCompare(nonexistentProduct.name) == .orderedSame })
    }

    init(withName searchText: String) {
        let sanitizedSearchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
        nonexistentProduct = Product(name: sanitizedSearchText)
        _filteredProducts = Query(
            filter: #Predicate {
                sanitizedSearchText.isEmpty || $0.name.localizedStandardContains(sanitizedSearchText)
            },
            sort: [SortDescriptor(\.name)],
            animation: .default
        )
    }

    var body: some View {
        List {
            if showNewProduct {
                Section {
                    ProductTile(nonexistentProduct)
                }
            }
            Section {
                ForEach(filteredProducts) {
                    ProductTile($0)
                }
            }
        }
        .animation(.default, value: filteredProducts)
        .animation(.default, value: nonexistentProduct)
    }
}

Notice we needed to add yet another animation modifier; that's a must since we recreate ProductsListing each time the user passes a new value into the search text field.

It seems like we have the basic functionality ready, but we are not done yet. Although you can use the new ProductTile for nonexistent Product in the same way as for the other ones, the changes the user made vanish as soon as the search text is changed. That's happening because, although the Product is being created in the ProductListing constructor, we don't save it to the ModelContext. Fortunately, it's a simple fix: we just need to add a Product to the context when it doesn't exist in one in the Button action inside ProductTile.

Button {
    product.checked.toggle()
    if product.modelContext == nil {
        modelContext.insert(product)
    }
}

That's everything we need to do to have a simple searchable list with an additional choice on top, so we can call it smart.

Final Touches

The current solution works and looks acceptable, yet it still requires some polish to be ready for use, in my opinion.

First, we'll merge the section with items the user is looking for and the rest of the results. That's really straightforward - just add a new helper property and update the body.

private var productsToShow: [Product] {
    var products = filteredProducts
    if showNewProduct {
        products.insert(nonexistentProduct, at: 0)
    }
    return products
}

var body: some View {
    List {
        ForEach(productsToShow) {
            ProductTile($0)
        }
    }
    .animation(.default, value: filteredProducts)
    .animation(.default, value: nonexistentProduct)
}

Thanks to that, we have a consistent UI for all items on our list.

Now let's face a potential problem we might encounter. When the user is looking for an item with a name that's a part of an already existing item (e.g., the user types ground and there is background already on the list), it might happen that when the user selects the new item, it'll be saved, causing the Query to refresh with new data, and the just selected Product will be moved from the top of the list to some other place (due to Products' sorting). Ideally, we'd handle this by passing an additional custom SortDescriptor to the Query constructor, but that's not possible. Fortunately, all we need to do is handle such a case inside the productsToShow property.

private var productsToShow: [Product] {
    var products = filteredProducts
    if showNewProduct {
        products.insert(nonexistentProduct, at: 0)
    } else if let productIndex = products.firstIndex(where: { $0.name.localizedCaseInsensitiveCompare(nonexistentProduct.name) == .orderedSame }) {
        products.move(fromOffsets: IndexSet(integer: productIndex), toOffset: 0)
    }
    return products
}

The last thing left to do is clean up our code a bit. Having all products in one property, we can remove multiple animation modifiers for the list and have just one.

List {
    ForEach(productsToShow) {
        ProductTile($0)
    }
}
.animation(.default, value: productsToShow)

We access the string with the name the user is looking for in a few places, but we keep it inside the Product instance, so we can make it a property.

private var searchText: String {
    nonexistentProduct.name
}

private var showNewProduct: Bool {
    !searchText.isEmpty && !filteredProducts.contains(where: { $0.name.localizedCaseInsensitiveCompare(searchText) == .orderedSame })
}

private var productsToShow: [Product] {
    var products = filteredProducts
    if showNewProduct {
        products.insert(nonexistentProduct, at: 0)
    } else if let productIndex = products.firstIndex(where: { $0.name.localizedCaseInsensitiveCompare(searchText) == .orderedSame }) {
        products.move(fromOffsets: IndexSet(integer: productIndex), toOffset: 0)
    }
    return products
}

The next improvement we can make is to move checking for the existence of an item inside Query results to a property.

private var indexOfSearchProduct: Int? {
    filteredProducts.firstIndex(where: { $0.name.localizedCaseInsensitiveCompare(searchText) == .orderedSame })
}

private var showNewProduct: Bool {
    !searchText.isEmpty && indexOfSearchProduct == nil
}

private var productsToShow: [Product] {
    var products = filteredProducts
    if showNewProduct {
        products.insert(nonexistentProduct, at: 0)
    } else if let indexOfSearchProduct {
        products.move(fromOffsets: IndexSet(integer: indexOfSearchProduct), toOffset: 0)
    }
    return products
}

Lastly, we can simplify our if statements a bit by removing some unnecessary checks.

private var indexOfSearchProduct: Int? {
    filteredProducts.firstIndex(where: { $0.name.localizedCaseInsensitiveCompare(searchText) == .orderedSame })
}

private var showNewProduct: Bool {
    !searchText.isEmpty
}

private var productsToShow: [Product] {
    var products = filteredProducts
    if let indexOfSearchProduct {
        products.move(fromOffsets: IndexSet(integer: indexOfSearchProduct), toOffset: 0)
    } else if showNewProduct {
        products.insert(nonexistentProduct, at: 0)
    }
    return products
}

With not too many lines of code, we created a nicely looking smart searchable list. Let's take a look at how the final view looks.

Wrapping Up

Creating a smart searchable list in SwiftUI with SwiftData is more than just simply displaying items. Even the current solution isn't ideal; for example, not all animations look as smooth as they should. That's okay - we can always make them better along the way. Nonetheless, the current code is MVP ready and can be easily used in many sorts of applications (e.g., for selecting tags).

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

0
Subscribe to my newsletter

Read articles from Aleksander Stojanowski directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Aleksander Stojanowski
Aleksander Stojanowski

Hi, I'm Olek - short for Aleksander if you're outside of Poland. I'm a software engineer with over 9 years of professional experience, plus many more as an amateur developer. I'm based in Poland, where I currently work as a graphics driver software engineer by day. By night, I'm a passionate Apple platforms developer, constantly exploring new ways to create and innovate.