Autocomplete for a text field in SwiftUI using async/await

Overview

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.

[String] { do { let data = try Data(contentsOf: location) let string = String(data: data, encoding: .utf8) return string?.components(separatedBy: .newlines) ?? [] } catch { return [] } } } ">
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.

You might also like...
SwiftUI TextEdit View - A proof-of-concept text edit component in SwiftUI & CoreText.
SwiftUI TextEdit View - A proof-of-concept text edit component in SwiftUI & CoreText.

A proof-of-concept text edit component in SwiftUI & CoreText. No UIKit, No AppKit, no UITextView/NSTextView/UITextField involved.

Render Markdown text in SwiftUI
Render Markdown text in SwiftUI

MarkdownUI MarkdownUI is a library for rendering Markdown in SwiftUI, fully compliant with the CommonMark Spec. Supported Platforms You can use the Ma

ExpandableText 😎 (SwiftUI) Expand the text with the
ExpandableText 😎 (SwiftUI) Expand the text with the "more" button

ExpandableText 😎 (SwiftUI) Expand the text with the "more" button Get Started import SwiftUI import ExpandableText struct ExpandableText_Test: View

Transition from any SwiftUI Text view into an inline navigation bar title when the view is scrolled off-screen, as seen in Apple's TV & TestFlight iOS apps.
Transition from any SwiftUI Text view into an inline navigation bar title when the view is scrolled off-screen, as seen in Apple's TV & TestFlight iOS apps.

SwiftUI Matched Inline Title Transition from any SwiftUI Text view into an inline navigation bar title when the view is scrolled off-screen, as seen i

Render Markdown text in SwiftUI
Render Markdown text in SwiftUI

Render Markdown text in SwiftUI. It is a preview based on the Marked implementation

RichTextKit is a Swift-based library for working with rich text in UIKit, AppKit and SwiftUI.
RichTextKit is a Swift-based library for working with rich text in UIKit, AppKit and SwiftUI.

About RichTextKit RichTextKit is a Swift-based library that lets you work with rich text in UIKit, AppKit and SwiftUI. RichTextKit is under developmen

Handles some of the base configuration to make creating a UIAlertController with text entry even easier. Plus validation!
Handles some of the base configuration to make creating a UIAlertController with text entry even easier. Plus validation!

🍅 FancyTextEntryController A simpler/easier API for adding text fields to UIAlertControllers. Not a custom view, just uses good ol' UIAlertController

Changes the color of the label text when the button is pressed and also prints hello to the console
Changes the color of the label text when the button is pressed and also prints hello to the console

MY FIRST APP App Description This app changes the color of the label text when the button is pressed and also prints "hello" to the console. App Walk-

ARAutocompleteTextView is a subclass of UITextView that automatically displays text suggestions in real-time
ARAutocompleteTextView is a subclass of UITextView that automatically displays text suggestions in real-time

ARAutocompleteTextView is a subclass of UITextView that automatically displays text suggestions in real-time. This is perfect for automatically suggesting the domain as a user types an email address, #hashtag or @alexruperez.

Owner
Dmytro Anokhin
iOS developer. https://dmytro-anokhin.medium.com/
Dmytro Anokhin
Focus text field in SwiftUI dynamically and progress through form using iOS keyboard.

Focuser Focuser allows to focus SwiftUI text fields dynamically and implements ability move go through the form using Keyboard for iOS 13 and iOS 14.

Art Technologies 118 Dec 25, 2022
A SwiftUI TextField with a prompt (or placeholder) that floats above the text field when active or not empty. Requires iOS 15.

FloatingPromptTextField A prompt is the label in a text field that informs the user about the kind of content the text field expects. In a default Tex

Emilio Peláez 43 Nov 3, 2022
Currency text field formatter available for UIKit and SwiftUI 💶✏️

CurrencyText provides lightweight libraries for formating text field text as currency, available for both UIKit and SwiftUI. Its main core, the Curren

Felipe Lefèvre Marino 183 Dec 15, 2022
AEOTPTextField - A beautiful iOS OTP Text Field library, written in Swift with full access customization in UI.

AEOTPTextField - A beautiful iOS OTP Text Field library, written in Swift with full access customization in UI.

Abdelrhman Kamal 79 Jan 3, 2023
A beautiful and flexible text field control implementation of "Float Label Pattern". Written in Swift.

SkyFloatingLabelTextField SkyFloatingLabelTextField is a beautiful, flexible and customizable implementation of the space saving "Float Label Pattern"

Skyscanner 4k Jan 1, 2023
Text entry controls which contain a built-in title/label so that you don't have to add a separate title for each field.

FloatLabelFields Overview Installation Via Interface Builder Via Code Credits Additional References Questions Overview FloatLabelFields is the Swift i

Fahim Farook 1.2k Jan 4, 2023
UITextfield subclass with autocomplete menu. For iOS.

MLPAutoCompleteTextField "We believe that every tap a user makes drains a tiny bit of their energy and patience. Typing is one of the biggest expendit

Eddy Borja 1.2k Nov 11, 2022
A simple and easily customizable InputAccessoryView for making powerful input bars with autocomplete and attachments

InputBarAccessoryView Features Autocomplete text with @mention, #hashtag or any other prefix A self-sizing UITextView with an optional fixed height (c

Nathan Tannar 968 Jan 2, 2023
Floating-textfield-swiftui - Floating textfield swiftui: Floating field with multiple icons

floating_textfield-swiftui Hey, Guys welcome to this tutorial. In this complete

Patrick 0 Jan 2, 2022
Easy-to-use token field that is used in the Venmo app.

VENTokenField VENTokenField is the recipients token field that is used in the Venmo compose screen. Installation The easiest way to get started is to

Venmo 797 Dec 6, 2022