Skip to content

Latest commit

 

History

History
254 lines (193 loc) · 10.5 KB

File metadata and controls

254 lines (193 loc) · 10.5 KB

HeatmapKit

A modern SwiftUI calendar heatmap and contribution graph component — visualize time-series data the way GitHub shows your contributions.

CI Swift Platforms SwiftPM License

Detail tab — three full-width heatmaps stacked, each in its own palette and card background    Boards tab — Streaks-style habit grid showing six different palettes side by side

From a single contribution grid to a full habit-tracker UI — both screens above are built with CalendarHeatmap, no extra views.

Why HeatmapKit?

Most existing heatmap libraries for Apple platforms target UIKit and have not been updated for years. HeatmapKit is built SwiftUI-first, supports the full Apple platform family (iOS / macOS / watchOS / tvOS / visionOS), and ships modern interactions like horizontal scrolling, automatic locale-aware month labels, and tap callbacks.

Features

  • 🍎 Pure SwiftUI — declarative API, no UIKit bridging
  • 📅 Calendar heatmap — GitHub-style 7×N grid, perfect for contributions / habits / activity
  • 📐 Adaptive layout — opt-in .fitToWidth(minCellSize:) sizes cells to the container; falls back to horizontal scrolling (anchored to the most recent week) when even the floor doesn't fit
  • 🎨 6 built-in palettes with auto light / dark variants (green mirrors github.com), plus full custom-color support
  • ⚖️ Auto or custom thresholds — let HeatmapKit bucket values from data.max(), or supply your own cutoffs
  • 🌍 Localized labels — month/weekday labels follow Calendar.current.locale
  • 👆 Tap-to-detail — opt-in callback per cell, plus a built-in popover tooltip
  • VoiceOver-ready — every cell ships an accessibility label, customizable per app
  • 📸 Share as image.snapshot(scale:background:) returns a CGImage ready for ShareLink
  • 🪶 Zero dependencies — Apple frameworks only

Requirements

Platform Minimum
iOS 17.0
macOS 14.0
watchOS 10.0
tvOS 17.0
visionOS 1.0
Swift 5.9
Xcode 15

Installation

Swift Package Manager

Add the dependency in Package.swift:

dependencies: [
    .package(url: "https://github.com/jacklv-coder/HeatmapKit", from: "0.5.0")
]

Or in Xcode: File → Add Package Dependencies… and paste the URL.

Try the demo

The repo ships with a runnable iOS sample app under Demo/:

git clone https://github.com/jacklv-coder/HeatmapKit
cd HeatmapKit
open Demo/HeatmapKitDemo/HeatmapKitDemo.xcodeproj

The project already references the parent HeatmapKit folder as a local Swift package — pick the HeatmapKitDemo scheme, run on an iOS Simulator, and you'll see the heatmap with palette switching, tooltips, accessibility labels, and a Share button wired through snapshot(scale:background:).

Quick Start

import SwiftUI
import HeatmapKit

struct ContentView: View {
    var body: some View {
        // Defaults to the last 365 days ending today.
        CalendarHeatmap(contributions: sampleData)
    }

    private var sampleData: [Date: Double] {
        // Map each day to a value (e.g. minutes focused, commits made, etc.)
        let today = Calendar.current.startOfDay(for: Date())
        return [today: 35.0]
    }
}

Custom date range

let cal = Calendar.current
let today = cal.startOfDay(for: Date())
let start = cal.date(byAdding: .day, value: -90, to: today)!

CalendarHeatmap(contributions: data, dateRange: start...today)

Working with your own model

If your data isn't already a [Date: Double] map, point HeatmapKit at any value type using key paths:

struct Session {
    var date: Date
    var minutes: Double
}

let sessions: [Session] = ...

CalendarHeatmap(
    data: sessions,
    dateKey: \.date,
    valueKey: \.minutes,
    aggregation: .sum  // .sum / .count / .max / .min / .average
)

Multiple items on the same day are combined per aggregation.

Customization

CalendarHeatmap(contributions: data)
    .cellSize(14)
    .cellSpacing(3)
    .cellCornerRadius(3)
    .levels(.orange)               // .green / .orange / .blue / .purple / .red / .grayscale
    .thresholds([1, 5, 10, 20])    // explicit cutoffs (count = levels.count - 1)
    .firstWeekday(.monday)
    .showMonthLabels(true)
    .showWeekdayLabels(false)
    .todayHighlightColor(.primary) // opt in to outline today's cell (default is nil, matching github.com)
    .scrollEnabled(true)
    .defaultScrollEdge(.trailing)  // anchor to most recent on first appearance
    .onCellTap { date, value in
        print("\(date): \(value)")
    }
    .tooltipOnTap { date, value in
        // Built-in popover. Tap the same cell again — or outside — to dismiss.
        // Default emits "{long date}\n{value}"; this example overrides it.
        let day = date.formatted(date: .abbreviated, time: .omitted)
        return "\(day)\(Int(value)) min"
    }
    .accessibilityCellLabel { date, value in
        // VoiceOver announces this for each in-range cell.
        // Default emits "{long date}, {value}" — override to inject units / wording.
        let day = date.formatted(date: .abbreviated, time: .omitted)
        return value == 0 ? "\(day), no activity"
                          : "\(day), \(Int(value)) minutes focused"
    }

