[SwiftUI][Charts] Chart で 折れ線グラフを表示する方法

SwiftUI2021

     
⌛️ 5 min.
Chart を使って、綺麗な(?) グラフを書いてみます。まずは、折れ線グラフ(LineMark) から。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Ventura 13.3 Beta3
  • Xcode 14.3 Beta3
  • iOS 16.2

Charts

Apple 謹製の グラフを描画するフレームワークです。
Apple のドキュメントは、こちら

非常に簡単に、みやすいグラフ表示が “できそう” なのですが、例によって、ドキュメントが非常にシンプルなので、工夫の余地がわからず 過去には断念して自作ライブラリに戻った経験があります・・・・

ということで、今回は色々なオプション等を探りつつ、使ってみます。

表示対象データ

表示対象のデータは、全世界の人口データにしました。

データは、こちらから入手しました。

使っているデータは、Population by Five-year Age Groups – Both Sexes です。
1950 年から 2021 年までの人口推移で、年齢を5歳ごとに区切った男性女性合計の数値です。

Excel で公開されていますが、適当に操作して、”Population.csv” という CSV にしました。

プロジェクトのリソースに追加しておいて、起動時に読み込ませてます。
Population.csv の中身は以下のような状態です。

Year,0-4,5-9,10-14,15-19,20-24,25-29,30-34,35-39,40-44,45-49,50-54,55-59,60-64,65-69,70-74,75-79,80-84,85-89,90-94,95-99,100+
1950,  341 877,  267 731,  258 081,  237 173,  220 116,  193 645,  163 243,  159 122,  144 039,  125 354,  104 545,  85 394,  70 797,  53 123,  36 653,  22 441,  10 870,  3 977,   969,   160,   14
1951,  356 601,  271 481,  259 192,  240 518,  223 052,  197 695,  166 848,  159 048,  146 024,  127 603,  106 945,  87 046,  71 418,  53 924,  37 034,  22 701,  10 913,  3 959,   967,   147,   16
1952,  370 487,  278 772,  259 555,  244 720,  225 201,  202 217,  171 505,  158 110,  148 027,  129 731,  109 466,  89 000,  71 930,  54 969,  37 522,  22 937,  11 037,  3 957,   967,   145,   16
... 2021 年分まで

細かいスペースの除去等は、コード側で対応しています。
# 理由は分かりませんが、Grouping Separator に space を使っているデータでした。

なお、データ中の数値の単位は、”千人” です。

以下のようなコードで読み込ませてます。

struct YearData: Identifiable {
    var id = UUID()
    var year: Date  // July 1st of every year
    var populations: [Int] // 0...4, 5...9, ...., 95...99, 100... note: should have 21 segments

    init(_ string: String) {
        let cells = string.split(separator: ",").map({$0.replacingOccurrences(of: " ", with: "")})
        guard let year = Int(cells.first ?? "") else { fatalError("invalid data") }
        self.year = Calendar.current.date(from: DateComponents(year: year, month: 7, day: 1))!
        self.populations = cells.dropFirst().compactMap({Int($0)})
        assert(populations.count == 21)
    }
}
class PopulationData {
    var data: [YearData] = []

    init() {
        guard let fileURL = Bundle.main.url(forResource: "Population",
                                            withExtension: "csv") else { fatalError("No CSV File") }
        data = try! read(from: fileURL)
    }

    func read(from file: URL) throws -> [YearData]{
        let strings = try String(contentsOf: file)

        let lines = strings.components(separatedBy: .newlines).dropFirst().filter({$0 != ""})
        return lines.map({YearData($0)})
    }
}

作成した CSV ファイルの改行コードが、”\r\n” だったので、空行を削除するようにもしています。

LineMark/PointMark

Charts では、さまざまな Mark と言う要素を使用して 表示要素を設定していきます。

まずは、各年の総人口をグラフにしてみます。

LineMark

折れ線グラフは、LineMark という要素を使用して表示します。

struct ContentView: View {
    let popData = PopulationData()
    var body: some View {
        VStack {
            Chart {
                ForEach(popData.data) { yearData in
                    LineMark(x: .value("Year", yearData.year),
                             y: .value("Population", yearData.total))
                }
            }
        }
        .padding()
    }
}
extension YearData {
    var total: Int {
        populations.reduce(0) { partialResult, value in
            partialResult + value
        }
    }
}

以下のようなグラフになりました・・・

InitialLineMark

何も調整せずに表示できたグラフとしてはまぁまぁですが、すこしづつ調整してみます。

