Animating SF Symbols on iOS 18

Over the years, Apple has been putting tons of work into the SF Symbols catalog. With SF Symbols, we’re able to leverage built-in iconography that will look familiar to users while also fitting into the Apple ecosystem very nicely. The fact that there’s thousands of symbols to choose from makes it a highly flexible and powerful catalog of icons that, in my opinion, should be every designer and developer’s first choice when they’re looking for visual components to add to their apps.

Initially, SF Symbols were pretty much static. We could configure them with a color and thickness but that was about it. Now that we’re a few years down the line, Apple has added multiple ways to animate SF Symbols.

In this post, I’d like to take a look at the current state of SF Symbol animations and explore some of the available animation options and styles that are available to us today.

If you prefer to learn through video instead of text, check out this video on my YouTube channel

Basic SF Symbol animations

Overall, the ways in which we can animate SF Symbols are plenty. It’s honestly quite likely that this post end up missing some fun technique that you’ve discovered and enjoy using.

The reason for this is that I’ve found that it’s not immediately obvious just how powerful SF Symbol animations can be.

At the very core, it’s really not that complex to animate an SF Symbol. For example, we could quite easily create the animation below with just a few lines of code:

Wiggle animation example

The code for creating an effect like that looks a bit like this:

Image(systemName: "airpodspro.chargingcase.wireless.radiowaves.left.and.right.fill")
    .symbolEffect(.wiggle, options: .repeat(.continuous))

What’s fun is that some symbols lend themselves to certain animations better than other. A wiggle is usually a fine way to draw attention to a symbol.

Notice how in the animation above you can distinguish between three layers that exist. The AirPods case, the inner “radio waves”, and the outer “radio waves”. SF Symbols lets us apply animations that change individual layers one by one. For example, to indicate a “searching” or “charging” animation you could want to have both radio waves be empty, then fill the inner ones, then the outer ones, and then have them be empty again.

A bit like this:

Animated waves on Airpods case

We can achieve that by changing the symbol effect that we’ve applied:

Image(systemName: "airpodspro.chargingcase.wireless.radiowaves.left.and.right.fill")
    .symbolEffect(.variableColor, options: .repeat(.continuous))

That’s pretty cool, right?

There’s a whole bunch of symbol effects available for you to try so I highly recommend to apply the symbolEffect view modifier to see which effects exist, and to see how they play with specific symbols. As you’ll see, some effects (like variableColor will work well with certain layered SF Symbols but not with others).

The variableColor effect is an effect that has a list of sub effects. In the example above, all layers get filled and then we reset back to a base state. This is equivalent to the following code:

Image(systemName: "airpodspro.chargingcase.wireless.radiowaves.left.and.right.fill")
    .symbolEffect(.variableColor.cumulative, options: .repeat(.continuous))

If you switch cumulative to iterative in the example above, the effect looks like this:

Iterative animation on Airpods case

I highly recommend that you take a look at the available options and play with them to see how much you can really do with SF Symbol animations.

In the code above I used options to set my animation up to be repeating. You can choose to repeat continuously like I did, or you can repeat a set number of times.

It’s also possible to set the repeat behavior to be periodic. That way, your SF Symbol can show its animation once every couple of seconds as a nice way to draw the user’s attention towards the symbol without being obnoxious:

A periodic wiggle effect on a bell

The code for this animation looks like this:

Image(systemName: "bell.circle")
    .symbolEffect(.wiggle, options: .repeat(.periodic(delay: 2)))

It’s pretty cool that we’re able to write animations this powerful with very little work. SF Symbols do a lot of the heavy lifting of building good looking animations for us.

It’s also possible to link a symbol effect to a specific value in your view so that the animation starts as soon as the linked value changes.

Here’s what the code to do that looks like:

Image(systemName: "bell.circle")
    .symbolEffect(.wiggle, options: .repeat(.periodic(2, delay: 2)), value: notificationsEnabled)

Button("Toggle Notifications") {
    notificationsEnabled.toggle()
}

Every time we click the button to change the value of notificationsEnabled we start our symbol effect which wiggles the bell twice before stopping our animation.

We can also link our effect to a boolean value that determines whether or not our effect is active at all:

Image(systemName: "bell.circle")
    .symbolEffect(.wiggle, options: .repeat(.periodic(delay: 2)), isActive: notificationsEnabled)

The code above is slightly different because it uses isActive instead of value to determine whether the animation is active. We’ve also gone back to a constantly repeating animation that will only be active whenever the notificationsEnabled property is true. As soon as it’s set to false, the animation will end.

It’s worth exploring which animations are available, and how you can mix and match different options and configurations in order to come up with some pretty cool animations.

Next, let’s take a look at symbol transitions.

SF Symbol Transitions

Sometimes, you might want to use an SF Symbol to represent a state-dependent piece of UI.