Fit to container width

By default the heatmap renders at a static cellSize (14pt) wrapped in a horizontal ScrollView. On wider screens (iPad, macOS) that wastes space and forces unnecessary scrolling; on narrower screens it can clip awkwardly at the edges. Opt into adaptive sizing:

CalendarHeatmap(contributions: data)
    .cellSize(14)                 // upper bound: cells never grow past this
    .fitToWidth(minCellSize: 10)  // lower bound: cells shrink down to this

Three-stage behavior driven by container width:

Width Cell size Scroll?
Wide (≥ ~900pt for a 53-week year) 14pt (capped) No — full year visible
Mid (~500–900pt) Adaptive between 10 and 14pt No — full year visible
Narrow (< ~500pt, typical iPhone) Sized so N whole weeks exactly fill the container (cell ≥ minCellSize) Yes — scroll older history

In the narrow case the trailing-anchored initial render shows N whole columns with no partial cell at the leading edge; subsequent scroll positions snap to week boundaries via .scrollTargetBehavior(.viewAligned). The scroll fallback engages automatically whenever even minCellSize doesn't fit all weeks, regardless of .scrollEnabled(_:).

Bring your own palette

CalendarHeatmap(contributions: data)
    .levels([
        Color.gray.opacity(0.15),  // empty / no-data
        Color.pink.opacity(0.4),
        Color.pink.opacity(0.7),
        Color.pink,
    ])

The first color is the empty / no-data shade; the rest are progressively more intense. The number of colors you pass determines the level count. Without .thresholds(_:), HeatmapKit splits data.max() evenly across the remaining buckets.

Note: built-in palettes (.green, .orange, …) automatically swap between light- and dark-mode variants based on the surrounding colorScheme environment. Custom palettes passed via .levels([Color]) are static — gate on @Environment(\.colorScheme) yourself if you need them to adapt.

Share as image

import SwiftUI
import HeatmapKit

struct ShareableHeatmap: View {
    let data: [Date: Double]

    var body: some View {
        let heatmap = CalendarHeatmap(contributions: data).levels(.green)

        VStack {
            heatmap

            if let cg = heatmap.snapshot(scale: 3, background: Color.black) {
                let image = Image(decorative: cg, scale: 3)
                ShareLink(
                    item: image,
                    preview: SharePreview("My activity", image: image)
                ) {
                    Label("Share", systemImage: "square.and.arrow.up")
                }
            }
        }
    }
}

snapshot(scale:background:) renders the full grid without its scroll wrapper, so the entire date range is captured at intrinsic width — even if the on-screen heatmap would normally scroll. Pass background: to fill behind the grid (with 12pt padding) so the output looks intentional outside the dark-mode app it lives in. The returned CGImage works with ShareLink, Image(decorative:scale:), UIImage(cgImage:), or any CGImageDestination for save-to-disk use cases.

Roadmap

  • v0.1 — CalendarHeatmap core: grid, horizontal scroll, 6 palettes, custom thresholds, tap callback, today highlight, locale-aware month/weekday labels
  • v0.2 — VoiceOver labels per cell, customizable via .accessibilityCellLabel
  • v0.3 — Built-in detail tooltip on tap, customizable via .tooltipOnTap
  • v0.4 — Shareable image renderer via .snapshot(scale:background:) (works with ShareLink)
  • v0.5 — Adaptive fitToWidth(minCellSize:) layout; cells size to the container, scroll only when the floor doesn't fit
  • v0.6 — Additional layouts: weekly heatmap, hour×weekday matrix
  • Localization audit (RTL, non-Gregorian calendars)

Contributing

Issues and pull requests are welcome. The project is in its early days, so feedback on API shape is especially valuable.

Acknowledgements

  • The visual language — 7-row grid, light/dark palette, today outline behavior — is patterned on GitHub's contribution graph.
  • The Boards tab in the demo app is inspired by the habit-tracking UI of Streaks.
  • Thanks to everyone filing issues and feedback during the 0.x series.

Changelog

See CHANGELOG.md for release notes.

License

MIT © 2026 jacklv-coder