# 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.

```swift
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.

```swift
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.

```swift
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`.

```swift
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.

```swift
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.

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

Then add the `animation` modifier to the `List` view.

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

All that gives us a really simple searchable list of `Product`s.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1722172244353/ef5819e7-3512-4975-ae43-012c49df8c6a.gif align="center")

# 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.

```swift
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`.

```swift
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*.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1722172238623/2e47ad92-e447-466c-84a5-7eea8122adcd.gif align="center")

# 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`.

```swift
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 `Product`s' 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.

```swift
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.

```swift
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.

```swift
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.

```swift
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.

```swift
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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1722172203738/c322f077-5a87-4aae-aad6-b4a1747d6fcd.gif align="center")

# 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](https://github.com/ostojan/SmartSerchableList), and each chapter of this tutorial is a separate commit in the repository.
