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 List
s are pretty critical to data-based apps. I use List
s 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:
The swipe actions are implemented in TasksView
:
List {
ForEach(tasks) { task in
TaskView(task: task)
.swipeActions(edge: .leading) {
if task.statusEnum == .Open || task.statusEnum == .InProgress {
CompleteButton(task: task)
}
if task.statusEnum == .Open || task.statusEnum == .Complete {
InProgressButton(task: task)
}
if task.statusEnum == .InProgress || task.statusEnum == .Complete {
NotStartedButton(task: task)
}
}
.swipeActions(edge: .trailing) {
Button(role: .destructive, action: { $tasks.remove(task) }) {
Label("Delete", systemImage: "trash")
}
}
}
}
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
:
struct CompleteButton: View {
@ObservedRealmObject var task: Task
var body: some View {
Button(action: { $task.statusEnum.wrappedValue = .Complete }) {
Label("Complete", systemImage: "checkmark")
}
.tint(.green)
}
}
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:
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:
struct SymbolPickerView: View {
...
@State private var searchText = ""
...
var searchResults: Dictionary<String, String> {
if searchText.isEmpty {
return Symbols.data.symbols
} else {
return Symbols.data.symbols.filter {
$0.key.contains(searchText.uppercased()) || $0.value.contains(searchText)}
}
}
}
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:
List {
ForEach(searchResults.sorted(by: <), id: \.key) { symbol in
...
}
}
.searchable(text: $searchText)
This is the full view:
struct SymbolPickerView: View {
@Environment(\.presentationMode) var presentationMode
var action: (String) -> Void
let existingSymbols: [String]
@State private var searchText = ""
var body: some View {
List {
ForEach(searchResults.sorted(by: <), id: \.key) { symbol in
Button(action: {
pickedSymbol(symbol.key)
}) {
HStack {
Image(symbol.key.lowercased())
Text("\(symbol.key): \(symbol.value)")
}
.foregroundColor(existingSymbols.contains(symbol.key) ? .secondary : .primary)
}
.disabled(existingSymbols.contains(symbol.key))
}
}
.searchable(text: $searchText)
.navigationBarTitle("Pick Currency", displayMode: .inline)
}
private func pickedSymbol(_ symbol: String) {
action(symbol)
presentationMode.wrappedValue.dismiss()
}
var searchResults: Dictionary<String, String> {
if searchText.isEmpty {
return Symbols.data.symbols
} else {
return Symbols.data.symbols.filter {
$0.key.contains(searchText.uppercased()) || $0.value.contains(searchText)}
}
}
}
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:
We allow the user to trigger the refresh by adding a .refreshable
modifier and action (refreshAll
) to the list of currencies in CurrencyListContainerView
:
List {
ForEach(userSymbols.symbols, id: \.self) { symbol in
CurrencyRowContainerView(baseSymbol: userSymbols.baseSymbol,
baseAmount: $baseAmount,
symbol: symbol,
refreshNeeded: refreshNeeded)
.listRowSeparator(.hidden)
}
.onDelete(perform: deleteSymbol)
}
.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:
struct MarkDownTest: View {
var body: some View {
Text("Let's see some **bold**, *italics* and some ***bold italic text***. ~~Strike that~~. We can even include a [link](https://realm.io).")
}
}
But, it doesn’t work out of the box for string constants or variables (e.g., data read from Realm):
struct MarkDownTest: View {
let myString = "Let's see some **bold**, *italics* and some ***bold italic text***. ~~Strike that~~. We can even include a [link](https://realm.io)."
var body: some View {
Text(myString)
}
}
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:
struct MarkDown: View {
let text: String
@State private var formattedText: AttributedString?
var body: some View {
Group {
if let formattedText = formattedText {
Text(formattedText)
} else {
Text(text)
}
}
.onAppear(perform: formatText)
}
private func formatText() {
do {
try formattedText = AttributedString(markdown: text)
} catch {
print("Couldn't convert this from markdown: \(text)")
}
}
}
I updated the ChatBubbleView
in RChat to use the Markdown
view:
if chatMessage.text != "" {
MarkDown(text: chatMessage.text)
.padding(Dimensions.padding)
}
RChat now supports markdown in user messages:
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:
struct TextDate: View {
let date: Date
private var isLessThanOneMinute: Bool { date.timeIntervalSinceNow > -60 }
private var isLessThanOneDay: Bool { date.timeIntervalSinceNow > -60 * 60 * 24 }
private var isLessThanOneWeek: Bool { date.timeIntervalSinceNow > -60 * 60 * 24 * 7}
private var isLessThanOneYear: Bool { date.timeIntervalSinceNow > -60 * 60 * 24 * 365}
var body: some View {
if isLessThanOneMinute {
Text(date.formatted(.dateTime.hour().minute().second()))
} else {
if isLessThanOneDay {
Text(date.formatted(.dateTime.hour().minute()))
} else {
if isLessThanOneWeek {
Text(date.formatted(.dateTime.weekday(.wide).hour().minute()))
} else {
if isLessThanOneYear {
Text(date.formatted(.dateTime.month().day()))
} else {
Text(date.formatted(.dateTime.year().month().day()))
}
}
}
}
}
}
This preview code lets me test it’s working in the Xcode Canvas preview:
struct TextDate_Previews: PreviewProvider {
static var previews: some View {
VStack {
TextDate(date: Date(timeIntervalSinceNow: -60 * 60 * 24 * 365)) // 1 year ago
TextDate(date: Date(timeIntervalSinceNow: -60 * 60 * 24 * 7)) // 1 week ago
TextDate(date: Date(timeIntervalSinceNow: -60 * 60 * 24)) // 1 day ago
TextDate(date: Date(timeIntervalSinceNow: -60 * 60)) // 1 hour ago
TextDate(date: Date(timeIntervalSinceNow: -60)) // 1 minute ago
TextDate(date: Date()) // Now
}
}
}
We can then use TextDate
in RChat’s ChatBubbleView
to add context-sensitive date and time information:
TextDate(date: chatMessage.timestamp)
.font(.caption)
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
:
TextField("email address", text: $email)
.submitLabel(.next)
SecureField("password", text: $password)
.onSubmit(userAction)
.submitLabel(.go)
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:
enum Field: Hashable {
case username
case password
}
@FocusState private var focussedField: Field?
I use the .focussed
modifier to bind focussedField
to the two fields:
TextField("email address", text: $email)
.focused($focussedField, equals: .username)
...
SecureField("password", text: $password)
.focused($focussedField, equals: .password)
...
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:
VStack(spacing: 16) {
...
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
focussedField = .username
...
}
}
The final step is to shift focus to the password field when the user hits the “next” key in the username field:
TextField("email address", text: $email)
.onSubmit { focussedField = .password }
...
This is the complete body from LoginView
:
var body: some View {
VStack(spacing: 16) {
Spacer()
TextField("email address", text: $email)
.focused($focussedField, equals: .username)
.submitLabel(.next)
.onSubmit { focussedField = .password }
SecureField("password", text: $password)
.focused($focussedField, equals: .password)
.onSubmit(userAction)
.submitLabel(.go)
Button(action: { newUser.toggle() }) {
HStack {
Image(systemName: newUser ? "checkmark.square" : "square")
Text("Register new user")
Spacer()
}
}
Button(action: userAction) {
Text(newUser ? "Register new user" : "Log in")
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Spacer()
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
focussedField = .username
}
}
.padding()
}
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:
Button(action: userAction) {
Text(newUser ? "Register new user" : "Log in")
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Before making this change, I experimented with other button styles:
Confirmation
It’s very easy to accidentally tap the “Logout” button, and so I wanted to add this confirmation dialog:
Again, iOS 15 makes this simple.
This is the modified version of the LogoutButton
view:
struct LogoutButton: View {
...
@State private var isConfirming = false
var body: some View {
Button("Logout") { isConfirming = true }
.confirmationDialog("Are you sure want to logout",
isPresented: $isConfirming) {
Button(action: logout) {
Text("Confirm Logout")
}
Button("Cancel", role: .cancel) {}
}
}
...
}
These are the changes I made:
- Added a new state variable (
isConfirming
) - Changed the logout button’s action from calling the
logout
function to settingisConfirming
totrue
- 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
- The dialog title (I didn’t override the
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:
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
:
var body: some View {
VStack {
if let message = message {
ProgressView(message)
} else {
ProgressView()
}
}
.padding(Dimensions.padding)
.background(.ultraThinMaterial,
in: RoundedRectangle(cornerRadius: Dimensions.cornerRadius))
}
The result looks like this:
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:
struct CurrencyRow_Previews: PreviewProvider {
static var previews: some View {
Group {
List {
CurrencyRowView(value: 3.23, symbol: "USD", baseValue: .constant(1.0))
CurrencyRowView(value: 1.0, symbol: "GBP", baseValue: .constant(10.0))
}
List {
CurrencyRowView(value: 3.23, symbol: "USD", baseValue: .constant(1.0))
CurrencyRowView(value: 1.0, symbol: "GBP", baseValue: .constant(10.0))
}
.preferredColorScheme(.dark)
.previewInterfaceOrientation(.landscapeLeft)
}
}
}
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:
struct ContentView: View {
@State private var username = ""
var body: some View {
print(Self._printChanges())
return NavigationView {
Group {
if app.currentUser == nil {
LoginView(username: $username)
} else {
ChatRoomsView(username: username)
}
}
.navigationBarTitle(username, displayMode: .inline)
.navigationBarItems(trailing: app.currentUser != nil ? LogoutButton(username: $username) : nil) }
}
}
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
):
ContentView: _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.