PointMark

グラフが寂しい(?) ので、各年のデータ位置に ドットを配置してみます。

グラフ上に点を表示するには、PointMark を使用します。
そのまま表示すると ごちゃごちゃしてしまうので、色を透明度を上げた purple にしてみました。

struct ContentView: View {
    let popData = PopulationData()
    var body: some View {
        VStack {
            Chart {
                ForEach(popData.data) { yearData in
                    LineMark(x: .value("Year", yearData.year),
                             y: .value("Population", yearData.total))
                    PointMark(x: .value("Year", yearData.year),
                              y: .value("Population", yearData.total))
                    .opacity(0.5)
                    .foregroundStyle(.purple)
                }
            }
        }
        .padding()
    }
}
LineMarkPointMark
MEMO

LineMark のカスタマイズでも、データ位置にシンボルを配置することは可能です。

XXMarkのカスタマイズ

このように、SwiftUI の他のView と同じように、ViewModifier を使用して、表示をカスタマイズすることができます。

ただし、それぞれ 使用できる View が指定されていますので、専用の ViewModifier を使用することが必要です。例えば、上の例で使用されている opacity も、some ChartContent を返す専用の ViewModifier になっています。
opacity についての Apple のドキュメントは、こちら

RuleMark

例えば、世界人口が、50億人を突破したい時をわかりやすく表示したいとすると、50億人の位置に 横線を引いておくと、いつぐらいに突破したかが視覚的にわかりやすくなります。

そのような 固定値の線を引く要素が、RuleMark です。

struct ContentView: View {
    let popData = PopulationData()
    var body: some View {
        VStack {
            Chart {
                ForEach(popData.data) { yearData in
                    LineMark(x: .value("Year", yearData.year),
                             y: .value("Population", yearData.total))
                    PointMark(x: .value("Year", yearData.year),
                              y: .value("Population", yearData.total))
                    .opacity(0.5)
                    .foregroundStyle(.purple)
                }
                RuleMark(y: .value("Population", 5_000_000))
                    .foregroundStyle(.red)

            }
        }
        .padding()
    }
}

50億の箇所の線ですが、データの単位が 千人 なので、5,000,000 となります。

withRuleMark

chartXAxis/chartYAxis

データをわかりやすく表示するには、データそのものだけでなく、グラフの軸についても適切に表示することが大切になります。

X,Y 軸のカスタマイズは、chartXAxis/chartYAxis という modifier で行います。

それぞれ AxisMarks を渡すことで詳細を指定することになります。
Apple のドキュメントは、こちら

空の chartXAxis

これまでの例でもわかりますが、Charts では、デフォルトでさまざまな表示が行われるようになっています。

chartXAxis を空指定してみると、デフォルトで表示されていた要素が表示されなくなります。

struct ContentView: View {
    let popData = PopulationData()
    var body: some View {
        VStack {
            Chart {
                ForEach(popData.data) { yearData in
                    LineMark(x: .value("Year", yearData.year),
                             y: .value("Population", yearData.total))
                    PointMark(x: .value("Year", yearData.year),
                              y: .value("Population", yearData.total))
                    .opacity(0.5)
                    .foregroundStyle(.purple)
                }
                RuleMark(y: .value("Population", 5_000_000))
                    .foregroundStyle(.red)

            }
            .chartXAxis(content: {
               // empty
            })
        }
        .padding()
    }
}
emptyChartXAxis

chartXAxis で X軸分のデフォルト表示を削除しているので、X 軸に対するものが表示されなくなっています。

デフォルト相当の chartXAxis

デフォルト相当を自分で書いてみると以下のようになります。

struct ContentView: View {
    let popData = PopulationData()
    var body: some View {
        VStack {
            Chart {
                ForEach(popData.data) { yearData in
                    LineMark(x: .value("Year", yearData.year),
                             y: .value("Population", yearData.total))
                    PointMark(x: .value("Year", yearData.year),
                              y: .value("Population", yearData.total))
                    .opacity(0.5)
                    .foregroundStyle(.purple)
                }
                RuleMark(y: .value("Population", 5_000_000))
                    .foregroundStyle(.red)

            }
            .chartXAxis(content: {
                AxisMarks(content: { _ in
                    AxisGridLine()
                    AxisTick()
                    AxisValueLabel()
                })
            })
        }
        .padding()
    }
}

AxisMarks が、軸におこなう追加表示を担当する struct です。

