以下の内容はhttps://techblog.zozo.com/entry/replace-chartsより取得しました。


DGChartsからSwift Chartsへの移行で検討した3つの実装アプローチ

DGChartsからSwift Chartsへの移行で検討した3つの実装アプローチ

はじめに

こんにちは、FAANS部フロントエンドブロックの加藤です。普段はFAANSのiOSアプリを開発しています。FAANSは、ショップスタッフの販売サポートツールであり、アプリ上でコーディネートの投稿や売上などの成果を確認できます。

成果の確認画面では以下の動画のように成果を棒グラフで可視化しています。これまでFAANS iOSでは、棒グラフの生成にサードパーティライブラリであるDGChartsを用いていました。一方で、FAANSではiOS 15のサポートを終了しているため、iOS 16以上で利用可能なApple標準のグラフ生成フレームワーク「Swift Charts」を利用できます。そこで、この度、DGChartsからSwift Chartsへの移行を実施しました。

この記事では、DGChartsからSwift Chartsへの移行にあたり検討した実装アプローチについて紹介します。

DGChartsを利用した成果画面

目次

成果画面のレイアウトと機能

FAANSにおける成果画面のレイアウトと機能は以下の画像のようになっています。 成果画面の機能とレイアウト

成果画面では、横軸が日付、縦軸が売上の棒グラフが表示されます。棒グラフは横方向のスクロール(画像の1)、およびタップが可能で、選択した日付の売上が画面上に表示される仕組みです(画像の2)。また、棒グラフは3〜4種類の値で構成されており、それぞれの値を色分けして積み上げています(画像の3)。さらに、棒グラフは1画面に7.5日分表示されており、左端に0.5日分が見切れた状態です。これにより、スクロールが可能であることを示唆しています(画像の4)。

以上がFAANSの成果画面におけるレイアウトと機能です。本記事では、これらの機能をSwift Chartsで実装するにあたり検討した3つのアプローチについて、比較・検証した過程を紹介します。

実装方法は以下の3つです。

  • Swift Chartsのみで実装する方法
  • Swift ChartsとUICollectionViewを組み合わせて実装する方法(今回採用した方法)
  • 表示するデータを工夫したSwift Chartsの実装方法(採用には至らなかったが、Swift Chartsのみで完結させる代替案として紹介)

また、実装要件と3つの実装方法に対する評価方法は以下の通りです。

  • 実装要件
    • 横スクロール、タップアクション、値の積み上げ、7.5日分の表示の4種類の機能を実装する
  • 評価方法
    • InstrumentsのHitches(フレームの描画遅延の回数・タイミングを可視化するツール)
    • 検証端末:iPhone 16 Pro(iOS 26.2.1)

Swift Chartsのみで実装

まずはSwift Chartsのみで実装する方法についてです。プログラムは以下の通りです。

// グラフデータの構造体
struct Sales: Identifiable {
    var id = UUID()
    var type: String
    var date: Date
    var sales: Double
}
private let salesChannels = ["zozotown", "wear", "yahoo!Shopping", "ownedEc"]

//------以下、グラフの生成
struct BarChartsView: View {
    private let visibleLength: TimeInterval = 24 * 60 * 60 * 7.5
    private let dateFormatter = DateFormatter(with: .weeklyChart) // 自作の拡張
    // データの作成
    private let barData: [Sales] = [
        (month: 9, days: 1...30),
        (month: 10, days: 1...30)
    ].flatMap { month, days in
        days.flatMap { day -> [Sales] in
            salesChannels.map { type in
                Sales(
                    type: type,
                    date: date(year: 2025, month: month, day: day), // Dateの作成
                    sales: round(Double.random(in: 0...50000000))
                )
            }
        }
    }

    @State private var scrollPosition: Date = barData.last!.date

