This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We reimplement SwiftUI's default button style to better understand its behavior.

00:06 Today we want to take a look at SwiftUI's default button style. In the workshops we do, we regularly notice that participants are surprised by how a button animates its contents. If we're not aware of this behavior, building buttons with custom labels can lead to some weird results.

00:39 So we'd like to take a good look at how a SwiftUI Button behaves, and then we want to reimplement its behavior on top of a primitive button style, just to get a really good grasp of what's going on.

Default vs. Plain Button Style

01:04 We start by creating a button and placing two copies of it in our view — one using the default style, and one with the .plain style:

struct ContentView: View {
    var body: some View {
        VStack {
            let button = Button("Hello, World!") { }
            button
            button
                .buttonStyle(.plain)

        }
        .font(.largeTitle)
        .padding()
    }
}

01:32 The first thing to notice is that when we press down on the first button, it instantly becomes transparent. When we release the button, it fades back to opaque with an animation. The same happens with the plain button style, but its change in opacity is more subtle.

02:22 In addition to the highlighting, the default button applies an accent color, whereas the plain button does not.

02:30 We can see the differences in the highlighted states of both buttons more clearly if we give them a gray background color. To do so, we need to switch to the initializer that lets us create our own label view:

struct ContentView: View {
    var body: some View {
        VStack {
            let button = Button(action: {
            }, label: {
                Text("Button")
                    .background(Color.gray)
            })
            button
            button
                .buttonStyle(.plain)
        }
        .font(.largeTitle)
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(Color.yellow)
    }
}

02:57 By placing the buttons over a yellow background, we can also see that the buttons really just change their opacity.

Animations

03:30 To see the implicit animations added by the default button style, we add a Boolean state property that we can toggle with the button. We then change the background color of the button labels based on the property's value:

struct ContentView: View {
    @State private var flag = true

    var body: some View {
        VStack {
            let button = Button(action: {
                flag.toggle()
            }, label: {
                Text("Button")
                    .background(flag ? Color.gray : Color.purple)
            })
            button
            button
                .buttonStyle(.plain)
        }
        .font(.largeTitle)
        .padding()
    }
}

04:20 When we press and release the top button, it gradually changes its color to the new value, but the second button changes to the new color instantly. It's difficult to see whether the default button style animates both the color value and the opacity, so let's change the setup slightly.

04:44 We add some padding to the label, and we overlay a circle that's aligned to either the leading or the trailing edge of the button, depending on the flag Boolean:

struct ContentView: View {
    @State private var flag = true
    
    var body: some View {
        VStack {
            let button = Button(action: {
                flag.toggle()
            }, label: {
                Text("Hello, World!")
                    .padding(20)
                    .background(Color.orange)
                    .overlay {
                        Circle()
                            .frame(width: 20, height: 20)
                            .frame(maxWidth: .infinity, alignment: flag ? .leading : .trailing)
                    }
            })
            button
            button
                .buttonStyle(.plain)
        }
        .font(.largeTitle)
        .padding()
    }
}

06:03 Now when we press down on the first button, we can see that it goes into its highlighted state by changing the label's opacity, and when we release the button, the circle animates from left to right. The circle in the plain button jumps to the other side without being animated. It's interesting that the default button style adds animations to the label, even though we haven't specified any animations ourselves.

06:34 This can sometimes lead to a strange behavior when we embed not just a simple label, but a more complicated view inside a button. In those cases, we might want to disable the implicit animation created by the default button style. The tricky thing is that we can't disable the animation from outside the button — even if we set the animation to nil, the circle still animates from side to side:

struct ContentView: View {
    @State private var flag = true
    
    var body: some View {
        VStack {
            let button = Button(action: {
                flag.toggle()
            }, label: {
                Text("Hello, World!")
                    .padding(20)
                    .background(Color.orange)
                    .overlay {
                        Circle()
                            .frame(width: 20, height: 20)
                            .frame(maxWidth: .infinity, alignment: flag ? .leading : .trailing)
                    }
            })
            button
                .animation(nil)
            button
                .buttonStyle(.plain)
        }
        .font(.largeTitle)
        .padding()
    }
}

