Building Custom SwiftUI Views and Animations
May 17, 2025 · 7 min read
iOS, SwiftUI, Animation, UI, Interview Prep
Introduction
Beautiful, responsive UIs separate great apps from good ones. Understanding SwiftUI's animation system, custom shapes, and gesture handling demonstrates your ability to build polished experiences. These skills come up in portfolio reviews and whiteboard sessions.
Creating delightful interactions in a nutrition app taught me which animation patterns feel natural and which frustrate users.
Custom View Components
Build reusable components with clear interfaces:
struct NutritionRing: View {
let current: Int
let goal: Int
let color: Color
let label: String
private var progress: Double {
min(Double(current) / Double(goal), 1.0)
}
var body: some View {
VStack {
ZStack {
// Background ring
Circle()
.stroke(color.opacity(0.2), lineWidth: 8)
// Progress ring
Circle()
.trim(from: 0, to: progress)
.stroke(color, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.rotationEffect(.degrees(-90))
// Center content
VStack(spacing: 2) {
Text("\(current)")
.font(.title2.bold())
Text("/ \(goal)")
.font(.caption)
.foregroundColor(.secondary)
}
}
.frame(width: 80, height: 80)
Text(label)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
// Usage
NutritionRing(current: 1500, goal: 2000, color: .orange, label: "Calories")
Custom Shapes with Path
Create unique shapes for your UI:
struct WaveShape: Shape {
var amplitude: CGFloat = 20
var frequency: CGFloat = 2
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: 0, y: rect.height))
for x in stride(from: 0, through: rect.width, by: 1) {
let relativeX = x / rect.width
let sine = sin(relativeX * frequency * .pi * 2)
let y = rect.height * 0.5 + sine * amplitude
path.addLine(to: CGPoint(x: x, y: y))
}
path.addLine(to: CGPoint(x: rect.width, y: rect.height))
path.closeSubpath()
return path
}
}
// Hexagon shape
struct Hexagon: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) / 2
for i in 0..<6 {
let angle = CGFloat(i) * .pi / 3 - .pi / 2
let point = CGPoint(
x: center.x + radius * cos(angle),
y: center.y + radius * sin(angle)
)
if i == 0 {
path.move(to: point)
} else {
path.addLine(to: point)
}
}
path.closeSubpath()
return path
}
}
Basic Animations
Implicit Animations
struct PulsingCircle: View {
@State private var scale: CGFloat = 1.0
var body: some View {
Circle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.scaleEffect(scale)
.animation(
.easeInOut(duration: 1.0).repeatForever(autoreverses: true),
value: scale
)
.onAppear {
scale = 1.2
}
}
}
Explicit Animations
struct AnimatedButton: View {
@State private var isPressed = false
var body: some View {
Button("Tap Me") {
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
isPressed.toggle()
}
}
.padding()
.background(isPressed ? Color.blue : Color.green)
.foregroundColor(.white)
.cornerRadius(10)
.scaleEffect(isPressed ? 1.1 : 1.0)
}
}
Implicit vs Explicit
Implicit (.animation modifier): Animates whenever the value changes. Good for properties that should always animate.
Explicit (withAnimation): Animates only changes within the closure. Better control over what animates when.
View Transitions
struct TransitionDemo: View {
@State private var showDetail = false
var body: some View {
VStack {
if showDetail {
DetailView()
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
}
Button("Toggle") {
withAnimation(.easeInOut(duration: 0.3)) {
showDetail.toggle()
}
}
}
}
}
// Custom transition
extension AnyTransition {
static var slideAndFade: AnyTransition {
.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .scale.combined(with: .opacity)
)
}
}
matchedGeometryEffect
Create hero transitions between views:
struct MealTypeSelector: View {
@Binding var selectedType: MealType
@Namespace private var animation
var body: some View {
HStack(spacing: 16) {
ForEach(MealType.allCases, id: \.self) { type in
MealTypeButton(
type: type,
isSelected: selectedType == type,
namespace: animation
) {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
selectedType = type
}
}
}
}
.padding()
}
}
struct MealTypeButton: View {
let type: MealType
let isSelected: Bool
let namespace: Namespace.ID
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
Image(systemName: type.iconName)
.font(.title2)
Text(type.displayName)
.font(.caption)
}
.padding(.vertical, 12)
.padding(.horizontal, 16)
.background {
if isSelected {
RoundedRectangle(cornerRadius: 12)
.fill(Color.blue)
.matchedGeometryEffect(id: "selection", in: namespace)
}
}
.foregroundColor(isSelected ? .white : .primary)
}
}
}
matchedGeometryEffect Magic
This modifier creates smooth transitions between views that share an identity. The selection indicator smoothly animates between buttons instead of appearing/disappearing. Use the same ID and namespace for views that represent the same element.
Gesture Handling
Tap and Long Press
struct TappableCard: View {
@State private var tapped = false
var body: some View {
RoundedRectangle(cornerRadius: 12)
.fill(tapped ? Color.blue : Color.gray)
.frame(height: 100)
.onTapGesture {
withAnimation {
tapped.toggle()
}
}
}
}
struct LongPressButton: View {
@State private var isPressed = false
var body: some View {
Circle()
.fill(isPressed ? Color.red : Color.blue)
.frame(width: 100, height: 100)
.gesture(
LongPressGesture(minimumDuration: 0.5)
.onEnded { _ in
withAnimation {
isPressed.toggle()
}
}
)
}
}
Drag Gesture
struct DraggableCard: View {
@State private var offset = CGSize.zero
@State private var isDragging = false
var body: some View {
RoundedRectangle(cornerRadius: 16)
.fill(isDragging ? Color.blue : Color.green)
.frame(width: 150, height: 100)
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
isDragging = true
}
.onEnded { _ in
withAnimation(.spring()) {
offset = .zero
isDragging = false
}
}
)
}
}
Magnification (Pinch to Zoom)
struct ZoomableImage: View {
@State private var scale: CGFloat = 1.0
var body: some View {
Image("photo")
.resizable()
.scaledToFit()
.scaleEffect(scale)
.gesture(
MagnificationGesture()
.onChanged { value in
scale = value
}
.onEnded { _ in
withAnimation {
scale = max(1.0, min(scale, 3.0))
}
}
)
}
}
Animatable Data
For custom animation interpolation:
struct AnimatableProgress: View, Animatable {
var progress: Double
var animatableData: Double {
get { progress }
set { progress = newValue }
}
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.gray.opacity(0.3))
Rectangle()
.fill(Color.blue)
.frame(width: geometry.size.width * progress)
}
}
.frame(height: 8)
.cornerRadius(4)
}
}
// Usage
struct ProgressDemo: View {
@State private var progress: Double = 0
var body: some View {
VStack {
AnimatableProgress(progress: progress)
Button("Animate") {
withAnimation(.easeInOut(duration: 1.0)) {
progress = progress == 0 ? 1.0 : 0
}
}
}
}
}
Interview Questions
Q1: What's the difference between implicit and explicit animations?
Answer:
Implicit (.animation modifier): Applies to property changes automatically
Circle()
.scaleEffect(scale)
.animation(.spring(), value: scale)
// Animates whenever scale changes
Explicit (withAnimation): Only animates changes within the closure
withAnimation(.spring()) {
scale = 1.5
opacity = 0.5
}
// Both changes animate
Use implicit for properties that should always animate; explicit for controlled, intentional animations.
Q2: How does matchedGeometryEffect work?
Answer: Creates smooth transitions between views sharing an identity:
@Namespace private var animation
// View A
Rectangle()
.matchedGeometryEffect(id: "shared", in: animation)
.frame(width: 100, height: 100)
// View B
Rectangle()
.matchedGeometryEffect(id: "shared", in: animation)
.frame(width: 200, height: 200)
When transitioning, SwiftUI interpolates position, size, and shape. Requires same ID and namespace.
Q3: How do you combine multiple gestures?
Answer:
// Simultaneous gestures
.gesture(
DragGesture()
.simultaneously(with: MagnificationGesture())
)
// Sequential gestures
.gesture(
LongPressGesture()
.sequenced(before: DragGesture())
)
// Priority gesture (overrides parent)
.highPriorityGesture(TapGesture())
Common Mistakes
1. Animating Too Many Properties
// ❌ Janky performance
withAnimation {
offset = CGPoint(x: 100, y: 100)
scale = 1.5
rotation = .degrees(45)
color = .red
opacity = 0.5
}
// ✅ Focus on key properties
withAnimation {
scale = 1.5
opacity = 0.5
}
2. Missing Animation Value
// ❌ Animates on ANY state change
Text("Hello")
.animation(.spring()) // Deprecated
// ✅ Specify the trigger
Text("Hello")
.animation(.spring(), value: isExpanded)
3. Gesture Conflicts in ScrollView
// ❌ Blocks scrolling
ScrollView {
ForEach(items) { item in
ItemView()
.gesture(DragGesture())
}
}
// ✅ Require minimum distance
ScrollView {
ForEach(items) { item in
ItemView()
.gesture(DragGesture(minimumDistance: 30))
}
}
Summary
| Technique | Use Case |
|---|---|
| Custom Shape | Unique visual elements |
| Implicit animation | Always-animated properties |
| Explicit animation | Controlled state changes |
| matchedGeometryEffect | Hero transitions |
| DragGesture | Swipe, drag interactions |
| MagnificationGesture | Pinch to zoom |
| Animatable protocol | Custom interpolation |
Polish comes from thoughtful animation - not adding more, but adding the right ones. A single well-timed spring animation creates more delight than a dozen flashy effects.
Part 7 of the iOS Interview Prep series.