    var body: some View {
        Chart(barData, id: \.id) { row in
            BarMark(
                x: .value("Day", row.date, unit: .day), // x座標のデータ(日付)
                y: .value("Sales", row.sales) // y座標のデータ(売上)
            )
            .foregroundStyle(by: .value("Type", row.type)) // ③データの積み上げ
        }
        .chartScrollableAxes(.horizontal) // ①横方向のスクロール方向(iOS 17+)
        .chartLegend(.hidden) // 凡例の非表示
        .chartXVisibleDomain(length: visibleLength) // ④可視化幅を7.5日分に設定(iOS 17+)
        .chartScrollPosition(x: $scrollPosition) // 最初に右端が映るように設定(iOS 17+)
        // 積み上げる色の定義
        .chartForegroundStyleScale([
            "zozotown": Color(.Token.serviceZozotown),
            "wear": Color(.Token.serviceWear),
            "yahoo!Shopping": Color(.Token.serviceYahoo),
            "ownedEc": Color(.Token.serviceBrandEc)
        ])
        // ②グラフタップ時の挙動(iOS 17+)
        .chartGesture { chart in
            SpatialTapGesture()
                .onEnded { value in
                    guard
                        let (date, _) = chart.value(
                            at: value.location,
                            as: (Date, Double).self
                        )
                    else { return }
                    // ↑dateがタップした日付
                }
        }
        // x軸のラベル定義
        .chartXAxis {
            AxisMarks(values: .stride(by: .day)) { value in
                if let date = value.as(Date.self) {
                    AxisValueLabel(centered: true) {
                        Text(dateFormatter.string(from: date)) // MM/dd\nEEE
                            .multilineTextAlignment(.center)
                    }
                }
            }
        }
        // y軸のラベル定義
        .chartYAxis {
            AxisMarks(values: .automatic(desiredCount: 4)) { value in
                AxisValueLabel(multiLabelAlignment: .leading) {
                    if let raw = value.as(Double.self) {
                        Text(
                            // 中身は省略
                        )
                    }
                }
            }
        }
    }
}

上記プログラムでは、横スクロール、タップアクション、値の積み上げ、7.5日分の表示の4種類の機能をそれぞれ以下の方法で実装しています。

  • 横スクロール:chartScrollableAxes(.horizontal)
  • タップアクション:chartGesture
  • 値の積み上げ:foregroundStyle
  • 7.5日分の表示:chartXVisibleDomain

注意が必要なのは、chartScrollableAxeschartGestureはiOS 17以降で利用できる機能である点です。また、chartScrollPositionで初期の表示位置を指定している点や、chartXAxisやchartYAxisで目盛りのレイアウトを調整している点も重要です。

これで、実装したかった成果画面のレイアウトと機能を全て実装できました。しかし、スクロール時の動作を確認してみると、スクロールが重たく感じます。主観では判断できないため、InstrumentsのHitchesを用いてパフォーマンスを計測しました。パフォーマンス計測では、グラフの表示画面を表示して、数回のスクロールを実施しました。パフォーマンス計測結果は以下の画像のようになりました。 パフォーマンス計測(Swift Charts)

上記画像におけるタイムライン上の赤線は、フレームの描画遅延が発生した時刻を表しています。Swift Chartsのみの実装では赤線が密集しており、スクロール中に連続してフレームの描画遅延が発生していることが確認できました。また、サマリーを見ると338回発生しており、最大Hitchは25msでした。ここで比較のため、DGChartsを用いた既存実装におけるHitchesを示します。 パフォーマンス計測(DGCharts)

Swift Chartsのみで実装した場合と比較して、赤線が密集している箇所が少なく、最大Hitchも12.50msであることが分かります。

Swift Chartsのみで実装された場合におけるパフォーマンス低下の原因を調査した結果、データ数の多さ(約2か月分)が主な要因のようです。また、multilineTextAlignment(.center)の指定や、chartScrollPositionの利用も影響していました(正確な原因の特定には至りませんでした)。multilineTextAlignment(.center)をやめると軽くなりますが、データ数は減らせないので、Swift Chartsのみの実装方法は採用しませんでした。

