Autocomplete for SwiftUI using async/await and actors
With Swift 5.5 released I want to offer a look how new concurrency model can be used to create autocomplete feature in SwiftUI.
Text autocomplete is a common feature that typically involves database lookup or networking. This operations must be asynchronous, not to block user input, and can include in-memory cache to speedup repeated lookups. This usecase is perfect to battle test new Swift concurrency model.
Let's say we have an app that can show information about a city. When user types city in a TextField
and we want to offer autocomplete suggestions.
This is our UI.
Here is SwiftUI code that hardcodes suggestions for a prototype.
struct ContentView: View {
private var suggestions = ["Amstelveen", "Amsterdam", "Amsterdam-Zuidoost", "Amstetten"]
@State var input: String = ""
var body: some View {
VStack {
TextField("", text: $input)
.textFieldStyle(.roundedBorder)
.padding()
}
List(suggestions, id: \.self) { suggestion in
ZStack {
Text(suggestion)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
}
}
Suggestions can come from a server or bundled with the app. For simplicity, in the example we store suggestions as a plain text (cities
file), where each city name separated with a newline.
...
Amstelveen
Amsterdam
Amsterdam-Zuidoost
Amstetten
...
To load the file in memory we use CitiesSource
protocol and CitiesFile
object that implements it. You may choose not to declare a protocol and use an object directly. But I find that having a protocol creates simple to understand abstraction, further useful for unit testing.
protocol CitiesSource {
func loadCities() -> [String]
}
struct CitiesFile: CitiesSource {
let location: URL
init(location: URL) {
self.location = location
}
/// Looks up for `cities` file in the main bundle
init?() {
guard let location = Bundle.main.url(forResource: "cities", withExtension: nil) else {
assertionFailure("cities file is not in the main bundle")
return nil
}
self.init(location: location)
}
func loadCities() -> [String] {
do {
let data = try Data(contentsOf: location)
let string = String(data: data, encoding: .utf8)
return string?.components(separatedBy: .newlines) ?? []
}
catch {
return []
}
}
}
Next we need to build a cache. In our example CitiesCache
keeps the complete list of cities in-memory. For a real app you should consider creating something smarter. We, instead, focus on concurrency. A good cache should be thread-safe. This is where new Swift concurrency model comes to life.
CitiesCache
is an actor
. Actor protects its own data, ensuring that only a single thread will access that data at a given time. Precisely what we need.
actor CitiesCache {
let source: CitiesSource
init(source: CitiesSource) {
self.source = source
}
var cities: [String] {
if let cities = cachedCities {
return cities
}
let cities = source.loadCities()
cachedCities = cities
return cities
}
private var cachedCities: [String]?
}
CitiesCache
stores the list of cities in cachedCities
, loaded lazily on first access to computed cities
property.
Cache lookup is a straight forward enumeration comparing prefixes. In the example we only do case-insensitive comparison. The real app may want more greedy algorithm.
extension CitiesCache {
func lookup(prefix: String) -> [String] {
let lowercasedPrefix = prefix.lowercased()
return cities.filter { $0.lowercased().hasPrefix(lowercasedPrefix) }
}
}
Notice a thing: so far there is not a line of synchronization code that we wrote. Actors allow only one task to access their state at a time. So we don't need to worry about.
Pieces are almost ready to connect. One small autocomplete feature to consider is a slight delay between user input and autocomplete routine, to limit number of calls. This is especially useful if autocomplete extensively uses I/O, like database lookup or sending network requests.
AutocompleteObject
object implements autocomplete and notifies SwiftUI using @Published var suggestions: [String]
property. To execute autocomplete asynchronously we use Task
, new in Swift Standard Library. A Task
can execute concurrent routines and supports cancellation.
You can also notice that AutocompleteObject
uses @MainActor
to always execute its code on the main thread.
Important that asyncronous calls, such as Task.sleep
to add delay, and using CitiesCache
actor, are marked with await
. What it does, is indicates that the routine must stop and wait for asynchronous subroutine (marked with async
keyword) to complete. You may previously used semaphores or asyncAndWait
in GCD to achieve similar behaviour. The difference is that await
won't block calling thread and simply return execution when async
subroutine completes. Even that AutocompleteObject
always uses the main thread, await Task.sleep
won't block it.
@MainActor
final class AutocompleteObject: ObservableObject {
let delay: TimeInterval = 0.3
@Published var suggestions: [String] = []
init() {
}
private let citiesCache = CitiesCache(source: CitiesFile()!)
private var task: Task<Void, Never>?
func autocomplete(_ text: String) {
guard !text.isEmpty else {
suggestions = []
task?.cancel()
return
}
task?.cancel()
task = Task {
await Task.sleep(UInt64(delay * 1_000_000_000.0))
guard !Task.isCancelled else {
return
}
let newSuggestions = await citiesCache.lookup(prefix: text)
if isSuggestion(in: suggestions, equalTo: text) {
// Do not offer only one suggestion same as the input
suggestions = []
} else {
suggestions = newSuggestions
}
}
}
private func isSuggestion(in suggestions: [String], equalTo text: String) -> Bool {
guard let suggestion = suggestions.first, suggestions.count == 1 else {
return false
}
return suggestion.lowercased() == text.lowercased()
}
}
Inside the view we create AutocompleteObject
and observe its suggestions
property. When input
changes we call autocomplete
function and the property will update.
struct ContentView: View {
@ObservedObject private var autocomplete = AutocompleteObject()
@State var input: String = ""
var body: some View {
VStack {
TextField("", text: $input)
.textFieldStyle(.roundedBorder)
.padding()
.onChange(of: input) { newValue in
autocomplete.autocomplete(input)
}
}
List(autocomplete.suggestions, id: \.self) { suggestion in
ZStack {
Text(suggestion)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.onTapGesture {
input = suggestion
}
}
}
}
I hope you find this example useful.