SimpleRoulette helps you to create customizable Roulette, with SwiftUI. (Compatible with both macOS and iOS.)
- iOS 17.0+ / macOS 14.0+
- Swift 6.0+
- Xcode 16.0+
Create Package.swift and add dependency like the following.
dependencies: [
.package(url: "https://github.com/fummicc1/SimpleRoulette.git", from: "2.0.0")
// or
.package(url: "https://github.com/fummicc1/SimpleRoulette.git", branch: "main")
]Create Podfile and add dependency like the following.
pod 'SimpleRoulette', '~> 2.0'Create Cartfile and add dependency like the following.
github "fummicc1/SimpleRoulette"All you need to know is just RouletteView and PartData.
RouletteView conforms to View, so you can use it like the following.
struct ContentView: View {
@State private var model = RouletteModel(parts: partDatas)
@State private var result: String = ""
var body: some View {
VStack {
Text(result)
RouletteView(model: model)
}
.onChange(of: model.state) { _, newState in
if case .stop(let part, _) = newState,
let text = part.content.text {
result = text
}
}
.task {
model.start(speed: .random(), automaticallyStopAfter: 5)
}
}
}
let partDatas: [PartData] = [
PartData(index: 0, content: .label("Swift"), area: .flex(3), fillColor: .red),
PartData(index: 1, content: .label("Kotlin"), area: .flex(1), fillColor: .purple),
PartData(index: 2, content: .label("JavaScript"), area: .flex(2), fillColor: .yellow),
PartData(index: 3, content: .label("Dart"), area: .flex(1), fillColor: .green),
PartData(index: 4, content: .label("Python"), area: .flex(2), fillColor: .blue),
PartData(index: 5, content: .label("C++"), area: .degree(60), fillColor: .orange),
]A part can render any SwiftUI view instead of a plain label. The view is built
lazily by a @Sendable closure so that PartData stays Sendable under Swift 6
strict concurrency — everything the closure captures must be Sendable too.
PartData(
index: 0,
content: .custom { AnyView(Image(systemName: "star.fill")) },
area: .flex(1),
fillColor: .red
)content.text returns nil for custom content, so check for it when you read
the result of a spin.
By default a roulette is laid out the way a real wheel is: labels anchored at the rim, arranged radially, and the separators stop short of the middle to leave a hub. Every label's outer end sits on the same circle whatever its length, and labels shrink (down to half size) rather than spill out of the wheel. As on a real wheel, labels on the left half read upside down.
Override any of that with .rouletteStyle(_:):
RouletteView(parts: partDatas)
.rouletteStyle(
RouletteStyle(
labelPosition: 0.9, // anchor radius: 0 = centre, 1 = rim
labelOrientation: .radial, // see the table below
innerRadiusRatio: 0.25 // size of the hub, 0 for a full pie
)
)Radial orientations align the label's outer end to labelPosition;
.tangential and .fixed centre the label on it. Leave it out (or pass nil)
to get 0.95 for radial orientations and 0.5 for the others.
labelOrientation |
Effect |
|---|---|
.radial |
Reads outward from the hub, exactly like a real roulette. Labels on the left half sit upside down. The default. |
.radialUpright |
Same radial layout, but the left half is flipped so every label reads upright. Better when your labels are words rather than numbers. |
.tangential |
Runs across the wedge, perpendicular to the radius. The 1.x behaviour. |
.fixed |
Always upright, whatever angle the wedge sits at. |
If the upside-down half does not suit your labels, one line switches it:
RouletteView(parts: partDatas)
.rouletteStyle(RouletteStyle(labelOrientation: .radialUpright))RouletteStyle.pie restores the 1.x appearance — labels halfway out, laid
across the wedge, with no hub:
RouletteView(parts: partDatas)
.rouletteStyle(.pie)RouletteModel uses the @Observable macro from the Observation framework. You can observe the roulette state directly via model.state.
// Get the decided part when roulette stops
.onChange(of: model.state) { _, newState in
if case .stop(let part, _) = newState {
// Handle the result
}
}
// Or use the convenience property
if let part = model.decidedPart {
// The roulette has stopped and selected this part
}struct ContentView: View {
@State private var model = RouletteModel(parts: partDatas)
var body: some View {
VStack {
RouletteView(model: model)
HStack {
Button(model.state.isAnimating ? "Pause" : "Start") {
if model.state.isAnimating {
model.pause()
} else {
model.start(speed: .random())
}
}
Button("Stop") {
model.stop()
}
}
}
}
}The state machine has four states:
public enum RouletteState {
case start // Initial state
case run(angle: Angle, speed: RouletteSpeed) // Currently rotating
case pause(angle: Angle, speed: RouletteSpeed) // Paused mid-rotation
case stop(location: PartData, angle: Angle) // Stopped with selected part
}Pull requests, bug reports and feature requests are welcome 🚀