For example, you might present a notification bell to your user if they’ve enabled notifications but you might want to cross out the notification bell if the user turns off notifications.

The code to achieve that could look a bit like this:

Image(systemName: notificationsEnabled ? "bell" : "bell.slash")

Button("Toggle Notifications") {
    withAnimation {
        notificationsEnabled.toggle()
    }
}

When run, the result looks a bit as follows:

Jarring example of swapping symbols

It’s not great and luckily, we can do better. SF Symbols can now nicely animate between different variants of the same symbols in most cases.

For example, SF Symbols can animate our bell example like this if we apply the right configuration:

A nicely animated symbol transition

All that we need to do is provide a contentTransition for our symbol:

Image(systemName: notificationsEnabled ? "bell" : "bell.slash")
    .contentTransition(.symbolEffect(.replace))

Pretty cool, right? The .replace transition will always try to perform the most appropriate transition to move from one symbol to the next. In this case, that’s by seamlessly adding or removing our slash.

If we combine this with a different rendering mode, the effect looks even better:

A symbol transition that animates colors and changes symbols

In the example above I’ve used a hierarchical rendering mode to automatically gain appropriate opacity levels for my symbol’s layers.

Image(systemName: notificationsEnabled ? "bell" : "bell.slash")
    .symbolRenderingMode(.hierarchical)
    .contentTransition(.symbolEffect(.replace))

Again, I encourage you to play around with different settings and options to see what you can come up with.

SF Symbols are a very powerful tool in your iOS development toolbox and I highly recommend that you spend some time exploring different options the next time you’re working on a design for your app’s UI. Adding the right animations at the right times can really make your app stand out in a good way.

Solving “Value of non-Sendable type accessed after being transferred; later accesses could race;”

Once you start migrating to the Swift 6 language mode, you'll most likely turn on strict concurrency first. Once you've done this there will be several warings and errors that you'll encounter and these errors can be confusing at times.

I'll start by saying that having a solid understanding of actors, sendable, and data races is a huge advantage when you want to adopt the Swift 6 language mode. Pretty much all of the warnings you'll get in strict concurrency mode will tell you about potential issues related to running code concurrently. For an in-depth understanding of actors, sendability and data races I highly recommend that you take a look at my Swift Concurrency course which will get you access to a series of videos, exercises, and my Practical Swift Concurrency book with a single purchase.

WIth that out of the way, let's take a look at the following warning that you might encounter in your project:

Value of non-Sendable type 'MyType' accessed after being transferred; later accesses could race;

For example, the following code produces such an error:

var myArray = [Int]()

Task {
  // Value of non-Sendable type '@isolated(any) @async @callee_guaranteed @substituted <τ_0_0> () -> @out τ_0_0 for <()>' accessed after being transferred; later accesses could race;
  myArray.append(1)
}

myArray.append(2)

Xcode offers a little guidance as to what that error is telling us:

Access can happen concurrently

In other words, the compiler is telling us that we're accessing myArray after we've "transferred" that property to our Task. You can see how we're appending to the array both inside of the task as well as outside of it.

Swift is telling us that we're potentially causing data races here because our append on myArray after the task might actually collide with the append inside of the task. When this happens, we have a data race and our code would crash.

The fix here would be to explicitly make a copy for our task when it's created:

Task { [myArray] in
  var myArray = myArray
  myArray.append(1)
}

This gets rid of our data race potential but it's also not really achieving our goal of appending to the array from inside of the task.

The fix here could be one of several approaches:

  1. You can wrapp your array in an actor to ensure proper isolation and synchronization
  2. You can rework your approach entirely
  3. Global actors could be useful here depending on the structure of your code

Ultimately, most strict concurrency related issues don't have a single solution that works. It's always going to require a case-by-case analysis of why a certain error appears, and from there you should figure out a solution.

In this case, we're taking a mutable object that we're mutating from within a task as well as right after where we've defined the task. The compiler is warning us that that will most likely cause a data race and you'll need to determine which solution works for you. My first attempt at fixing this would be to wrap the mutable state in an actor to make sure we achieve proper isolation and prevent future data races.

Setting the Swift Language mode for an SPM Package

When you create a new Swift Package in Xcode 16, the Package.swift contents will look a bit like this:

// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "AppCore",
    products: [
        // Products define the executables and libraries a package produces, making them visible to other packages.
        .library(
            name: "AppCore",
            targets: ["AppCore"]),
    ],
    targets: [
        // Targets are the basic building blocks of a package, defining a module or a test suite.
        // Targets can depend on other targets in this package and products from dependencies.
        .target(
            name: "AppCore"
        )
    ]
)

Notice how the package's Swift tools version is set to 6.0. If you want your project to reference iOS18 for example, you're going to need to have you Swift tools version set to 6.0. A side effect of that is that your package will now build in the Swift 6 language mode. That means that you're going to get Swift's full suite of sendability and concurrency checks in your package, and that the compiler will flag any issues as errors.

