Skip to content

Spin Wheel Roulette

A prize wheel that spins with friction and lands on a random slice.

Spin Wheel Roulette, playing
Download .swift
Origin
Original work
Found by
spin wheel · roulette · prize wheel · fortune · random · game · lucky draw · ruleta · rueda de premios · premio · fortuna · aleatorio · juego · sorteo

A prize wheel that spins with friction and lands on a random slice.

With Reduce Motion on, the wheel moves straight to the result.

struct SpinWheelRouletteDemo: View {
@State private var result: String?
private let slices = ["10% off", "Free tea", "Retry", "Gift", "25% off", "Bonus", "Sticker", "Jackpot"]
var body: some View {
VStack(spacing: 28) {
Text("Spin to Win")
.font(.system(.largeTitle, design: .rounded, weight: .bold))
.foregroundStyle(.white)
SpinWheelRoulette(slices: slices) { result = $0 }
.padding(.horizontal, 24)
Text(result.map { "You got: \($0)" } ?? "Tap the wheel to try your luck")
.font(.title3.weight(.semibold))
.foregroundStyle(.white.opacity(0.9))
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(LinearGradient(colors: [.indigo, .black], startPoint: .top, endPoint: .bottom))
}
}
ParameterTypeDefault
slices[String]Required
onResult(String) -> Void{ _ in }

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

SpinWheelRoulette.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// A prize wheel that spins with friction and lands on a random slice.
///
/// With Reduce Motion on, the wheel moves straight to the result.
struct SpinWheelRoulette: View {
let slices: [String]
var onResult: (String) -> Void = { _ in }
@State private var rotation = 0.0
@State private var isSpinning = false
@State private var lastResult: String?
@State private var finishedSpins = 0
@State private var lastSpinSlices: [String]?
@ScaledMetric(relativeTo: .headline) private var hubSize: CGFloat = 78
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let palette: [Color] = [.pink, .orange, .yellow, .green, .teal, .blue, .indigo, .purple]
/// The wheel keeps the slices of the last spin until the next one, so the result always matches it.
private var drawnSlices: [String] { lastSpinSlices ?? slices }
var body: some View {
ZStack {
wheel
.rotationEffect(.degrees(rotation))
.contentShape(.circle)
.onTapGesture(perform: spin)
.accessibilityElement()
.accessibilityLabel("Prize wheel")
.accessibilityValue(drawnSlices.joined(separator: ", "))
Circle()
.strokeBorder(.white.opacity(0.9), lineWidth: 6)
.shadow(color: .black.opacity(0.25), radius: 10, y: 4)
.accessibilityHidden(true)
VStack {
Image(systemName: "arrowtriangle.down.fill")
.font(.largeTitle)
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.4), radius: 3, y: 2)
.offset(y: -16)
Spacer()
}
.accessibilityHidden(true)
Button(action: spin) {
Text("Spin")
.font(.headline.weight(.heavy))
.lineLimit(1)
.minimumScaleFactor(0.6)
.foregroundStyle(.white)
.padding(8)
.frame(width: hubSize, height: hubSize)
.background(.black.gradient, in: .circle)
.overlay { Circle().strokeBorder(.white, lineWidth: 3) }
}
.buttonStyle(.plain)
.disabled(isSpinning || slices.isEmpty)
.accessibilityValue(lastResult ?? "")
}
.aspectRatio(1, contentMode: .fit)
.sensoryFeedback(.success, trigger: finishedSpins)
}
private var wheel: some View {
Canvas { [drawnSlices] context, size in
let radius = min(size.width, size.height) / 2
guard radius > 0, !drawnSlices.isEmpty else { return }
let center = CGPoint(x: size.width / 2, y: size.height / 2)
let slice = SpinWheelRouletteMath.sliceAngle(count: drawnSlices.count)
for (index, label) in drawnSlices.enumerated() {
let start = Double(index) * slice - 90
var wedge = Path()
if drawnSlices.count == 1 {
wedge.addEllipse(in: CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2))
} else {
wedge.move(to: center)
wedge.addArc(center: center, radius: radius, startAngle: .degrees(start), endAngle: .degrees(start + slice), clockwise: false)
wedge.closeSubpath()
}
context.fill(wedge, with: .color(palette[index % palette.count].opacity(0.92)))
context.stroke(wedge, with: .color(.white.opacity(0.75)), lineWidth: 1.5)
var labelContext = context
labelContext.translateBy(x: center.x, y: center.y)
labelContext.rotate(by: .degrees(start + slice / 2))
labelContext.draw(
Text(label).font(.subheadline.weight(.bold)).foregroundStyle(.white),
in: CGRect(x: radius * 0.34, y: -radius * 0.1, width: radius * 0.62, height: radius * 0.2)
)
}
}
.clipShape(.circle)
}
private func spin() {
guard !isSpinning, !slices.isEmpty else { return }
let winner = Int.random(in: slices.indices)
let label = slices[winner]
let target = SpinWheelRouletteMath.targetRotation(
from: rotation,
winner: winner,
count: slices.count,
jitter: .random(in: -0.35...0.35)
)
isSpinning = true
lastSpinSlices = slices
if reduceMotion {
rotation = target
finish(with: label)
} else {
withAnimation(.timingCurve(0.12, 0.75, 0.12, 1, duration: 3.2), completionCriteria: .removed) {
rotation = target
} completion: {
finish(with: label)
}
}
}
private func finish(with label: String) {
isSpinning = false
lastResult = label
finishedSpins += 1
AccessibilityNotification.Announcement("Landed on \(label)").post()
onResult(label)
}
}
enum SpinWheelRouletteMath {
static func sliceAngle(count: Int) -> Double {
count > 0 ? 360 / Double(count) : 360
}
/// The rotation that brings `winner` under the pointer after `turns` whole turns, always moving forward.
/// `jitter` moves the landing point inside the slice, as a fraction of it.
static func targetRotation(from current: Double, winner: Int, count: Int, turns: Int = 5, jitter: Double = 0) -> Double {
let resting = -(Double(winner) + 0.5 + jitter) * sliceAngle(count: count)
return current + Double(max(turns, 0)) * 360 + normalized(resting - current)
}
/// The slice under the pointer at the top of the wheel for a rotation in degrees.
static func winner(rotation: Double, count: Int) -> Int? {
guard count > 0, rotation.isFinite else { return nil }
return min(Int(normalized(-rotation) / sliceAngle(count: count)), count - 1)
}
private static func normalized(_ degrees: Double) -> Double {
let remainder = degrees.truncatingRemainder(dividingBy: 360)
return remainder < 0 ? remainder + 360 : remainder
}
}
// MARK: - Demo
struct SpinWheelRouletteDemo: View {
@State private var result: String?
private let slices = ["10% off", "Free tea", "Retry", "Gift", "25% off", "Bonus", "Sticker", "Jackpot"]
var body: some View {
VStack(spacing: 28) {
Text("Spin to Win")
.font(.system(.largeTitle, design: .rounded, weight: .bold))
.foregroundStyle(.white)
SpinWheelRoulette(slices: slices) { result = $0 }
.padding(.horizontal, 24)
Text(result.map { "You got: \($0)" } ?? "Tap the wheel to try your luck")
.font(.title3.weight(.semibold))
.foregroundStyle(.white.opacity(0.9))
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(LinearGradient(colors: [.indigo, .black], startPoint: .top, endPoint: .bottom))
}
}
#Preview {
SpinWheelRouletteDemo()
}