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 Product
s.
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 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.
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.