0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftUIで「GitHubの草」風ヒートマップカレンダーを作る

0
Posted at

自己紹介

株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。

Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
コーポレートサイト

1. はじめに

GitHubのプロフィールに表示される「草(Contribution Graph)」、毎日眺めるとモチベーションが上がりますよね。あの 「やった日が濃い緑、やらなかった日が薄いグレー」 という視覚表現は、習慣化アプリや学習記録アプリと相性抜群です。

この記事では、SwiftUI のみ・外部ライブラリゼロ で、GitHubの草風ヒートマップカレンダーを実装する手順を紹介します。LazyVGridCalendar API だけで、コピペでそのまま動く完成版コードを最後に置いてあります。

対象環境は Swift 6 / SwiftUI / iOS 18.0+ です。

2. 完成形デモ

完成するとこんな画面になります。日曜〜土曜の7列グリッドに、その日の学習時間に応じて5段階の色(薄→濃)でセルが塗られます。

raw_03_calendar.png

GitHub の草と違うポイントは2つです。

  • 1ヶ月単位 で表示する(GitHubは1年単位)
  • その日の数字 をセル内に表示する(カレンダーとしても機能させるため)

このヒートマップは、実際に私がリリースしている学習記録アプリ 「勉強タイマー」 で使われています。実機で動いている様子を見たい方は、記事末尾でアプリを紹介しているのでぜひチェックしてみてください。

3. 実装方針

ヒートマップの本体は 7列の LazyVGrid です。LazyVGrid を選ぶ理由は3つあります。

  1. 列数を固定したグリッドGridItem で簡潔に書ける
  2. 画面外のセルは描画されない(パフォーマンスが良い)
  3. アスペクト比1:1 のセルを aspectRatio で簡単に作れる

データは [Date: TimeInterval] の Dictionary で持ちます。Key を「その日の0時」に正規化しておくと、検索がO(1)で済んで高速です。

色分けは秒数 → レベル(0〜4)に変換する純粋関数として実装します。これなら Storyboardもプレビューも不要で、テストも簡単 に書けます。

4. ステップ1: データモデル&グリッド描画

まず、ヒートマップに必要なデータと「その月の日付配列」を作る部分から書いていきます。

4.1 ダミーデータの生成

実アプリでは SwiftData などから取り出しますが、記事用に 過去30日分のランダムデータ を生成します。

import SwiftUI

/// 過去30日分のランダム学習時間データを生成する
func makeDummyData() -> [Date: TimeInterval] {
    let calendar = Calendar.current
    let today = calendar.startOfDay(for: Date())
    return (0..<30).reduce(into: [:]) { result, offset in
        guard let date = calendar.date(byAdding: .day, value: -offset, to: today) else { return }
        let randomMinutes = Int.random(in: 0...240)
        result[date] = TimeInterval(randomMinutes * 60)
    }
}

ポイントは calendar.startOfDay(for:)時刻を切り捨てている ことです。これをやらないと、Dictionary の Key として「同じ日の別時刻」が別物として扱われてしまい、検索ができません。

4.2 月の日付配列を作る

「その月の1日から末日まで」の [Date] を作る関数です。

/// 指定した月の全日付を返す(1日〜末日)
func daysInMonth(for month: Date, calendar: Calendar) -> [Date] {
    let components = calendar.dateComponents([.year, .month], from: month)
    guard let start = calendar.date(from: components),
          let range = calendar.range(of: .day, in: .month, for: start) else {
        return []
    }
    return range.compactMap { day -> Date? in
        var comps = components
        comps.day = day
        return calendar.date(from: comps)
    }
}

calendar.range(of: .day, in: .month, for:)「その月の日数」 を返してくれるので、2月や閏年も自動で正しく扱えます。自前で「30日 or 31日」を判定する必要はありません。

4.3 7列グリッドを描画する

ここまで作ったデータを LazyVGrid に流し込みます。

private let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7)

LazyVGrid(columns: columns, spacing: 4) {
    ForEach(daysInMonth(for: currentMonth, calendar: calendar), id: \.self) { date in
        Text("\(calendar.component(.day, from: date))")
            .frame(maxWidth: .infinity)
            .aspectRatio(1, contentMode: .fit)
            .background(Color.green.opacity(0.3))
            .clipShape(RoundedRectangle(cornerRadius: 8))
    }
}

これで 「その月の日数ぶんのセル」 が7列で並びます。ただし、まだ「1日が何曜日からスタートするか」が考慮されていません。次のステップで色分けと一緒に対応します。

5. ステップ2: 5段階の色分け

「学習時間 → レベル(0〜4) → 色」の変換を分離して書きます。

5段階の色分け(カレンダー全体で薄〜濃のグラデーションを確認できます)

5.1 レベル判定関数

/// 学習時間(秒)から濃さレベル(0〜4)を返す
func intensityLevel(duration: TimeInterval) -> Int {
    let minutes = duration / 60
    if minutes == 0 { return 0 }      // 学習なし
    if minutes < 30 { return 1 }      // 30分未満
    if minutes < 60 { return 2 }      // 30分〜1時間
    if minutes < 180 { return 3 }     // 1〜3時間
    return 4                          // 3時間以上
}