You might not be ready to use Swift 6.0 in your new packages yet. In those cases you can either set the Swift tools version back to 5.10 if you're not using any features from the 6.0 toolchain anyway or you can set your package's language mode to Swift 5 while keeping the 6.0 toolchain:

// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "AppCore",
    platforms: [.iOS(.v18)],
    // ... the rest of the package description
    swiftLanguageModes: [.v5]
)

It's also possible to assign the swift language mode for specific targets in your package instead. Here's what that looks like:

targets: [
  // Targets are the basic building blocks of a package, defining a module or a test suite.
  // Targets can depend on other targets in this package and products from dependencies.
  .target(
    name: "AppCore",
    swiftSettings: [.swiftLanguageMode(.v5)]
  )
]

By using the Swift 5 language mode you can continue to write your code as usual until you're ready to start migrating to Swift 6. For example, you might want to start by enabling strict concurrency checks.

Solving “Task-isolated value of type ‘() async -> Void’ passed as a strongly transferred parameter”

Once you start migrating to the Swift 6 language mode, you'll most likely turn on strict concurrency first. Once you've done this there will be several warings and errors that you'll encounter and these errors can be confusing at times.

I'll start by saying that having a solid understanding of actors, sendable, and data races is a huge advantage when you want to adopt the Swift 6 language mode. Pretty much all of the warnings you'll get in strict concurrency mode will tell you about potential issues related to running code concurrently. For an in-depth understanding of actors, sendability and data races I highly recommend that you take a look at my Swift Concurrency course which will get you access to a series of videos, exercises, and my Practical Swift Concurrency book with a single purchase.

WIth that out of the way, let's take a look at the following warning that you might encounter in your project:

Task-isolated value of type '() async -> Void' passed as a strongly transferred parameter

When I first encountered the error above, I was puzzled. The code that made this happen wasn't all that strange and I had no idea what could be wrong here.

Let's look at an example of the code that would make this error show up:

var myArray = [1, 2, 3]

await withTaskGroup(of: Void.self) { group in
  for _ in 0..<10 {
    // Task-isolated value of type '() async -> Void' passed as a strongly transferred parameter; later accesses could race;
    group.addTask { 
      myArray.append(Int.random(in: 0..<10))
    }
  }
}

The problem above can also occur when you create an unstructured task with Task or a detached task with Task.detached. The error and the reason for the error appearing are the same for all cases, but what exactly is wrong in the code above?

Unfortunately, the compiler isn't of much help here so we'll need to figure this one out on our own...

In every case that I've seen for this specific error, the task that we create (whether it's a child task, unstructured task or a detached task) captures a non-sendable object. To learn more about sendable, take a look at my post that explains Sendable and @Sendable closures.

So while the compiler error is extremely hard to read and understand, the reason for it appearing is actually relatively simple. We've got a strong capture to something that's not Sendable inside of a task that might run concurrently with other work. The result is a possible data race.

The fix can sometimes be relatively simple if you're able to make the captured type sendable or an actor. In the case of the code above that would be tricky; myArray is an array of Int which means that we're already as sendable as we could be. But because the array is mutable, there's a chance that we'll race.

There are multiple possible fixes in this case. One of them is to mutate the array outside of the child tasks by having child tasks produce numbers and then iterating over the task group:

var myArray = [1, 2, 3]

await withTaskGroup(of: Int.self) { group in
  for _ in 0..<10 {
      group.addTask {
          // Task-isolated value of type '() async -> Void' passed as a strongly transferred parameter; later accesses could race;
          return (myArray.first ?? 2) * 2
      }
  }

    for await value in group {
        myArray.append(value)
    }
}

Unfortunately, the above still produces an error...

The reason for that is that myArray is still being accessed from within a child task. So that means that while a child task is reading, our async for loop could be writing and then we have a data race.

To fix that we need to make a copy of myArray in the child task's capture list like this:

group.addTask { [myArray] in
  return (myArray.first ?? 2) * 2
}

With that change in place, the code compiles and runs correctly.

Unfortunately, Task-isolated value of type '() async -> Void' passed as a strongly transferred parameter is a very tough to read error with no single fix. What this error tells you though, is that you're accessing or capturing a value that's not sendable or safe to be accessed concurrently. Fixes for this could be:

  1. To make the captured object an actor
  2. To make the captured object sendable
  3. To make a copy of the object
  4. To capture properties on the object outside of your task
  5. To rethink your approach completely (this is rarely needed)

As with many other strict concurrency related issues, solving this error will depend on your ability to analyze the problem, and your understanding of actors and sendable. These are topics that you should try and understand as good as you can before you attempt to migrate to Swift 6.

Solving “reference to var myVariable is not concurrency-safe because it involves shared mutable state” in Swift

