Designing Multi-Model Search with SwiftUI and SwiftData

Designing Multi-Model Search with SwiftUI and SwiftData

Designing Multi-Model Search with SwiftUI and SwiftData

Adding a fast, powerful, and flexible search feature to Informed

Nov 30, 2025

When I set out to add search to Informed, a civic and political data app I'm building for iOS, I had a fast, powerful, and flexible feature in mind that leveraged the built-in benefits of SwiftUI's searchable() modifier. I was inspired by the use of search filters and scopes built into network-logging app Pulse, described here, to create something approachable and useful for new and power users alike.

In Informed, search allows users to filter across seven categories: civics guides, congressional districts, states, upcoming elections, candidates, committees, and congress members. The goal:

  • search tokens and search suggestions would allow for faster search input

  • users would be able to sort and filter search results within each category of data

  • local and remote search would allow access to data in the app (stored as SwiftData models) and in the FEC database

    • FEC results that the user navigates to would be cached in SwiftData

  • search history would allow for quicker navigation to recent items

This was a fun feature to build, and I think it's one of my favorites so far. I won't go over all the code for it, but I do want to highlight five key aspects. If your app leverages SwiftData to store multiple types of objects, and you want to offer a search feature, I hope this helps you get started.

1. Leveraging modern Observation and Search APIs

Most of the properties and methods associated with the search feature are part of an Observable class, SearchStore, that I then put into the environment to share this state across views. The Observation framework, introduced in iOS 17, makes it easy to update the UI based on changing state while keeping most of the logic for this feature outside of my views. SearchStore includes properties for the selected search tokens, search text, search mode (local or remote), and the arrays used to store search results as well as methods for filtering, network calls, and more.

@MainActor @Observable
final class SearchStore {
    var currentTokens = [SearchToken]()
    var searchText: String = ""
    var showFullSearchHistory: Bool = false
	var searchMode: SearchMode = .local
	
	var remoteSearchResults = [SearchResult]()
	var localSearchResults = [SearchResult]()
	var searchableData = [SearchResult]()
	
	var prompt: String {
		switch searchMode {
		case .local: "Search"
		case .remote: "Search by name or FEC ID"
		}
	}
	
	var suggestedTokens: [SearchToken] {
		switch searchMode {
		case .local:
			return searchText.count < 3 ? SearchToken.casesForLocalSearch : []
		case .remote:
			return searchText.count < 3 ? SearchToken.casesForRemoteSearch : []
		}
	}
	
	func updateLocalSearch() -> [SearchResult] {
		return searchableData.filter { filterByQueryAndToken(for: $0) }
	}
  ...
}

Updated for iOS 26, the search feature also uses Tab(role: .search) to adopt new system behaviors in the bottom toolbar and tab bar. And like the search feature in Apple Music, my implementation also includes a segmented picker in the toolbar (in the .principal placement) to switch between searching locally or remotely (to avoid hiding the toolbar/navigation bar when the search field is activated, use .searchPresentationToolbarBehavior(.avoidHidingContent)).

2. Fetching SwiftData objects for filtering

The first step to searching through SwiftData objects is to fetch all the objects and store them in a single array called searchableData. The number of objects I'm fetching isn't a lot (~2,000), but initially, I wasn't sure if there would be a performance cost to doing this on the @MainActor or if something like a @ModelActor would be better suited. Obviously, those fetching hundreds of thousands or millions of items may need to consider other options, but in my case, just using modelContext.fetch() for my model types and then transforming each one into a uniform type, called SearchResult, is fast and stable.

func importData(modelContext: ModelContext) -> [SearchResult] {
	var results: [SearchResult] = []
		
	do {
		let guides = try modelContext.fetch(FetchDescriptor<Guide>())
		let districts = try modelContext.fetch(FetchDescriptor<CongressionalDistrict>())
		let members = try modelContext.fetch(FetchDescriptor<CongressMember>())
		let elections = try modelContext.fetch(FetchDescriptor<Election>())
		let states = try modelContext.fetch(FetchDescriptor<USState>())
		let candidates = try modelContext.fetch(FetchDescriptor<Candidate>())
		let committees = try modelContext.fetch(FetchDescriptor<Committee>())

		guides.forEach { guide in
			let result = SearchResult.guide(guide)
			results.append(result)
		}
			
		districts.forEach { district in
			let result = SearchResult.congressionalDistrict(district)
			results.append(result)
		}
			
		members.forEach { member in
			let result = SearchResult.congressMember(member)
			results.append(result)
		}
			
		elections.forEach { election in
			let result = SearchResult.election(election)
			results.append(result)
		}
          ...
    }
}

The process for fetching the objects from the context and mapping them to a SearchResult is pretty straightforward. Once fetched from the modelContext, I iterated through the objects and assigned each one to the appropriate SearchResult case, passing in the object as the associated value. As we see below, SearchResult is an enum with associated types that I use to store an underlying SwiftData object.

enum SearchResult: Hashable, Identifiable {    
    var id: Self { self }
    
    case guide(Guide)
    case congressionalDistrict(CongressionalDistrict)
    case congressMember(CongressMember)
    case election(Election)
    case state(USState)
    case candidate(Candidate)
    case committee(Committee)
	
	case candidatesFEC(SearchByCandidate.SearchResult)
	case committeesFEC(SearchByCommittee.SearchResult)
	
