Table of contents
- Starting Point - Don't Do This!
- Know what @Binding is for
- Prefer declarative logic to programmatic
- Use Dynamic Type fonts
- Don't use .frame() or other modifiers when unnecessary
- Avoid GeometryReader as whenever as possible
- Prefer .overlay() or .background() to ZStack
- Prefer padding/minWidth/minHeight to hardcoded frames
- Prefer view modifiers to conditional content
- Prefer .primary or .systemX to .black and .white
- Don't try to recreate functionality that is provided by SwiftUI
- Final State
I truly believe that I am 10x more productive with SwiftUI than with UIKit. However, there is no denying that SwiftUI has a steep learning curve. In fact, UIKit programmers might be at a disadvantage to newcomers because they know certain ways to make things work which are no longer relevant in SwiftUI. SwiftUI requires to to throw out everything you know, and retrain your brain.
In today's blog post, we are going to refactor a SwiftUI view to use simple, idiomatic SwiftUI. We will add support for Dark Mode and Dynamic Type. In the process, I will point out a bunch of mistakes that I find UIKit programmers make when first starting SwiftUI.
Starting Point - Don't Do This!
This is a view that I came across in a project I was working on. I expect that is was written by an experienced UIKit develop dipping their feet into SwiftUI.
import Combine
import SwiftUI
struct RoundButton: View {
let title: String
var titleColor = Color.black
var fillColor = Color(white: 0.8)
@State private var textColor = Color.black
@State private var backgroundColor = Color(white: 0.8)
@Binding var isLoading: Bool
@Binding var isActive: Bool
var action: () -> Void
var body: some View {
GeometryReader { geometry in
ZStack {
Button(action: isActive ? action : {}) {
Text(isLoading ? "" : title)
.foregroundColor(textColor)
.font(.system(size: 16))
.multilineTextAlignment(.center)
.frame(
minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity,
alignment: .center
)
}
if isLoading {
LoadingCircle()
.frame(height: geometry.size.height / 2)
}
}
.frame(
width: geometry.size.width,
height: geometry.size.height,
alignment: .center
)
.background {
backgroundColor
.clipShape(Capsule(style: .circular))
}
}
.onReceive(Just(isActive), perform: on(newIsActiveState:))
.frame(width: 220, height: 48)
}
private func on(newIsActiveState: Bool) {
textColor = newIsActiveState ? titleColor : titleColor.opacity(0.4)
backgroundColor = newIsActiveState ? fillColor : fillColor.opacity(0.4)
}
}
struct PreviewWrapper: View {
var body: some View {
VStack(spacing: 30) {
RoundButton(
title: "Continue",
isLoading: .constant(false),
isActive: .constant(true)
) {}
RoundButton(
title: "Disabled",
isLoading: .constant(false),
isActive: .constant(false)
) {}
RoundButton(
title: "Continue to step 2",
isLoading: .constant(false),
isActive: .constant(true)
) {}
RoundButton(
title: "Continue to step 2",
isLoading: .constant(true),
isActive: .constant(true)
) {}
Spacer()
}
}
}
#Preview {
PreviewWrapper()
}
#Preview("Dark mode") {
PreviewWrapper()
.previewDisplayName("Dark mode")
.preferredColorScheme(.dark)
}
#Preview("Dynamic type") {
PreviewWrapper()
.previewDisplayName("Dynamic Type")
.environment(\.sizeCategory, .accessibilityExtraLarge)
}
One note: I haven't showed the definition of LoadingCircle
but it's a view that with .frame(maxWidth: .infinity, maxHeight: .infinity)
so it will try to take up it's entire container. The definition isn't important. You can see what it looks like below.
Let's have a look at the Previews. I added a lot of Preview code to this author's view. This is common for me, it's a requirement on my projects to have working Previews for all your views. I like to think of Previews as unit tests of the visual appearance of your view.
I like to have Previews setup to view Dark Mode and Dynamic Type for the same content. It makes it easy to make sure you are keeping those things in mind as you're developing. The Dark Mode tab looks OK but not great. On the Dynamic Type tab, you'll see it doesn't look any different than the normal state. We'll fix that as we refactor.
Let's jump into refactoring. I encourage you to make a new SwiftUI app in Xcode, copy/paste in the code, and interactively follow along.
Know what @Binding
is for
One of the most common mistakes I see UIKit programmers make is misusing @Binding
. Quoth Apple's docs:
Use a binding to create a two-way connection between a property that stores data, and a view that displays and changes the data
The only reason that you need binding is if your view needs to update the value that the @Binding
refers to. @Binding
is not so that you can observe changes in the source variable.
In our RoundButton
sample code, there are two properties marked as @Binding
:
isLoading
- This is to optionally show a spinnerisActive
- To change the button to look disabled
Looking carefully at the code, you can see that nothing ever updates the binding. It's only reading the values. In fact, the code does the exact same thing if these are standard let
variables.
It is a common temptation for a UIKit programmer to think "I need a reference to this variable so I can observe it in my child view". That's now how SwiftUI views work. A SwiftUI view is really just a list of instructions on how to render some content on the screen. The view will be initialized multiple times while it's drawn on the screen. Even when we declare it as let isActive: Bool
, the code that responds to changes in that bool to change the text and background colors works fine. How does it work? I don't know, but it's SwiftUI magic, and you don't need @Binding
for it to happen. Try it for yourself.
Be sure to remove the now unnecessary .constants()
in your Previews.
Prefer declarative logic to programmatic
Another very common mistake I see from UIKit programmers adapting to SwiftUI - they will still be thinking in a programmatic mindset where they have to write code to update things themselves. For example, this snippet:
// At the bottom of the body of the View:
.onReceive(Just(isActive), perform: on(newIsActiveState:))
private func on(newIsActiveState: Bool) {
textColor = newIsActiveState ? titleColor : titleColor.opacity(0.4)
backgroundColor = newIsActiveState ? fillColor : fillColor.opacity(0.4)
}
The author was listening for any changes in isActive
state and using that to update @State
variables for the textColor
and backgroundColor
. This is completely unnecessary. The SwiftUI view will automatically redraw when the inputs change. This is part of the SwiftUI magic. We don't need to calculate anything, we can just use declarative logic:
var textColor: Color {
titleColor.opacity(isActive ? 1 : 0.4)
}
var backgroundColor: Color {
fillColor.opacity(isActive ? 1 : 0.4)
}
The separate variables aren't even necessary, you can just inline the colors in the View body if you prefer.
Use Dynamic Type fonts
Instead of .font(.system(size: 16))
we should be using Dynamic Type so that users who have bigger fonts will have them automatically scaled up. Dynamic Type is probably much more common than you think. I've seen data points that anywhere from 25% to 40% of users are not using the default font size.
Supporting Dynamic Type is incredibly easy in SwiftUI. Have a look at Apple's Typography page, look under iOS for Large (Default), see what font most closely matches up with this. It looks like Callout is size 16, so we'll use that instead. Replace with .font(.callout)
You can even do this for custom fonts; see my blog post on creating a Design System module in SwiftUI.
Don't use .frame()
or other modifiers when unnecessary
The next few items I'm going to discuss are all interrelated and it's difficult to refactor one without refactoring them all.
I often see UIKit programmers adding frames like this:
.frame(
minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity,
alignment: .center
)
This code makes the button expand to infinite size, and then relies on the hardcoded .frame(width: 220, height: 48)
to constrain it to be a reasonable size. This is not the right approach. Continue reading to find a better way.
The minWidth: 0, minHeight: 0, alignment: .center
are all unnecessary because those are all the defaults. A view can't shrink to be smaller than zero.
Similarly, Text is already center-aligned by default. There is no need to specify .multilineTextAlignment(.center)
.
Avoid GeometryReader
as whenever as possible
GeometryReader
is generally a code smell. Don't take it from me; this tweet from an Apple engineer that worked on SwiftUI:
There are edge cases where you need it, but they are few and far between. Geometry also has a very unfortunate side-effect of making whatever view take up infinite width and height, which we almost never want.
In our example here, the author seems to have been using GeometryReader
to make a LoadingCircle
take up half the height of the button. Let's find a better way to do that. Read on.
Prefer .overlay()
or .background()
to ZStack
We can see that the author was using a ZStack
to overlay a LoadingCircle
on top of the button. They were also using a GeometryReader
to read the size of the button and make the LoadingCircle
take up half the height.
In these sorts of situations, it's preferable to use a .overlay()
or .background()
on the view you are modifying instead of a ZStack
because the overlay/background will be the exact same size as the view it modifies. You don't need to use GeometryReaders or any or any other tricks to try to line up the size.
Prefer padding/minWidth/minHeight to hardcoded frames
When I'm reviewing SwiftUI code and I see a hardcoded frame like .frame(width: 220, height: 48)
that's an immediate code smell to me. Hardcoded frames like this will break it for users who use Dynamic Type. What if the user's font size is bigger than 48 points? What if the button has a long title? Their button text will just be cut off.
Let's take all of the items we've discussed earlier to refactor the view. No more hardcoded frames or GeometryReaders.
var body: some View {
Button(action: isActive ? action : {}) {
Text(isLoading ? "" : title)
.foregroundColor(titleColor.opacity(isActive ? 1 : 0.4))
.font(.callout)
}
.overlay {
if isLoading {
LoadingCircle()
}
}
.background {
fillColor.opacity(isActive ? 1 : 0.4)
.clipShape(Capsule(style: .circular))
}
}
OK, this is much simpler now, but the button is tiny!
Surely we need some hardcoded frames right? Wrong. Instead, let's add some padding so that the button is 48 points tall at the default size. We will also add a .frame(minWidth: 220)
on the Button so it is at least 220 points wide. This allows it to be bigger if the text expands, but by default it will be a reasonable size.
Button(action: isActive ? action : {}) {
Text(isLoading ? "" : title)
.foregroundColor(isActive ? titleColor : titleColor.opacity(0.4))
.font(.callout)
.padding(.vertical, 14)
}
.frame(minWidth: 220)
Much better! How does this look if we increase the Dynamic Type size?
Great! The button got bigger than 48 pts, but that's what your users want - to be able to see it. What happens if we had a longer title?
OK, it's bigger than 220 pts – that's fine too. We probably want it to have some padding between the text and the edge of the button. Let's change that padding to .padding(14)
so it takes effect for both vertical and horizontal axes.
Great! It works for Dynamic Type while still looking precisely how we wanted it at default sizes.
So now we need to make the LoadingCircle
take up about half the height. Right now it's taking the full height:
Now if it's really important to you that it takes precisely half-height, you can use a GeometryReader here. One benefit of using a GeometryReader in an overlay like this is that it won't expand the size of the view at all.
.overlay {
if isLoading {
GeometryReader { proxy in
LoadingCircle()
.frame(height: proxy.size.height / 2)
.frame(maxHeight: .infinity) // to vertically center it
}
}
}
However, given that this author wasn't even trying to support Dynamic Type, I'm not sure if they cared. Let's just add on .padding(.vertical, 11)
so that it's approximately half size. That looks pretty good.
Prefer view modifiers to conditional content
If we check with Dynamic Type on, we see that the button with a title of "Continue to step 2" is is smaller when using the isLoading == true
state. That's not what we want, it would be jarring for the button to change size once someone enables the isLoading
flag.
The reason is because of Text(isLoading ? "" : title)
. It's going to render an empty string when we are in the loading state, and that affects the size of the button (because we are no longer using hardcoded frames). So instead, we will always render the title the same size, and just set the opacity to 0 if it's loading. This is a general SwiftUI tip - try not to use if/else or ternary ? operators for different content/appearance. Instead use ViewModifiers to adjust the appearance. It will lead to stable view identity, better animation, and less sudden janky changes in appearance.
Button(action: isActive ? action : {}) {
Text(title)
.opacity(isLoading ? 0 : 1)
.foregroundColor(isActive ? titleColor : titleColor.opacity(0.4))
.font(.callout)
.padding(14)
}
.frame(minWidth: 220)
.overlay {
if isLoading {
LoadingCircle()
.padding(.vertical, 11)
}
}
Let's see the Dynamic Type layout with the spinner now:
Not bad! It's not half height, but it's perfectly acceptable in my opinion.
Prefer .primary
or .systemX
to .black
and .white
I'll often see new SwiftUI developers explicitly using hardcoded colors like Color.white
for the background of a view or Color.black
for the text. That immediately breaks dark mode support. It's much easier to support dark mode from the beginning than to retrofit it later.
For starters, in light mode, the text will already be black by default and the background will already be light. You don't need to write any code for it. What's even better - the default colors used by iOS are will automatically adapt to dark mode!
What iOS uses:
Color.primary
- This will be black in light mode, but white in dark mode. This is the default color for text.Color(.systemBackground)
- This will be white in light mode, but black in dark mode. This is the default background color for views. There are also many more likeColor(.secondarySystemBackground)
, andColor(.tertiarySystemBackground)
,Color(.systemGray[2-6])
for when you want gray colors that adapt to dark mode.
Reading the Color section of Apple's Human Interface Guidelines is very instructive.
Also, if you are using an entirely custom color scheme, you can specify dark mode versions for every color in your xcassets catalog.
The author started with these colors:
var titleColor = Color.black
var fillColor = Color(white: 0.8)
Looking at the dark mode previews, it doesn't look horrible, but it looks oddly bright:
Let's replace the colors with:
var titleColor = Color.primary
var fillColor = Color(.systemGray3)
In light mode, it looks the same:
However, dark mode looks much better!
Don't try to recreate functionality that is provided by SwiftUI
One of the immediate code smells is that this RoundButton view is taking in an argument for for isActive
when SwiftUI itself already has the disabled(_ disabled: Bool)
ViewModifier. It's a bad idea to be recreating this functionality for a few reasons:
The Button will still have a touch down appearance even if
isActive == false
.When a Button has
.disabled(true)
it will automatically draw a disabled state, and maybe that looks perfectly fine to you. You can omit all the.opacity
code if so.If you were to mark a parent view that contains this view as
.disabled(true)
, it wouldn't cascade down to this view.
For disabled state specifically, we can access the isEnabled state within the view, and completely remove the isActive
property.
@Environment(\.isEnabled) private var isEnabled
As a side benefit, this code is no longer necessary because when the button is disabled it is not tappable anyway: Button(action: isEnabled ? action : {})
It will also draw the button in a disabled state and not show any animation when you touch down inside it. As mentioned above, if the default disabled state appearance is good enough for you we don't need any of the .opacity
modifiers.
There won't always be an environment property to match what you are looking for, but often there is. If you find yourself re-implementing something built into SwiftUI, try searching for if there is a way to access that functionality instead of re-implement it.
Along these lines, an even better refactoring would be to use a custom ButtonStyle
to accomplish everything in this view, but that's beyond the scope of this article.
Final State
Let's have a look at this view after fully refactoring it.
struct RoundButton: View {
let title: String
let isLoading: Bool
var titleColor = Color.primary
var fillColor = Color(.systemGray3)
let action: () -> Void
@Environment(\.isEnabled) private var isEnabled
var body: some View {
Button(action: action) {
Text(title)
.opacity(isLoading ? 0 : 1)
.foregroundColor(titleColor.opacity(isEnabled ? 1 : 0.4))
.font(.callout)
.padding(14)
}
.frame(minWidth: 220)
.background {
fillColor.opacity(isEnabled ? 1 : 0.4)
.clipShape(Capsule(style: .circular))
}
.overlay {
if isLoading {
LoadingCircle()
.padding(.vertical, 11)
}
}
}
}
We've actually dramatically simplified the code; there are far less lines than before. It's much easier to read. It's more flexible - no more hardcoded sizes. And it works properly in Dark Mode and Dynamic Type!