Swift Charts + UICollectionViewで実装

Swift Chartsにおけるスクロールのパフォーマンス問題を解消するために、UICollectionViewを用いる方法を検討しました。具体的には、UICollectionViewのscrollDirectionで横スクロールを実現して、UICollectionViewCellとしてSwift Chartsを表示します。UICollectionViewはUICollectionViewCellを再利用して描画するため、データ量が多い場合でもパフォーマンスへの影響を抑えられます。これまでのDGChartsを用いた実装でも、この方法を採用していました。

また、UICollectionViewを用いた実装では、y軸を別途実装する必要があります。FAANSの成果画面では右端にy軸が固定されており、棒グラフのみがスクロールできるデザインです。そのため、UICollectionViewCellに載せるViewではy軸は非表示にして、別のViewとして実装する必要があります。図にすると下記のような構成です。 UICollectionViewとSwift Chartsを用いた実装の内訳

UICollectionViewCellに載せるSwift Chartsの実装は以下の通りです。

// 表示するデータのチャンネル
enum StackedOutcomeChannel: String, Plottable, CaseIterable {
    case zozotown
    case wear
    case yahooShopping
    case ownedEc
}

// グラフデータの構造体
struct StackedOutcomeBarMarkEntry: Hashable {
    var type: StackedOutcomeChannel
    var date: Date
    var value: Double
}

struct StackedOutcomeBarMarkView: View {
    // 外部から代入する値
    struct ChartModel {
        var colors: [UIColor]
        var entries: [StackedOutcomeBarMarkEntry]
        var yAxisMax: Double
        var selectedDate: Date?
        var onSelectDate: ((Date) -> Void)?
    }

    let chartModel: ChartModel
    @State private var selectDate: Date? // 選択されたグラフ日時の格納先

    // グラフの色(chartForegroundStyleScaleで利用するためにKeyValuePairsで定義)
    private var barMarkColors: KeyValuePairs<StackedOutcomeChannel, Color> {
        return [
            StackedOutcomeChannel.zozotown: Color(chartModel.colors[0]),
            StackedOutcomeChannel.wear: Color(chartModel.colors[1]),
            StackedOutcomeChannel.yahooShopping: Color(chartModel.colors[2]),
            StackedOutcomeChannel.ownedEc: Color(chartModel.colors[3])
        ]
    }

    init(chartModel: ChartModel) {
        self.chartModel = chartModel
        _selectDate = State(initialValue: chartModel.selectedDate)
    }

    var body: some View {
        Chart(chartModel.entries, id: \.self) { row in
            BarMark(
                x: .value("Day", row.date),
                y: .value("Value", row.value)
            )
            .foregroundStyle(by: .value("Type", row.type))
        }
        .chartLegend(.hidden) // 凡例の非表示
        .chartForegroundStyleScale(barMarkColors) // 積み上げる色の定義
        // グラフタップ時の挙動(iOS 17+)
        .chartGesture { chart in
            SpatialTapGesture()
                .onEnded { value in
                    guard
                        let (date, _) = chart.value(
                            at: value.location,
                            as: (Date, Double).self
                        )
                    else { return }
                    self.selectDate = date
                    chartModel.onSelectDate?(date)
                }
        }
        // x軸のラベル定義
        .chartXAxis {
            AxisMarks(values: .stride(by: .day)) { value in
                if let date = value.as(Date.self) {
                    AxisValueLabel(centered: true) {
                        Text(DateFormatter(with: .weeklyChart).string(from: date))
                            .multilineTextAlignment(.center)
                    }
                }
            }
        }
        .chartYScale(domain: 0...chartModel.yAxisMax)  // 重要: y軸スケールの定義
        .chartYAxis(.hidden) // y軸の非表示
    }
}

