GO FURTHER, FASTER: Try the Swift Career Accelerator today! >>

What’s new in SwiftUI for iOS 16

Bottom sheets, fixed grids, and a wholly new way to handle navigation

Paul Hudson       @twostraws

WWDC was back in person for the first time since 2019, and once again was absolutely packed with new features for app developers – huge new features in Swift 5.7, new APIs such as WeatherKit and Swift Charts, and, of course, lots of goodies for SwiftUI developers.

Please keep in mind that these changes are very new – I'm pushing it as hard as I can, experimenting, refining, sharing, and learning all at the same time. If you have any feedback, please tweet me @twostraws. I’ll be adding more soon!

You can watch the video below, or scroll down for links to articles.

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure and A/B test your entire paywall UI without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

The big stuff

Additional: It’s now possible to create multi-column tables on iOS and iPadOS, but I think it’s a bit buggy in beta 1. I’ve documented it here in case you’d like to try it yourself: How to create multi-column lists using Table.

More welcome improvements

And there’s more

This release comes with a whole batch of small tweaks, fixes, and improvements to make the SwiftUI experience smoother all around.

First, you can get a beautifully simple linear gradient by appending .gradient to whatever color you’re using:

Rectangle().fill(.blue.gradient)

This works with any color, and is always just the smallest hint of a gradient to help bring your UI to life:

struct ContentView: View {
    let colors: [Color] = [.blue, .cyan, .green, .yellow, .orange, .red, .purple]

    var body: some View {
        VStack {
            ForEach(colors, id: \.self) { color in
                Rectangle().fill(color.gradient)
            }
        }
    }
}

There’s new API for creating shadows for shapes, both regular drop shadows:

Circle()
    .fill(.red.shadow(.drop(color: .black, radius: 10)))
    .padding()

And also now inner shadows:

Circle()
    .fill(.red.shadow(.inner(color: .black, radius: 10)))
    .padding()

The lineLimit() modifier has been upgraded to handle ranges as well as simple integers:

Text("This is some longer text that is limited to a specific range of lines, so anything more than six lines will cause the text to clip.")
    .lineLimit(3...6)
    .frame(width: 200)

Some modifiers have been upgraded to support parameters, so they can be adjusted at runtime:

struct ContentView: View {
    @State private var useBold = false
    @State private var useItalic = false

    var body: some View {
        VStack {
            Text("Welcome SwiftUI 4.0")
                .bold(useBold)
                .italic(useItalic)

            Toggle("Use Bold", isOn: $useBold)
            Toggle("Use Italic", isOn: $useItalic)
        }
        .padding()
        .font(.title)
    }
}

We can animate all sorts of text characteristics just by using a plain withAnimation() call, even in ways you might think impossible. For example, we can animate the weight of a font from very thin to very thick:

struct ContentView: View {
    @State private var useBlack = false

    var body: some View {
        Text("Hello, world!")
            .font(.largeTitle)
            .fontWeight(useBlack ? .black : .ultraLight)
            .onTapGesture {
                withAnimation {
                    useBlack.toggle()
                }
            }
    }
}

Next, we can now create text fields that automatically grow with their content, optionally turning them into scrolling views when they pass a line limit:

struct ContentView: View {
    @State private var bio = ""

    var body: some View {
        TextField("Enter your bio", text: $bio, axis: .vertical)
            .textFieldStyle(.roundedBorder)
            .lineLimit(...5)
            .padding()
    }
}

Moving on, it’s now possible to bind a Toggle to an array of Booleans, which is helpful for times when you want to enable or disable several values all at once. For example, we could write some code to let the user subscribe to individual newsletters, or have one toggle to switch them all:

struct EmailList: Identifiable {
    var id: String
    var isSubscribed = false
}

struct ContentView: View {
    @State private var lists = [
        EmailList(id: "Monthly Updates", isSubscribed: true),
        EmailList(id: "News Flashes", isSubscribed: true),
        EmailList(id: "Special Offers", isSubscribed: true)
    ]

    var body: some View {
        Form {
            Section {
                ForEach($lists) { $list in
                    Toggle(list.id, isOn: $list.isSubscribed)
                }
            }

            Section {
                Toggle(sources: $lists, isOn: \.isSubscribed) {
                    Text("Subscribe to all")
                }
            }
        }
    }
}

It is at last now possible to animate foregroundColor():

struct ContentView: View {
    @State private var useRed = false

    var body: some View {
        Text("WWDC22")
            .font(.largeTitle.bold())
            .foregroundColor(useRed ? .red : .blue)
            .onTapGesture {
                withAnimation {
                    useRed.toggle()
                }
            }
    }
}

And one last thing before we move onto the biggest change of all: SwiftUI for iOS 16 leans heavily on a new Transferable protocol for sending data around the system. This is a newer, simpler, Swiftier replacement for NSItemProvider, and you’ll see it used by PasteButton, ShareLink, drag and drop, and more.

NSItemProvider was never a happy fit in any Swift app, never mind SwiftUI apps, so it’s great to see a significantly smarter approach come in – Transferable is one to watch, for sure.

