3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftChartsでルーレットを作ってみた

Last updated at Posted at 2025-12-12

記事の概要

こんにちは!ZOZOでFAANS iOSの開発をしている加藤です!
最近、仕事でSwiftChartsを触る機会が増えてきました。SwiftChartsの勉強をしていく中で、円グラフを作成できることを知ったのですが、SwiftCharts の円グラフを見たときに「これ、ルーレットに使えるのでは……?」と思い、実際に作ってみました。
今回はSwiftChartsを用いたルーレットの作り方を記事として紹介したいと思います!

SwiftChartsを触ったことがない方や、円グラフを使いたい方にもオススメの記事です!
↓完成したルーレットアプリ画面
Simulator Screen Recording - iPhone 17 Pro - 2025-11-30 at 21.37.44 2.gif

1. SwiftChartsって何?

SwiftChartsは、AppleがWWDC22で発表したグラフ作成のためのフレームワークです。iOS 16以降で利用可能でSwiftUIを用いて記述します(円グラフはiOS 17から作成可能)。グラフの種類は、棒グラフ、円グラフ、折れ線グラフなど多種類をサポートしています。
さらに、VoiceOverやAudioGraphをサポートしており、グラフの読み上げができるなどアクセシビリティにも対応しています。また、WWDC25 では三次元グラフの描画にも対応し、よりリッチな可視化が可能になりました。
興味がある方はWWDCの以下のセッションをご覧ください。

次節では、SwiftChartsを使ってどのようにルーレットを作成するのか紹介します。

2. ルーレットの土台を作ってみよう

まず、SwiftChartsでルーレットの基盤となる土台を作ってみましょう。土台にはSwiftChartsの円グラフを作成する機能であるSectorMarkを使用します。
以下、土台作りのプログラムです。

import SwiftUI
import Charts

@State private var item: [Item] = [
    .init(name: "A", rate: 1, color: .red),
    .init(name: "B", rate: 1, color: .blue),
    .init(name: "C", rate: 1, color: .green),
    .init(name: "D", rate: 1, color: .orange),
    .init(name: "E", rate: 1, color: .purple),
]

var body: some View {
    Chart(item, id: \.name) { item in
        SectorMark( // 円グラフの作成
            angle: .value("rate", item.rate),
            innerRadius: .ratio(0.45)
        )
        .foregroundStyle(item.color) // 色分けするための定義
    }
    .chartLegend(.hidden) // 凡例の非表示
    .frame(width: 300, height: 300)
}

ポイントは、SectorMarkで円グラフを作成している点と、itemのrateを統一することで円グラフ中の割合を均一にしている点です。今回はすべてのセクションを同じ大きさにしたいので、rate をすべて1にしています(実際の割合にしたい場合はそれぞれ異なる値を設定できます)。
このプログラムで以下のような画面が作成されます。

Simulator Screenshot - iPhone 17 Pro - 2025-11-30 at 21.52.12.png

3. 針の作成&ルーレットを回してみよう

続いてルーレットを回してみましょう。ルーレットは画面上のボタンをタップした際に回るように設定します。
以下、プログラムです。

@State private var rotation: Double = 0
var body: some View {
    ZStack {
        // MARK: - ルーレット
        Chart(item, id: \.name) { item in
            SectorMark(
                angle: .value("rate", item.rate),
                innerRadius: .ratio(0.45)
            )
            .foregroundStyle(item.color)
        }
        .chartLegend(.hidden)
        .frame(width: 300, height: 300)
        .rotationEffect(.degrees(rotation))
                    
        // MARK: - 針
        Triangle()
            .fill(.black)
            .frame(width: 30, height: 30)
            .rotationEffect(.degrees(180))
            .offset(y: -160)
                    
        // MARK: - ボタン
        Button("Spin") {
            spin()
        }
        .buttonStyle(.borderedProminent)
    }
}

private func spin() {
    // ぐるぐる回すための角度の定義
    let extraRotations = Double.random(in: 3...6) * 360
    // 最後の結果をランダムにする定義
    let randomOffset = Double.random(in: 0..<360)
    let target = rotation + extraRotations + randomOffset
        
    // 3秒かけてtargetの位置まで変化させるアニメーション
    withAnimation(.easeOut(duration: 3)) {
        rotation = target
    }
}

struct Triangle: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: rect.midX, y: rect.minY))
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
        path.closeSubpath()
        return path
    }
}

このプログラムのポイントは3点です。

  • rotationEffectを用いたルーレットの回転
  • spin関数の中身
  • 針は自作のShape