Swift Chartsのみで実装した場合と異なり、横スクロールの設定やchartScrollPositionによる初期位置の調整は不要です。また、y軸は非表示にしたいので、.chartYAxis(.hidden)を設定しています。このとき、chartYScaleを用いて、y軸の最小値と最大値を設定しておくことがポイントです。この定義で、独立したy軸のみのViewと棒グラフの目盛りの整合性を取ります。

続いて、右側に固定するy軸のViewを下記のプログラムで実装します。

struct BarMarkYAxis: View {
    // 外部から代入する値(仕様の関係)
    final class YAxisModel: ObservableObject {
        @Published var yAxisMax: Double = 100
    }

    @ObservedObject var model: YAxisModel = YAxisModel()

    var body: some View {
        Chart {
            // y軸最大値のルールの定義(あってもなくてもよい)
            RuleMark(y: .value("max", model.yAxisMax))
                .foregroundStyle(.clear)
        }
        .chartXAxis(.hidden) // x軸の非表示
        .chartYScale(domain: 0...model.yAxisMax) // y軸範囲の定義
        // y軸のラベル定義
        .chartYAxis {
            // おおよそ6つの目盛りで構成
            AxisMarks(values: .automatic(desiredCount: 6)) { value in
                // 補助線の非表示化
                AxisGridLine(stroke: StrokeStyle(lineWidth: 0))
                AxisValueLabel(multiLabelAlignment: .leading) {
                    if let raw = value.as(Double.self) {
                        Text(
                            // 中身は省略
                        )
                    }
                }
            }
        }
        .chartPlotStyle { plot in
            plot.frame(width: 0) // y軸だけ欲しいのでグラフのプロット幅を0に
        }
        .frame(width: 39)
    }
}

このプログラムでは、chartXAxis(.hidden)でx軸を非表示にしており、棒グラフとして表示するデータも与えていません。一方で、これだけではグラフのプロット領域が確保されてしまうので、chartPlotStyleplot.frame(width: 0)を定義して、プロット領域の幅を0にしています。また、Swift ChartsのViewと同様にchartYScaleを定義しており、chartYAxisでy軸の目盛りを設定しています。加えて、chartYAxis内のAxisMarks(values: .automatic(desiredCount: 6))で、おおよそ6つの目盛りをy軸上に表示しています。

以上のSwift ChartsのViewをCellとしたUICollectionViewと、Swift Chartsで作成したy軸を組み合わせて実装した成果画面の完成版が下記の動画です。最初に述べたFAANSにおけるレイアウトと機能を実装できていることが確認できます。

完成した成果画面

また、InstrumentsのHitchesを用いてパフォーマンスを計測した結果、以下の画像のように赤線の密集が少なく、パフォーマンスの著しい低下が発生していないことが確認できました。 パフォーマンス計測(Swift Charts+UICollectionView)

Swift Charts + 表示データの工夫で実装

先に述べた通り、Swift Chartsのみの実装では横スクロールが重たく感じる事象を確認したため、UICollectionViewと組み合わせた方法を採用しました。一方で、UICollectionViewを使わずSwift Chartsのみで完結させたいケースもあるかと思います。そこで、一度に渡すデータ量を制限すればスクロール時のパフォーマンス低下を緩和できると考え、試作しました。今回は採用に至りませんでしたが、Swift Chartsのみで実装する際の代替案として紹介します。データ量の制限方法は以下の図の通りです。

表示するデータ範囲遷移の模式図

図の例では、1/31をデータの最終日とした場合、最初に1/31から1か月前までのデータをSwift Chartsに渡します(図の上段)。その後、ユーザが1/1までスクロールした際には、1/1を中心とした前後15日分、すなわち合計30日分(約1か月)を新たな表示データとしてSwift Chartsに渡します(図の下段)。このように実装することで、Swift Chartsは常に1か月分のデータのみ描画することになり、大量データを渡したときと比較して、スクロールが重くなりにくいと考えられます。実装は下記の通りです。

struct BarChartsView: View {
    private let visibleLength: TimeInterval = 24 * 60 * 60 * 7.5
    private let stopDebounce: TimeInterval = 0.25

