fummicc1/simpleroulette
SimpleRoulette
SimpleRoulette
SimpleRoulette helps you to create customizable Roulette, with SwiftUI. (Compatible with both macOS and iOS.)
Requirements
- iOS 17.0+ / macOS 14.0+
- Swift 6.0+
- Xcode 16.0+
Demo
iOS
<img src="https://user-images.githubusercontent.com/44002126/180654806-58f70b4f-9bbd-4345-b7a5-4e1ba5aebbd9.gif" width="30%">
macOS
<img src="https://user-images.githubusercontent.com/44002126/180655023-ccb71dce-9478-4f44-a8c1-b914184e607c.gif" width="30%">
Install
Swift Package Manager
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")
]Cocoapods
Create Podfile and add dependency like the following.
pod 'SimpleRoulette', '~> 2.0'Carthage
Create Cartfile and add dependency like the following.
github "fummicc1/SimpleRoulette"Usage
RouletteView
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),
]Custom content
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.
Styling
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
RouletteModel uses the @Observable macro from the Observation framework. You can observe the roulette state directly via model.state.
State Observation
// 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
}Pause / Restart
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()
}
}
}
}
}RouletteState
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
}Documentation
Contributing
Pull requests, bug reports and feature requests are welcome 🚀
License
Package Metadata
Repository: fummicc1/simpleroulette
Default branch: main
README: README.md