SwiftUI has built-in support for Swift Concurrency tasks and allows us to link tasks to the lifetime of views. We’ve briefly discussed this earlier in the introduction lesson to tasks. Yet, there’s more to discover to benefit from SwiftUI’s integration with Swift Concurrency fully.

Building a search functionality using SwiftUI and Concurrency

In one of the earlier lessons, we’ve discussed the differences between Task.sleep() and Task.yield(). One of those code examples covered search functionality:

func search(_ query: String) {
    /// Cancel any previous searches that might be 'sleeping'.
    currentSearchTask?.cancel()

    currentSearchTask = Task {
        do {
            /// Sleep for 0.5 seconds to wait for a pause in typing before executing the search.
            try await Task.sleep(for: .milliseconds(500))

            print("Starting to search!")

            /// A simplified static result and search implementation.
            searchResults = Self.articleTitlesDatabase
                .filter { $0.lowercased().contains(query.lowercased()) }
        } catch {
            print("Search was cancelled!")
        }
    }
}

With the sleep in place, we allow typing to continue, and we only start searching if typing is paused.

We can wrap this method inside an observable article searcher:

@MainActor
@Observable
final class ArticleSearcher {

    private static let articleTitlesDatabase = [
        "Article one",
        "Article two",
        "Article three",
    ]

    var searchResults: [String] = ArticleSearcher.articleTitlesDatabase

    private var currentSearchTask: Task<Void, Never>?

    func search(_ query: String) {
        /// Cancel any previous searches that might be 'sleeping'.
        currentSearchTask?.cancel()

        currentSearchTask = Task {
            do {
                /// Sleep for 0.5 seconds to wait for a pause in typing before executing the search.
                try await Task.sleep(for: .milliseconds(500))

                print("Starting to search!")

                /// A simplified static result and search implementation.
                searchResults = Self.articleTitlesDatabase
                    .filter { $0.lowercased().contains(query.lowercased()) }
            } catch {
                print("Search was cancelled!")
            }
        }
    }
}

And use it inside a SwiftUI view as follows:

struct SearchArticleView: View {
    @State private var searchQuery = ""
    @State private var articleSearcher = ArticleSearcher()

    var body: some View {
        NavigationStack {
            List {
                ForEach(articleSearcher.searchResults, id: \.self) { title in
                    Text(title)
                }
            }
        }
        .searchable(text: $searchQuery)
        .onChange(of: searchQuery) { oldValue, newValue in
            articleSearcher.search(newValue)
        }
    }
}

While this all works fine, we can benefit from SwiftUI’s task() modifier to simplify our code.

Using the .task() modifier in SwiftUI

The task() modifier adds a task to perform before a view appears. In our case, we only need to execute the task when the search query changes, so there’s no need to run it before the view appears. Yet, we can still benefit from this modifier.

The modifier has the benefit of connecting the task to the lifetime of the view. If the view disappears, any connected tasks will be canceled. This is great for canceling any search requests that are running while we’re navigating away.

The first thing we need to do is to remove manual cancelation responsibility from within the ArticleSearcher:

func search(_ query: String) async {
    do {
        /// Sleep for 0.5 seconds to wait for a pause in typing before executing the search.
        try await Task.sleep(for: .milliseconds(500))

        print("Starting to search!")

        /// A simplified static result and search implementation.
        searchResults = Self.articleTitlesDatabase
            .filter { $0.lowercased().contains(query.lowercased()) }
    } catch {
        print("Search was cancelled!")
    }
}

The method is now async and doesn’t store a task in a property anymore. We can now update our SwiftUI view and make use of the task() modifier:

struct SearchArticleTaskModifierView: View {
    @State private var searchQuery = ""
    @State private var articleSearcher = ArticleSearcher()

    var body: some View {
        NavigationStack {
            List {
                ForEach(articleSearcher.searchResults, id: \.self) { title in
                    Text(title)
                }
            }
        }
        .searchable(text: $searchQuery)
        .task {
            await articleSearcher.search(searchQuery)
        }
    }
}

After making this change, you’ll notice two things:

We can solve the latter by restoring the original search results whenever the search query is empty:

func search(_ query: String) async {
    do {
        guard !query.isEmpty else {
            searchResults = Self.articleTitlesDatabase
            return
        }

        /// Sleep for 0.5 seconds to wait for a pause in typing before executing the search.
        try await Task.sleep(for: .milliseconds(500))

        print("Starting to search!")

        /// A simplified static result and search implementation.
        searchResults = Self.articleTitlesDatabase
            .filter { $0.lowercased().contains(query.lowercased()) }
    } catch {
        print("Search was cancelled!")
    }
}

And we can solve the other point by making use of the id property.

Re-running tasks using identifiers

The task() modifier in SwiftUI only runs once per view appearance. In our example, we need it to run again whenever the search query changes. We can do this by making use of the id property:

.task(id: searchQuery) {
    await articleSearcher.search(searchQuery)
}

This makes the task perform before the view appears or when a specified value changes. In this case, it will also run when searchQuery changes. In that case, the previous task will automatically be canceled.

Altogether, we’ve now implemented the same searching functionality, but we connected the lifetime of a search task to the lifecycle of the SearchArticleView:

struct SearchArticleTaskModifierView: View {
    @State private var searchQuery = ""
    @State private var articleSearcher = ArticleSearcher()

    var body: some View {
        NavigationStack {
            List {
                ForEach(articleSearcher.searchResults, id: \.self) { title in
                    Text(title)
                }
            }
        }
        .searchable(text: $searchQuery)
        .task(id: searchQuery) {
            await articleSearcher.search(searchQuery)
        }
    }
}

Configuring priorities

Finally, you can decide to set the priority of a task inside the SwiftUI modifier:

.task(id: searchQuery, priority: .userInitiated) {
    await articleSearcher.search(searchQuery)
}

As we’ve learned, Swift Concurrency uses a cooperative thread pool, so it might make sense to set the priority to userInitiated for this search operation. We want to perform user-interfacing work as soon as possible to let the user continue their journey.

However, you wouldn’t be surprised to hear that SwiftUI uses the userInitiated priority as its default. Therefore, the above example is the same as:

.task(id: searchQuery) {
    await articleSearcher.search(searchQuery)
}

Therefore, you should mostly use this option to lower the priority in case work does not have to perform right away:

.task(priority: .low) {
    await Tracker.trackViewAppearance()
}

Summary

SwiftUI provides great integration with Swift Concurrency. By connecting the lifetime of a view to a task, we simplify our concurrency logic and reduce chances of mistakes. Our tasks will be canceled when the view disappears or when a new task appears based on a new connected identifier.