Skip to content

Wheel Picker

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

Wheel Picker, playing
Download .swift
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

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)"
}
}
ParameterTypeDefault
titleLocalizedStringResourceRequired
values[Value]Required
selectionBinding<Value>Required
visibleRowsInt5
text(Value) -> StringRequired

Copy this code into your project, or download the file.

WheelPicker.swift
// 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()
}