「30分未満」「1時間未満」「3時間未満」「3時間以上」の 境界値の閉じ方 に注意してください。< を使うと「ちょうど30分」はレベル2になります。仕様に合わせて <=< を使い分けます。

5.2 レベル → 色の変換

GitHubの草っぽい緑のグラデーションを .greenopacity で作ります。

func heatColor(for level: Int) -> Color {
    switch level {
    case 0: return Color.gray.opacity(0.15)
    case 1: return Color.green.opacity(0.25)
    case 2: return Color.green.opacity(0.45)
    case 3: return Color.green.opacity(0.70)
    case 4: return Color.green.opacity(0.95)
    default: return Color.gray.opacity(0.15)
    }
}

opacity を使うとライト/ダーク両モードで自動的に馴染む のがポイントです。Color(red: g: b:) でRGB直指定すると、ダークモードで浮いて見えがちです。

6. ステップ3: 月またぎ・カレンダー対応

GitHubの草と違って、月単位カレンダーでは 「1日が水曜日なら、日〜火に空白セル3つを置く」 必要があります。

raw_03_calendar.png

6.1 先頭の空白セル

Calendar.weekday日曜=1, 月曜=2, ... 土曜=7 を返します。グリッドの列インデックスは0始まりにしたいので、-1 してから空白セル数として使います。

let days = daysInMonth(for: currentMonth, calendar: calendar)
guard let firstDay = days.first else { return }
let leadingEmptyCount = calendar.component(.weekday, from: firstDay) - 1

// 空白セルを leadingEmptyCount 個入れる
ForEach(0..<leadingEmptyCount, id: \.self) { _ in
    Color.clear
        .aspectRatio(1, contentMode: .fit)
}

Color.clearaspectRatio(1, contentMode: .fit) で正方形にしておくと、 空白部分も他のセルと同じサイズ を保てます。

6.2 曜日ヘッダー

「日 月 火 水 木 金 土」のラベルを上に並べます。これも同じ7列の LazyVGrid で書きます。

let weekdayLabels = ["日", "月", "火", "水", "木", "金", "土"]

LazyVGrid(columns: columns, spacing: 4) {
    ForEach(weekdayLabels, id: \.self) { label in
        Text(label)
            .font(.caption2.weight(.semibold))
            .foregroundStyle(.secondary)
            .frame(maxWidth: .infinity)
    }
}

6.3 日付検索の正規化

最後に、データ Dictionary から「その日の学習時間」を引く関数です。Key を startOfDay で正規化 しているので、検索側でも揃えます。

func durationFor(date: Date, data: [Date: TimeInterval], calendar: Calendar) -> TimeInterval {
    let key = calendar.startOfDay(for: date)
    return data[key] ?? 0
}

7. 完成版コード

ここまでのパーツを1ファイルにまとめました。新規 SwiftUI プロジェクトに貼り付けるだけで動きます

import SwiftUI

// MARK: - メインView

struct GitHubStyleHeatmapView: View {
    @State private var currentMonth = Date()
    @State private var data: [Date: TimeInterval] = [:]

