SwiftUIのChartsで円グラフや棒グラフを使用してエンジニアの構成比率を作成してみた。

# 概要
iOS16以上から、一部のグラフを簡単に作成できるフレームワークであるChartsが使用できるようになり、さらにiOS17以上からは、円グラフも対応できるようになりデータの視認性を良くできるようになりました！

そんなChartsを使用してみたいとかねてから考えていたので、今回は「エンジニアの構成比率を表示する」という題材でデータを可視化するためにサンプルアプリを実装してみました！最低限グラフを表示できるよう雑に解説していきます！

以下のサンプル動画を作成してみたのでぜひ確認してみてください！

[![Image from Gyazo](https://i.gyazo.com/175c7ec60105f8e20c275a1c53ad57bf.gif)](https://gyazo.com/175c7ec60105f8e20c275a1c53ad57bf)

:::note info
説明すること
- ChartsのChart構造体について
- SectorMark(iOS17以上)、BarMark、PointMark、LineMarkについて
- Chartsを使用する上で、考慮する点について
:::

:::note warn
説明しないこと
- 各データに対する効果的なグラフの種類について
- チャートの細かいモディファイアの使い分けについて
- アクションによるグラフの表示を変化させる方法について
:::

# 環境
macOS 14.1.2（23B92）
Xcode Version 15.0.1 (15A507)
iOS(simulater) 17.0.1

# Chartsについて
[Charts](https://developer.apple.com/documentation/charts)とは、どのAppleのプラットフォームでも使用できる、チャートやグラフを作成することのできるApple純正のフレームワークです。自前のデータを視認性の良いチャートやグラフにすることを目的にしているフレームワークです！
純正のChartsができるまでにサードパーティ製の[MPAndroidChart Documentation](https://github.com/danielgindi/Charts)という外部のフレームワーク等も存在していたそうです。（筆者は使用したことがないので説明は割愛させていただきます。）

使用方法としては、`Chart`という構造体内に、円グラフを表示する構造体や棒グラフや折れ線グラフを表示するための構造体を定義して、画面にグラフを表示します。
Chartsには、以下のような初期化の状態を定義されているので、Identifirbleに準拠したデータクラスもしくは、配列等を使用して要素をグラフに落とし込んでいきます！
使用していた感覚としては、`ForEach`に似ていると感じました。

```Charts
public struct Chart<Content> : View where Content : ChartContent {
    public init(@ChartContentBuilder content: () -> Content)
    public init<Data, C>(_ data: Data, @ChartContentBuilder content: @escaping (Data.Element) -> C) where Content == ForEach<Data, Data.Element.ID, C>, Data : RandomAccessCollection, C : ChartContent, Data.Element : Identifiable
    public init<Data, ID, C>(_ data: Data, id: KeyPath<Data.Element, ID>, @ChartContentBuilder content: @escaping (Data.Element) -> C) where Content == ForEach<Data, ID, C>, Data : RandomAccessCollection, ID : Hashable, C : ChartContent
    public var actualBody: some View { get }
    @MainActor public var body: some View { get }
    public typealias Body = some View
}
```

## SectorMark
SectorMarkでは、円グラフを作成することができて以下のように引数に`angle: .value("count", $0.numberOfEnginners)`を持たせて、第一引数は、表示する値の単位、第二引数には実際に値を入れることで円グラフを生成できます！
`angle`以外にも、`innerRadius`や`outerRadius`、`angularInset`等で円グラフの見せ方を変更できます！

<details><summary>サンプルコード</summary>

```CircleGraphView.swift
import SwiftUI
import Charts

struct CircleGraphView: View {

    let geometry: GeometryProxy
    @Binding var engineerModelList: [NumberOfEngineersModel]

    var body: some View {
        Chart(engineerModelList) {
            SectorMark(
                angle: .value("count", $0.numberOfEnginners)
            )
            .foregroundStyle(by: .value("engineerType", $0.engineerType.rawValue))
        }
        .frame(width: geometry.size.width * 0.8, height: geometry.size.width * 0.8)
        // 円グラフの下に表示されている項目を非表示にすることができる
//        .chartLegend(.hidden)
    }
}
```
</details>

## BarMark
BarMarkは、棒グラフを生成するために使用できるChartsのパーツです。BarMarkをすようするときは、`BarMark(x: .value(), y: .value())`のような形でx軸とy軸を設定して、その値をChartパーツから取得して画面を生成しています。
他には、`xStart`や`yStart`、`xEnd`、`yEnd`という引数を初期化時に選べるのでこちらも初期化するタイミングで変更できます。

`.annotation`等を使用し、平均値の横線や縦線を表示したりしてより見やすいグラフにできるようです。

<details><summary>サンプルコード</summary>

```BarMarkGraphView.swift
import SwiftUI
import Charts

struct BarMarkGraphView: View {

    let geometry: GeometryProxy
    @Binding var engineerModelList: [NumberOfEngineersModel]

    var body: some View {
        Chart(engineerModelList) {
            BarMark(x: .value("EnginnerType", $0.engineerType.rawValue),
                    y: .value("NumberOfEnginners", $0.numberOfEnginners))
        }
        .frame(width: geometry.size.width * 0.8, height: geometry.size.width * 0.8)
    }
}
```
</details>

## PointMark
PointMarkとは、与えられた値に点を生成してくれます。こちらもChart内で`PointMark`を定義して、ただデータを入れれば完成します。
分布図を実装でどのように使用するかは理解しきれていませんが、次に紹介するLineMarkと一緒に使用すれば、折れ線グラフの強調として使用できます。

<details><summary>サンプルコード</summary>

```PointMarkGraphView.swift
import SwiftUI
import Charts

struct PointMarkGraphView: View {
    let geometry: GeometryProxy
    @Binding var engineerModelList: [NumberOfEngineersModel]

    var body: some View {
        Chart(engineerModelList) {
            PointMark(x: .value("EnginnerType", $0.engineerType.rawValue),
                      y: .value("NumberOfEnginners", $0.numberOfEnginners))
        }
        .frame(width: geometry.size.width * 0.8, height: geometry.size.width * 0.8)
    }
}
```
</details>

## LineMark
今回最後の、LineMarkは折れ線グラフを生成するために使用します。
Chart内で`LineMark`を追加して、x軸とy軸を設定するとこちらも折れ線グラフが完成するので、すぐ実装が可能です。

<details><summary>サンプルコード</summary>

```LineMarkGraphView.swift
import SwiftUI
import Charts

struct LineMarkGraphView: View {
    let geometry: GeometryProxy
    @Binding var engineerModelList: [NumberOfEngineersModel]

    var body: some View {
        Chart(engineerModelList) {
            LineMark(x: .value("EnginnerType", $0.engineerType.rawValue),
                      y: .value("NumberOfEnginners", $0.numberOfEnginners))
        }
        .frame(width: geometry.size.width * 0.8, height: geometry.size.width * 0.8)
    }
}
```
</details>


# 今回実装したCharts以外の全コード
簡単なチャートを表示する実装をしたので、コピペで動くであろうソースコードを貼っておきます！（おそらく動くはずです...）

## Model
<details><summary>サンプルコード</summary>


```NumberOfEngineersModel.swift

import Foundation

/// エンジニア職種とエンジニア以外の人数を管理するためのモデル
struct NumberOfEngineersModel: Identifiable {

    /// 代表的なエンジニアの職種を定義しているenumクラスで、基本的に
    /// クラスやストラクト等の外から直接使用することはないため、NumberOfEngineersModel内管理
    enum EngineerType: String, CaseIterable {
        case iOS
        case Android
        case Backend
        case Frontend
        case Infra
        case QA
        case Others
    }

    let id = UUID()
    let engineerType: EngineerType
    let numberOfEnginners: Int

    init(engineerType: EngineerType = EngineerType.Others, numberOfEnginners: Int = 0) {
        self.engineerType = engineerType
        self.numberOfEnginners = numberOfEnginners
    }

    /// グラフ生成のための初期化時に各職種の人数をランダムにセットするための処理で配列で当モデルを返却
    static func setRandamNumberOfEngineersList() -> [Self] {
        EngineerType.allCases.map { NumberOfEngineersModel(engineerType: $0, numberOfEnginners: Int.random(in: 0...20)) }
    }
}


```
</details>

## ViewModel
<details><summary>サンプルコード</summary>

```ContentViewModel.swift

import Foundation
import Observation

@Observable
final class ContentViewModel {

    /// チャートの種類を管理するための列挙型
    /// ContentViewとContentViewModelのみで使用するため、クラス内に定義
    enum ChartType: CaseIterable {
        case circle, barMark, pointMark, lineMark
        var titleName: String {
            return switch self {
            case .circle: "Circle🟢"
            case .barMark: "BarMark📊"
            case .pointMark: "PointMark✊"
                case .lineMark: "LineMark📈"
            }
        }
    }

    var engineerModelList: [NumberOfEngineersModel] = [NumberOfEngineersModel()]
    var chartType: ChartType = .circle

    init() {
        setUpMockData()
    }

    func tappedRefreshedMockData() {
        setUpMockData()
    }

    func tappedOptionBottomBarButton(with chartType: ChartType) {
        self.chartType = chartType
    }
}

// MARK: - Private

private extension ContentViewModel {

    func setUpMockData() {
        engineerModelList = NumberOfEngineersModel.setRandamNumberOfEngineersList()
    }
}

```
</details>


## View
<details><summary>サンプルコード</summary>

```ContentView.swift

import SwiftUI
import Charts

struct ContentView: View {

    @State private var viewModel = ContentViewModel()

    var body: some View {
        NavigationStack {
            GeometryReader { geometry in
                VStack {
                    Spacer()
                    HStack {
                        Spacer()
                        switch viewModel.chartType {
                        case .circle:
                            // 円グラフを表示するための子ビュー(iOS17以上)
                            CircleGraphView(geometry: geometry, engineerModelList: $viewModel.engineerModelList)
                        case .barMark:
                            // 棒グラフを表示するための子ビュー
                            BarMarkGraphView(geometry: geometry, engineerModelList: $viewModel.engineerModelList)
                        case .pointMark:
                            // 点(分布？)グラフを表示するための子ビュー
                            PointMarkGraphView(geometry: geometry, engineerModelList: $viewModel.engineerModelList)
                        case .lineMark:
                            // 折れ線グラフを表示するための子ビュー
                            LineMarkGraphView(geometry: geometry, engineerModelList: $viewModel.engineerModelList)
                        }
                        Spacer()
                    }
                    Spacer()
                }
            }
            .toolbar {
                ToolbarItem(placement: .bottomBar) {
                    HStack {
                        ForEach(0..<ContentViewModel.ChartType.allCases.count, id: \.self) { index in
                            Button {
                                let chartType = ContentViewModel.ChartType.allCases[index]
                                viewModel.tappedOptionBottomBarButton(with: chartType)
                            } label: {
                                Text(ContentViewModel.ChartType.allCases[index].titleName)
                                    .font(.system(size: 15))
                            }
                        }
                    }
                }
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button {
                        viewModel.tappedRefreshedMockData()
                    } label: {
                        Text("Refresh Data")
                    }
                }
            }
            .navigationTitle(viewModel.chartType.titleName)
        }
    }
}
```
</details>

# さいごに
時間の関係上かなり駆け足になってしまい、深い内容までは踏み込めていないですが、`Charts`を使用してすぐにUIを作成できました。

簡単なデータをただ表示するだけであれば、すぐに実装できてしまいますが、グラフを選択した時の挙動や細かい仕様を実装する場合は、モディファイアを駆使して実装を調節していく必要があります。

また、今回実装していて思ったのは、データの持ち方によってグラフ生成の柔軟性は変わってくるということです。Apple純正のスクリーンタイムアプリのような`1日の中でいくつかのカテゴリーの何かを行った時間値をカテゴリーで色分けしてグラフを表示する`ような場合のデータ構造の実装に悩みました！
上記の実装ができるようにサンプルアプリで実装を試してみたいと思います！

あと何気にiOS17以上の[Observation](https://developer.apple.com/documentation/observation)を使用していますが、プロパティラッパー(@Published)を毎度書かなくていいのめっちゃ楽ですね！


# 参考記事

https://developer.apple.com/documentation/charts

https://developer.apple.com/documentation/charts/linemark

https://developer.apple.com/documentation/charts/pointmark

https://developer.apple.com/documentation/charts/barmark

https://developer.apple.com/documentation/charts/sectormark

https://github.com/danielgindi/Charts

https://developer.apple.com/documentation/observation