Improve your app's UX with SwiftUI's task view modifier
Mastering the art of the pause
I’m currently working on an app that helps me collect articles, read them later - and eventually curate them for my newsletter. (Oh, and of course there are some AI features that I will write about later).
When the user navigates into an article, I want the app to mark the article as read (or rather “seen”). But not immediately - the user might have accidentally navigated into the article, or after reading the first sentence they realise they were looking for something else. Instead, I want to mark the article as read / seen after short delay of 5 seconds. That should be enough to consider the user’s interaction as intentional.
Let’s take a look at how to implement this using SwiftUI’s .task
view modifier.
What is the task modifier?
The .task
view modifier performs “an asynchronous task with a lifetime that matches that of the modified view” (source). In short, the task starts as soon as the view appears, and SwiftUI will cancel the task if it doesn’t complete before the view disappears.
By default, the priority of this task is userInitiated
, meaning the user directly requested the operation, and probably happy to wait for a short amount of time for the result.
This is useful when you want to kick off a long-running operation (such as downloading an image), and want to cancel that operation when the user leaves the view before the task has completed (in most cases, it doesn’t make a lot of sense to continue downloading an image that the user isn’t going to look at).
The following code snippet shows how to use the task modifier to start a (potentially long running) operation, and cancel it when the view disappears using the .task
view modifier:
Guarding code with a task timer
To understand how we can use this behaviour to our advantage, let’s turn our attention to Task.sleep(for:)
and friends.
Did you ever wonder why all of the sleep
methods (except for Task.sleep(_ duration:)
) are marked as throws
? I mean, how can a timer possibly throw - if the clock stops working?
It turns out this is actually pretty smart, as we will see in a minute. Consider the following code snippet:
By throwing, the sleeping task jumps straight into the catch clause, essentially forgoing the execution of the code directly after the sleep call.
Using this technique, we can make sure that any code after Task.sleep(for:)
isn’t executed if the view disappears before the specified time has run out. Exactly what we want.
A delayed task modifier
Now that we have a solution, let’s make it reusable. After all, the code is a bit verbose.
To make this reusable, we need to override the .task
view modifer. As a first step, we need to implement a ViewModifier
:
To make using this view modifier easier, let’s provide an extension on View
:
With this in place, the call site can be simplified to the following, which is a lot easier to understand (and much less verbose):
Further reading
Pol recently published an article that shows how to migrate from Combine to AsyncStream:
How to listen for property changes in an @Observable class using AsyncStreams. He discusses how implement debouncing for a text input field. While this might sound very similar to the solution I showed in this post, debouncing doesn’t cancel the task, but instead restarts it. This is a subtle difference.
Majid wrote about The power of task view modifier in SwiftUI a while ago, and shows how to build a version of the .task
view modifier that supports debouncing.
Conclusion
One of the key aspects of Combine (and other functional approaches such as RxSwift) is that is provides a DSL for common operations such as debouncing or delaying.
However, as you saw in this blog post, we can build equivalent solutions based on Swift’s concurrency model and leverging SwiftUI’s building blocks such as the .task
view modifier.
SwiftUI Hero Animations with NavigationTransition
Replicating the App Store Hero Animation
Styling SwiftUI Views
How does view styling work?
Previewing Stateful SwiftUI Views
Interactive Previews for your SwiftUI views
Asynchronous programming with SwiftUI and Combine
The Future of Combine and async/await
Building a Custom Combine Operator for Exponential Backoff
Make your Combine code reusable