HomeLearnHow-toMost Useful iOS 15 SwiftUI Features

Most Useful iOS 15 SwiftUI Features

Updated: Sep 30, 2021 |

Published: Sep 27, 2021

  • Realm
  • Mobile
  • Swift
  • ...

By Andrew Morgan

Rate this article

#Introduction

I'm all-in on using SwiftUI to build iOS apps. I find it so much simpler than wrangling with storyboards and UIKit. Unfortunately, there are still occasions when SwiftUI doesn't let you do what you need—forcing you to break out into UIKit.

That's why I always focus on Apple's SwiftUI enhancements at each year's WWDC. And, each year I'm rewarded with a few more enhancements that make SwiftUI more powerful and easy to work with. For example, iOS14 made it much easier to work with Apple Maps.

WWDC 2021 was no exception, introducing a raft of SwiftUI enhancements that were coming in iOS 15/ SwiftUI 3 / Xcode 13. As iOS 15 has now been released, it feels like a good time to cover the features that I've found the most useful.

I've revisited some of my existing iOS apps to see how I could exploit the new iOS 15 SwiftUI features to improve the user experience and/or simplify my code base. This article steps through the features I found most interesting/useful, and how I tested them out on my apps. These are the apps/branches that I worked with:

#Prerequisites

  • Xcode 13
  • iOS 15
  • Realm-Cocoa (varies by app, but 10.13.0+ is safe for them all)

#Lists

SwiftUI Lists are pretty critical to data-based apps. I use Lists in almost every iOS app I build, typically to represent objects stored in Realm. That's why I always go there first when seeing what's new.

#Custom Swipe Options

We've all used mobile apps where you swipe an item to the left for one action, and to the right for another. SwiftUI had a glaring omission—the only supported action was to swipe left to delete an item.

This was a massive pain.

This limitation meant that my task-tracker-swiftui app had a cumbersome UI. You had to click on a task to expose a sheet that let you click on your preferred action.

With iOS 15, I can replace that popup sheet with swipe actions:

iOS app showing that action buttons are revealed when swiping a list item to the left or right

The swipe actions are implemented in TasksView:

1List {
2 ForEach(tasks) { task in
3 TaskView(task: task)
4 .swipeActions(edge: .leading) {
5 if task.statusEnum == .Open || task.statusEnum == .InProgress {
6 CompleteButton(task: task)
7 }
8 if task.statusEnum == .Open || task.statusEnum == .Complete {
9 InProgressButton(task: task)
10 }
11 if task.statusEnum == .InProgress || task.statusEnum == .Complete {
12 NotStartedButton(task: task)
13 }
14 }
15 .swipeActions(edge: .trailing) {
16 Button(role: .destructive, action: { $tasks.remove(task) }) {
17 Label("Delete", systemImage: "trash")
18 }
19 }
20 }
21}

The role of the delete button is set to .destructive which automatically sets the color to red.

For the other actions, I created custom buttons. For example, this is the code for CompleteButton:

1struct CompleteButton: View {
2 @ObservedRealmObject var task: Task
3
4 var body: some View {
5 Button(action: { $task.statusEnum.wrappedValue = .Complete }) {
6 Label("Complete", systemImage: "checkmark")
7 }
8 .tint(.green)
9 }
10}

#Searchable Lists

When you're presented with a long list of options, it helps the user if you offer a way to filter the results.

RCurrency lets the user choose between 150 different currencies. Forcing the user to scroll through the whole list wouldn't make for a good experience. A search bar lets them quickly jump to the items they care about:

Animation showing currencies being filtered as a user types into the search box

The selection of the currency is implemented in the SymbolPickerView view.

The view includes a state variable to store the searchText (the characters that the user has typed) and a searchResults computed value that uses it to filter the full list of symbols:

1struct SymbolPickerView: View {
2 ...
3 @State private var searchText = ""
4 ...
5 var searchResults: Dictionary<String, String> {
6 if searchText.isEmpty {
7 return Symbols.data.symbols
8 } else {
9 return Symbols.data.symbols.filter {
10 $0.key.contains(searchText.uppercased()) || $0.value.contains(searchText)}
11 }
12 }
13}

The List then loops over those searchResults. We add the .searchable modifier to add the search bar, and bind it to the searchText state variable:

1List {
2 ForEach(searchResults.sorted(by: <), id: \.key) { symbol in
3 ...
4 }
5}
6.searchable(text: $searchText)

This is the full view:

1struct SymbolPickerView: View {
2 @Environment(\.presentationMode) var presentationMode
3
4 var action: (String) -> Void
5 let existingSymbols: [String]
6
7 @State private var searchText = ""
8
9 var body: some View {
10 List {
11 ForEach(searchResults.sorted(by: <), id: \.key) { symbol in
12 Button(action: {
13 pickedSymbol(symbol.key)
14 }) {
15 HStack {
16 Image(symbol.key.lowercased())
17 Text("\(symbol.key): \(symbol.value)")
18 }
19 .foregroundColor(existingSymbols.contains(symbol.key) ? .secondary : .primary)
20 }
21 .disabled(existingSymbols.contains(symbol.key))
22 }
23 }
24 .searchable(text: $searchText)
25 .navigationBarTitle("Pick Currency", displayMode: .inline)
26 }
27
28 private func pickedSymbol(_ symbol: String) {
29 action(symbol)
30 presentationMode.wrappedValue.dismiss()
31 }
32
33 var searchResults: Dictionary<String, String> {
34 if searchText.isEmpty {
35 return Symbols.data.symbols
36 } else {
37 return Symbols.data.symbols.filter {
38 $0.key.contains(searchText.uppercased()) || $0.value.contains(searchText)}
39 }
40 }
41}

#Pull to Refresh

We've all used this feature in iOS apps. You're impatiently waiting on an important email, and so you drag your thumb down the page to get the app to check the server.

This feature isn't always helpful for apps that use Realm and Realm Sync. When Realm cloud data changes, the local realm is updated, and your SwiftUI view automatically refreshes to show the new data.

However, the feature is useful for the RCurrency app. I can use it to refresh all of the locally-stored exchange rates with fresh data from the API:

Animation showing currencies being refreshed when the screen is dragged dowm

We allow the user to trigger the refresh by adding a .refreshable modifier and action (refreshAll) to the list of currencies in CurrencyListContainerView:

1List {
2 ForEach(userSymbols.symbols, id: \.self) { symbol in
3 CurrencyRowContainerView(baseSymbol: userSymbols.baseSymbol,
4 baseAmount: $baseAmount,
5 symbol: symbol,
6 refreshNeeded: refreshNeeded)
7 .listRowSeparator(.hidden)
8 }
9 .onDelete(perform: deleteSymbol)
10}
11.refreshable{ refreshAll() }

In that code snippet, you can see that I added the .listRowSeparator(.hidden) modifier to the List. This is another iOS 15 feature that hides the line that would otherwise be displayed between each List item. Not a big feature, but every little bit helps in letting us use native SwiftUI to get the exact design we want.

#Text

#Markdown

I'm a big fan of Markdown. Markdown lets you write formatted text (including tables, links, and images) without taking your hands off the keyboard. I added this post to our CMS in markdown.

iOS 15 allows you to render markdown text within a Text view. If you pass a literal link to a Text view, then it's automatically rendered correctly:

1struct MarkDownTest: View {
2 var body: some View {
3 Text("Let's see some **bold**, *italics* and some ***bold italic text***. ~~Strike that~~. We can even include a [link](https://realm.io).")
4 }
5}

Text formatted. Included bold, italics and a link

But, it doesn't work out of the box for string constants or variables (e.g., data read from Realm):

1struct MarkDownTest: View {
2 let myString = "Let's see some **bold**, *italics* and some ***bold italic text***. ~~Strike that~~. We can even include a [link](https://realm.io)."
3
4 var body: some View {
5 Text(myString)
6 }
7}

Raw Markdown source code, rather than rendered text

The issue is that the version of Text that renders markdown expects to be passed an AttributedString. I created this simple Markdown view to handle this for us:

1struct MarkDown: View {
2 let text: String
3
4 @State private var formattedText: AttributedString?
5
6 var body: some View {
7 Group {
8 if let formattedText = formattedText {
9 Text(formattedText)
10 } else {
11 Text(text)
12 }
13 }
14 .onAppear(perform: formatText)
15 }
16
17 private func formatText() {
18 do {
19 try formattedText = AttributedString(markdown: text)
20 } catch {
21 print("Couldn't convert this from markdown: \(text)")
22 }
23 }
24}

