swiftui · tutorial · animation · components
Building an expandable add button in SwiftUI
What we’re building
A simple expandable action button in the top-right corner:
- A plus icon
- Tapping it rotates the plus into an ×
- Two secondary actions appear:
- From Photo Library
- From Camera
- Tapping again closes the menu
No UIKit. No custom shapes. Pure SwiftUI.
The mental model
There is only one piece of state:
isOpen = false
isOpen = true
Everything else derives from that single boolean:
- rotation
- visibility
- animation
- hit testing
SwiftUI recomputes the view based on state instead of you manually opening or closing anything.
Step 1: State and container
struct AddMenuView: View {
@State private var isOpen = false
var body: some View {
ZStack(alignment: .topTrailing) {
Color.clear
menu
}
}
}
@Statebelongs to the owner of the interactionZStacklets us layer content.topTrailingpins the menu to the corner
Step 2: The main add button
private var menu: some View {
VStack(alignment: .trailing, spacing: 12) {
if isOpen {
subButtons
}
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
isOpen.toggle()
}
} label: {
Image(systemName: "plus")
.rotationEffect(.degrees(isOpen ? 45 : 0))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.background(Circle().fill(Color.accentColor))
.shadow(radius: 4)
}
}
.padding()
}
- Rotating the plus icon by
45°visually reads as a close symbol - The same button opens and closes the menu
- Animation is fully driven by state changes
Step 3: The secondary actions
private var subButtons: some View {
VStack(alignment: .trailing, spacing: 8) {
actionButton(
title: "From Photo Library",
systemImage: "photo.on.rectangle"
) {
print("Photo library tapped")
}
actionButton(
title: "From Camera",
systemImage: "camera"
) {
print("Camera tapped")
}
}
.transition(
.opacity.combined(with: .move(edge: .top))
)
}
The transition describes how the buttons enter and leave the view hierarchy:
- fade in and slide down when appearing
- reverse when disappearing
Step 4: A reusable action button
private func actionButton(
title: String,
systemImage: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Label(title, systemImage: systemImage)
.font(.subheadline)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
Capsule().fill(Color(.systemGray6))
)
}
}
Extracting this keeps the main view readable and makes it easy to add more actions later.
Full working example
struct AddMenuView: View {
@State private var isOpen = false
var body: some View {
ZStack(alignment: .topTrailing) {
Color.clear
VStack(alignment: .trailing, spacing: 12) {
if isOpen {
VStack(alignment: .trailing, spacing: 8) {
actionButton(
title: "From Photo Library",
systemImage: "photo.on.rectangle"
) {}
actionButton(
title: "From Camera",
systemImage: "camera"
) {}
}
.transition(
.opacity.combined(with: .move(edge: .top))
)
}
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
isOpen.toggle()
}
} label: {
Image(systemName: "plus")
.rotationEffect(.degrees(isOpen ? 45 : 0))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.background(Circle().fill(Color.accentColor))
.shadow(radius: 4)
}
}
.padding()
}
}
private func actionButton(
title: String,
systemImage: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Label(title, systemImage: systemImage)
.font(.subheadline)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
Capsule().fill(Color(.systemGray6))
)
}
}
}
Key takeaways
- SwiftUI menus are just state-driven views
- Rotation, transitions, and visibility come from a single source of truth
- Avoid imperative show/hide logic
- Describe how the UI looks for each state
This pattern scales well from simple menus to complex, reusable components.
Mental model
This tutorial builds on the idea that views are lightweight descriptions:
Related