AxisGridLine は、図中の中に書かれる 補助線です。

AxisTick は、軸から下に伸ばされる線です。(よくみないとわかりにくいです)

AxisValueLabel は、軸の下に表示されている “1990” や “2000” です。

どの程度の頻度で 追加表示を行うかは、AxisMarks 側で制御しています。

AxisMarks をカスタマイズ

AxisMarks を指定する時に、values: という引数でどのような位置に表示するかを指定することができます。

ただ、どの程度の頻度/ どの位置 で表示を行うかを決めるのはデータの範囲が固定されていない限り 一意に決めるのは 難しいです。

とうことで、AxisMarks にも “どのくらいの数” という希望を聞いてくれる設定が用意されています。
(必ずしもその数になるわけではありません。)

以下は、8箇所くらいの表示 という設定を行っています。

struct ContentView: View {
    let popData = PopulationData()
    var body: some View {
        VStack {
            Chart {
                ForEach(popData.data) { yearData in
                    LineMark(x: .value("Year", yearData.year),
                             y: .value("Population", yearData.total))
                    PointMark(x: .value("Year", yearData.year),
                              y: .value("Population", yearData.total))
                    .opacity(0.5)
                    .foregroundStyle(.purple)
                }
                RuleMark(y: .value("Population", 5_000_000))
                    .foregroundStyle(.red)

            }
            .chartXAxis(content: {
                AxisMarks(values: .automatic(desiredCount: 8),
                          content: { value in
                    AxisGridLine()
                    AxisTick()
                    AxisValueLabel()
                })
            })
        }
        .padding()
    }
}

以下のような表示になりました。実際には、7箇所の表示になっています。

automatic

ここで使用した方法以外にも、”最低限の移動量と 希望設定数”や stride を使用して設定する 等の方法が用意されています。stride で決定する方法では Calendar.Component の .year や .month を指定して決めることもできるようになっています。

# デフォルトでも大差ないので、上記の values 指定は削除して以降を進めます。

AxisValueLabel のカスタマイズ

ケースによっては、ラベル表示される文字列をカスタマイズしたくなることもありそうです。

今回のケースでは、Y軸の指数表示のラベルは、手を入れたくなる箇所です。

ラベル自体は、AxisValueLabel が担当している箇所なので、AxisValueLabel を使ってのカスタマイズとなります。

元々の数値の単位が、”千人”ですが、実数として”億人” がラベル表示に良いかと思うので以下のようにして、ラベルの表示を変更します。
# Localization はサボってます。

struct ContentView: View {
    let popData = PopulationData()
    var body: some View {
        VStack {
            Chart {
                ForEach(popData.data) { yearData in
                    LineMark(x: .value("Year", yearData.year),
                             y: .value("Population", yearData.total))
                    PointMark(x: .value("Year", yearData.year),
                              y: .value("Population", yearData.total))
                    .opacity(0.5)
                    .foregroundStyle(.purple)
                }
                RuleMark(y: .value("Population", 5_000_000))
                    .foregroundStyle(.red)

            }
            .chartYAxis(content: {
                AxisMarks(content: { value in
                    AxisGridLine()
                    AxisTick()
                    AxisValueLabel(content: {
                        if let intValue = value.as(Int.self) {
                            Text("\(intValue * 1_000 / 100_000_000) 億人")
                        }
                    })
                })
            })
        }
        .padding()
    }
}

ここでは、AxisMarks から指定される位置としては、デフォルトを使っています。表示には、渡される値を使ってラベルを作成する処理としました。

AxisLabelValueCustom

グラフ右側に表示されているラベルが、”20億” ~ “80億” になっていることがわかります。
なお、AxisValueLabel の content が返すのは、普通(?) の View ですので、自由にカスタマイズすることができます。

chartXAxisLabel/chartYAxisLabel

次に、各軸のラベルをカスタマイズしてみます。

ラベルのカスタマイズには、chartXAxisLabel, chartYAxisLabel を使用します。

これまで、X軸、Y軸ともに、値の表示はありましたが、それぞれ何を表すかの表示がなかったので、追加してみます。
X軸には “Year” を、Y軸には “Population” を指定しました。

