Improve your app's UX with SwiftUI's task view modifier

Mastering the art of the pause


Oct 18, 2024 • 4 min read

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:

Text("Some view")
  .task {
    do {
      let result = try await performSomeLongRunningOperation()
      print(result)
    } 
    catch {
      print("Error: \(error)")
    }
  }

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:

Text("Some view")
  .task {
    do {
      try await Task.sleep(for: .seconds(3))
      // some code we want to guard
    }
    catch {
      print("Code not executed!")
    }
  }

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:

struct DelayTaskViewModifier: ViewModifier {
  let delay: ContinuousClock.Instant.Duration
  let action: @Sendable () async -> Void
 
  func body(content: Content) -> some View {
    content
      .task {
        do {
          try await Task.sleep(for: delay)
          await action()
        }
        catch {
          // do nothing
        }
      }
  }
}

To make using this view modifier easier, let’s provide an extension on View:

extension View {
  /// Executes the given action after the specified delay unless
  /// the task is cancelled.
  ///
  /// - Parameters:
  ///   - delay: The duration to wait before executing the task.
  ///   - action: The asynchronous action to execute.
  func task(
    delay: ContinuousClock.Duration,
    action: @Sendable @escaping () async -> Void
  ) -> some View {
    modifier(DelayTaskViewModifier(delay: delay, action: action))
  }
}

With this in place, the call site can be simplified to the following, which is a lot easier to understand (and much less verbose):

WebView(htmlString: styledHTML)
  .task(timeout: .seconds(3)) {
    // mark the article as read after 3 seconds
    article.isRead = true
  }

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.

Newsletter
Enjoyed reading this article? Subscribe to my newsletter to receive regular updates, curated links about Swift, SwiftUI, Combine, Firebase, and - of course - some fun stuff 🎈