まず、ChartにrotationEffectを付与して、ルーレットを回転できるようにします。ルーレットの回転角度はrotationの値に依存します。rotationの角度はspin関数内で計算しています。ランダムな値を足し合わせることで、ルーレットの回転角度を不規則にしており、withAnimationを用いて3秒かけて任意の角度までルーレットを回転させています。また、針の部分は自作のTriangleShapeを使ってシンプルに描画しています。

このプログラムの実装後は以下のようになります。
Simulator Screen Recording - iPhone 17 Pro - 2025-11-30 at 21.53.00.gif

4. 針の刺した値を取得しよう

最後にルーレットが止まったときの針が刺している値を取得しましょう。
以下が完成版のプログラムです。

@State private var selectedItem: String? = nil

var body: some View {
    VStack {
        ZStack {
            // MARK: - ルーレット
            Chart(item, id: \.name) { item in
                SectorMark(
                    angle: .value("rate", item.rate),
                    innerRadius: .ratio(0.45)
                )
                .foregroundStyle(item.color)
            }
            .chartLegend(.hidden)
            .frame(width: 300, height: 300)
            .rotationEffect(.degrees(rotation))
            
            // MARK: - 針
            Triangle()
                .fill(.black)
                .frame(width: 30, height: 30)
                .rotationEffect(.degrees(180))
                .offset(y: -160)
            
            // MARK: - ボタン
            Button("Spin") {
                spin()
            }
            .buttonStyle(.borderedProminent)
        }
        
        Text(selectedItem != nil ? "当選: \(selectedItem!)" : "当選結果")
            .font(.title)
            .bold()
            .foregroundStyle(selectedItem == nil ? .clear : .primary)
            .padding(.top, 30)
            .animation(.default, value: selectedItem)
        
        VStack(spacing: 8) {
            ForEach(item, id: \.name) { item in
                HStack {
                    Circle()
                        .fill(item.color)
                        .frame(width: 16, height: 16)
                    
                    Text(item.name)
                        .font(.body)
                    
                    Spacer()
                    
                    Text("rate: \(item.rate)")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
            }
        }
        .padding(.horizontal, 16)
    }
}

// MARK: - 回転処理
private func spin() {
    // ぐるぐる回すための角度の定義
    let extraRotations = Double.random(in: 3...6) * 360
    // 最後の結果をランダムにする定義
    let randomOffset = Double.random(in: 0..<360)
    let target = rotation + extraRotations + randomOffset
    
    // 3秒かけてtargetの位置まで変化させるアニメーション
    withAnimation(.easeOut(duration: 3)) {
        rotation = target
    }
    
    // 3秒のアニメーションを待ってから結果の印字
    Task {
        try? await Task.sleep(nanoseconds: 3_000_000_000)
        judgeResult()
    }
}

private func judgeResult() {
    let n = item.count
    guard n > 0 else { return }
    
    // 角度の算出
    let degree = (rotation.truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360)
    
    let divisor = 360.0 / Double(n)
    let ccwIndex = Int(degree / divisor)
    let index = (n - 1 - ccwIndex + n) % n
    selectedItem = item[index].name
}

このプログラムではTextによる結果の表示と、凡例を表示しています。また、針の刺した値をjudgeResultで算出しており、ルーレット回転のアニメーションに合わせて、3秒後に算出、反映されます。
このプログラムのポイントは、judgeResultです。judgeResultでは、まず、回転した角度(0〜360)を算出してdegreeに代入しています。ここで、rotationは360以上の値を取るので、.truncatingRemainder(dividingBy: 360)を付与することでrotationを360で割った余りを取得しています。ただし、SwiftのtruncatingRemainderは負の値を返すことがあるため、一度360を足してから再度剰余を取り、最終角度を常に0〜360°に収めています。

その後、算出されたdegreeが分割されたルーレットのどこの値に該当するかを算出します。このとき注意すべき点は、ルーレットはitemの値を時計回りに配置していく点です。これを考慮すると上記のような式で針の刺す値を取得することができます。
以下がこのプログラムで完成したルーレットの映像です。
Simulator Screen Recording - iPhone 17 Pro - 2025-11-30 at 21.37.44.gif

5. まとめ

いかがでしたでしょうか?SwiftChartsを用いることでルーレットの土台を作成でき、おしゃれなルーレットを簡単に作成できました。SwiftChartsを使うことで、ルーレットのようなUIも短時間で実装できることに驚きました。機会があれば皆さんSwiftChartsを色々触ってみてください!

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?