Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

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

TechniqueUse Case
Custom ShapeUnique visual elements
Implicit animationAlways-animated properties
Explicit animationControlled state changes
matchedGeometryEffectHero transitions
DragGestureSwipe, drag interactions
MagnificationGesturePinch to zoom
Animatable protocolCustom 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.

← Previous

iOS System Integrations: HealthKit, Widgets, and More

Next →

Mastering Combine and Reactive Patterns