What You Will Build
A button that physically expands and flattens when pressed, with the text letter-spacing widening for a satisfying tactile feel. When the finger lifts, the button snaps back to its resting state. This microinteraction is ideal for download, subscribe, and purchase buttons where you want the tap to feel weighty and intentional.
Why This Pattern Matters
Default SwiftUI buttons provide minimal visual feedback on press. iOS users expect responsive touch feedback — Apple's own App Store download button subtly compresses on press. Building a custom press animation with DragGesture(minimumDistance: 0) gives you fine-grained control over the pressed and released states without needing a custom ButtonStyle.
Key SwiftUI Concepts
- DragGesture(minimumDistance: 0) — captures press and release without requiring actual drag movement.
- simultaneousGesture — ensures the gesture does not conflict with parent scroll views.
- .kerning() — animates letter spacing for a stretching text effect.
- Implicit animation with
.animation(.default, value:)for smooth state transitions.
Step 1: Define the Expanding Button
The button uses a Bool state expand to toggle between pressed and resting states. When pressed, the button widens, flattens, and the letter kerning increases:
struct ExpandingButton: View {
@State var expand = false
var title: String
var color: Color
var action: () -> Void
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: expand ? 5 : 10)
.frame(width: expand ? 300 : 190, height: expand ? 25 : 50)
.foregroundStyle(color)
Text(title)
.foregroundStyle(.white)
.fontWeight(.bold)
.kerning(expand ? 15 : 5)
.frame(width: 300, height: 50)
.offset(x: expand ? 8 : 5)
}
.animation(.default, value: expand)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in expand = true }
.onEnded { _ in
expand = false
action()
}
)
}
}
Step 2: Use Multiple Buttons Together
Stack several buttons with different colors and labels to create a cohesive action panel:
struct ExpandButtonDemo: View {
var body: some View {
VStack(spacing: 30) {
ExpandingButton(title: "DOWNLOAD", color: .blue) {
// handle download
}
ExpandingButton(title: "SUBSCRIBE", color: .green) {
// handle subscribe
}
ExpandingButton(title: "PURCHASE", color: .orange) {
// handle purchase
}
}
}
}
How It Works
The trick is using DragGesture(minimumDistance: 0) instead of a tap gesture. A tap gesture only fires on release, so you cannot animate the pressed state. A zero-distance drag gesture fires onChanged the instant the finger touches down, letting you set expand = true immediately. On release, onEnded resets the state and fires the action callback.
The .simultaneousGesture modifier is used instead of .gesture so the button works correctly inside ScrollView or List without stealing the scroll gesture.
Tips and Pitfalls
- Why not ButtonStyle? A custom
ButtonStylewithisPressedworks too, but gives you less control over the animation timing. The DragGesture approach lets you add delays or multi-stage animations. - Kerning offset: When kerning increases, the text shifts slightly. The
.offset(x:)compensates for this visual shift. - Corner radius animation: The radius shrinks from 10 to 5 when expanded, giving the button a flatter, more "pressed into surface" look.
- Accessibility: Add
.accessibilityAddTraits(.isButton)since this is not a native Button view. - Haptic feedback: Add
UIImpactFeedbackGenerator(style: .light).impactOccurred()inonChangedfor a tactile response.
iOS Version: iOS 15+