Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,47 @@ PartData(
`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 out at the rim,
arranged radially, and the separators stop short of the middle to leave a hub.
As on a real wheel, labels on the left half therefore sit upside down.

Override any of that with `.rouletteStyle(_:)`:

```swift
RouletteView(parts: partDatas)
.rouletteStyle(
RouletteStyle(
labelPosition: 0.8, // 0 = centre, 1 = outer edge
labelOrientation: .radial, // see the table below
innerRadiusRatio: 0.25 // size of the hub, 0 for a full pie
)
)
```

| `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:

```swift
RouletteView(parts: partDatas)
.rouletteStyle(RouletteStyle(labelOrientation: .radialUpright))
```

`RouletteStyle.pie` restores the 1.x appearance — labels halfway out, laid
across the wedge, with no hub:

```swift
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`.
Expand Down
20 changes: 9 additions & 11 deletions Sources/SimpleRoulette/Sources/Pie/RouletteInternalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public struct RouletteInternalView<StopView: View>: View {

var model: RouletteModel

@Environment(\.rouletteStyle) private var style

@State private var currentAngle: Angle = .init()
@State private var radius: CGFloat = 0
@State private var center: CGPoint = .zero
Expand Down Expand Up @@ -54,23 +56,19 @@ public struct RouletteInternalView<StopView: View>: View {
RoulettePart(
data: part,
center: center,
radius: radius
radius: radius,
innerRadius: radius * style.innerRadiusRatio
)
}
ForEach(model.parts) { part in
let angle = ((part.endAngle + part.startAngle) / 2) + Angle(degrees: 90)
let mean = (part.startAngle + part.endAngle) / 2
let distance = radius * style.labelPosition
part.content.view
.rotationEffect(angle)
.rotationEffect(style.labelOrientation.rotation(midAngle: mean))
.offset(
CGSize(
width: { () -> Double in
let mean = (part.startAngle + part.endAngle) / 2
return radius * 0.5 * cos(mean.radians)
}(),
height: { () -> Double in
let mean = (part.startAngle + part.endAngle) / 2
return radius * 0.5 * sin(mean.radians)
}()
width: distance * cos(mean.radians),
height: distance * sin(mean.radians)
)
)
}
Expand Down
29 changes: 27 additions & 2 deletions Sources/SimpleRoulette/Sources/Pie/RoulettePart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,22 @@ struct RoulettePart: View {
init(
data: PartData,
center: CGPoint,
radius: Double
radius: Double,
innerRadius: Double = 0
) {
self.data = data
self.center = center
self.radius = radius
self.innerRadius = innerRadius
}


// MARK: Property
var data: PartData
var center: CGPoint
var radius: Double
/// Radius of the hub left empty in the middle. `0` draws a full wedge.
var innerRadius: Double


var body: some View {
Expand All @@ -42,7 +46,20 @@ struct RoulettePart: View {

private func path() -> Path {
Path { path in
path.move(to: center)
guard innerRadius > 0 else {
path.move(to: center)
path.addArc(
center: center,
radius: radius,
startAngle: data.startAngle,
endAngle: data.endAngle,
clockwise: false,
transform: .identity
)
path.closeSubpath()
return
}
// Annular sector: out along the rim, back along the hub.
path.addArc(
center: center,
radius: radius,
Expand All @@ -51,6 +68,14 @@ struct RoulettePart: View {
clockwise: false,
transform: .identity
)
path.addArc(
center: center,
radius: innerRadius,
startAngle: data.endAngle,
endAngle: data.startAngle,
clockwise: true,
transform: .identity
)
path.closeSubpath()
}
}
Expand Down
135 changes: 135 additions & 0 deletions Sources/SimpleRoulette/Sources/Types/RouletteStyle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//
// RouletteStyle.swift
// SimpleRoulette
//
// Created by Fumiya Tanaka on 2026/08/15.
//

import Foundation
import SwiftUI

/// How a part's label is rotated inside its wedge.
public enum RouletteLabelOrientation: Sendable, Hashable {

/// Reads outward from the hub, following the wedge, the way a real roulette is
/// laid out. Labels on the left half of the wheel therefore sit upside down —
/// fine for numbers, harder to read for words.
case radial

/// Follows the wedge like ``radial``, but labels on the left half of the wheel
/// are turned a further 180° so every one of them reads upright at rest.
case radialUpright

/// Runs across the wedge, perpendicular to the radius.
case tangential

/// Stays upright whatever angle the wedge sits at.
case fixed

/// The rotation to apply to a label whose wedge is centred on `midAngle`.
func rotation(midAngle: Angle) -> Angle {
switch self {
case .radial:
return midAngle
case .radialUpright:
return isOnLeftHalf(midAngle) ? midAngle + .degrees(180) : midAngle
case .tangential:
return midAngle + .degrees(90)
case .fixed:
return .zero
}
}

/// Whether a wedge centred on `angle` points into the half of the wheel where
/// radial text would read upside down.
private func isOnLeftHalf(_ angle: Angle) -> Bool {
let wrapped = angle.degrees.truncatingRemainder(dividingBy: 360)
let positive = wrapped < 0 ? wrapped + 360 : wrapped
return positive > 90 && positive < 270
}
}

/// Visual configuration shared by every part of a roulette.
///
/// Apply one with ``SwiftUI/View/rouletteStyle(_:)``:
///
/// ```swift
/// RouletteView(parts: parts)
/// .rouletteStyle(.pie) // the 1.x look
/// ```
public struct RouletteStyle: Sendable, Hashable {

/// Where a label sits between the centre (`0`) and the outer edge (`1`).
public var labelPosition: Double

/// How a label is rotated within its wedge.
public var labelOrientation: RouletteLabelOrientation

/// Radius of the empty hub in the middle, as a fraction of the outer radius.
///
/// `0` draws a full pie, so every separator meets in the centre.
public var innerRadiusRatio: Double

/// - Parameters:
/// - labelPosition: clamped to `0...1`.
/// - labelOrientation: how each label is rotated.
/// - innerRadiusRatio: clamped to `0...1`.
public init(
labelPosition: Double = 0.74,
labelOrientation: RouletteLabelOrientation = .radial,
innerRadiusRatio: Double = 0.22
) {
self.labelPosition = min(max(labelPosition, 0), 1)
self.labelOrientation = labelOrientation
self.innerRadiusRatio = min(max(innerRadiusRatio, 0), 1)
}

/// Labels out at the rim, laid out radially, with a hub in the middle — the way
/// a real roulette wheel is arranged. Used when no style is applied.
///
/// Being a faithful radial layout, labels on the left half of the wheel sit
/// upside down, exactly as the numbers on a real wheel do. If your labels are
/// words rather than numbers and you would rather every one of them stayed
/// readable, use ``RouletteLabelOrientation/radialUpright``:
///
/// ```swift
/// RouletteView(parts: parts)
/// .rouletteStyle(RouletteStyle(labelOrientation: .radialUpright))
/// ```
public static let `default` = RouletteStyle()

/// The 1.x look: labels halfway out, laid across the wedge, no hub.
public static let pie = RouletteStyle(
labelPosition: 0.5,
labelOrientation: .tangential,
innerRadiusRatio: 0
)
}

// MARK: - Environment

private struct RouletteStyleKey: EnvironmentKey {
static let defaultValue: RouletteStyle = .default
}

extension EnvironmentValues {
/// The style applied to roulettes in this view hierarchy.
public var rouletteStyle: RouletteStyle {
get { self[RouletteStyleKey.self] }
set { self[RouletteStyleKey.self] = newValue }
}
}

extension View {
/// Applies a visual style to every roulette in this view hierarchy.
///
/// ```swift
/// RouletteView(parts: parts)
/// .rouletteStyle(
/// RouletteStyle(labelPosition: 0.8, innerRadiusRatio: 0.25)
/// )
/// ```
public func rouletteStyle(_ style: RouletteStyle) -> some View {
environment(\.rouletteStyle, style)
}
}
108 changes: 108 additions & 0 deletions Tests/SimpleRouletteTests/Types/RouletteStyleTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//
// RouletteStyleTests.swift
//
//
// Created by Fumiya Tanaka on 2026/08/15.
//

import Testing
import SwiftUI
@testable import SimpleRoulette

@Suite("RouletteStyle Tests")
struct RouletteStyleTests {

private let accuracy = 0.0001

@Test("Default style lays the wheel out like a real roulette")
func defaultStyle() {
let style = RouletteStyle.default

// Faithful radial layout, labels out at the rim, hub in the middle.
#expect(style.labelOrientation == .radial)
#expect(style.labelPosition > 0.5)
#expect(style.innerRadiusRatio > 0)
}

@Test("Every orientation stays selectable alongside the default")
func orientationsRemainAvailable() {
for orientation in [
RouletteLabelOrientation.radial,
.radialUpright,
.tangential,
.fixed
] {
let style = RouletteStyle(labelOrientation: orientation)
#expect(style.labelOrientation == orientation)
}
}

@Test("Pie style reproduces the 1.x look")
func pieStyle() {
let style = RouletteStyle.pie

#expect(style.labelOrientation == .tangential)
#expect(abs(style.labelPosition - 0.5) < accuracy)
#expect(style.innerRadiusRatio == 0)
}

@Test("Ratios are clamped to 0...1")
func ratioClamping() {
let tooLarge = RouletteStyle(labelPosition: 4, innerRadiusRatio: 2.5)
#expect(tooLarge.labelPosition == 1)
#expect(tooLarge.innerRadiusRatio == 1)

let negative = RouletteStyle(labelPosition: -1, innerRadiusRatio: -0.5)
#expect(negative.labelPosition == 0)
#expect(negative.innerRadiusRatio == 0)
}

@Test("Radial labels rotate with their wedge")
func radialRotation() {
let mid = Angle(degrees: 45)
let rotation = RouletteLabelOrientation.radial.rotation(midAngle: mid)
#expect(abs(rotation.degrees - 45) < accuracy)
}

@Test("Upright labels are flipped only on the half where text would be inverted")
func radialUprightRotation() {
let orientation = RouletteLabelOrientation.radialUpright

// Right half reads outward already, so it is left alone.
#expect(abs(orientation.rotation(midAngle: .degrees(45)).degrees - 45) < accuracy)
#expect(abs(orientation.rotation(midAngle: .degrees(315)).degrees - 315) < accuracy)

// Left half would be upside down, so it gets a further 180 degrees.
#expect(abs(orientation.rotation(midAngle: .degrees(135)).degrees - 315) < accuracy)
#expect(abs(orientation.rotation(midAngle: .degrees(180)).degrees - 360) < accuracy)

// Boundaries stay unflipped.
#expect(abs(orientation.rotation(midAngle: .degrees(90)).degrees - 90) < accuracy)
#expect(abs(orientation.rotation(midAngle: .degrees(270)).degrees - 270) < accuracy)
}

@Test("Upright flipping survives angles outside 0..<360")
func radialUprightWrapping() {
let orientation = RouletteLabelOrientation.radialUpright

// 495 wraps to 135, which is on the flipped half.
#expect(abs(orientation.rotation(midAngle: .degrees(495)).degrees - 675) < accuracy)
// -225 wraps to 135 as well.
#expect(abs(orientation.rotation(midAngle: .degrees(-225)).degrees - (-45)) < accuracy)
}

@Test("Tangential labels sit perpendicular to the radius")
func tangentialRotation() {
let mid = Angle(degrees: 45)
let rotation = RouletteLabelOrientation.tangential.rotation(midAngle: mid)
#expect(abs(rotation.degrees - 135) < accuracy)
}

@Test("Fixed labels stay upright whatever the wedge angle")
func fixedRotation() {
for degrees in stride(from: 0.0, to: 360.0, by: 45.0) {
let rotation = RouletteLabelOrientation.fixed.rotation(midAngle: .degrees(degrees))
#expect(rotation.degrees == 0)
}
}
}
Loading