
はじめに
こんにちは、FAANS部フロントエンドブロックの加藤です。普段はFAANSのiOSアプリを開発しています。FAANSは、ショップスタッフの販売サポートツールであり、アプリ上でコーディネートの投稿や売上などの成果を確認できます。
成果の確認画面では以下の動画のように成果を棒グラフで可視化しています。これまでFAANS iOSでは、棒グラフの生成にサードパーティライブラリであるDGChartsを用いていました。一方で、FAANSではiOS 15のサポートを終了しているため、iOS 16以上で利用可能なApple標準のグラフ生成フレームワーク「Swift Charts」を利用できます。そこで、この度、DGChartsからSwift Chartsへの移行を実施しました。
この記事では、DGChartsからSwift Chartsへの移行にあたり検討した実装アプローチについて紹介します。

目次
成果画面のレイアウトと機能
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),
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),
y: .value("Sales", row.sales)
)
.foregroundStyle(by: .value("Type", row.type))
}
.chartScrollableAxes(.horizontal)
.chartLegend(.hidden)
.chartXVisibleDomain(length: visibleLength)
.chartScrollPosition(x: $scrollPosition)
.chartForegroundStyleScale([
"zozotown": Color(.Token.serviceZozotown),
"wear": Color(.Token.serviceWear),
"yahoo!Shopping": Color(.Token.serviceYahoo),
"ownedEc": Color(.Token.serviceBrandEc)
])
.chartGesture { chart in
SpatialTapGesture()
.onEnded { value in
guard
let (date, _) = chart.value(
at: value.location,
as: (Date, Double).self
)
else { return }
}
}
.chartXAxis {
AxisMarks(values: .stride(by: .day)) { value in
if let date = value.as(Date.self) {
AxisValueLabel(centered: true) {
Text(dateFormatter.string(from: date))
.multilineTextAlignment(.center)
}
}
}
}
.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
注意が必要なのは、chartScrollableAxesとchartGestureはiOS 17以降で利用できる機能である点です。また、chartScrollPositionで初期の表示位置を指定している点や、chartXAxisやchartYAxisで目盛りのレイアウトを調整している点も重要です。
これで、実装したかった成果画面のレイアウトと機能を全て実装できました。しかし、スクロール時の動作を確認してみると、スクロールが重たく感じます。主観では判断できないため、InstrumentsのHitchesを用いてパフォーマンスを計測しました。パフォーマンス計測では、グラフの表示画面を表示して、数回のスクロールを実施しました。パフォーマンス計測結果は以下の画像のようになりました。

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

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として実装する必要があります。図にすると下記のような構成です。

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?
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)
.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)
}
}
.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)
.chartYAxis(.hidden)
}
}
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 {
RuleMark(y: .value("max", model.yAxisMax))
.foregroundStyle(.clear)
}
.chartXAxis(.hidden)
.chartYScale(domain: 0...model.yAxisMax)
.chartYAxis {
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)
}
.frame(width: 39)
}
}
このプログラムでは、chartXAxis(.hidden)でx軸を非表示にしており、棒グラフとして表示するデータも与えていません。一方で、これだけではグラフのプロット領域が確保されてしまうので、chartPlotStyleでplot.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 + 表示データの工夫で実装
先に述べた通り、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
@State private var scrollStopTask: Task<Void, Never>?
@State private var visibleData: [Sales] = []
@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)
.chartScrollableAxes(.horizontal)
.chartLegend(.hidden)
.chartXVisibleDomain(length: visibleLength)
.chartScrollPosition(x: $scrollPosition)
.chartScrollTargetBehavior(
.valueAligned(matching: DateComponents(hour: 12, minute: 0, second: 0))
)
.chartForegroundStyleScale([
])
.chartXAxis {
}
.chartYAxis {
}
.onChange(of: scrollPosition) { _, newValue in
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
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)
chartEpoch += 1
visibleData = next
let pendingPosition = Calendar.current.date(byAdding: .day, value: 1, to: center)!
pendingScrollTarget = pendingPosition
}
}
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 }
}
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によるグラフ位置の調整
まず、chartScrollPositionにscrollPositionの変数を設定して、現在のスクロール位置を監視します(ポイント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のみで実装できます。一方で、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