Once you start migrating to the Swift 6 language mode, you'll most likely turn on strict concurrency first. Once you've done this there will be several warings and errors that you'll encounter and these errors can be confusing at times.

I'll start by saying that having a solid understanding of actors, sendable, and data races is a huge advantage when you want to adopt the Swift 6 language mode. Pretty much all of the warnings you'll get in strict concurrency mode will tell you about potential issues related to running code concurrently. For an in-depth understanding of actors, sendability and data races I highly recommend that you take a look at my Swift Concurrency course which will get you access to a series of videos, exercises, and my Practical Swift Concurrency book with a single purchase.

WIth that out of the way, let's take a look at the following warning that you might encounter in your project:

reference to var myVariable is not concurrency-safe because it involves shared mutable state

There are multiple reasons for this warning to pop up in Xcode. For example, the code below would cause Xcode to warn us:

// Var 'myVariable' is not concurrency-safe because it is nonisolated global shared mutable state; this is an error in the Swift 6 language mode
var myVariable = UUID()

func randomCharacter() async -> Character {
    myVariable = UUID()
    return myVariable.uuidString.randomElement() ?? "1"
}

The following code makes myVariable a static var which results in the same warning being shown:

struct CharacterMaker {
    // Var 'myVariable' is not concurrency-safe because it is nonisolated global shared mutable state; this is an error in the Swift 6 language mode
    static var myVariable = UUID()

    static func randomCharacter() async -> Character {
        myVariable = UUID()
        return myVariable.uuidString.randomElement() ?? "1"
    }
}

The Swift compiler considers any globally accessible var to be unsafe from a concurrency point of view. The reason for that is that nothing is preventing us from making multiple calls to randomCharacter concurrently which would result in a data race on myVariable. We'd end up with multiple read and write operations at the same time.

To fix this, myVariable should either be moved into an actor or be isolated to a global actor.

For example, you could isolate myVariable to @MainActor like this:

// with a global variable
@MainActor
var myVariable = UUID()

// or as a static property
struct CharacterMaker {
    @MainActor
    static var myVariable = UUID()
    // ...
}

The downside of this is, of course, that we need to be on the main actor to interact with the variable. You can work around this by defining your own (empty) global actor which will ensure that our accesses are on the global executor instead of the main actor:

@globalActor
actor GlobalIsolator {
  static let shared = GlobalIsolator()
}

@GlobalIsolator
var myVariable = UUID()

// or as a static property
struct CharacterMaker {
    @GlobalIsolator
    static var myVariable = UUID()
    // ...
}

This makes accessing myVariable a bit less convenient because you'll need to place yourself on the GlobalIsolator actor when interacting with myVariable:

@GlobalIsolator
static func randomCharacter() async -> Character {
    myVariable = UUID()
    return myVariable.uuidString.randomElement() ?? "1"
}

In some cases you'll know that even though the compiler doesn't like your shared mutable state, you know that it's fine due to the way your code is structured.

If that's the case, and you're absolutely 100% sure that you won't have any issues related to your shared mutable state, you can use nonisolated(unsafe) on your variable to tell the compiler that the lack of isolation is intentional and that you're aware of its data safety issues:

// with a global variable
nonisolated(unsafe) var myVariable = UUID()

// or as a static property
struct CharacterMaker {
    nonisolated(unsafe) static var myVariable = UUID()
    // ...
}

You should only use nonisolated(unsafe) as a last-resort solution because the compiler will no longer be able to help you detect possible data races around myVariable.

Solving “Converting non-sendable function value may introduce data races” in Swift

Once you start migrating to the Swift 6 language mode, you'll most likely turn on strict concurrency first. Once you've done this there will be several warings and errors that you'll encounter and these errors can be confusing at times.

I'll start by saying that having a solid understanding of actors, sendable, and data races is a huge advantage when you want to adopt the Swift 6 language mode. Pretty much all of the warnings you'll get in strict concurrency mode will tell you about potential issues related to running code concurrently. For an in-depth understanding of actors, sendability and data races I highly recommend that you take a look at my Swift Concurrency course which will get you access to a series of videos, exercises, and my Practical Swift Concurrency book with a single purchase.

WIth that out of the way, let's take a look at the following warning that you might encounter in your project:

Converting non-sendable function value may introduce data races

Usually the warning is a bit more detailed, for example in a project I worked on this was the full warning:

Converting non-sendable function value to '@Sendable (Data?, URLResponse?, (any Error)?) -> Void' may introduce data races

This warning (or error in the Swift 6 language mode) tells you that you're trying to pass a non-sendable closure or function to a place that expects something that's @Sendable. For convenience I will only use the term closure but this applies equally to functions.

Consider a function that's defined as follows:

func performNetworkCall(_ completion: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) {
    // ...
}

This function should be called with a closure that's @Sendable to make sure that we're not introducting data races in our code. When we try and call this function with a closure that's not @Sendable the compiler will complain:

var notSendable: (Data?, URLResponse?, (any Error?)) -> Void = { data, response, error in 
    // ...
}

// Converting non-sendable function value to '@Sendable (Data?, URLResponse?, (any Error)?) -> Void' may introduce data races
performNetworkCall(notSendable)

The compiler is unable to guarantee that our closure is safe to be called in a different actor, task, or other isolation context. So it tells us that we need to fix this.

Usually, the fix for this error is to mark your function or closure as @Sendable:

var notSendable: @Sendable (Data?, URLResponse?, (any Error?)) -> Void = { data, response, error in 
    // ...
}

Now the compiler knows that we intend on our closure to be Sendable and it will perform checks to make sure that it is. We're now also allowed to pass this closure to the performNetworkCall method that you saw earlier.

If you'd like to learn more about Sendable and @Sendable check out my course or read a summary of the topic right here.

What are Optionals in Swift?

In an earlier article, I explained how variables are defined in Swift using let and var. Both constants (let) and variables (var) in Swift always have a type; it's what makes Swift a strongly typed language.

For example, we could define a String variable like this:

// the compiler will know myString is a String
var myString = "Hello, world"

// we're explicitly telling the compiler that myString2 is a String
var myString2: String = "Hello, world"

This way of defining variables makes a lot of sense when it's possible to immediately assign a value to our variable.

However, sometimes you'll write code where it's not possible to assign a value to your variable immediately. Or you're working with functions that may or may not be able to return a valid value.

In Swift, we call values that can distiguish betwene having a value and not having a value an Optional. Before we dig too deeply into how we work with optionals, let's explore the difference between "no value" and "default" value so that we understand exactly why optionals exist in the first place.

If you prefer to learn through video instead of text, check out this video on my YouTube channel

The difference between a default value and no value

In programming, working with a concept called null or as Swift calls it nil will generally mean that a variable or a function's return value can be "nothing". There's a lot of technical baggage surrounding the terminology but in order to establish a good working knowledge, we won't dig into that too deeply.

The important thing to understand here is that defining an empty String like this: var myString = "" defines a String with a default value. The value is empty but the var myString is holding on to some data that will represent an empty String. Often this is a perfectly fine choice.

Now let's consider a different example where a default value would be a lot harder to define.

var theUser: User = ???

Our User object can't be created without input from other sources. And this input might not be present at that time that we define our variable. We'll need a way to define this var theUser with no data rather than a default value.

A real world analogy you might think of is the following. When you sit down at a cafe for some drinks, you will initially have no glasses or cups on your table. As a result, your waiter will know that you haven't been served anything at all so they'll know to go over and hand you a menu, introduce themselves and see whether they can take any orders. Once you've been served you might have some empty glasses on your table. The waiter will now know to ask to refill or take a different order.

This is a demonstration of how no value (no glass on the table) and an empty value (empty glasses on the table) can have significant differences in meaning and they can't always be used interchangeably.

In Swift, we express the ability of a property having no value rather than a default value by defining an optional User object:

var theUser: User?

The ? after our User tells the compiler that var theUser will either contain a value of type User or it will hold nothing at all (we call this nil).

It's nice to know that the ? is a more convenient to write the following:

var theUser: Optional<User>

While the two ways of defining theUser do the same thing, it's best practice to write var theUser: User?. It's easier to read and faster to write.

Note that all types in Swift can be written as an optional. For example, if you're defining a String that might need to be initialized as "no value" you could write: var theString: String?.

The main difference between "no value" and "default value" is often whether there's any semantic meaning to pointing at nothing or pointing to a default value. For example, an optional Bool (Bool?) almost never makes sense; in most scenarios you will be able to pick a sensible default value that's safe to use. In other cases, something being empty or missing could indicate that input from the user is required, or that you need to fetch data from an external source and it's not possible or reasonable to provide a default value.

Now that you know how to write optional properties, let's see how optionals are used in Swift.

Using optionals in your code

Once you've defined an optional value in Swift, it's important that we handle the possibility of a value being nil as well as the value being non-nil. Swift is pretty strict about this so optionals aren't used in the same way as you would use normal variables or constants.

For example, if we consider the theUser variable from earlier, we can't read the name from this property like this:

var theUser: User?

// Value of optional type 'User?' must be unwrapped to refer to member 'name' of wrapped base type 'User'
print(theUser.name)

The Swift compiler will tell us that we need to "unwrap" value of optional type User? in order to access its member name. This is the compiler's way of telling us that theUser may or may not be nil so we need to handle both scenarios.

Let's take a look at severals ways in which we can "unwrap" our optional.

Unwrapping with if let

If we're writing code where we want to only execute a part of our script or function in case the value isn't nil, we can use something called an if let unwrap. Here's what that looks like:

var theUser: User?

// somewhere else in the code...
if let userValue = theUser {
  print(userValue.name)
} else {
  print("the user is nil")
}

