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.