I updated the ChatBubbleView in RChat to use the Markdown view:

1if chatMessage.text != "" {
2 MarkDown(text: chatMessage.text)
3 .padding(Dimensions.padding)
4}

RChat now supports markdown in user messages:

Animation showing that Markdown source is converted to formated text in the RChat app

#Dates

We all know that working with dates can be a pain. At least in iOS 15 we get some nice new functionality to control how we display dates and times. We use the new Date.formatted syntax.

In RChat, I want the date/time information included in a chat bubble to depend on how recently the message was sent. If a message was sent less than a minute ago, then I care about the time to the nearest second. If it were sent a day ago, then I want to see the day of the week plus the hour and minutes. And so on.

I created a TextDate view to perform this conditional formatting:

1struct TextDate: View {
2 let date: Date
3
4 private var isLessThanOneMinute: Bool { date.timeIntervalSinceNow > -60 }
5 private var isLessThanOneDay: Bool { date.timeIntervalSinceNow > -60 * 60 * 24 }
6 private var isLessThanOneWeek: Bool { date.timeIntervalSinceNow > -60 * 60 * 24 * 7}
7 private var isLessThanOneYear: Bool { date.timeIntervalSinceNow > -60 * 60 * 24 * 365}
8
9 var body: some View {
10 if isLessThanOneMinute {
11 Text(date.formatted(.dateTime.hour().minute().second()))
12 } else {
13 if isLessThanOneDay {
14 Text(date.formatted(.dateTime.hour().minute()))
15 } else {
16 if isLessThanOneWeek {
17 Text(date.formatted(.dateTime.weekday(.wide).hour().minute()))
18 } else {
19 if isLessThanOneYear {
20 Text(date.formatted(.dateTime.month().day()))
21 } else {
22 Text(date.formatted(.dateTime.year().month().day()))
23 }
24 }
25 }
26 }
27 }
28}

This preview code lets me test it's working in the Xcode Canvas preview:

1struct TextDate_Previews: PreviewProvider {
2 static var previews: some View {
3 VStack {
4 TextDate(date: Date(timeIntervalSinceNow: -60 * 60 * 24 * 365)) // 1 year ago
5 TextDate(date: Date(timeIntervalSinceNow: -60 * 60 * 24 * 7)) // 1 week ago
6 TextDate(date: Date(timeIntervalSinceNow: -60 * 60 * 24)) // 1 day ago
7 TextDate(date: Date(timeIntervalSinceNow: -60 * 60)) // 1 hour ago
8 TextDate(date: Date(timeIntervalSinceNow: -60)) // 1 minute ago
9 TextDate(date: Date()) // Now
10 }
11 }
12}

Screen capture of dates rendered in various formatt

We can then use TextDate in RChat's ChatBubbleView to add context-sensitive date and time information:

1TextDate(date: chatMessage.timestamp)
2 .font(.caption)

Screen capture of properly formatted dates against each chat message in the RChat app

#Keyboards

Customizing keyboards and form input was a real pain in the early days of SwiftUI—take a look at the work we did for the WildAid O-FISH app if you don't believe me. Thankfully, iOS 15 has shown some love in this area. There are a couple of features that I could see an immediate use for...

#Submit Labels

It's now trivial to rename the on-screen keyboard's "return" key. It sounds trivial, but it can give the user a big hint about what will happen if they press it.

To rename the return key, add a .submitLabel modifier to the input field. You pass the modifier one of these values:

  • done
  • go
  • send
  • join
  • route
  • search
  • return
  • next
  • continue

I decided to use these labels to improve the login flow for the LiveTutorial2021 app. In LoginView, I added a submitLabel to both the "email address" and "password" TextFields:

1TextField("email address", text: $email)
2 .submitLabel(.next)
3SecureField("password", text: $password)
4 .onSubmit(userAction)
5 .submitLabel(.go)

Screen capture showing that the "return" key is replaced with "next" when editing the email/username field

Screen capture showing that the "return" key is replaced with "go" when editing the password field

Note the .onSubmit(userAction) modifier on the password field. If the user taps "go" (or hits return on an external keyboard), then the userAction function is called. userAction either registers or logs in the user, depending on whether "Register new user” is checked.

#Focus

It can be tedious to have to click between different fields on a form. iOS 15 makes it simple to automate that shifting focus.

Sticking with LiveTutorial2021, I want the "email address" field to be selected when the view opens. When the user types their address and hits "return" "next", focus should move to the "password" field. When the user taps "go," the app logs them in.

You can use the new FocusState SwiftUI property wrapper to create variables to represent the placement of focus in the view. It can be a boolean to flag whether the associated field is in focus. In our login view, we have two fields that we need to switch focus between and so we use the enum option instead.

In LoginView, I define the Field enumeration type to represent whether the username (email address) or password is in focus. I then create the focussedField @FocusState variable to store the value using the Field type:

1enum Field: Hashable {
2 case username
3 case password
4}
5
6@FocusState private var focussedField: Field?

I use the .focussed modifier to bind focussedField to the two fields:

1TextField("email address", text: $email)
2 .focused($focussedField, equals: .username)
3 ...
4SecureField("password", text: $password)
5 .focused($focussedField, equals: .password)
6 ...

It's a two-way binding. If the user selects the email field, then focussedField is set to .username. If the code sets focussedField to .password, then focus switches to the password field.

This next step feels like a hack, but I've not found a better solution yet. When the view is loaded, the code waits half a second before setting focus to the username field. Without the delay, the focus isn't set:

1VStack(spacing: 16) {
2 ...
3}
4.onAppear {
5 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
6 focussedField = .username
7 ...
8 }
9}

The final step is to shift focus to the password field when the user hits the "next" key in the username field:

1TextField("email address", text: $email)
2 .onSubmit { focussedField = .password }
3 ...

This is the complete body from LoginView:

1var body: some View {
2 VStack(spacing: 16) {
3 Spacer()
4 TextField("email address", text: $email)
5 .focused($focussedField, equals: .username)
6 .submitLabel(.next)
7 .onSubmit { focussedField = .password }
8 SecureField("password", text: $password)
9 .focused($focussedField, equals: .password)
10 .onSubmit(userAction)
11 .submitLabel(.go)
12 Button(action: { newUser.toggle() }) {
13 HStack {
14 Image(systemName: newUser ? "checkmark.square" : "square")
15 Text("Register new user")
16 Spacer()
17 }
18 }
19 Button(action: userAction) {
20 Text(newUser ? "Register new user" : "Log in")
21 }
22 .buttonStyle(.borderedProminent)
23 .controlSize(.large)
24 Spacer()
25 }
26 .onAppear {
27 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
28 focussedField = .username
29 }
30 }
31 .padding()
32}

#Buttons

#Formatting

Previously, I've created custom SwiftUI views to make buttons look like…. buttons.

Things get simpler in iOS 15.

In LoginView, I added two new modifiers to my register/login button:

1Button(action: userAction) {
2 Text(newUser ? "Register new user" : "Log in")
3}
4.buttonStyle(.borderedProminent)
5.controlSize(.large)

Before making this change, I experimented with other button styles:

Xcode. Showing button source code and the associated previews

#Confirmation

It's very easy to accidentally tap the "Logout" button, and so I wanted to add this confirmation dialog:

Dialog for the user to confirm that they wish to log out

Again, iOS 15 makes this simple.

This is the modified version of the LogoutButton view:

1struct LogoutButton: View {
2 ...
3 @State private var isConfirming = false
4
5 var body: some View {
6 Button("Logout") { isConfirming = true }
7 .confirmationDialog("Are you sure want to logout",
8 isPresented: $isConfirming) {
9 Button(action: logout) {
10 Text("Confirm Logout")
11 }
12 Button("Cancel", role: .cancel) {}
13 }
14 }
15 ...
16}

These are the changes I made:

  • Added a new state variable (isConfirming)
  • Changed the logout button's action from calling the logout function to setting isConfirming to true
  • Added the confirmationDialog modifier to the button, providing three things:

    • The dialog title (I didn't override the titleVisibility option and so the system decides whether this should be shown)
    • A binding to isConfirming that controls whether the dialog is shown or not
    • A view containing the contents of the dialog:

      • A button to logout the user
      • A cancel button

#Material

I'm no designer, and this is blurring the edges of what changes I consider worth adding.

The RChat app may have to wait a moment while the backend MongoDB Realm application confirms that the user has been authenticated and logged in. I superimpose a progress view while that's happening:

A semi-transparrent overlay to indicate that the apps is working on something

To make it look a bit more professional, I can update OpaqueProgressView to use Material to blur the content that's behind the overlay. To get this effect, I update the background modifier for the VStack:

1var body: some View {
2 VStack {
3 if let message = message {
4 ProgressView(message)
5 } else {
6 ProgressView()
7 }
8 }
9 .padding(Dimensions.padding)
10 .background(.ultraThinMaterial,
11 in: RoundedRectangle(cornerRadius: Dimensions.cornerRadius))
12}

The result looks like this:

A semi-transparrent overlay, with the background blurred, to indicate that the apps is working on something

#Developer Tools

Finally, there are a couple of enhancements that are helpful during your development phase.

#Landscape Previews

I'm a big fan of Xcode's "Canvas" previews. Previews let you see what your view will look like. Previews update in more or less real time as you make code changes. You can even display multiple previews at once for example:

  • For different devices: .previewDevice(PreviewDevice(rawValue: "iPhone 12 Pro Max"))
  • For dark mode: .preferredColorScheme(.dark)

A glaring omission was that there was no way to preview landscape mode. That's fixed in iOS 15 with the addition of the .previewInterfaceOrientation modifier.

For example, this code will show two devices in the preview. The first will be in portrait mode. The second will be in landscape and dark mode:

1struct CurrencyRow_Previews: PreviewProvider {
2 static var previews: some View {
3 Group {
4 List {
5 CurrencyRowView(value: 3.23, symbol: "USD", baseValue: .constant(1.0))
6 CurrencyRowView(value: 1.0, symbol: "GBP", baseValue: .constant(10.0))
7 }
8 List {
9 CurrencyRowView(value: 3.23, symbol: "USD", baseValue: .constant(1.0))
10 CurrencyRowView(value: 1.0, symbol: "GBP", baseValue: .constant(10.0))
11 }
12 .preferredColorScheme(.dark)
13 .previewInterfaceOrientation(.landscapeLeft)
14 }
15 }
16}

Animation of Xcode preview. Shows that the preview updates in real time as the code is changed. There are previews for both landscape and portrait modes

#Self._printChanges

SwiftUI is very smart at automatically refreshing views when associated state changes. But sometimes, it can be hard to figure out exactly why a view is or isn't being updated.

iOS 15 adds a way to print out what pieces of state data have triggered each refresh for a view. Simply call Self._printChanges() from the body of your view. For example, I updated ContentView for the LiveChat app:

1struct ContentView: View {
2 @State private var username = ""
3
4 var body: some View {
5 print(Self._printChanges())
6 return NavigationView {
7 Group {
8 if app.currentUser == nil {
9 LoginView(username: $username)
10 } else {
11 ChatRoomsView(username: username)
12 }
13 }
14 .navigationBarTitle(username, displayMode: .inline)
15 .navigationBarItems(trailing: app.currentUser != nil ? LogoutButton(username: $username) : nil) }
16 }
17}

If I log in and check the Xcode console, I can see that it's the update to username that triggered the refresh (rather than app.currentUser):

1ContentView: _username changed.

There can be a lot of these messages, and so remember to turn them off before going into production.

#Conclusion

SwiftUI is developing at pace. With each iOS release, there is less and less reason to not use it for all/some of your mobile app.

This post describes how to use some of the iOS 15 SwiftUI features that caught my attention. I focussed on the features that I could see would instantly benefit my most recent mobile apps. In this article, I've shown how those apps could be updated to use these features.

There are lots of features that I didn't include here. A couple of notable omissions are:

  • AsyncImage is going to make it far easier to work with images that are stored in the cloud. I didn't need it for any of my current apps, but I've no doubt that I'll be using it in a project soon.
  • The task view modifier is going to have a significant effect on how people run asynchronous code when a view is loaded. I plan to cover this in a future article that takes a more general look at how to handle concurrency with Realm.
  • Adding a toolbar to your keyboards (e.g., to let the user switch between input fields).

If you have any questions or comments on this post (or anything else Realm-related), then please raise them on our community forum. To keep up with the latest Realm news, follow @realm on Twitter and join the Realm global community.

Rate this article
© 2021 MongoDB, Inc.

About

  • Careers
  • Legal Notices
  • Privacy Notices
  • Security Information
  • Trust Center
© 2021 MongoDB, Inc.