In this tutorial we will build the main screen of the ArrowDateSelector app: a glassy date selector that lets you step one day at a time using 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
- A single ContentView.swift that owns design tokens, the button, and layout
Requirements: Xcode 15 or later, iOS 17 or later, and basic SwiftUI experience.
1. Design tokens: metrics and colors
We start by centralising all the magic numbers for the button into a private ArrowButtonMetrics enum. This is where we define the button size, reflection size, blur amount, stroke width, and shadow values. Changing the look later becomes a matter of tweaking these constants instead of hunting through the view hierarchy.
| 1 | private enum ArrowButtonMetrics { |
| 2 | static let buttonSize: CGFloat = 125 |
| 3 | static let reflectionSize: CGSize = .init(width: 90, height: 90) |
| 4 | static let reflectionCornerRadius: CGFloat = 30 |
| 5 | static let reflectionBlur: CGFloat = 8 |
| 6 | static let reflectionYOffset: CGFloat = 14 |
| 7 | static let strokeLineWidth: CGFloat = 3 |
| 8 | static let arrowShadowOpacity: CGFloat = 0.1 |
| 9 | static let arrowShadowRadius: CGFloat = 1 |
| 10 | static let arrowShadowYOffset: CGFloat = 1 |
| 11 | } |
Next, we extend Color with a small steel palette: four background colors that run from a deep blue-grey to a lighter steel, plus a buttonFill colour used for the main circle. Keeping this in an extension instead of inline makes the gradient definitions much easier to read.
| 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 | |
| 7 | static let buttonFill = Color(red: 120/255, green: 133/255, blue: 141/255) |
| 8 | } |
2. Gradients for background and reflection
The screen uses a custom LinearGradient.appBackground that runs diagonally from topLeading to bottomTrailing through the four steel colours. A second gradient, LinearGradient.reflectionFill, is a vertical grey gradient we use behind the button to fake a soft reflection.
Both gradients are defined as static properties on a private LinearGradient extension, which keeps the body of the view clean and makes it easy to reuse the same look across multiple components.
| 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 | |
| 15 | static var reflectionFill: LinearGradient { |
| 16 | LinearGradient( |
| 17 | stops: [ |
| 18 | .init(color: Color(red: 0.48, green: 0.52, blue: 0.55), location: 0.00), |
| 19 | .init(color: Color(red: 0.64, green: 0.67, blue: 0.70), location: 1.00), |
| 20 | ], |
| 21 | startPoint: .top, |
| 22 | endPoint: .bottom |
| 23 | ) |
| 24 | } |
| 25 | } |
3. Building the steel ring as a ViewModifier
To get the metallic ring around the button, we create a CircularSteelStrokeOverlay ViewModifier. Inside it we overlay four stroked circles with different gradients and blend modes. Two strokes darken the top-left and top-right edges using black gradients with .darken blend mode, and two strokes add highlights using white gradients with .overlay and .normal.
Finally we wrap everything in .compositingGroup() so the strokes render as a single layer. A small View extension, circularSteelStrokeOverlay(), gives us a clean API: any circle can become “metal” by calling this one modifier.
| 1 | private struct CircularSteelStrokeOverlay: ViewModifier { |
| 2 | func body(content: Content) -> some View { |
| 3 | content.overlay(strokeLayer) |
| 4 | } |
| 5 | |
| 6 | private var strokeLayer: some View { |
| 7 | ZStack { |
| 8 | // four Circle().stroke(...) layers with different gradients |
| 9 | } |
| 10 | .compositingGroup() |
| 11 | } |
| 12 | } |
| 13 | |
| 14 | private extension View { |
| 15 | func circularSteelStrokeOverlay() -> some View { |
| 16 | modifier(CircularSteelStrokeOverlay()) |
| 17 | } |
| 18 | } |
4. Composing the ArrowButton
The ArrowButton view is a ZStack of three layers: the base circle with the steel overlay, a blurred reflection rectangle behind it, and the arrow image on top. The same Image("arrow") asset is reused for both directions by passing a rotation Angle into the component.
We set a contentShape(Circle()) so the hit target matches the visual shape and add a subtle drop shadow on the arrow itself. The result is a button that feels like a physical piece of glass sitting on top of the background.
| 1 | struct ArrowButton: View { |
| 2 | var rotation: Angle = .zero |
| 3 | |
| 4 | var body: some View { |
| 5 | ZStack { |
| 6 | Circle() |
| 7 | .fill(Color.buttonFill) |
| 8 | .frame(width: ArrowButtonMetrics.buttonSize, |
| 9 | height: ArrowButtonMetrics.buttonSize) |
| 10 | .circularSteelStrokeOverlay() |
| 11 | |
| 12 | RoundedRectangle(cornerRadius: ArrowButtonMetrics.reflectionCornerRadius, |
| 13 | style: .continuous) |
| 14 | .fill(LinearGradient.reflectionFill) |
| 15 | .frame(width: ArrowButtonMetrics.reflectionSize.width, |
| 16 | height: ArrowButtonMetrics.reflectionSize.height) |
| 17 | .blur(radius: ArrowButtonMetrics.reflectionBlur) |
| 18 | .offset(y: ArrowButtonMetrics.reflectionYOffset) |
| 19 | |
| 20 | Image("arrow") |
| 21 | .rotationEffect(rotation) |
| 22 | } |
| 23 | .contentShape(Circle()) |
| 24 | } |
| 25 | } |
5. Managing date state and label logic
ArrowDateSelectorView owns a single @State variable, currentDate, plus a Calendar and a DateFormatter for month+day strings. Two computed properties keep the logic tidy: formattedLabel decides whether to show Today, Tomorrow, Yesterday, or a formatted date, and showsDetailedDate determines when to display a second line with weekday + full date.
Tapping the previous or next button calls calendar.date(byAdding:.day,value:-1/1,to:currentDate) to step a single day at a time. All of the label logic reacts automatically because it depends on currentDate.
| 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 | } |
6. Laying out the selector
The body of the view is a ZStack with the appBackground gradient stretched edge to edge using .ignoresSafeArea(). On top of that we place an HStack with three elements: the previous arrow, the date label stack, and the next arrow.
A negative HStack spacing pulls the arrows in towards the center, and each ArrowButton is scaled down to 0.4 so it reads as a control rather than a huge hero element. The center VStack has a fixed width so the layout stays stable as the label text changes length.
| 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 | |
| 15 | // center date labels… |
| 16 | |
| 17 | Button { |
| 18 | currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate |
| 19 | HapticsHelper.impactMedium() |
| 20 | } label { |
| 21 | ArrowButton(rotation: .degrees(0)) |
| 22 | .scaleEffect(0.4) |
| 23 | } |
| 24 | } |
| 25 | .padding() |
| 26 | } |
| 27 | } |
7. Motion, haptics, and accessibility
To keep the UI feeling alive, the labels use .contentTransition(.numericText()) with a spring animation keyed off formattedLabel. This gives you a subtle flip-style animation whenever the date changes.
Taps on the arrows trigger HapticsHelper.impactMedium(), which wraps UIImpactFeedbackGenerator. To avoid breaking SwiftUI previews we early return when XCODE_RUNNING_FOR_PREVIEWS is set in the environment. On the accessibility side, the arrows get explicit labels like “Previous day” and “Next day”, the decorative arrow image is hidden, and the text stack is combined into a single accessible element.
8. Previewing the design
The file ends with a simple #Preview block that renders ArrowDateSelectorView with a dark color scheme. Because haptics are disabled in the preview environment the canvas can build and refresh without issues, letting you iterate on gradients, spacing, and animation quickly.
| 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 code for ArrowDateSelector, including ContentView.swift and assets, in the swiftuibuttons GitHub repository.