HistoryGrid Scroll Performance Profiling

Deep analysis of scrolling performance issues and proposed solutions.

Current Performance (2025-12-31)

MetricCurrentTargetGap
App Launch2.2s<2.0s+10%
Scroll (6 swipes)9.7s<8.8s+10%

Architecture Overview

ContentView
└── List (scroll container, virtualized)
    └── HistorySection
        └── HistoryGrid (Grid - NOT lazy, creates all views upfront)
            └── ForEach(monthGroups)
                ├── StickyLabel (period)
                └── DaysGrid (Grid - NOT lazy)
                    └── ForEach(dayGroups)
                        ├── StickyLabel (day)
                        └── RecordsGrid (Grid - NOT lazy)
                            └── ForEach(records)
                                └── RecordRows

Critical insight: List virtualizes at section level, but Grid inside sections is NOT virtualized. All Grid children are created upfront.


Problem Areas

1. Double Grouping Computation

Location: HistoryGrid.swift:186-197

private var allMonthGroups: [MonthGroup] {
    groupRecordsByMonth(records)  // O(n log n) - called every render
}

private var visibleMonthGroups: [MonthGroup] {
    guard displayedRecordCount < records.count else {
        return allMonthGroups     // Calls allMonthGroups (computed again)
    }
    let visibleRecords = Array(records.prefix(displayedRecordCount))
    return groupRecordsByMonth(visibleRecords)  // O(n log n) AGAIN
}

Cost: For 500 records, this runs O(500 log 500) twice per render.

2. Nested Computed Property in MonthGroup

Location: HistoryGrouping.swift:17-25

public var dayGroups: [DayGroup] {
    let calendar = Calendar.current
    let grouped = Dictionary(grouping: records) { record in
        calendar.startOfDay(for: record.startTime)
    }
    return grouped.map { ... }.sorted { $0.date > $1.date }
}

Cost: Recomputed every time MonthGroup is accessed. No caching.

3. StickyLabel Scroll Churn

Location: HistoryGrid.swift:101-140

struct StickyLabel<Content: View>: View {
    @State private var offset: CGFloat = 0

    var body: some View {
        content
            .background(
                GeometryReader { geo in
                    Color.clear.onChange(of: geo.frame(in: .named("scroll")).minY) { _, newY in
                        updateOffset(cellY: newY)  // FIRES 60x/sec per cell
                    }
                }
            )
    }
}

Cost: 42 visible cells x 60 fps = 2,520 onChange callbacks/second during scroll.

4. Preference Key Accumulation

Location: HistoryGrid.swift:159-163

struct CellHeightPreferenceKey: PreferenceKey {
    static func reduce(value: inout [String: CGFloat], nextValue: () -> [String: CGFloat]) {
        value.merge(nextValue()) { _, new in new }  // Dictionary merge per cell
    }
}

Cost: Dictionary merge for every visible cell every frame.

5. Full Re-grouping on Progressive Load

Location: HistoryGrid.swift:279-281

private func loadMore() {
    let newCount = min(displayedRecordCount + Self.loadMoreCount, records.count)
    displayedRecordCount = newCount  // Triggers full re-grouping
}

Cost: Loading 50 more records triggers complete re-sort and re-group of ALL 500 records.


Measurements

Per-Render Costs (50 visible records)

OperationTimeFrequency
groupRecordsByMonth()2-5ms1x per render
dayGroups computed0.5-1ms x 6 months6x per render
DateFormatter.string()10-50us x 1001-5ms total
View creation (320+ views)3-5ms1x per render
Preference key reduction0.5-1ms1x per render
Total~10-20msper body eval

Scroll Frame Costs

OperationTime per FrameImpact
StickyLabel onChange (42x)0.5-1msState churn
GeometryReader frame reads0.5msContinuous
Preference key merges0.5-1msDictionary ops
Total~2-3ms12-18% of 16.7ms budget

Memory (500 records fully loaded)

  • View instances: ~2,500+
  • StickyLabel state objects: 137
  • MonthGroup/DayGroup structs: ~137
  • Retained strings (formatters): ~1,500

Proposed Solutions

Solution A: Cache Grouped Records at Parent Level

Approach: Move grouping to HistorySection, pass pre-grouped data to HistoryGrid.

Changes:

// HistorySection.swift
@State private var monthGroups: [MonthGroup] = []

private func updateFilteredRecords() {
    filteredRecords = ...
    monthGroups = groupRecordsByMonth(filteredRecords)  // Cache here
}

// Pass to HistoryGrid
HistoryGrid(monthGroups: monthGroups, ...)

Benefits:

  • Grouping computed once per filter change, not per render
  • Eliminates double grouping in HistoryGrid

Estimated improvement: 5-10ms saved per render


Solution B: Pre-compute DayGroups

Approach: Store dayGroups as stored property, not computed.

Changes:

// HistoryGrouping.swift
public struct MonthGroup {
    public let year: Int
    public let month: Int
    public let dayGroups: [DayGroup]  // Stored, not computed
}

public func groupRecordsByMonth(_ records: [TimeRecord]) -> [MonthGroup] {
    // Group by month
    // Then immediately group by day
    // Return MonthGroup with pre-computed dayGroups
}

Benefits:

  • dayGroups computed once during grouping
  • No re-computation on each access

Estimated improvement: 3-6ms saved per render


Solution C: Debounce StickyLabel Updates

Approach: Throttle offset updates to reduce state churn.

Option C1: Threshold-based updates

private func updateOffset(cellY: CGFloat) {
    let newOffset = calculateOffset(cellY)
    if abs(newOffset - offset) > 2.0 {  // Only update if change > 2px
        offset = newOffset
    }
}

Option C2: Frame-rate throttling