    var tag: String {
        switch self {
        case .guide(_): return "Guides"
        case .congressionalDistrict(_): return "Districts"
        case .congressMember(_): return "Congress Members"
        case .election(_): return "Elections"
        case .state(_): return "States"
        case .candidate(_): return "Candidates"
        case .committee(_): return "Committees"
		case .candidatesFEC(_): return "FEC Candidates"
		case .committeesFEC(_): return "FEC Committees"
        }
    }
}

3. Scalable filtering by search token and search text

When the user types into the search field, a filtering function is run across those ~2,000 SwiftData items. The goal is for this filtering to be essentially instant with no noticeable lag or hangs, and I didn't want it to be a headache to code for the seven different model types, so it had to be somewhat scalable.

Once there are 3 or more characters in the search field—or if the user adds a search token corresponding to one of the SwiftData types—the function switches over each SearchResult item in searchableData and handles each case, checking if the associated value should be included in the filtered array. For civics guides, for example, only the guide's topic property (i.e., "Voting," "Legislative Branch," "U.S. Constitution", etc.) is compared against the search text. The filtering function also utilizes localizedCaseInsensitiveContains(_:) for case-insensitive and locale-aware searches and trimmingCharacters(in:) to ensure that spaces before or after the text are removed. (trimWhitespace() in the snippet below is just a convenience function for trimmingCharacters(in: .whitespaces).)

switch item {
    case .guide(let guide):
        if currentTokens.isEmpty && searchText.count >= 3 {
  		    if guide.topic.localizedCaseInsensitiveContains(searchText.trimWhitespace()) {
				return true
			}
        }

        for token in currentTokens {
            if token.rawValue == item.tag {
                if !searchText.isEmpty {
					if guide.topic.localizedCaseInsensitiveContains(searchText.trimWhitespace()) {
                        return true
                    } else {
                        return false
                    }
                }
                return true
            }
        }
        return false
    ...
  }

Lastly, I tried to allow for flexibility in terms of how a user might search for things. For example, when searching for a candidate, users can obviously search by name or FEC ID, but if they use the "candidates" search token, they can also search by political party to, say, quickly see all the Democratic candidates running for office this election cycle. Or searching for a state like "Arizona" yields results across matching congressional districts, congress members, elections, committees, and of course the Arizona state object itself. This kind of behavior makes it really easy to quickly find data associated with a particular state, and in fact, the goal is for search to be the fastest way to navigate Informed for most kinds of data.

Of course, there's room for improvement, but this sets up a good foundation for most users and makes it easy to add additional functionality based on specific feedback from power users.

4. Transforming search results back into SwiftData objects

We've covered filtering through an array of SearchResult elements, but then what? The properties I want to show in each row of the search results—the name or party of a candidate or the office and amount of money raised for a campaign committee—are part of the SwiftData object itself, not SearchResult. And when navigating to a detail view, I need to pass the SwiftData object directly, not a SearchResult.

Let's take a look at how we can convert SearchResult back into a SwiftData type, such as CongressMember, and here I would say that the syntax isn't very legible, but it is really compact (pun intended). Inside a computed property, we can extract the associated value using compactMap and if case let. The code below iterates through whatever search results match the entered search text with compactMap, which will discard any nil results from the given transformation with each element of the sequence.

case let is a shortcut that lets us bind the subsequent associated values with variable names, and if case let allows us to return the associated value if it meets our given condition (e.g., if the congressMember case matches the given element from search.localSearchResults). With that, we can iterate through congressMembers in a ForEach or List, like we would for any other array.

var congressMembers: [CongressMember] {
	return search.localSearchResults.compactMap {
		if case let .congressMember(member) = $0 { return member }
		else { return nil }
	}
}

5. Remote search with task(id:) and Task.sleep

The task() modifier is a convenient way to create an asynchronous context to run async functions as soon as a view appears and automatically cancel those tasks when the view disappears.

Conveniently, task() also has the ability to track an identifier and restart its task when the identifier changes, which is perfect for our use case if we use the search text as the id; whenever the user types a character into the search field, a task is fired, and if the user keeps typing before the first task has finished, it cancels it and starts a new one.

.task(id: search.searchText) {
	if search.searchMode == .remote {
				
		appState = .loading
		defer { appState = .idle }
				
			if search.searchText.count >= 3 {
				do {
					search.remoteSearchResults = try await search.updateRemoteSearch() ?? []
				} catch {
					print(error)
				}
			}
	}
}

Even then, the network call to the FEC API is pretty fast, and we want to make sure we're not starting a network call unless it seems like the user has finished typing. This is called debouncing, and my implementation of it is dead simple.

func updateRemoteSearch() async throws -> [SearchResult]? {
	// for debouncing
	try await Task.sleep(for: .seconds(0.3))
    ...
}

Other examples of debouncing may rely on Combine, but I wanted to find a solution that uses modern Swift Concurrency. In his blog post on the subject, Majid writes:

Swift concurrency doesn’t provide a particular function for debouncing tasks, but we can easily implement it using the sleep function on the Task type. All we need to do is to call the sleep function by providing some amount of time and then run our heavy job. Whenever a task is cancelled while sleeping, it throws an error and interrupts the execution without running the heavy job. This way, you can reduce the amount of work you run to display the final results.


Summary

  • Take advantage of Observation and recent enhancements to searchable.

  • You can fetch a smaller number of SwiftData objects directly on the Main Actor without performance costs.

  • Filter by token and search text using localizedCaseInsensitiveContains(_:) and trimmingCharacters(in:).

  • Use compactMap and if case let syntax to extract associated values.

  • For network calls, use task(id:) using the search text as the identifier and Task.sleep for debouncing.