    private let calendar = Calendar.current
    private let weekdayLabels = ["日", "月", "火", "水", "木", "金", "土"]
    private let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7)

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 20) {
                    monthNavigator
                    legendView
                    heatmapGrid
                }
                .padding()
            }
            .navigationTitle("学習ヒートマップ")
            .navigationBarTitleDisplayMode(.inline)
            .onAppear {
                if data.isEmpty {
                    data = Self.makeDummyData(calendar: calendar)
                }
            }
        }
    }

    // MARK: - 月ナビゲーション

    private var monthNavigator: some View {
        HStack {
            Button {
                if let prev = calendar.date(byAdding: .month, value: -1, to: currentMonth) {
                    withAnimation { currentMonth = prev }
                }
            } label: {
                Image(systemName: "chevron.left.circle.fill").font(.title2)
            }

            Spacer()
            Text(monthYearString).font(.title3.weight(.bold))
            Spacer()

            Button {
                if let next = calendar.date(byAdding: .month, value: 1, to: currentMonth) {
                    withAnimation { currentMonth = next }
                }
            } label: {
                Image(systemName: "chevron.right.circle.fill").font(.title2)
            }
        }
    }

    // MARK: - 凡例

    private var legendView: some View {
        HStack(spacing: 6) {
            Text("少").font(.caption2).foregroundStyle(.secondary)
            ForEach(0..<5, id: \.self) { level in
                RoundedRectangle(cornerRadius: 4)
                    .fill(Self.heatColor(for: level))
                    .frame(width: 14, height: 14)
            }
            Text("多").font(.caption2).foregroundStyle(.secondary)
        }
    }

    // MARK: - ヒートマップ本体

    private var heatmapGrid: some View {
        VStack(spacing: 4) {
            // 曜日ヘッダー
            LazyVGrid(columns: columns, spacing: 4) {
                ForEach(weekdayLabels, id: \.self) { label in
                    Text(label)
                        .font(.caption2.weight(.semibold))
                        .foregroundStyle(.secondary)
                        .frame(maxWidth: .infinity)
                }
            }

            // 日付セル
            LazyVGrid(columns: columns, spacing: 4) {
                let days = Self.daysInMonth(for: currentMonth, calendar: calendar)
                let firstWeekday = days.first.map {
                    calendar.component(.weekday, from: $0) - 1
                } ?? 0

                // 月初前の空白
                ForEach(0..<firstWeekday, id: \.self) { _ in
                    Color.clear.aspectRatio(1, contentMode: .fit)
                }

                // 日付セル
                ForEach(days, id: \.self) { date in
                    let duration = Self.durationFor(date: date, data: data, calendar: calendar)
                    let level = Self.intensityLevel(duration: duration)
                    let isToday = calendar.isDateInToday(date)

                    Text("\(calendar.component(.day, from: date))")
                        .font(.caption2.weight(.semibold))
                        .foregroundStyle(level >= 2 ? .white : .primary)
                        .frame(maxWidth: .infinity)
                        .aspectRatio(1, contentMode: .fit)
                        .background(
                            RoundedRectangle(cornerRadius: 8)
                                .fill(Self.heatColor(for: level))
                        )
                        .overlay(
                            RoundedRectangle(cornerRadius: 8)
                                .stroke(isToday ? Color.accentColor : .clear, lineWidth: 2)
                        )
                        .accessibilityLabel("\(calendar.component(.day, from: date))日 学習\(Int(duration / 60))分")
                }
            }
        }
        .padding()
        .background(Color(.systemBackground))
        .clipShape(RoundedRectangle(cornerRadius: 16))
        .shadow(color: .black.opacity(0.05), radius: 4, y: 2)
    }

    // MARK: - フォーマッタ

    private var monthYearString: String {
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "ja_JP")
        formatter.dateFormat = "yyyy年M月"
        return formatter.string(from: currentMonth)
    }

    // MARK: - 静的ヘルパー

    static func makeDummyData(calendar: Calendar) -> [Date: TimeInterval] {
        let today = calendar.startOfDay(for: Date())
        return (0..<30).reduce(into: [:]) { result, offset in
            guard let date = calendar.date(byAdding: .day, value: -offset, to: today) else { return }
            let randomMinutes = Int.random(in: 0...240)
            result[date] = TimeInterval(randomMinutes * 60)
        }
    }

    static func daysInMonth(for month: Date, calendar: Calendar) -> [Date] {
        let components = calendar.dateComponents([.year, .month], from: month)
        guard let start = calendar.date(from: components),
              let range = calendar.range(of: .day, in: .month, for: start) else {
            return []
        }
        return range.compactMap { day -> Date? in
            var comps = components
            comps.day = day
            return calendar.date(from: comps)
        }
    }

    static func durationFor(date: Date, data: [Date: TimeInterval], calendar: Calendar) -> TimeInterval {
        let key = calendar.startOfDay(for: date)
        return data[key] ?? 0
    }

    static func intensityLevel(duration: TimeInterval) -> Int {
        let minutes = duration / 60
        if minutes == 0 { return 0 }
        if minutes < 30 { return 1 }
        if minutes < 60 { return 2 }
        if minutes < 180 { return 3 }
        return 4
    }

    static func heatColor(for level: Int) -> Color {
        switch level {
        case 0: return Color.gray.opacity(0.15)
        case 1: return Color.green.opacity(0.25)
        case 2: return Color.green.opacity(0.45)
        case 3: return Color.green.opacity(0.70)
        case 4: return Color.green.opacity(0.95)
        default: return Color.gray.opacity(0.15)
        }
    }
}

#Preview {
    GitHubStyleHeatmapView()
}

このコードは、新規 SwiftUI プロジェクトの ContentView.swift をまるごとこの内容に置き換えるだけで動きます(あるいは別ファイルとして追加して ContentView から GitHubStyleHeatmapView() を呼び出してもOK)。Xcode の Canvas でプレビュー確認もできます。

8. まとめ+アプリ紹介

ポイントをおさらいします。

  • LazyVGrid + 7列の GridItem でカレンダー風グリッドを作る
  • データは [Date: TimeInterval] で持ち、Key を startOfDay で正規化
  • 色は .green.opacity(level) で5段階。ダークモード対応も自動
  • 月初の空白セルColor.clear.aspectRatio(1, .fit) で揃える

実際に動くアプリを公開しています

このヒートマップを実装した 「勉強タイマー - 受験対策&学習記録」 を App Store で公開中です。

  • 1ヶ月続けた結果のヒートマップを見るとモチベが上がります
  • ポモドーロ・科目別記録もあります

App Storeで見る

ss3_calendar_final.png

「自分のアプリにも草を生やしたい」と思った方は、ぜひこの記事のコードを起点に試してみてください。

参考


@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?