@State private var lastUpdate: CFTimeInterval = 0

private func updateOffset(cellY: CGFloat) {
    let now = CFAbsoluteTimeGetCurrent()
    guard now - lastUpdate > 0.016 else { return }  // 60fps max
    lastUpdate = now
    offset = calculateOffset(cellY)
}

Option C3: Use onGeometryChange (iOS 17+)

.onGeometryChange(for: CGFloat.self) { geo in
    geo.frame(in: .named("scroll")).minY
} action: { newY in
    updateOffset(cellY: newY)
}

Benefits:

  • Reduces 2,520 callbacks/sec to ~60-120
  • Less state mutation pressure

Estimated improvement: 1-2ms per scroll frame


Solution D: Incremental Progressive Loading

Approach: Append to existing groups instead of re-grouping all.

Changes:

@State private var loadedMonthGroups: [MonthGroup] = []

private func loadMore() {
    let newRecords = Array(records[displayedRecordCount..<min(displayedRecordCount + 50, records.count)])
    let newGroups = groupRecordsByMonth(newRecords)

    // Merge into existing groups
    loadedMonthGroups = mergeMonthGroups(loadedMonthGroups, newGroups)
    displayedRecordCount += 50
}

private func mergeMonthGroups(_ existing: [MonthGroup], _ new: [MonthGroup]) -> [MonthGroup] {
    // Smart merge: combine records for same year/month
}

Benefits:

  • Loading 50 records only groups those 50
  • Existing groups untouched

Estimated improvement: 5-10ms per load more action


Solution E: Replace Grid with LazyVStack + pinnedViews

Approach: Use native lazy container with sticky headers.

Changes:

ScrollView {
    LazyVStack(alignment: .leading, spacing: 12, pinnedViews: .sectionHeaders) {
        ForEach(monthGroups) { monthGroup in
            Section(header: PeriodHeader(monthGroup)) {
                ForEach(monthGroup.dayGroups) { dayGroup in
                    Section(header: DayHeader(dayGroup)) {
                        ForEach(dayGroup.records) { record in
                            RecordRow(record)
                        }
                    }
                }
            }
        }
    }
}

Benefits:

  • Native lazy loading - only visible views created
  • Built-in sticky header support
  • View recycling for memory efficiency

Drawbacks:

  • Loses precise Grid column alignment
  • Sticky headers pin to scroll container, not section
  • May require custom HStack layout for columns

Estimated improvement: 50-70% reduction in view creation


Solution F: Hybrid List + Per-Record Grid

Approach: Use List for virtualization, Grid only for single record columns.

Changes:

List {
    ForEach(monthGroups) { monthGroup in
        Section(header: Text(monthGroup.periodLabel)) {
            ForEach(monthGroup.dayGroups) { dayGroup in
                Section(header: Text(dayGroup.dayLabel)) {
                    ForEach(dayGroup.records) { record in
                        Grid(alignment: .leading) {
                            GridRow {
                                Text(duration)
                                Text(timeRange)
                                if showTag { tagView }
                            }
                        }
                    }
                }
            }
        }
    }
}

Benefits:

  • List handles all virtualization
  • Grid only for single row alignment (minimal cost)
  • Native section headers with stickiness

Drawbacks:

  • Changes visual layout
  • Period labels no longer span full height

Estimated improvement: 60-80% reduction in initial view creation


Solution G: Virtualized Window with Manual View Recycling

Approach: Implement UICollectionView-style recycling in SwiftUI.

Concept:

struct VirtualizedHistoryGrid: View {
    let records: [TimeRecord]
    @State private var visibleRange: Range<Int> = 0..&lt;50

    var body: some View {
        ScrollViewReader { proxy in
            ScrollView {
                LazyVStack {
                    ForEach(visibleRange, id: \.self) { index in
                        RecordRow(record: records[index])
                            .id(index)
                    }
                }
                .background(
                    GeometryReader { geo in
                        // Update visibleRange based on scroll position
                    }
                )
            }
        }
    }
}

Benefits:

  • Complete control over what’s rendered
  • True view recycling

Drawbacks:

  • Complex implementation
  • Loses grouping structure
  • Manual scroll position tracking

Estimated improvement: 70-90% reduction in memory/views


Solution Comparison Matrix

SolutionEffortRiskImpactPreserves Layout
A: Cache at parentLowLowMediumYes
B: Pre-compute dayGroupsLowLowMediumYes
C: Debounce stickyLowLowLow-MediumYes
D: Incremental loadingMediumMediumMediumYes
E: LazyVStackHighMediumHighNo
F: Hybrid List+GridMediumMediumHighPartial
G: Virtual recyclingHighHighVery HighNo

Recommended Implementation Order

Phase 1: Quick Wins (Low effort, no layout changes)

  1. Solution A: Cache grouped records in HistorySection
  2. Solution B: Pre-compute dayGroups in MonthGroup
  3. Solution C: Debounce StickyLabel updates

Expected result: 10-15ms saved per render, smoother scrolling

Phase 2: Progressive Loading Fix

  1. Solution D: Incremental loading instead of full re-group

Expected result: 5-10ms saved per load action

Phase 3: Architecture Change (if needed)

  1. Solution F: Hybrid approach with List + Grid per record

Expected result: 50-70% reduction in view creation


Profiling Tools

Instruments

# Time Profiler
xcrun xctrace record --template 'Time Profiler' --launch -- /path/to/Minuta.app

# SwiftUI View Body
xcrun xctrace record --template 'SwiftUI View Body' --launch -- /path/to/Minuta.app

Console.app

subsystem:tools.minuta.app category:tracking

Debug Overlay

Enable in app: Settings > Debug > Grid Debug Overlay


References


Related