struct ContentView: View {
    let popData = PopulationData()
    var body: some View {
        VStack {
            Chart {
                ForEach(popData.data) { yearData in
                    LineMark(x: .value("Year", yearData.year),
                             y: .value("Population", yearData.total))
                    PointMark(x: .value("Year", yearData.year),
                              y: .value("Population", yearData.total))
                    .opacity(0.5)
                    .foregroundStyle(.purple)
                }
                RuleMark(y: .value("Population", 5_000_000))
                    .foregroundStyle(.red)
            }
            .chartYAxis(content: {
                AxisMarks(content: { value in
                    AxisGridLine()
                    AxisTick()
                    AxisValueLabel(content: {
                        if let intValue = value.as(Int.self) {
                            Text("\(intValue * 1_000 / 100_000_000) 億人")
                        }
                    })
                })
            })
            .chartXAxisLabel("Year")
            .chartYAxisLabel("Population")
        }
        .padding()
    }
}

以下のようになります。

AxisLabel

X軸については 左下に “Year” が、Y軸については、右上に “Population” が表示されるようになりました。

現状でもそれなりですが、以下のようにカスタマイズしてみます。
・ Year は、図の下 左側に配置
・Population は、図の左 下側に配置し、大きめのサイズ

struct ContentView: View {
    let popData = PopulationData()
    var body: some View {
        VStack {
            Chart {
                ForEach(popData.data) { yearData in
                    LineMark(x: .value("Year", yearData.year),
                             y: .value("Population", yearData.total))
                    PointMark(x: .value("Year", yearData.year),
                              y: .value("Population", yearData.total))
                    .opacity(0.5)
                    .foregroundStyle(.purple)
                }
                RuleMark(y: .value("Population", 5_000_000))
                    .foregroundStyle(.red)
            }
            .chartYAxis(content: {
                AxisMarks(content: { value in
                    AxisGridLine()
                    AxisTick()
                    AxisValueLabel(content: {
                        if let intValue = value.as(Int.self) {
                            Text("\(intValue * 1_000 / 100_000_000) 億人")
                        }
                    })
                })
            })
            .chartXAxisLabel("Year", position: .bottom, alignment: .leading)
            .chartYAxisLabel(position: .leading, alignment: .bottom, content: {
                Text("Population").font(.title)
            })
        }
        .padding()
    }
}

chartYAxisLabel の表示位置をカスタマイズ

以下のようになります。

TitleAlignment

コードを見てもわかりますが、位置指定には、.leading/trailing/.bottom/.top 等を使用します。ラベル自体は、LocalizedStringKey を使って指定することも、closure を使って View で指定することもできます。
上記の例では、大きめのサイズにするために、Text を .font 指定で大きくしています。

位置指定は、AxisLabel だけでなく、AxisMarks にも指定可能です。
“Population” のラベルを左側に配置したことに合わせて、数値ラベルも左側に配置するためには、AxisMarks にも同様の指定を行います。

struct ContentView: View {
    let popData = PopulationData()
    var body: some View {
        VStack {
            Chart {
                ForEach(popData.data) { yearData in
                    LineMark(x: .value("Year", yearData.year),
                             y: .value("Population", yearData.total))
                    PointMark(x: .value("Year", yearData.year),
                              y: .value("Population", yearData.total))
                    .opacity(0.5)
                    .foregroundStyle(.purple)
                }
                RuleMark(y: .value("Population", 5_000_000))
                    .foregroundStyle(.red)
            }
            .chartYAxis(content: {
                AxisMarks(position: .leading, content: { value in  // - !! NEW !!
                    AxisGridLine()
                    AxisTick()
                    AxisValueLabel(content: {
                        if let intValue = value.as(Int.self) {
                            Text("\(intValue * 1_000 / 100_000_000) 億人")
                        }
                    })
                })
            })
            .chartXAxisLabel("Year", position: .bottom, alignment: .leading)
            .chartYAxisLabel(position: .leading, alignment: .bottom, content: {
                Text("Population").font(.title)
            })
        }
        .padding()
    }
}

AxisMarks に配置指定したことで、数値ラベルも左側(.leading) に移動されます。

ValueLabelAlignment

まとめ

Charts を使ったデータ表示について、さまざまな要素を使ってカスタマイズできることを見てきました。

Summary
Charts を使ってデータ表示
  • 折れ線グラフは、LineMark
  • 点グラフは、PointMark
  • 固定値は、RuleMark
  • 軸ラベルは、chartXAxisLabel / chartYAxisLabel
  • 数値ラベルは、AxisMarks/AxisValueLabel
  • グリッド表示は、AxisMarks/AxisGridLine
  • 軸表示の境界線は、AxisMarks/AxisTick

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

SwiftUI ViewMatery

SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。

英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

SwiftUI 徹底入門

# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です