# Avoid These Common Errors When Switching from UIKit to SwiftUI

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.

```swift
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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1708283195614/319dd35e-fa30-43f1-bd16-f51d9419b116.png align="center")

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` :

1. `isLoading` - This is to optionally show a spinner
    
2. `isActive` - 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:

```swift
    // 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:

```swift
    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](https://developer.apple.com/design/human-interface-guidelines/typography), 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](https://dev.jeremygale.com/swiftui-how-to-use-custom-fonts-and-images-in-a-swift-package).

### 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:

```swift
.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:

%[https://x.com/harlanhaskins/status/1501582946616045570] 

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.

```swift
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!

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1708282581654/4d40dc09-21f2-402b-992e-c7fdc1486e49.png align="center")

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.

```swift
Button(action: isActive ? action : {}) {
    Text(isLoading ? "" : title)
        .foregroundColor(isActive ? titleColor : titleColor.opacity(0.4))
        .font(.callout)
        .padding(.vertical, 14)
}
.frame(minWidth: 220)
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1708282602357/4d769c4a-e2ce-4912-9ca1-bb91de15acc4.png align="center")

Much better! How does this look if we increase the Dynamic Type size?

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1708282618235/c6dd7b8d-2382-45ff-969f-b32e492623a7.png align="center")

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?

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1708282914007/f16db109-7536-4130-8576-312294440ae5.png align="center")

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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1708282933817/3747aeb1-9ee6-4aeb-a75d-3d8b1b3f2380.png align="center")

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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1708282964162/97041fd0-54c9-473e-9905-7b079b064036.png align="center")

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.

```swift
 .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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1708283010334/e4928a66-5755-4609-b067-a8e31acf48ef.png align="center")

### 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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1708283041063/cf60c9d4-13c5-4565-91ef-07a29ba24efe.png align="center")

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](https://developer.apple.com/wwdc21/10022), better animation, and less sudden janky changes in appearance.

```swift
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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1708283113344/c4a8ab86-44e7-4032-b818-3ec5800b7875.png align="center")

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 like `Color(.secondarySystemBackground)`, and `Color(.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](https://developer.apple.com/design/human-interface-guidelines/color) 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:

```swift
    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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1708283342914/6b1e607b-ae3b-4359-a67d-2e0449aecd80.png align="center")

Let's replace the colors with:

```swift
    var titleColor = Color.primary
    var fillColor = Color(.systemGray3)
```

In light mode, it looks the same:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1708283786862/df3df050-dfc8-40c0-ade7-e008f217eaf7.png align="center")

However, dark mode looks much better!

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1708283374987/9f493795-39e6-4e51-bdf8-023337a48da5.png align="center")

### 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:

1. The Button will still have a touch down appearance even if `isActive == false`.
    
2. 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.
    
3. 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.

```swift
@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.

```swift
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!