This if let attempts to read theUser and we assign it to a constant. This constant is then made available inside of the if's body where we know that userValue is of type User. Outside of our if body we won't be able to access userValue; it's only made available inside of the if. As needed, we can provide an else to handle scenarios where theUser is nil.

Note that the code above could be simplified a bit. Swift allows us to use something called a shadow variable (variable of the same name) for theUser which would change the if let as follows:

var theUser: User?

// somewhere else in the code...
if let theUser {
  print(theUser.name)
} else {
  print("the user is nil")
}

Note that theUser inside of the if body is not the same variable as theUser outside of the if body; it's a different property with the same name. For that reason, theUser inside of the if body is of type User and outside of the if body it's User?. This feature of Swift is nice when you're familiar with optionals but I find that sometimes it's better to provide a different name so that it's clear when you're using your unwrapped property or when you're using your optional property.

Unwrapping optionals with guard let

While if let is great for usage inside of code where it doesn't matter that much whether a value is or isn't nil, you sometimes want to make sure that a value isn't nil at the start of a function. With if let this would generally mean that you write an if let at the start of your function and then write the whole function body inside of your if let:

func performWork() {
  if let unwrappedUser = theUser {
    // do the work
  }
}

This works but it can lead to a lot of nested code. For scenarios where you only wish to proceed in your function if a value is not nil, you can use guard let instead:

func performWork() {
  guard let unwrappedUser = theUser else {
    return
  }

// do the work
// unwrappedUser is available to all code that comes after the guard
}

A guard allows us to ensure that our user has a value and that the unwrapped value is available to all code that comes after the guard. When we're using a guard we must provide an else clause that exits the current scope. Usually this means that we put a return there in order to bail out of the function early.

Unwrapping multiple properties

Both if let and guard let allow us to unwrap multiple properties at once. This is done using a comma separated list:

if let unwrappedUser = theUser, let file = getFile() {
  // we have access to `unwrappedUser` and `file`
}

The syntax for guard let is the same but requires the else:

guard let unwrappedUser = theUser, let file = getFile() else {
  return
}

  // we have access to `unwrappedUser` and `file`

Note that writing your code like this will require all unwraps to succeed. If either our user or file would be nil in the example above, the if body wouldn't be executed and our guard would enter its else condition.

Reading through optional chaining

When you're working with an optional and you'd like to get access to a property that's defined on your object, you could write an if let and then access the property you're interested in. You saw this earlier with User and its name property:

if let theUser {
  print(theUser.name)
}

If we know that we're only interested in the name property we can use a technique called optional chaining to immediately access the name property and assign that to the property we're writing the if let for instead.

Here's what that looks like

if let userName = theUser?.name {
  print(userName)
}

This is very convenient when we're in a situation where we really only care about a single property. If either theUser is nil or (if name is optional) name is nil the if body won't be executed.

We can use this technique to access larger chains of optionals, for example:

if let department = theUser?.department?.name {

}

Both theUser and department are optionals and we can write a chain of access using ? after each optional property. Once any of the properties in the chain is found to be nil the chain ends and the result is nil.

For example, if we just assign the chain from above to a property that property is a String?

// department is String?
let department = theUser?.department?.name

The name on the department property doesn't have to be a String? but because we're using optional chaining we'll get a nil value if either theUser or department is nil.

This leads me to one last method that I'd recommend for working with and that's using the nil coalescing operator.

Unwrapping optionals using nil coalescing

For any optional in Swift, we can provide a default value inline of where we access it. For example:

let username: String?

let displayName = username ?? ""

The ?? operator in the example above is called the nil coalescing operator and we can use it to provide a default value that's used in case the value we're trying to access is nil.

This is particularly useful when you need to provide values to render in a user interface for example.

You can also use this technique in combination with optional chaining:

// department is String
let department = theUser?.department?.name ?? "No department"

Now, let's take a look at one last method to unwrapping that I'm only including for completeness; this approach should only be used as a last resort in my opinion.

Force unwrapping optionals

If you're 100% absolutely sure that an optional value that you're about to access can never be nil, you can force unwrap the optional when accessing it:

print(theUser!.name)

By writing an ! after my optional variable I'm telling the compiler to treat that property as non-optional. This means that I can easily interact with the property without writing an if let, guard let, without optional chaining or without using nil coaslescing. The major downside here is that if my assumptions are wrong and the value is nil after all my program will crash.

For that reason it's almost always preferred to use one of the four safe approaches to unwrapping your optionals instead.

Solving “Capture of non-sendable type in @Sendable closure” in Swift

Once you start migrating to the Swift 6 language mode, you'll most likely turn on strict concurrency first. Once you've done this there will be several warings and errors that you'll encounter and these errors can be confusing at times.

