Before you step
Fork swiftuibuttons and create ArrowButton.swift. Paste the constants and colours first — the same tokens you locked in Part 1 before touching strokes or blur.
Each section below adds the next layer. The step titles match ArrowButtonStep.title in the repo and the labels in the video above.
| 1 | import SwiftUI |
| 2 | |
| 3 | // MARK: - Constants |
| 4 | private enum ArrowButtonMetrics { |
| 5 | static let buttonSize: CGFloat = 125 |
| 6 | static let reflectionDiameter: CGFloat = 90 |
| 7 | static let reflectionBlur: CGFloat = 8 |
| 8 | static let reflectionYOffset: CGFloat = 14 |
| 9 | static let strokeLineWidth: CGFloat = 3 |
| 10 | static let arrowShadowOpacity: CGFloat = 0.1 |
| 11 | static let arrowShadowRadius: CGFloat = 1 |
| 12 | static let arrowShadowYOffset: CGFloat = 1 |
| 13 | } |
| 14 | |
| 15 | // MARK: - Colors |
| 16 | private extension Color { |
| 17 | static let buttonFill = Color(red: 120/255, green: 133/255, blue: 141/255) |
| 18 | } |
| 19 | |
| 20 | private extension LinearGradient { |
| 21 | static var reflectionFill: LinearGradient { |
| 22 | LinearGradient( |
| 23 | stops: [ |
| 24 | Gradient.Stop(color: Color(red: 0.48, green: 0.52, blue: 0.55), location: 0.00), |
| 25 | Gradient.Stop(color: Color(red: 0.64, green: 0.67, blue: 0.70), location: 1.00), |
| 26 | ], |
| 27 | startPoint: .top, |
| 28 | endPoint: .bottom |
| 29 | ) |
| 30 | } |
| 31 | } |
Base Circle
Add CircularSteelStrokeOverlay, ArrowButtonStep, and ArrowButton. At .base, visibleStrokeCount is 0 — the overlay draws nothing yet. You only see Circle().fill(Color.buttonFill).
showsReflection and showsArrow are still false, so reflectionLayer and the arrow Image stay hidden.
| 1 | // MARK: - Stroke Overlay |
| 2 | private struct CircularSteelStrokeOverlay: ViewModifier { |
| 3 | /// How many of the four steel strokes are visible (0–4). |
| 4 | var visibleStrokeCount: Int |
| 5 | |
| 6 | func body(content: Content) -> some View { |
| 7 | content.overlay(strokeLayer) |
| 8 | } |
| 9 | |
| 10 | private var strokeLayer: some View { |
| 11 | ZStack { |
| 12 | if visibleStrokeCount >= 1 { |
| 13 | Circle() |
| 14 | .stroke( |
| 15 | LinearGradient( |
| 16 | gradient: Gradient(stops: [ |
| 17 | .init(color: .black.opacity(0.4), location: 0.0), |
| 18 | .init(color: .black.opacity(0.0), location: 0.1) |
| 19 | ]), |
| 20 | startPoint: .leading, |
| 21 | endPoint: .topTrailing |
| 22 | ), |
| 23 | lineWidth: ArrowButtonMetrics.strokeLineWidth |
| 24 | ) |
| 25 | .blendMode(.darken) |
| 26 | } |
| 27 | |
| 28 | if visibleStrokeCount >= 2 { |
| 29 | Circle() |
| 30 | .stroke( |
| 31 | LinearGradient( |
| 32 | gradient: Gradient(stops: [ |
| 33 | .init(color: .black.opacity(0.4), location: 0.0), |
| 34 | .init(color: .black.opacity(0.0), location: 0.1) |
| 35 | ]), |
| 36 | startPoint: .trailing, |
| 37 | endPoint: .topLeading |
| 38 | ), |
| 39 | lineWidth: ArrowButtonMetrics.strokeLineWidth |
| 40 | ) |
| 41 | .blendMode(.darken) |
| 42 | } |
| 43 | |
| 44 | if visibleStrokeCount >= 3 { |
| 45 | Circle() |
| 46 | .stroke( |
| 47 | LinearGradient( |
| 48 | gradient: Gradient(stops: [ |
| 49 | .init(color: .white.opacity(0.8), location: 0.1), |
| 50 | .init(color: .white.opacity(0.0), location: 0.4) |
| 51 | ]), |
| 52 | startPoint: .bottom, |
| 53 | endPoint: .top |
| 54 | ), |
| 55 | lineWidth: ArrowButtonMetrics.strokeLineWidth |
| 56 | ) |
| 57 | .blendMode(.overlay) |
| 58 | } |
| 59 | |
| 60 | if visibleStrokeCount >= 4 { |
| 61 | Circle() |
| 62 | .stroke( |
| 63 | LinearGradient( |
| 64 | gradient: Gradient(stops: [ |
| 65 | .init(color: .white.opacity(0.8), location: 0.0), |
| 66 | .init(color: .white.opacity(0.0), location: 0.3) |
| 67 | ]), |
| 68 | startPoint: .top, |
| 69 | endPoint: .bottom |
| 70 | ), |
| 71 | lineWidth: ArrowButtonMetrics.strokeLineWidth |
| 72 | ) |
| 73 | .blendMode(.normal) |
| 74 | } |
| 75 | } |
| 76 | .compositingGroup() |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | private extension View { |
| 81 | func circularSteelStrokeOverlay(visibleStrokeCount: Int = 4) -> some View { |
| 82 | modifier(CircularSteelStrokeOverlay(visibleStrokeCount: visibleStrokeCount)) |
| 83 | } |
| 84 | } |
| 1 | // MARK: - Build Steps (matches design breakdown progression) |
| 2 | enum ArrowButtonStep: Int, CaseIterable, Comparable { |
| 3 | case base = 1 |
| 4 | case strokeDarkLeading = 2 |
| 5 | case strokeDarkTrailing = 3 |
| 6 | case strokeHighlightBottom = 4 |
| 7 | case strokeHighlightTop = 5 |
| 8 | case reflection = 6 |
| 9 | case reflectionBlur = 7 |
| 10 | case arrow = 8 |
| 11 | case complete = 9 |
| 12 | |
| 13 | static func < (lhs: ArrowButtonStep, rhs: ArrowButtonStep) -> Bool { |
| 14 | lhs.rawValue < rhs.rawValue |
| 15 | } |
| 16 | |
| 17 | var title: String { |
| 18 | switch self { |
| 19 | case .base: "Base Circle" |
| 20 | case .strokeDarkLeading: "Dark Edge I" |
| 21 | case .strokeDarkTrailing: "Dark Edge II" |
| 22 | case .strokeHighlightBottom: "Light Edge I" |
| 23 | case .strokeHighlightTop: "Light Edge II" |
| 24 | case .reflection: "Reflection" |
| 25 | case .reflectionBlur: "Frosted Blur" |
| 26 | case .arrow: "Arrow Icon" |
| 27 | case .complete: "Complete" |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | var visibleStrokeCount: Int { |
| 32 | switch self { |
| 33 | case .base: 0 |
| 34 | case .strokeDarkLeading: 1 |
| 35 | case .strokeDarkTrailing: 2 |
| 36 | case .strokeHighlightBottom: 3 |
| 37 | case .strokeHighlightTop, .reflection, .reflectionBlur, .arrow, .complete: 4 |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | var reflectionBlurRadius: CGFloat { |
| 42 | switch self { |
| 43 | case .reflection: 0 |
| 44 | case .reflectionBlur, .arrow, .complete: ArrowButtonMetrics.reflectionBlur |
| 45 | default: 0 |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | var showsReflection: Bool { |
| 50 | rawValue >= ArrowButtonStep.reflection.rawValue |
| 51 | } |
| 52 | |
| 53 | var showsArrow: Bool { |
| 54 | self == .arrow || self == .complete |
| 55 | } |
| 56 | } |
| 1 | // MARK: - Arrow Button |
| 2 | struct ArrowButton: View { |
| 3 | var rotation: Angle = .zero |
| 4 | var step: ArrowButtonStep = .complete |
| 5 | |
| 6 | var body: some View { |
| 7 | ZStack { |
| 8 | buttonFace |
| 9 | |
| 10 | if step.showsArrow { |
| 11 | Image("arrow") |
| 12 | .rotationEffect(rotation) |
| 13 | .shadow(color: .black.opacity(ArrowButtonMetrics.arrowShadowOpacity), |
| 14 | radius: ArrowButtonMetrics.arrowShadowRadius, |
| 15 | y: ArrowButtonMetrics.arrowShadowYOffset) |
| 16 | .accessibilityHidden(true) |
| 17 | .transition(.opacity) |
| 18 | } |
| 19 | } |
| 20 | .animation(.easeInOut(duration: 0.3), value: step) |
| 21 | .contentShape(Circle()) |
| 22 | .accessibilityLabel("Arrow button") |
| 23 | } |
| 24 | |
| 25 | private var buttonFace: some View { |
| 26 | ZStack { |
| 27 | Circle() |
| 28 | .fill(Color.buttonFill) |
| 29 | |
| 30 | reflectionLayer |
| 31 | } |
| 32 | .frame(width: ArrowButtonMetrics.buttonSize, height: ArrowButtonMetrics.buttonSize) |
| 33 | .clipShape(Circle()) |
| 34 | .circularSteelStrokeOverlay(visibleStrokeCount: step.visibleStrokeCount) |
| 35 | } |
| 36 | |
| 37 | @ViewBuilder |
| 38 | private var reflectionLayer: some View { |
| 39 | if step.showsReflection { |
| 40 | Circle() |
| 41 | .fill(LinearGradient.reflectionFill) |
| 42 | .frame( |
| 43 | width: ArrowButtonMetrics.reflectionDiameter, |
| 44 | height: ArrowButtonMetrics.reflectionDiameter |
| 45 | ) |
| 46 | .offset(y: ArrowButtonMetrics.reflectionYOffset) |
| 47 | .blur(radius: step.reflectionBlurRadius) |
| 48 | .allowsHitTesting(false) |
| 49 | .transition(.opacity) |
| 50 | } |
| 51 | } |
| 52 | } |

Dark Edge I
Step to .strokeDarkLeading. visibleStrokeCount becomes 1 — the first if visibleStrokeCount >= 1 branch in CircularSteelStrokeOverlay draws the leading dark gradient with .blendMode(.darken).
| 1 | var visibleStrokeCount: Int { |
| 2 | switch self { |
| 3 | case .base: 0 |
| 4 | case .strokeDarkLeading: 1 |
| 5 | case .strokeDarkTrailing: 2 |
| 6 | case .strokeHighlightBottom: 3 |
| 7 | case .strokeHighlightTop, .reflection, .reflectionBlur, .arrow, .complete: 4 |
| 8 | } |
| 9 | } |

Dark Edge II
At .strokeDarkTrailing, visibleStrokeCount is 2. The second darken stroke runs from trailing toward topLeading — the mirror of Dark Edge I.
| 1 | var visibleStrokeCount: Int { |
| 2 | switch self { |
| 3 | case .base: 0 |
| 4 | case .strokeDarkLeading: 1 |
| 5 | case .strokeDarkTrailing: 2 |
| 6 | case .strokeHighlightBottom: 3 |
| 7 | case .strokeHighlightTop, .reflection, .reflectionBlur, .arrow, .complete: 4 |
| 8 | } |
| 9 | } |

Light Edge I
At .strokeHighlightBottom, visibleStrokeCount is 3. The third stroke adds a white gradient along the bottom with .blendMode(.overlay).
| 1 | var visibleStrokeCount: Int { |
| 2 | switch self { |
| 3 | case .base: 0 |
| 4 | case .strokeDarkLeading: 1 |
| 5 | case .strokeDarkTrailing: 2 |
| 6 | case .strokeHighlightBottom: 3 |
| 7 | case .strokeHighlightTop, .reflection, .reflectionBlur, .arrow, .complete: 4 |
| 8 | } |
| 9 | } |

Light Edge II
At .strokeHighlightTop, visibleStrokeCount reaches 4 and stays there for every later step. The fourth stroke adds the top highlight with .blendMode(.normal).
| 1 | var visibleStrokeCount: Int { |
| 2 | switch self { |
| 3 | case .base: 0 |
| 4 | case .strokeDarkLeading: 1 |
| 5 | case .strokeDarkTrailing: 2 |
| 6 | case .strokeHighlightBottom: 3 |
| 7 | case .strokeHighlightTop, .reflection, .reflectionBlur, .arrow, .complete: 4 |
| 8 | } |
| 9 | } |

Reflection
At .reflection, showsReflection turns on and reflectionBlurRadius is still 0. reflectionLayer draws the vertical grey gradient circle, offset down by reflectionYOffset.
| 1 | // MARK: - Arrow Button |
| 2 | struct ArrowButton: View { |
| 3 | var rotation: Angle = .zero |
| 4 | var step: ArrowButtonStep = .complete |
| 5 | |
| 6 | var body: some View { |
| 7 | ZStack { |
| 8 | buttonFace |
| 9 | |
| 10 | if step.showsArrow { |
| 11 | Image("arrow") |
| 12 | .rotationEffect(rotation) |
| 13 | .shadow(color: .black.opacity(ArrowButtonMetrics.arrowShadowOpacity), |
| 14 | radius: ArrowButtonMetrics.arrowShadowRadius, |
| 15 | y: ArrowButtonMetrics.arrowShadowYOffset) |
| 16 | .accessibilityHidden(true) |
| 17 | .transition(.opacity) |
| 18 | } |
| 19 | } |
| 20 | .animation(.easeInOut(duration: 0.3), value: step) |
| 21 | .contentShape(Circle()) |
| 22 | .accessibilityLabel("Arrow button") |
| 23 | } |
| 24 | |
| 25 | private var buttonFace: some View { |
| 26 | ZStack { |
| 27 | Circle() |
| 28 | .fill(Color.buttonFill) |
| 29 | |
| 30 | reflectionLayer |
| 31 | } |
| 32 | .frame(width: ArrowButtonMetrics.buttonSize, height: ArrowButtonMetrics.buttonSize) |
| 33 | .clipShape(Circle()) |
| 34 | .circularSteelStrokeOverlay(visibleStrokeCount: step.visibleStrokeCount) |
| 35 | } |
| 36 | |
| 37 | @ViewBuilder |
| 38 | private var reflectionLayer: some View { |
| 39 | if step.showsReflection { |
| 40 | Circle() |
| 41 | .fill(LinearGradient.reflectionFill) |
| 42 | .frame( |
| 43 | width: ArrowButtonMetrics.reflectionDiameter, |
| 44 | height: ArrowButtonMetrics.reflectionDiameter |
| 45 | ) |
| 46 | .offset(y: ArrowButtonMetrics.reflectionYOffset) |
| 47 | .blur(radius: step.reflectionBlurRadius) |
| 48 | .allowsHitTesting(false) |
| 49 | .transition(.opacity) |
| 50 | } |
| 51 | } |
| 52 | } |

Frosted Blur
At .reflectionBlur, reflectionBlurRadius switches to ArrowButtonMetrics.reflectionBlur. The same reflectionLayer softens into frost — the same beat as Part 1’s blur step.
| 1 | var reflectionBlurRadius: CGFloat { |
| 2 | switch self { |
| 3 | case .reflection: 0 |
| 4 | case .reflectionBlur, .arrow, .complete: ArrowButtonMetrics.reflectionBlur |
| 5 | default: 0 |
| 6 | } |
| 7 | } |
| 8 | |
| 9 | var showsReflection: Bool { |
| 10 | rawValue >= ArrowButtonStep.reflection.rawValue |
| 11 | } |
| 12 | |
| 13 | var showsArrow: Bool { |
| 14 | self == .arrow || self == .complete |
| 15 | } |

Arrow Icon
At .arrow, showsArrow is true. The body adds Image("arrow") with rotationEffect and a light drop shadow. Pass rotation for previous vs next in the date selector.
| 1 | // MARK: - Arrow Button |
| 2 | struct ArrowButton: View { |
| 3 | var rotation: Angle = .zero |
| 4 | var step: ArrowButtonStep = .complete |
| 5 | |
| 6 | var body: some View { |
| 7 | ZStack { |
| 8 | buttonFace |
| 9 | |
| 10 | if step.showsArrow { |
| 11 | Image("arrow") |
| 12 | .rotationEffect(rotation) |
| 13 | .shadow(color: .black.opacity(ArrowButtonMetrics.arrowShadowOpacity), |
| 14 | radius: ArrowButtonMetrics.arrowShadowRadius, |
| 15 | y: ArrowButtonMetrics.arrowShadowYOffset) |
| 16 | .accessibilityHidden(true) |
| 17 | .transition(.opacity) |
| 18 | } |
| 19 | } |
| 20 | .animation(.easeInOut(duration: 0.3), value: step) |
| 21 | .contentShape(Circle()) |
| 22 | .accessibilityLabel("Arrow button") |
| 23 | } |
| 24 | |
| 25 | private var buttonFace: some View { |
| 26 | ZStack { |
| 27 | Circle() |
| 28 | .fill(Color.buttonFill) |
| 29 | |
| 30 | reflectionLayer |
| 31 | } |
| 32 | .frame(width: ArrowButtonMetrics.buttonSize, height: ArrowButtonMetrics.buttonSize) |
| 33 | .clipShape(Circle()) |
| 34 | .circularSteelStrokeOverlay(visibleStrokeCount: step.visibleStrokeCount) |
| 35 | } |
| 36 | |
| 37 | @ViewBuilder |
| 38 | private var reflectionLayer: some View { |
| 39 | if step.showsReflection { |
| 40 | Circle() |
| 41 | .fill(LinearGradient.reflectionFill) |
| 42 | .frame( |
| 43 | width: ArrowButtonMetrics.reflectionDiameter, |
| 44 | height: ArrowButtonMetrics.reflectionDiameter |
| 45 | ) |
| 46 | .offset(y: ArrowButtonMetrics.reflectionYOffset) |
| 47 | .blur(radius: step.reflectionBlurRadius) |
| 48 | .allowsHitTesting(false) |
| 49 | .transition(.opacity) |
| 50 | } |
| 51 | } |
| 52 | } |

Complete
At .complete, every layer is on — four strokes, frosted reflection, and the arrow. ArrowButtonTutorial drives the same step enum with chevron prev/next, matching the video above.
When the tutorial reaches .complete, the button becomes tappable and opens ArrowDateSelector via onOpenDateSelector in ContentView.swift.
| 1 | // MARK: - Tutorial |
| 2 | private struct ArrowButtonPressStyle: ButtonStyle { |
| 3 | func makeBody(configuration: Configuration) -> some View { |
| 4 | configuration.label |
| 5 | .scaleEffect(configuration.isPressed ? 0.96 : 1) |
| 6 | .animation(.easeInOut(duration: 0.15), value: configuration.isPressed) |
| 7 | } |
| 8 | } |
| 9 | |
| 10 | struct ArrowButtonTutorial: View { |
| 11 | var onOpenDateSelector: (() -> Void)? = nil |
| 12 | @State private var currentStep: ArrowButtonStep = .base |
| 13 | |
| 14 | var body: some View { |
| 15 | ZStack { |
| 16 | Color(red: 93/255, green: 106/255, blue: 114/255) |
| 17 | .ignoresSafeArea() |
| 18 | |
| 19 | VStack(spacing: 0) { |
| 20 | Group { |
| 21 | if currentStep != .complete { |
| 22 | Text(currentStep.title) |
| 23 | .font(.system(size: 17, weight: .medium)) |
| 24 | .foregroundStyle(.white) |
| 25 | .contentTransition(.numericText()) |
| 26 | .animation(.easeInOut(duration: 0.2), value: currentStep) |
| 27 | } else { |
| 28 | Color.clear |
| 29 | } |
| 30 | } |
| 31 | .frame(height: 28) |
| 32 | .padding(.top, 72) |
| 33 | |
| 34 | Spacer() |
| 35 | |
| 36 | Group { |
| 37 | if currentStep == .complete { |
| 38 | Button { |
| 39 | HapticsHelper.impactMedium() |
| 40 | onOpenDateSelector?() |
| 41 | } label: { |
| 42 | ArrowButton(step: .complete) |
| 43 | } |
| 44 | .buttonStyle(ArrowButtonPressStyle()) |
| 45 | .accessibilityLabel("Arrow button") |
| 46 | } else { |
| 47 | ArrowButton(step: currentStep) |
| 48 | .allowsHitTesting(false) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | Spacer() |
| 53 | |
| 54 | HStack(spacing: 48) { |
| 55 | Button { |
| 56 | if let previous = ArrowButtonStep(rawValue: currentStep.rawValue - 1) { |
| 57 | currentStep = previous |
| 58 | } |
| 59 | } label: { |
| 60 | Image(systemName: "chevron.left") |
| 61 | .font(.system(size: 18, weight: .semibold)) |
| 62 | .frame(width: 44, height: 44) |
| 63 | .background(Circle().fill(.white.opacity(currentStep == .base ? 0.08 : 0.2))) |
| 64 | .foregroundStyle(.white.opacity(currentStep == .base ? 0.3 : 1)) |
| 65 | } |
| 66 | .buttonStyle(.plain) |
| 67 | .disabled(currentStep == .base) |
| 68 | |
| 69 | Button { |
| 70 | if let next = ArrowButtonStep(rawValue: currentStep.rawValue + 1) { |
| 71 | currentStep = next |
| 72 | } |
| 73 | } label: { |
| 74 | Image(systemName: "chevron.right") |
| 75 | .font(.system(size: 18, weight: .semibold)) |
| 76 | .frame(width: 44, height: 44) |
| 77 | .background(Circle().fill(.white.opacity(currentStep == .complete ? 0.08 : 0.2))) |
| 78 | .foregroundStyle(.white.opacity(currentStep == .complete ? 0.3 : 1)) |
| 79 | } |
| 80 | .buttonStyle(.plain) |
| 81 | .disabled(currentStep == .complete) |
| 82 | } |
| 83 | .padding(.bottom, 60) |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | #Preview("Tutorial") { |
| 90 | ArrowButtonTutorial() |
| 91 | .accessibilityHidden(true) |
| 92 | } |

A shout out is a small burst of encouragement. When you tap 📢, you let us know this tutorial helped — and that keeps us happy and motivated to write more like it.