Goodbye NavigationView, hello NavigationStack and NavigationSplitView

Probably the biggest change in this release is a rather dramatic rethink of navigation in SwiftUI: NavigationView has been soft deprecated, and is replaced by two new container views depending on what you’re trying to achieve.

The simpler option of the two is called NavigationStack, and it’s the equivalent of using an old NavigationView with the navigation style of .stack. In fact, you can just replace one with the other and get exactly the same result:

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List(1..<50) { i in
                NavigationLink {
                    Text("Detail \(i)")
                } label: {
                    Label("Row \(i)", systemImage: "\(i).circle")
                }
            }
            .navigationTitle("Navigation")
        }
    }
}

However, what the new NavigationStack allows is for us to separate the navigation destination from the navigation link – to attach that as a separate modifier, like this:

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List(1..<50) { i in
                NavigationLink(value: i) {
                    Label("Row \(i)", systemImage: "\(i).circle")
                }
            }
            .navigationDestination(for: Int.self) { i in
                Text("Detail \(i)")
            }
            .navigationTitle("Navigation")
        }
    }
}

So, you can see the NavigationLink is responsible for setting a value for whatever row number was tapped, and navigationDestination() is responsible for reading that back out and displaying a detail view.

That might seem little different from what we had before, but this new approach to navigation makes it much easier to create deep links, to handle state restoration, and also to jump to arbitrary places in our navigation – perhaps to push several views at once, or to pop all our views and return to the root.

For example, we could pre-select some specific rows by creating a new @State property, then pass that into the NavigationStack as its path:

struct ContentView: View {
    @State private var presentedNumbers = [1, 4, 8]

    var body: some View {
        NavigationStack(path: $presentedNumbers) {
            List(1..<50) { i in
                NavigationLink(value: i) {
                    Label("Row \(i)", systemImage: "\(i).circle")
                }
            }
            .navigationDestination(for: Int.self) { i in
                Text("Detail \(i)")
            }
            .navigationTitle("Navigation")
        }
    }
}

That approach works great when you have only integers, but part of the magic of NavigationStack is that you can attach multiple navigation destinations to handle various types of data being selected. In this case, your path should be NavigationPath rather than a specific array of data – this is type erased, so it’s able to handle a mix of data types depending on what you push onto the stack.

Here’s how that looks:

struct ContentView: View {
    @State private var presentedNumbers = NavigationPath()

    var body: some View {
        NavigationStack(path: $presentedNumbers) {
            NavigationLink(value: "Example") {
                Text("Tap Me")
            }

            List(1..<50) { i in
                NavigationLink(value: i) {
                    Label("Row \(i)", systemImage: "\(i).circle")
                }
            }
            .navigationDestination(for: Int.self) { i in
                Text("Detail \(i)")
            }
            .navigationDestination(for: String.self) { i in
                Text("String Detail \(i)")
            }
            .navigationTitle("Navigation")
        }
    }
}

So, NavigationStack is a great choice when you want views being pushed and popped onto a single view. If you’re looking for multi-column layouts, you should use NavigationSplitView instead, which lets you mark exactly what should go where in your split layout.

For example, we can get a simple two-column layout with a list and detail view like this:

struct ContentView: View {
    @State private var players = ["Dani", "Jamie", "Roy"]
    @State private var selectedPlayer: String?

    var body: some View {
        NavigationSplitView {
            List(players, id: \.self, selection: $selectedPlayer, rowContent: Text.init)
        } detail: {
            Text(selectedPlayer ?? "Please choose a player.")
        }
    }
}

You can also get a three-column layout by adding another trailing closure:

struct Team: Identifiable, Hashable {
    let id = UUID()
    var name: String
    var players: [String]
}

struct ContentView: View {
    @State private var teams = [Team(name: "AFC Richmond", players: ["Dani", "Jamie", "Row"])]

    @State private var selectedTeam: Team?
    @State private var selectedPlayer: String?

    var body: some View {
        NavigationSplitView {
            List(teams, selection: $selectedTeam) { team in
                Text(team.name).tag(team)
            }
            .navigationSplitViewColumnWidth(250)
        } content: {
            List(selectedTeam?.players ?? [], id: \.self, selection: $selectedPlayer) { player in
                Text(player)
            }
        } detail: {
            Text(selectedPlayer ?? "Please choose a player.")
        }
        .navigationSplitViewStyle(.prominentDetail)
    }
}

I snuck in a couple of extra modifiers in there so you can see more of what NavigationSplitView can do:

  • I’ve used navigationSplitViewColumnWidth() to specify that the sidebar should be exactly 250 points wide.
  • And I’ve used navigationSplitViewStyle(.prominentDetail) to tell SwiftUI to try to keep the detail view the same size as it displays the other views around it.

Now what?

I’ve managed to explore maybe two thirds of the new API, but there’s still more to explore – I’ll update this article soon.

What’s your favorite change in SwiftUI for iOS 16? What do you think SwiftUI is still missing to really work for your apps? Tweet me @twostraws and let me know!

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure and A/B test your entire paywall UI without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.