I'll start by saying that having a solid understanding of actors, sendable, and data races is a huge advantage when you want to adopt the Swift 6 language mode. Pretty much all of the warnings you'll get in strict concurrency mode will tell you about potential issues related to running code concurrently. For an in-depth understanding of actors, sendability and data races I highly recommend that you take a look at my Swift Concurrency course which will get you access to a series of videos, exercises, and my Practical Swift Concurrency book with a single purchase.

WIth that out of the way, let's take a look at the following warning that you might encounter in your project:

Capture of non-sendable type in @Sendable closure

This warning tells us that we're capturing and using a property inside of a closure. This closure is marked as @Sendable which means that we should expect this closure to run in a concurrent environment. The Swift compiler warns us that, because this closure will run concurrently, we should make sure that any properties that we capture inside of this closure can safely be used from concurrent code.

In other words, the compiler is telling us that we're risking crashes because we're passing an object that can't be used from multiple tasks to a closure that we should expect to be run from multiple tasks. Or at least we should expect our closure to be transferred from one task to another.

Of course, there's no guarantees that our code will crash. Nor is it guaranteed that our closure will be run from multiple places at the same time. What matters here is that the closure is marked as @Sendable which tells us that we should make sure that anything that's captured inside of the closure is also Sendable.

For a quick overview of Sendability, check out my post on the topic here.

An example of where this warning might occur could look like this:

func run(completed: @escaping TaskCompletion) {
    guard !metaData.isFinished else {
        DispatchQueue.main.async {
            // Capture of 'completed' with non-sendable type 'TaskCompletion' (aka '(Result<Array<any ScheduledTask>, any Error>) -> ()') in a `@Sendable` closure; this is an error in the Swift 6 language mode
            // Sending 'completed' risks causing data races; this is an error in the Swift 6 language mode
            completed(.failure(TUSClientError.uploadIsAlreadyFinished))
        }
        return
    }

    // ...
}

The compiler is telling us that the completed closure that we're receiving in the run function can't be passed toDispatchQueue.main.async safely. The reason for this is that the run function is assumed to be run in one isolation context, and the closure passed to DispatchQueue.main.async will run in another isolation context. Or, in other words, run and DispatchQueue.main.async might run as part of different tasks or as part of different actors.

To fix this, we need. to make sure that our TaskCompletion closure is @Sendable so the compiler knows that we can safely pass that closure across concurrency boundaries:

// before
typealias TaskCompletion = (Result<[ScheduledTask], Error>) -> ()

// after
typealias TaskCompletion = @Sendable (Result<[ScheduledTask], Error>) -> ()

In most apps, a fix like this will introduce new warnings of the same kind. The reason for this is that because the TaskCompletion closure is now @Sendable, the compiler is going to make sure that every closure passed to our run function doesn't captuire any non-sendable types.

For example, one of the places where I call this run function might look like this:

task.run { [weak self] result in
    // Capture of 'self' with non-sendable type 'Scheduler?' in a `@Sendable` closure; this is an error in the Swift 6 language mode
    guard let self = self else { return }
    // ...
}

Because the closure passed to task.run needs to be @Sendable any captured types also need to be made Sendable.

At this point you'll often find that your refactor is snowballing into something much bigger.

In this case, I need to make Scheduler conform to Sendable and there's two ways for me to do that:

  • Conform Scheduler to Sendable
  • Make Scheduler into an actor

The second option is most likely the best option. Making Scheduler an actor would allow me to have mutable state without data races due to actor isolation. Making the Scheduler conform to Sendable without making it an actor would mean that I have to get rid of all mutable state since classes with mutable state can't be made Sendable.

Using an actor would mean that I can no longer directly access a lot of the state and functions on that actor. It'd be required to start awaiting access which means that a lot of my code has to become async and wrapped in Task objects. The refactor would get out of control real fast that way.

To limit the scope of my refactor it makes sense to introduce a third, temporary option:

  • Conform Scheduler to Sendable using the unchecked attribute

For this specific case I have in mind, I know that Scheduler was written to be thread-safe. This means that it's totally safe to work with Scheduler from multiple tasks, threads, and queues. However, this safety was implemented using old mechanisms like DispatchQueue. As a result, the compiler won't just accept my claim that Scheduler is Sendable.

By applying @unchecked Sendable on this class the compiler will accept that Scheduler is Sendable and I can continue my refactor.

Once I'm ready to convert Scheduler to an actor I can remove the @unchecked Sendable, change my class to an actor and continue updating my code and resolving warnings. This is great because it means I don't have to jump down rabbit hole after rabbit hole which would result in a refactor that gets way out of hand and becomes almost impossible to manage correctly.

Solving “Reference to captured var in concurrently-executing code” in Swift

In Xcode 16, this error actually is sometimes presented as "Passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure". The cause is the exact same as what's covered in this post.

Once you start migrating to the Swift 6 language mode, you'll most likely turn on strict concurrency first. Once you've done this there will be several warnings and errors that you'll encounter and these errors can be confusing at times.

I'll start by saying that having a solid understanding of actors, sendable, and data races is a huge advantage when you want to adopt the Swift 6 language mode. Pretty much all of the warnings you'll get in strict concurrency mode will tell you about potential issues related to running code concurrently. For an in-depth understanding of actors, sendability and data races I highly recommend that you take a look at my Swift Concurrency course which will get you access to a series of videos, exercises, and my Practical Swift Concurrency book with a single purchase.

WIth that out of the way, let's take a look at the following warning that you might encounter in your project:

Reference to captured var in concurrently-executing code

This warning tells you that you're capturing a variable inside of a body of code that will run asynchornously. For example, the following code will result in this warning:

var task = NetworkTask<Int, URLSessionUploadTask>(
    urlsessionTask: urlSessionTask
)

upload(fromTask: urlSessionTask, metaData: metaData, completion: { result in
    Task {
        await task.sendResult(result) // Reference to captured var 'task' in concurrently-executing code; this is an error in the Swift 6 language mode
    }
})

The task variable that we create a couple of lines earlier is mutable. This means that we can assign a different value to that task at any time and that could result in inconsistencies in our data. For example, if we assign a new value to the task before the closure starts running, we might have captured the old task which could be unexpected.

Since strict concurrency is meant to help us make sure that our code runs as free of surprises as possible, Swift wants us to make sure that we capture a constant value instead. In this case, I'm not mutating task anyway so it's safe to make it a let:

let task = NetworkTask<Int, URLSessionUploadTask>(
    urlsessionTask: urlSessionTask
)

upload(fromTask: urlSessionTask, metaData: metaData, completion: { result in
    Task {
        await task.sendResult(result)
    }
})

This change gets rid of the warning because the compiler now knows for sure that task won't be given a new value at some unexpected time.

Another way to fix this error would be to make in explicit capture in the completion closure that I'm passing. This capture will happen immediately as a let so Swift will know that the captured value will not change unexpectedly.

var task = NetworkTask<Int, URLSessionUploadTask>(
    urlsessionTask: urlSessionTask
)

upload(fromTask: urlSessionTask, metaData: metaData, completion: { [task] result in
    Task {
        await task.sendResult(result.mapError({ $0 as any Error }))
    }
})

Altenatively, you could make an explicit constant capture before your Task runs:

var task = NetworkTask<Int, URLSessionUploadTask>(
    urlsessionTask: urlSessionTask
)

let theTask = task
upload(fromTask: urlSessionTask, metaData: metaData, completion: { result in
    Task {
        await theTask.sendResult(result)
    }
})

This is not as elegant but might be needed in cases where you do want to pass your variable to a piece of concurrently executing code but you also want it to be a mutable property for other objects. It's essentially the exact same thing as making a capture in your completion closure (or directly in the task if there's no extra wrapping closures involved).

When you first encounter this warning it might be immediately obvious why you're seeing this error and how you should fix it. In virtual all cases it means that you need to either change your var to a let or that you need to perform an explicit capture of your variable either by making a shadowing let or through a capture list on the first concurrent bit of code that accesses your variable. In the case of the example in this post that's the completion closure but for you it might be directly on the Task.

Adding values to the SwiftUI environment with Xcode 16’s Entry macro

Adding custom values to SwiftUI’s environment has never been very hard to do to. However, the syntax for doing it is verbose and easy to forget. To refresh your mind, take a look at this post where I explain how to add your own environment values to a SwiftUI view.

To summarize what’s shown in that post; here’s how you add a custom value to the environment using Xcode 15 and earlier:

private struct DateFormatterKey: EnvironmentKey {
    static let defaultValue: DateFormatter = {
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.dateFormat = "MM/dd/yyyy"
        return formatter
    }()
}

extension EnvironmentValues {
    var dateFormatter: DateFormatter {
        get { self[DateFormatterKey.self] }
        set { self[DateFormatterKey.self] = newValue }
    }
}

We have to define an environment key, define a default value, and write a getter and setter to retrieve our value from the environment using our key.

This is repetitive, easy to forget, and just annoying to do.

If you prefer learning thorugh video, here's the video to watch:

Luckily, in Xcode 16 we have access to the @Entry macro. This macro allows us to define the exact same environment key like this:

extension EnvironmentValues {
    @Entry var dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.dateFormat = "MM/dd/yyyy"
        return formatter
    }()
}

All we have to define now is a variable that’s annotated with @Entry and we’re done.

The property name is used as the environment key so in this case we’d set our date formatter like this:

myView
    .environment(\.dateFormatter, Dateformatter())

I absolutely love this new syntax because it gets rid of all the boilerplate in one go.

And the best part of this macro?

We can use it in projects that target iOS versions older than 18! So as soon as you start developing your project with Xcode 16 you’ll be able to use this macro regardless of your deployment target.