Wheel Picker
A vertical wheel that picks one value from a list, with rows turning in 3D as they leave the center.

- Pickers
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- wheel picker · picker · scroll wheel · 3d · time picker · snap · rotation · selector de rueda · rueda · selector · desplazamiento · 3d · selector de hora · rotación
Description
Section titled “Description”A vertical wheel that picks one value. Rows turn away in 3D as they leave the center.
values must be unique. With Reduce Motion on, rows stay flat and only fade.
struct WheelPickerDemo: View { @State private var hour = 7 @State private var minute = 30
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { VStack(spacing: 28) { VStack(spacing: 4) { Text("Wake Up") .font(.headline) .foregroundStyle(.secondary) Text(formatted(hour) + ":" + formatted(minute)) .font(.system(.largeTitle, design: .rounded, weight: .bold).monospacedDigit()) .contentTransition(.numericText()) .animation(reduceMotion ? nil : .snappy, value: hour) .animation(reduceMotion ? nil : .snappy, value: minute) }
HStack(spacing: 12) { WheelPicker(title: "Hour", values: Array(0..<24), selection: $hour, text: formatted) WheelPicker(title: "Minute", values: Array(stride(from: 0, to: 60, by: 5)), selection: $minute, text: formatted) } .padding(.horizontal, 24) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) }
private func formatted(_ value: Int) -> String { value < 10 ? "0\(value)" : "\(value)" }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
title | LocalizedStringResource | Required |
values | [Value] | Required |
selection | Binding<Value> | Required |
visibleRows | Int | 5 |
text | (Value) -> String | Required |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// A vertical wheel that picks one value. Rows turn away in 3D as they leave the center.////// `values` must be unique. With Reduce Motion on, rows stay flat and only fade.struct WheelPicker<Value: Hashable>: View { let title: LocalizedStringResource let values: [Value] @Binding var selection: Value var visibleRows: Int = 5 var text: (Value) -> String
@ScaledMetric(relativeTo: .title3) private var rowHeight: CGFloat = 44 @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var scrolledValue: Value?
private let wheelSpace = "WheelPicker"
var body: some View { let height = rowHeight * CGFloat(max(1, min(visibleRows, 15))) let row = rowHeight let tilts = !reduceMotion // A local copy: the effect below runs off the main actor and cannot capture the view. let space = wheelSpace
ScrollView(.vertical) { LazyVStack(spacing: 0) { ForEach(values, id: \.self) { value in Text(text(value)) .font(.title3.weight(.semibold).monospacedDigit()) .lineLimit(1) .minimumScaleFactor(0.6) .frame(maxWidth: .infinity) .frame(height: row) .contentShape(.rect) .onTapGesture { selection = value } .visualEffect { content, proxy in let position = WheelPickerMath.position(midY: proxy.frame(in: .named(space)).midY, height: height) return content .rotation3DEffect(.degrees(tilts ? position * -62 : 0), axis: (1, 0, 0), perspective: 0.55) .scaleEffect(tilts ? 1 - abs(position) * 0.12 : 1) .opacity(1 - abs(position) * 0.72) } } } .scrollTargetLayout() } .contentMargins(.vertical, (height - row) / 2, for: .scrollContent) .scrollIndicators(.hidden) .scrollTargetBehavior(.viewAligned) .scrollPosition(id: $scrolledValue, anchor: .center) .frame(height: height) // The frame of the wheel itself. The scroll view's own space starts after its content // margin, which put the middle of the effect two rows below the selected one. .coordinateSpace(.named(wheelSpace)) .background { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(.quaternary) .frame(height: row) .accessibilityHidden(true) } .onAppear { scrolledValue = selection } .onChange(of: scrolledValue) { _, value in if let value, value != selection { selection = value } } .onChange(of: selection) { _, value in if value != scrolledValue { withAnimation(reduceMotion ? nil : .snappy) { scrolledValue = value } } } .sensoryFeedback(.selection, trigger: selection) .accessibilityElement(children: .ignore) .accessibilityLabel(title) .accessibilityValue(text(selection)) .accessibilityAdjustableAction { direction in let step = direction == .increment ? 1 : -1 if let index = WheelPickerMath.adjustedIndex(current: values.firstIndex(of: selection), step: step, count: values.count) { selection = values[index] } } }}
nonisolated enum WheelPickerMath { /// How far a row is from the center of the wheel: 0 at the center, -1 at the top, 1 at the bottom. static func position(midY: Double, height: Double) -> Double { guard height > 0, midY.isFinite else { return 0 } let halfHeight = height / 2 return min(max((midY - halfHeight) / halfHeight, -1), 1) }
/// The index one step away, stopping at both ends. Nil when there is nothing to pick. static func adjustedIndex(current: Int?, step: Int, count: Int) -> Int? { guard count > 0 else { return nil } guard let current else { return 0 } return min(max(current + step, 0), count - 1) }}
// MARK: - Demo
struct WheelPickerDemo: View { @State private var hour = 7 @State private var minute = 30
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { VStack(spacing: 28) { VStack(spacing: 4) { Text("Wake Up") .font(.headline) .foregroundStyle(.secondary) Text(formatted(hour) + ":" + formatted(minute)) .font(.system(.largeTitle, design: .rounded, weight: .bold).monospacedDigit()) .contentTransition(.numericText()) .animation(reduceMotion ? nil : .snappy, value: hour) .animation(reduceMotion ? nil : .snappy, value: minute) }
HStack(spacing: 12) { WheelPicker(title: "Hour", values: Array(0..<24), selection: $hour, text: formatted) WheelPicker(title: "Minute", values: Array(stride(from: 0, to: 60, by: 5)), selection: $minute, text: formatted) } .padding(.horizontal, 24) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) }
private func formatted(_ value: Int) -> String { value < 10 ? "0\(value)" : "\(value)" }}
#Preview { WheelPickerDemo()}