Skip to content

Repository files navigation

Images

Pod Platform Pod License Pod Version Carthage Compatible Swift Package Manager compatible

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

macOS

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

Releases

Packages

Used by

Contributors

Languages