07:28 However, if we specify a nil animation inside the button, it does disable the implicit animation:

struct ContentView: View {
    @State private var flag = true
    
    var body: some View {
        VStack {
            let button = Button(action: {
                flag.toggle()
            }, label: {
                Text("Hello, World!")
                    .padding(20)
                    .background(Color.orange)
                    .overlay {
                        Circle()
                            .frame(width: 20, height: 20)
                            .frame(maxWidth: .infinity, alignment: flag ? .leading : .trailing)
                    }
                    .animation(nil)
            })
            button
            button
                .buttonStyle(.plain)
        }
        .font(.largeTitle)
        .padding()
    }
}

07:52 The opacity fade still animates because it's probably applied with a modifier from outside the label view.

Replicating the Default Button Style

08:09 We now have an idea of how the default Button behaves — in this particular context — so let's try to replicate it in a custom button style. We start by conforming to PrimitiveButtonStyle, which is a more stripped-down version of ButtonStyle, without any of the animation magic:

struct CustomDefaultButtonStyle: PrimitiveButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
    }
}

struct ContentView: View {
    @State private var flag = true
    
    var body: some View {
        VStack {
            let button = Button(action: {
                flag.toggle()
            }, label: {
                // ...
            })
            button
                .buttonStyle(CustomDefaultButtonStyle())
            button
            button
                .buttonStyle(.plain)
        }
        .font(.largeTitle)
        .padding()
    }
}

09:21 The new button isn't interactive yet, because the primitive button style doesn't come with any gestures. We could call onTapGesture to trigger the button's action, but a tap gesture only fires after a completed tap, and we want to detect the moment the user presses down on the button, so that we can highlight the button in its pressed state. So instead, we need to define a drag gesture, because that lets us provide callbacks for when the gesture starts and for when it ends.

09:56 We add an isPressed state property, which we set to true in the drag gesture's onChanged callback, and to false in the onEnded callback. We can then use this property to set the button label's opacity:

struct CustomDefaultButtonStyle: PrimitiveButtonStyle {
    @State private var isPressed = false

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .opacity(isPressed ? 0.2 : 1)
            .gesture(DragGesture().onChanged { _ in
                isPressed = true
            }.onEnded({ _ in
                isPressed = false
            }))
    }
}

10:43 By default, the drag gesture only works when we move a few points after pressing down. To make the gesture hit as soon as we press down on the button, we set its minimum distance to zero:

DragGesture(minimumDistance: 0)

11:14 To run the button's action when the drag gesture ends, we call the trigger function on the configuration struct:

struct CustomDefaultButtonStyle: PrimitiveButtonStyle {
    @State private var isPressed = false

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .opacity(isPressed ? 0.2 : 1)
            .gesture(DragGesture().onChanged { _ in
                isPressed = true
            }.onEnded({ _ in
                isPressed = false
                configuration.trigger()
            }))
    }
}

Animating the Highlighted State

11:25 The button now works, but it doesn't animate yet. Copying the default style, the highlighted state should appear instantly, and when the button is released, the button should gradually fade back to fully opaque. We get close when we apply a default implicit animation:

struct CustomDefaultButtonStyle: PrimitiveButtonStyle {
    @State private var isPressed = false

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .opacity(isPressed ? 0.2 : 1)
            .animation(.default, value: isPressed)
            .gesture(DragGesture().onChanged { _ in
                isPressed = true
            }.onEnded({ _ in
                isPressed = false
                configuration.trigger()
            }))
    }
}

But now our button slowly fades to the highlighted state, while the default button style jumps to its highlighted state immediately.

12:30 Since we only want to animate when we release the button, we can take the line that mutates isPressed when the gesture ends and move it into a withAnimation closure:

struct CustomDefaultButtonStyle: PrimitiveButtonStyle {
    @State private var isPressed = false

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .opacity(isPressed ? 0.2 : 1)
            .gesture(DragGesture().onChanged { _ in
                isPressed = true
            }.onEnded({ _ in
                withAnimation {
                    isPressed = false
                }
                configuration.trigger()
            }))
    }
}

Now if we press the button, it highlights right away, and if we release it, it fades back. But unlike the default button style, the circle in the button's label jumps to its new position instead of being animated.

12:58 The default button style doesn't just animate the changes caused by updating the isPressed state; it also animates the view updates inside the button's label caused by the button's action being executed.

13:22 We could put the trigger call in the withAnimation closure, but that causes the other buttons to animate as well, and that's not what we want:

struct CustomDefaultButtonStyle: PrimitiveButtonStyle {
    @State private var isPressed = false

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .opacity(isPressed ? 0.2 : 1)
            .gesture(DragGesture().onChanged { _ in
                isPressed = true
            }.onEnded({ _ in
                withAnimation {
                    isPressed = false
                    configuration.trigger()
                }
            }))
    }
}

13:51 Instead, we want to animate the label contents when isPressed is changed. This means we should modify the configuration.label view with an implicit animation. And to avoid creating two separate transactions when the gesture's onEnded callback is run, we remove the withAnimation call there. This brings us back to the behavior where the button animates to and from its highlighted state, and the label view animates as well:

struct CustomDefaultButtonStyle: PrimitiveButtonStyle {
    @State private var isPressed = false

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .opacity(isPressed ? 0.2 : 1)
            .animation(.default, value: isPressed)
            .gesture(DragGesture().onChanged { _ in
                isPressed = true
            }.onEnded({ _ in
                isPressed = false
                configuration.trigger()
            }))
    }
}

15:22 Then, we can provide a Transaction value to disable the implicit animations when we set isPressed to true. By enabling the transaction's disablesAnimations flag, we tell SwiftUI to ignore the view tree's implicit animations:

struct CustomDefaultButtonStyle: PrimitiveButtonStyle {
    @State private var isPressed = false
    
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .opacity(isPressed ? 0.2 : 1)
            .animation(.default, value: isPressed)
            .gesture(DragGesture(minimumDistance: 0).onChanged { _ in
                var t = Transaction()
                t.disablesAnimations = true
                withTransaction(t) {
                    isPressed = true
                }
            }.onEnded({ _ in
                isPressed = false
                configuration.trigger()
            }))
    }
}

16:20 Now, when we press the button, it immediately goes to its highlighted state. This happens without an animation because the transaction prevents the implicit animation that's added to the label view. When we release the button, the implicit animation does work, so we see the opacity changing and the circle moving.

Accent Color

16:37 One thing still missing in our custom style is the button's color. Like the default button style, we want to set the current accent color as the label's foreground style:

struct CustomDefaultButtonStyle: PrimitiveButtonStyle {
    @State private var isPressed = false
    
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .foregroundStyle(Color.accentColor)
            .opacity(isPressed ? 0.2 : 1)
            .animation(.default, value: isPressed)
            .gesture(DragGesture(minimumDistance: 0).onChanged { _ in
                var t = Transaction()
                t.disablesAnimations = true
                withTransaction(t) {
                    isPressed = true
                }
            }.onEnded({ _ in
                isPressed = false
                configuration.trigger()
            }))
    }
}

17:01 The only thing we haven't covered is accessibility, which is something we probably get for free when using the built-in button styles.

17:16 But our main goal was to better understand how Button behaves. Now, when we see weird animations happening inside of our buttons, we can refer back to this exploration and see exactly what's happening under the hood. And we've been able to reimplement the behavior within SwiftUI itself, which really helps us get a better grasp on how SwiftUI works.

Resources

  • Sample Code

    Written in Swift 5.9

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

162 Episodes · 56h09min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes