We build the main screen of ArrowDateSelector: a glassy date selector that steps one day at a time with steel-style arrow buttons, smart labels like “Today” and “Tomorrow”, and smooth motion.
What we’re building
- A date selector that steps forward/backward one day at a time
- Steel/glass-style circular arrow buttons with a reflection and metallic rim
- Smart labels: Today, Tomorrow, Yesterday, or a localized date like “March 15”
- Haptics and accessibility so it feels right on device
- Two Swift files — ArrowButton.swift for the control, ContentView.swift for the screen
Requirements: Xcode 15 or later, iOS 17 or later, and basic SwiftUI experience.
1. Design tokens: metrics and colors
Start in ArrowButton.swift by centralising button metrics in a private ArrowButtonMetrics enum — the same way Chapter 1 locked sizes in Figma before touching effects. Button size, reflection diameter, blur amount, stroke width, and shadow values all live here.
| 1 | private enum ArrowButtonMetrics { |
| 2 | static let buttonSize: CGFloat = 125 |
| 3 | static let reflectionDiameter: CGFloat = 90 |
| 4 | static let reflectionBlur: CGFloat = 8 |
| 5 | static let reflectionYOffset: CGFloat = 14 |
| 6 | static let strokeLineWidth: CGFloat = 3 |
| 7 | static let arrowShadowOpacity: CGFloat = 0.1 |
| 8 | static let arrowShadowRadius: CGFloat = 1 |
| 9 | static let arrowShadowYOffset: CGFloat = 1 |
| 10 | } |
The steel background palette lives in ContentView.swift. buttonFill — the face of the circle — stays in ArrowButton.swift next to the control that uses it.
| 1 | extension Color { |
| 2 | static let steelBackground1 = Color(red: 67/255, green: 80/255, blue: 89/255) |
| 3 | static let steelBackground2 = Color(red: 93/255, green: 106/255, blue: 114/255) |
| 4 | static let steelBackground3 = Color(red: 120/255, green: 133/255, blue: 141/255) |
| 5 | static let steelBackground4 = Color(red: 116/255, green: 123/255, blue: 129/255) |
| 6 | } |
| 1 | private extension Color { |
| 2 | static let buttonFill = Color(red: 120/255, green: 133/255, blue: 141/255) |
| 3 | } |
2. Gradients for background and reflection
ContentView.swift owns LinearGradient.appBackground — the diagonal steel backdrop from Chapter 1’s base fill. ArrowButton.swift owns reflectionFill — the vertical grey gradient behind the frosted core.
| 1 | private extension LinearGradient { |
| 2 | static var appBackground: LinearGradient { |
| 3 | LinearGradient( |
| 4 | gradient: Gradient(stops: [ |
| 5 | .init(color: .steelBackground1, location: 0.0), |
| 6 | .init(color: .steelBackground2, location: 0.30), |
| 7 | .init(color: .steelBackground3, location: 0.59), |
| 8 | .init(color: .steelBackground4, location: 1.0) |
| 9 | ]), |
| 10 | startPoint: .topLeading, |
| 11 | endPoint: .bottomTrailing |
| 12 | ) |
| 13 | } |
| 14 | } |
| 1 | private extension LinearGradient { |
| 2 | static var reflectionFill: LinearGradient { |
| 3 | LinearGradient( |
| 4 | stops: [ |
| 5 | Gradient.Stop(color: Color(red: 0.48, green: 0.52, blue: 0.55), location: 0.00), |
| 6 | Gradient.Stop(color: Color(red: 0.64, green: 0.67, blue: 0.70), location: 1.00), |
| 7 | ], |
| 8 | startPoint: .top, |
| 9 | endPoint: .bottom |
| 10 | ) |
| 11 | } |
| 12 | } |
3. Building the steel ring as a ViewModifier
The metallic ring from Chapter 1’s four stroke layers becomes CircularSteelStrokeOverlay in ArrowButton.swift. Four stroked circles with gradients and blend modes — two darken the edges, two add highlights. visibleStrokeCount lets the in-app tutorial reveal strokes one at a time, matching the build progression carousel above.
| 1 | private struct CircularSteelStrokeOverlay: ViewModifier { |
| 2 | var visibleStrokeCount: Int |
| 3 | |
| 4 | func body(content: Content) -> some View { |
| 5 | content.overlay(strokeLayer) |
| 6 | } |
| 7 | |
| 8 | private var strokeLayer: some View { |
| 9 | ZStack { |
| 10 | if visibleStrokeCount >= 1 { |
| 11 | Circle() |
| 12 | .stroke( |
| 13 | LinearGradient( |
| 14 | gradient: Gradient(stops: [ |
| 15 | .init(color: .black.opacity(0.4), location: 0.0), |
| 16 | .init(color: .black.opacity(0.0), location: 0.1) |
| 17 | ]), |
| 18 | startPoint: .leading, |
| 19 | endPoint: .topTrailing |
| 20 | ), |
| 21 | lineWidth: ArrowButtonMetrics.strokeLineWidth |
| 22 | ) |
| 23 | .blendMode(.darken) |
| 24 | } |
| 25 | |
| 26 | if visibleStrokeCount >= 2 { |
| 27 | Circle() |
| 28 | .stroke( |
| 29 | LinearGradient( |
| 30 | gradient: Gradient(stops: [ |
| 31 | .init(color: .black.opacity(0.4), location: 0.0), |
| 32 | .init(color: .black.opacity(0.0), location: 0.1) |
| 33 | ]), |
| 34 | startPoint: .trailing, |
| 35 | endPoint: .topLeading |
| 36 | ), |
| 37 | lineWidth: ArrowButtonMetrics.strokeLineWidth |
| 38 | ) |
| 39 | .blendMode(.darken) |
| 40 | } |
| 41 | |
| 42 | if visibleStrokeCount >= 3 { |
| 43 | Circle() |
| 44 | .stroke( |
| 45 | LinearGradient( |
| 46 | gradient: Gradient(stops: [ |
| 47 | .init(color: .white.opacity(0.8), location: 0.1), |
| 48 | .init(color: .white.opacity(0.0), location: 0.4) |
| 49 | ]), |
| 50 | startPoint: .bottom, |
| 51 | endPoint: .top |
| 52 | ), |
| 53 | lineWidth: ArrowButtonMetrics.strokeLineWidth |
| 54 | ) |
| 55 | .blendMode(.overlay) |
| 56 | } |
| 57 | |
| 58 | if visibleStrokeCount >= 4 { |
| 59 | Circle() |
| 60 | .stroke( |
| 61 | LinearGradient( |
| 62 | gradient: Gradient(stops: [ |
| 63 | .init(color: .white.opacity(0.8), location: 0.0), |
| 64 | .init(color: .white.opacity(0.0), location: 0.3) |
| 65 | ]), |
| 66 | startPoint: .top, |
| 67 | endPoint: .bottom |
| 68 | ), |
| 69 | lineWidth: ArrowButtonMetrics.strokeLineWidth |
| 70 | ) |
| 71 | .blendMode(.normal) |
| 72 | } |
| 73 | } |
| 74 | .compositingGroup() |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | private extension View { |
| 79 | func circularSteelStrokeOverlay(visibleStrokeCount: Int = 4) -> some View { |
| 80 | modifier(CircularSteelStrokeOverlay(visibleStrokeCount: visibleStrokeCount)) |
| 81 | } |
| 82 | } |
4. Composing the ArrowButton
ArrowButton stacks the fill, frosted reflection, steel ring, and arrow icon. The reflection is a blurred circle offset downward — closer to Chapter 1’s frosted core than a flat rectangle. The date selector passes step: .complete so all four strokes, blur, and the arrow render at once.
| 1 | struct ArrowButton: View { |
| 2 | var rotation: Angle = .zero |
| 3 | var step: ArrowButtonStep = .complete |
| 4 | |
| 5 | var body: some View { |
| 6 | ZStack { |
| 7 | buttonFace |
| 8 | |
| 9 | if step.showsArrow { |
| 10 | Image("arrow") |
| 11 | .rotationEffect(rotation) |
| 12 | .shadow( |
| 13 | color: .black.opacity(ArrowButtonMetrics.arrowShadowOpacity), |
| 14 | radius: ArrowButtonMetrics.arrowShadowRadius, |
| 15 | y: ArrowButtonMetrics.arrowShadowYOffset |
| 16 | ) |
| 17 | .accessibilityHidden(true) |
| 18 | } |
| 19 | } |
| 20 | .contentShape(Circle()) |
| 21 | } |
| 22 | |
| 23 | private var buttonFace: some View { |
| 24 | ZStack { |
| 25 | Circle() |
| 26 | .fill(Color.buttonFill) |
| 27 | |
| 28 | if step.showsReflection { |
| 29 | Circle() |
| 30 | .fill(LinearGradient.reflectionFill) |
| 31 | .frame( |
| 32 | width: ArrowButtonMetrics.reflectionDiameter, |
| 33 | height: ArrowButtonMetrics.reflectionDiameter |
| 34 | ) |
| 35 | .offset(y: ArrowButtonMetrics.reflectionYOffset) |
| 36 | .blur(radius: step.reflectionBlurRadius) |
| 37 | } |
| 38 | } |
| 39 | .frame(width: ArrowButtonMetrics.buttonSize, height: ArrowButtonMetrics.buttonSize) |
| 40 | .clipShape(Circle()) |
| 41 | .circularSteelStrokeOverlay(visibleStrokeCount: step.visibleStrokeCount) |
| 42 | } |
| 43 | } |
5. Managing date state and label logic
ArrowDateSelectorView in ContentView.swift owns a single @State currentDate. formattedLabel picks Today, Tomorrow, Yesterday, or a localized date. showsDetailedDate controls the second line with weekday + full date.
| 1 | struct ArrowDateSelectorView: View { |
| 2 | @State private var currentDate = Date() |
| 3 | private let calendar = Calendar.current |
| 4 | private let monthDayFormatter: DateFormatter = { |
| 5 | let f = DateFormatter() |
| 6 | f.setLocalizedDateFormatFromTemplate("MMMMd") |
| 7 | return f |
| 8 | }() |
| 9 | |
| 10 | private var formattedLabel: String { |
| 11 | if calendar.isDateInToday(currentDate) { return "Today" } |
| 12 | if calendar.isDateInTomorrow(currentDate) { return "Tomorrow" } |
| 13 | if calendar.isDateInYesterday(currentDate) { return "Yesterday" } |
| 14 | return monthDayFormatter.string(from: currentDate) |
| 15 | } |
| 16 | |
| 17 | private var showsDetailedDate: Bool { |
| 18 | calendar.isDateInToday(currentDate) |
| 19 | || calendar.isDateInTomorrow(currentDate) |
| 20 | || calendar.isDateInYesterday(currentDate) |
| 21 | } |
| 22 | } |
6. Laying out the selector
The body is a ZStack with appBackground edge to edge, then an HStack: previous arrow, date labels, next arrow. Negative spacing pulls the arrows toward the centre; scaleEffect(0.4) keeps them control-sized rather than hero-sized.
| 1 | var body: some View { |
| 2 | ZStack { |
| 3 | LinearGradient.appBackground |
| 4 | .ignoresSafeArea() |
| 5 | |
| 6 | HStack(spacing: -30) { |
| 7 | Button { |
| 8 | currentDate = calendar.date(byAdding: .day, value: -1, to: currentDate) ?? currentDate |
| 9 | HapticsHelper.impactMedium() |
| 10 | } label: { |
| 11 | ArrowButton(rotation: .degrees(180)) |
| 12 | .scaleEffect(0.4) |
| 13 | } |
| 14 | .accessibilityLabel("Previous day") |
| 15 | |
| 16 | VStack(spacing: 4) { |
| 17 | Text(formattedLabel) |
| 18 | .font(.system(size: 24, weight: .regular)) |
| 19 | .foregroundStyle( |
| 20 | LinearGradient( |
| 21 | colors: [Color.white.opacity(0.8), Color.white.opacity(0.2)], |
| 22 | startPoint: .topLeading, |
| 23 | endPoint: .bottomTrailing |
| 24 | ) |
| 25 | ) |
| 26 | .contentTransition(.numericText()) |
| 27 | .animation(.spring(response: 0.25, dampingFraction: 0.5), value: formattedLabel) |
| 28 | |
| 29 | if showsDetailedDate { |
| 30 | Text(currentDate.formatted(.dateTime.weekday().month().day())) |
| 31 | .font(.system(size: 14, weight: .regular)) |
| 32 | .foregroundStyle(.white.opacity(0.6)) |
| 33 | .contentTransition(.numericText()) |
| 34 | .animation(.spring(response: 0.25, dampingFraction: 0.5), value: formattedLabel) |
| 35 | .accessibilityHidden(true) |
| 36 | } |
| 37 | } |
| 38 | .frame(width: 120) |
| 39 | .accessibilityElement(children: .combine) |
| 40 | .accessibilityLabel(formattedLabel) |
| 41 | |
| 42 | Button { |
| 43 | currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate |
| 44 | HapticsHelper.impactMedium() |
| 45 | } label: { |
| 46 | ArrowButton(rotation: .degrees(0)) |
| 47 | .scaleEffect(0.4) |
| 48 | } |
| 49 | .accessibilityLabel("Next day") |
| 50 | } |
| 51 | .buttonStyle(.plain) |
| 52 | .padding() |
| 53 | } |
| 54 | } |
7. Motion, haptics, and accessibility
Labels animate with .contentTransition(.numericText()) and a spring keyed off formattedLabel. HapticsHelper wraps UIImpactFeedbackGenerator and bails out in previews so the canvas stays stable.
| 1 | enum HapticsHelper { |
| 2 | static func impactMedium() { |
| 3 | #if canImport(UIKit) |
| 4 | if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { return } |
| 5 | UIImpactFeedbackGenerator(style: .medium).impactOccurred() |
| 6 | #endif |
| 7 | } |
| 8 | } |
Arrows get explicit accessibility labels, the decorative arrow image is hidden from VoiceOver, and the label stack combines into one element. The whole view hides from accessibility in previews to avoid canvas crashes on complex trees.
8. Previewing the design
ContentView.swift ends with a #Preview that renders ArrowDateSelectorView in dark mode — the same setup you use while iterating on spacing and animation in the canvas.
| 1 | #Preview("Date selector") { |
| 2 | ArrowDateSelectorView() |
| 3 | .preferredColorScheme(.dark) |
| 4 | } |
Takeaways
- Treat metrics and colours as design tokens so you can iterate without rewriting views.
- Wrap visual effects like the steel ring in ViewModifiers to make them portable.
- Layer simple shapes—gradients, blurs, strokes—to get a complex glass/metal look.
- Keep view bodies clean with computed properties for labels and visibility flags.
- Guard haptics in previews so you can lean on the SwiftUI canvas while designing.
- Think about accessibility from the start: labels, combined elements, and hidden decoration.
You can explore the full source — ArrowButton.swift, ContentView.swift, and assets — in the swiftuibuttons GitHub repository.
View ArrowDateSelector on GitHub
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.