HistoryGrid Scroll Performance Profiling
Deep analysis of scrolling performance issues and proposed solutions.
Current Performance (2025-12-31)
| Metric | Current | Target | Gap |
|---|---|---|---|
| App Launch | 2.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)
| Operation | Time | Frequency |
|---|---|---|
groupRecordsByMonth() | 2-5ms | 1x per render |
dayGroups computed | 0.5-1ms x 6 months | 6x per render |
DateFormatter.string() | 10-50us x 100 | 1-5ms total |
| View creation (320+ views) | 3-5ms | 1x per render |
| Preference key reduction | 0.5-1ms | 1x per render |
| Total | ~10-20ms | per body eval |
Scroll Frame Costs
| Operation | Time per Frame | Impact |
|---|---|---|
| StickyLabel onChange (42x) | 0.5-1ms | State churn |
| GeometryReader frame reads | 0.5ms | Continuous |
| Preference key merges | 0.5-1ms | Dictionary ops |
| Total | ~2-3ms | 12-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:
dayGroupscomputed 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..<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
| Solution | Effort | Risk | Impact | Preserves Layout |
|---|---|---|---|---|
| A: Cache at parent | Low | Low | Medium | Yes |
| B: Pre-compute dayGroups | Low | Low | Medium | Yes |
| C: Debounce sticky | Low | Low | Low-Medium | Yes |
| D: Incremental loading | Medium | Medium | Medium | Yes |
| E: LazyVStack | High | Medium | High | No |
| F: Hybrid List+Grid | Medium | Medium | High | Partial |
| G: Virtual recycling | High | High | Very High | No |
Recommended Implementation Order
Phase 1: Quick Wins (Low effort, no layout changes)
- Solution A: Cache grouped records in HistorySection
- Solution B: Pre-compute dayGroups in MonthGroup
- Solution C: Debounce StickyLabel updates
Expected result: 10-15ms saved per render, smoother scrolling
Phase 2: Progressive Loading Fix
- Solution D: Incremental loading instead of full re-group
Expected result: 5-10ms saved per load action
Phase 3: Architecture Change (if needed)
- 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
- Demystify SwiftUI performance - WWDC23
- Tips for Using Lazy Containers in SwiftUI
- GeometryReader - Blessing or Curse?
- Avoiding SwiftUI Value Recomputation
- Tuning Lazy Stacks and Grids
- Tracking Geometry Changes in SwiftUI (iOS 17+)
- Throttling and Debouncing in SwiftUI
- DebouncedOnChange library
Related
- 801-optimization-plan - Previous optimization phases
- 910-backlog - Performance backlog items