Contents

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

MIT LICENSE

Package Metadata

Repository: fummicc1/simpleroulette

Default branch: main

README: README.md