    private let dateFormatter = DateFormatter(with: .weeklyChart)

    @State private var scrollPosition: Date = barData.last!.date // barDataは1つ目の実装例と同様の定義
    @State private var scrollStopTask: Task<Void, Never>?
    @State private var visibleData: [Sales] = [] // 表示するデータを格納(1か月分)
    @State private var pendingScrollTarget: Date?
    @State private var isProgrammaticScroll = false
    @State private var chartEpoch: Int = 0

    init() {
        let center = barData.last!.date
        _visibleData = State(initialValue: extractWindowData(around: center))
    }

    var body: some View {
        Chart(visibleData, id: \.id) { row in
            BarMark(
                x: .value("Day", row.date, unit: .day),
                y: .value("Sales", row.sales)
            )
            .foregroundStyle(by: .value("Type", row.type))
        }
        .id(chartEpoch) // visibleData差し替え時にChartも再構築
        .chartScrollableAxes(.horizontal)
        .chartLegend(.hidden)
        .chartXVisibleDomain(length: visibleLength)
        .chartScrollPosition(x: $scrollPosition)
        // スクロール時に左端のグラフが見切れる位置で止まるように制御(iOS 17+)
        .chartScrollTargetBehavior(
            .valueAligned(matching: DateComponents(hour: 12, minute: 0, second: 0))
        )
        .chartForegroundStyleScale([
            // (省略)
        ])
        .chartXAxis {
            // (省略)
        }
        .chartYAxis {
            // (省略)
        }
        .onChange(of: scrollPosition) { _, newValue in
            // 自動スクロールでscrollPositionが更新された場合、scrollStopCheckを呼ばない
            if isProgrammaticScroll {
                isProgrammaticScroll = false
                return
            }
            // ユーザ操作でスクロールされた際に呼び出し
            scrollStopCheck(after: stopDebounce)
        }
        // 表示するデータの差し替え後に、差し替え前に表示していた位置に遷移
        .onChange(of: visibleData) { _, _ in
            guard let target = pendingScrollTarget else { return }
            pendingScrollTarget = nil

            Task { @MainActor in
                isProgrammaticScroll = true
                scrollPosition = target
            }
        }
    }

    // グラフがスクロールされた場合の処置
    func scrollStopCheck(after delay: TimeInterval) {
        scrollStopTask?.cancel()
        scrollStopTask = Task { @MainActor in
            // Task.sleepで待機中に次のタスクが来たら前のタスクをキャンセル
            do {
                try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
            } catch { return }
            guard !Task.isCancelled else { return }

            let center = alignToNoon(scrollPosition) // データ更新後の遷移先の指定
            let next = extractWindowData(around: center) // 新たなデータの抽出(centerを中心として前後15日のおよそ1か月分)

            chartEpoch += 1 // idの更新

            visibleData = next // 表示するデータ位置の更新
            let pendingPosition = Calendar.current.date(byAdding: .day, value: 1, to: center)!
            pendingScrollTarget = pendingPosition // データ更新後の遷移位置の指定
        }
    }

    // 引数: centerの値から前後15日分の1か月分を親配列から抽出
    func extractWindowData(around center: Date, days: Int = 15) -> [Sales] {
        let cal = Calendar.current
        let start = cal.date(byAdding: .day, value: -days, to: center) ?? center
        let end = cal.date(byAdding: .day, value:  days, to: center) ?? center
        return barData.filter { $0.date >= start && $0.date <= end }
    }

    // 入力されたDateの時間を12時に固定
    func alignToNoon(_ date: Date) -> Date {
        var comps = Calendar.current.dateComponents([.year, .month, .day], from: date)
        comps.hour = 12
        comps.minute = 0
        comps.second = 0
        return Calendar.current.date(from: comps) ?? date
    }
}

上記プログラムのポイントは、以下の3つです。

  • chartScrollPositionによるスクロールの監視
  • 表示データとChartのidの更新
  • scrollPositionによるグラフ位置の調整

まず、chartScrollPositionscrollPositionの変数を設定して、現在のスクロール位置を監視します(ポイント1)。スクロールがあった場合には、onChange(of: scrollPosition)が呼ばれ、内部に定義されているscrollStopCheck(after: stopDebounce)が呼ばれます。この関数では、スクロール後、一定の時間静止した場合に表示データを更新します。更新後のデータは、extractWindowDataという自作の関数を用いて取得しています。また、データの更新時にはchartEpochを更新してChart自体を新しく構築し直す必要があります(ポイント2)。Chartを再構築しない場合、データを更新する度に、Chartのスクロールが重くなっていきます。

最後にデータを更新した際の表示位置を調整します。表示位置を調整せず、データの更新のみを行った場合、更新前に表示されていた日付からずれます。これは、Swift Chartsがスクロール位置を座標として記録しているためです。例えば、先ほどの図の上段において1/1までスクロールしたとします。すなわち、左端のデータが表示されている状態です。この状態で図の下段のようにデータを更新すると、左端のデータがそのまま表示されるので、1/1ではなく、12/16が表示されてしまいます。データ更新後も1/1が表示されている状態を維持したいので、データ更新前の表示位置をあらかじめ記録します。上記プログラムでは、pendingScrollTargetに表示位置を記録しています。そして、記録した表示位置を用いて、scrollPositionを更新することでデータ更新後の表示位置を調整します。

また、InstrumentsのHitchesを用いてパフォーマンスを計測した結果、下記画像に示すように赤線の密集が発生していません。すなわち、データの量を制限していない場合と比較して、大幅にパフォーマンスを改善できていることが確認できました。 パフォーマンス計測(Swift Charts+データアレンジ)

このプログラムを用いることでSwift Chartsのみで実装できます。一方で、chartScrollPositionはスクロール位置の同期が主な用途です(公式ドキュメント)。そのため、データ差し替え後の位置制御に用いる場合は意図しない挙動が発生するかもしれません。また、端までスクロールした際にデータを更新すると、見切れている棒グラフとの位置関係によるグラフのずれが発生します。採用には注意が必要です。

DGChartsとSwift Chartsの比較

最後に、Swift Chartsへの置き換えで学んだDGChartsとSwift Chartsの違いを表で示します。基本的にはApple純正のフレームワークであるSwift Chartsを用いるのが良いと考えています。

項目 DGCharts Swift Charts
フレームワーク種別 サードパーティ Apple純正
対応OS iOS 12+ iOS 16+
UI基盤 UIKit SwiftUI
積み上げ棒グラフの実現方法 x座標を指定して、積み上げる値の配列を渡す 配列内でx座標が同じ要素を重ねて表示
グラフのハイライト色指定 highlightAlphaで色の指定 専用の色指定APIはない
スクロール挙動の制御 スナップやページングは自前実装が必要 .chartScrollTargetBehaviorで単位揃えやスナップを指定可能(iOS 17+)
大量データのスクロール(パフォーマンスの問題) UICollectionViewのセル再利用により、大量データでもパフォーマンスの問題は発生しにくい 標準の横スクロール(chartScrollableAxes)では大量データで描画遅延が発生。UICollectionViewとの併用や表示データ量の制限で対処が必要

まとめ

本記事では、DGChartsからSwift Chartsへの移行にあたり、3つの実装アプローチを比較・検証した過程を紹介しました。

Swift Chartsは宣言的な記述で手軽にグラフを実装できる一方、大量データのスクロール描画ではパフォーマンス上の課題があります。そのため、UICollectionViewとの併用やデータの動的な差し替えといった工夫が求められる場面もあります。今回はUICollectionViewとの組み合わせを採用しましたが、要件やデータ量に応じて最適な方法は異なるため、本記事で紹介した各アプローチが実装方針の判断材料になれば幸いです。

さいごに

ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com




以上の内容はhttps://techblog.zozo.com/entry/replace-chartsより取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14