| name | swift-charts-advanced |
| description | Expert Swift Charts patterns including custom axis marks, label positioning and collision detection, @ChartContentBuilder composition, GeometryReader integration, and chart performance optimization. Use when building complex charts beyond basic bar/line marks. |
Swift Charts - Advanced Patterns
Expert guidance for building sophisticated charts with Swift Charts framework, including custom axis customization, label collision detection, and performance optimization.
When to Use This Skill
- Building charts with custom axis labels and tick marks
- Implementing label collision detection to prevent overlap
- Using GeometryReader for chart positioning and layout calculations
- Creating conditional chart marks with @ChartContentBuilder
- Composing complex charts from multiple mark types (LineMark, RuleMark, PointMark)
- Customizing chart axes with @AxisMarkBuilder
- Performance optimization for charts with many data points
- Implementing "now" indicators or reference lines
Chart Basics - Composition with @ChartContentBuilder
Understanding @ChartContentBuilder
From Apple Docs:
A result builder that creates chart content from closures. Use this to compose multiple chart marks into a single chart.
Pattern: Extract Chart Marks into Computed Properties
From HealthTrends: Composing chart marks for clean, maintainable code
@ChartContentBuilder
private var averageLines: some ChartContent {
let darkGray = Color(.systemGray4)
let lightGray = Color(.systemGray6)
ForEach(averageDataBeforeNow) { data in
LineMark(
x: .value("Hour", data.hour),
y: .value("Calories", data.calories),
series: .value("Series", "AverageUpToNow")
)
.foregroundStyle(darkGray)
.lineStyle(StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round))
}
ForEach(averageDataAfterNow) { data in
LineMark(
x: .value("Hour", data.hour),
y: .value("Calories", data.calories),
series: .value("Series", "AverageRestOfDay")
)
.foregroundStyle(lightGray)
.lineStyle(StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round))
}
}
@ChartContentBuilder
private var todayLine: some ChartContent {
ForEach(todayHourlyData) { data in
LineMark(
x: .value("Hour", data.hour),
y: .value("Calories", data.calories),
series: .value("Series", "Today")
)
.foregroundStyle(activeEnergyColor)
.lineStyle(StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round))
}
}
@ChartContentBuilder
private var goalLine: some ChartContent {
if moveGoal > 0 {
RuleMark(y: .value("Goal", moveGoal))
.foregroundStyle(goalColor.opacity(0.5))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
}
}
@ChartContentBuilder
private var nowLine: some ChartContent {
RuleMark(x: .value("Now", now))
.foregroundStyle(Color(.systemGray5))
.lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
}
Chart {
nowLine
goalLine
averageLines
averagePoint
todayLine
todayPoint
}
Benefits:
- Clear separation of concerns (each line type is isolated)
- Easy to reorder marks (layering is explicit)
- Business logic stays in computed properties
- Chart composition reads like a table of contents
Mark Types and Layering
Key insight: Order matters! Marks are drawn in order, so background marks first, foreground last.
Chart {
goalLine
nowLine
averageLines
todayLine
averagePoint
todayPoint
}
Common Mark Types:
LineMark: Continuous data (trends, cumulative values)
BarMark: Discrete comparisons (daily totals, categories)
PointMark: Individual data points, current value indicators
RuleMark: Reference lines (goals, averages, thresholds)
AreaMark: Filled regions (confidence intervals, ranges)
GeometryReader for Chart Positioning
Using GeometryReader for Chart Layout
Pattern: Wrap chart in GeometryReader to access dimensions for calculations
GeometryReader { geometry in
let chartWidth = geometry.size.width
let chartHeight = geometry.size.height
let maxValue = chartMaxValue(chartHeight: chartHeight)
VStack(spacing: 0) {
Chart {
}
.frame(maxHeight: .infinity)
.chartXScale(domain: startOfDay...endOfDay)
.chartYScale(domain: 0...maxValue)
.overlay {
if moveGoal > 0 {
let goalYPosition = chartHeight * (1 - moveGoal / maxValue)
Text("\(Int(moveGoal)) cal")
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(goalColor)
.padding(2)
.background(.background.opacity(0.5))
.cornerRadius(4)
.offset(x: -2, y: goalYPosition)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
ChartXAxisLabels(chartWidth: chartWidth)
.padding(.top, 8)
}
}
Key technique: Calculate positions as proportions of chart dimensions, then overlay labels
Custom X-Axis Labels with GeometryReader
From HealthTrends: Smart label positioning that adapts to current time
private struct ChartXAxisLabels: View {
let chartWidth: CGFloat
private var calendar: Calendar { Calendar.current }
private var now: Date { Date() }
var body: some View {
ZStack(alignment: .bottom) {
let startOfDay = calendar.startOfDay(for: Date())
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
let collisions = calculateLabelCollisions(chartWidth: chartWidth, now: now)
if !collisions.hidesStart {
Text(startOfDay, format: .dateTime.hour())
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
Text(now, format: .dateTime.hour().minute())
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: )
.offset(x: )
if !collisions.hidesEnd {
Text(endOfDay, format: .dateTime.hour())
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.frame(height: 20, alignment: .bottom)
}
}
Pattern: Use ZStack with calculated alignments and offsets for precise label positioning
Label Collision Detection
Algorithm: Detect Overlapping Labels
From HealthTrends: Prevent label overlap on time-based charts
private func calculateLabelCollisions(chartWidth: CGFloat, now: Date) -> (hidesStart: Bool, hidesEnd: Bool) {
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: now)
let nowOffset = now.timeIntervalSince(startOfDay)
let dayDuration = TimeInterval(24 * 60 * 60)
let nowPosition = chartWidth * (nowOffset / dayDuration)
let nowFormatter = Date.FormatStyle().hour().minute()
let nowLabelText = now.formatted(nowFormatter)
let nowLabelWidth = measureTextWidth(nowLabelText, textStyle: .caption1)
let hourFormatter = Date.FormatStyle().hour()
let startLabelText = startOfDay.formatted(hourFormatter)
let startEndLabelWidth = measureTextWidth(startLabelText, textStyle: .caption1)
let minSeparation: CGFloat = 4
let nowLeft = nowPosition - nowLabelWidth / 2
let nowRight = nowPosition + nowLabelWidth / 2
let startLabelRight = startEndLabelWidth
let hidesStart = nowLeft < (startLabelRight + minSeparation)
let endLabelLeft = chartWidth - startEndLabelWidth
let hidesEnd = nowRight > (endLabelLeft - minSeparation)
return (hidesStart, hidesEnd)
}
Algorithm steps:
- Calculate NOW position as proportion of day progress
- Measure actual text widths of all labels
- Calculate left/right edges of NOW label (considering center alignment)
- Check if NOW label edges overlap with start/end labels (+ minimum separation)
- Return flags indicating which labels should be hidden
Helper: Measure Text Width
func measureTextWidth(_ text: String, textStyle: UIFont.TextStyle) -> CGFloat {
let font = UIFont.preferredFont(forTextStyle: textStyle)
let attributes = [NSAttributedString.Key.font: font]
let size = (text as NSString).size(withAttributes: attributes)
return size.width
}
Edge Case: NOW Label Near Edges
Problem: Centered label can overflow chart edges when NOW is very early or late in the day
Solution: Dynamically adjust alignment and offset
Text(now, format: .dateTime.hour().minute())
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: {
let startOfDay = calendar.startOfDay(for: now)
let nowOffset = now.timeIntervalSince(startOfDay)
let dayDuration = TimeInterval(24 * 60 * 60)
let nowPosition = chartWidth * (nowOffset / dayDuration)
let nowLabelWidth = measureTextWidth(nowLabelText, textStyle: .caption1)
let centeredLeft = nowPosition - nowLabelWidth / 2
let centeredRight = nowPosition + nowLabelWidth / 2
if centeredLeft < 0 {
return .leading
} else if centeredRight > chartWidth {
return .trailing
} else {
return .center
}
}())
.offset(x: {
let nowPosition = chartWidth * (nowOffset / dayDuration)
let centeredLeft = nowPosition - nowLabelWidth / 2
let centeredRight = nowPosition + nowLabelWidth / 2
if centeredLeft < 0 || centeredRight > chartWidth {
return 0
} else {
return nowPosition - chartWidth / 2
}
}())
Key insight: Use both .frame(alignment:) and .offset(x:) together for precise positioning
Custom Axis Marks with @AxisMarkBuilder
Understanding @AxisMarkBuilder
Pattern: Conditionally render axis marks based on business logic
@AxisMarkBuilder
private func hourlyTickMark(
for date: Date,
startOfDay: Date,
endOfDay: Date,
collisions: (hidesStart: Bool, hidesEnd: Bool),
now: Date
) -> some AxisMark {
let minutesFromNow = abs(date.timeIntervalSince(now)) / 60
if minutesFromNow >= 20 {
let isStartOfDay = abs(date.timeIntervalSince(startOfDay)) < 60
let isEndOfDay = abs(date.timeIntervalSince(endOfDay)) < 60
let showTickLine = (isStartOfDay && !collisions.hidesStart)
|| (isEndOfDay && !collisions.hidesEnd)
if showTickLine {
AxisTick(centered: true, length: 6, stroke: StrokeStyle(lineWidth: 2, lineCap: .round))
.offset(CGSize(width: 0, height: 8))
} else {
AxisTick(centered: true, length: 0, stroke: StrokeStyle(lineWidth: 2, lineCap: .round))
.offset(CGSize(width: 0, height: 11))
}
}
}
Using Custom Axis Marks in Chart
.chartXAxis {
let startOfDay = calendar.startOfDay(for: Date())
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
let collisions = calculateLabelCollisions(chartWidth: chartWidth, now: now)
AxisMarks(values: .stride(by: .hour, count: 1)) { value in
if let date = value.as(Date.self) {
hourlyTickMark(
for: date,
startOfDay: startOfDay,
endOfDay: endOfDay,
collisions: collisions,
now: now
)
}
}
AxisMarks(values: [now]) { _ in
AxisTick(centered: true, length: 6, stroke: StrokeStyle(lineWidth: 2, lineCap: .round))
.offset(CGSize(width: 0, height: 8))
}
}
Key technique: Pre-calculate constants outside of AxisMarks iteration for performance
Chart Scales and Domains
Setting Chart Scales
Pattern: Control axis ranges for consistent visualization
Chart {
}
.chartXScale(domain: Calendar.current.startOfDay(for: Date())...endOfDay)
.chartYScale(domain: 0...maxValue)
Why set domains explicitly:
- Prevent chart from auto-scaling to extreme outliers
- Ensure consistent visualization across updates
- Control what data ranges are visible
Dynamic Y-Scale Based on Data
private func chartMaxValue(chartHeight: CGFloat) -> Double {
return max(
todayHourlyData.last?.calories ?? 0,
averageHourlyData.last?.calories ?? 0,
moveGoal,
projectedTotal
)
}
Pattern: Set Y-scale to the maximum of all visible data + reference lines
Chart Performance Optimization
Key Principle: Calculate Constants Outside Loops
From Apple Docs - Understanding and Improving SwiftUI Performance:
Layout readers, including chart axes, observe layout changes in their parent views to recalculate layouts. Reduce simultaneous layout and state updates by calculating constants once.
Anti-pattern:
.chartXAxis {
AxisMarks(values: .stride(by: .hour, count: 1)) { value in
if let date = value.as(Date.self) {
let startOfDay = calendar.startOfDay(for: Date())
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
let collisions = calculateLabelCollisions(chartWidth: chartWidth, now: now)
hourlyTickMark(for: date, )
}
}
}
Better pattern:
.chartXAxis {
let startOfDay = calendar.startOfDay(for: Date())
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
let collisions = calculateLabelCollisions(chartWidth: chartWidth, now: now)
AxisMarks(values: .stride(by: .hour, count: 1)) { value in
if let date = value.as(Date.self) {
hourlyTickMark(for: date, startOfDay: startOfDay, endOfDay: endOfDay,
collisions: collisions, now: now)
}
}
}
From HealthTrends: See EnergyChartView.swift:331-341
Limit Data Points for Large Datasets
Strategy: Aggregate or sample data before charting
func downsampleData(_ data: [DataPoint], maxPoints: Int) -> [DataPoint] {
guard data.count > maxPoints else { return data }
let stride = data.count / maxPoints
return stride(from: 0, to: data.count, by: stride).map { data[$0] }
}
let chartData = downsampleData(allData, maxPoints: 100)
When to use:
- Datasets with hundreds or thousands of points
- Chart performance is noticeably slow
- Visual fidelity isn't compromised by aggregation
Minimize Chart Updates
Pattern: Only update chart when meaningful data changes
@State private var lastRefreshMinute: Int = Calendar.current.component(.minute, from: Date())
.onReceive(timer) { _ in
let currentMinute = Calendar.current.component(.minute, from: Date())
guard currentMinute != lastRefreshMinute else { return }
lastRefreshMinute = currentMinute
updateChartData()
}
Key insight: Check frequently (every second), but only trigger expensive updates when necessary (every minute)
Data Preparation for Charts
Computing Cumulative Data
Pattern: Transform raw data into cumulative values for line charts
func toCumulativeData(_ hourlyData: [HourlyData]) -> [HourlyData] {
var cumulative: Double = 0
return hourlyData.map { data in
cumulative += data.calories
return HourlyData(hour: data.hour, calories: cumulative)
}
}
Interpolating Data Points
Pattern: Calculate intermediate values for smooth visualizations
extension Array where Element == HourlyEnergyData {
func interpolatedValue(at date: Date) -> Double? {
let calendar = Calendar.current
let hour = calendar.component(.hour, from: date)
let minute = calendar.component(.minute, from: date)
guard let currentHourData = self.first(where: { calendar.component(.hour, from: $0.hour) == hour }),
let nextHourData = self.first(where: { calendar.component(.hour, from: $0.hour) == hour + 1 })
else {
return self.last?.calories
}
let interpolationFactor = Double(minute) / 60.0
return currentHourData.calories + (nextHourData.calories - currentHourData.calories) * interpolationFactor
}
}
Filtering and Cleaning Data
Pattern: Remove stale or invalid data points before charting
private var cleanedAverageData: [HourlyEnergyData] {
averageHourlyData.filter { data in
let minute = calendar.component(.minute, from: data.hour)
return minute == 0
}
}
Use cases:
- Remove interpolated points from cached data
- Filter out zero-value artifacts
- Exclude outliers that would skew the chart
Styling Charts
Colors and Visual Hierarchy
Pattern: Use color to convey meaning and hierarchy
private let activeEnergyColor: Color = Color(red: 254/255, green: 73/255, blue: 1/255) // Bright pink
private let goalColor: Color = Color(.systemGray)
private let lineWidth: CGFloat = 4
@ChartContentBuilder
private var averageLines: some ChartContent {
let darkGray = Color(.systemGray4)
let lightGray = Color(.systemGray6)
ForEach(averageDataBeforeNow) { data in
LineMark().foregroundStyle(darkGray)
}
ForEach(averageDataAfterNow) { data in
LineMark().foregroundStyle(lightGray)
}
}
Semantic colors:
- Use system colors (
.primary, .secondary) for adaptability
- Use semantic colors (
.red for errors, .green for success)
- Test in both light and dark mode
Line Styles
.lineStyle(StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
Point Styles
PointMark(x: .value("Hour", interpolated.hour), y: .value("Calories", interpolated.calories))
.foregroundStyle(.background)
.symbolSize(256)
PointMark(x: .value("Hour", interpolated.hour), y: .value("Calories", interpolated.calories))
.foregroundStyle(Color(.systemGray4))
.symbolSize(100)
Pattern: Layer two PointMarks (large background + smaller foreground) for a halo effect
Common Pitfalls & Solutions
Issue: Labels Overlap at Chart Edges
Problem: Centered labels can overflow chart bounds when data is at extremes
Solution: Use edge alignment when center alignment would overflow (see "Edge Case: NOW Label Near Edges")
Issue: Chart Marks Appear in Wrong Order
Problem: Background elements drawn on top of data
Solution: Order matters! Draw marks from background to foreground:
Chart {
nowLine
goalLine
averageLines
todayLine
averagePoint
todayPoint
}
Issue: Axis Marks Perform Poorly
Problem: Calculating constants inside AxisMarks iteration causes performance hitches
Solution: Calculate constants once outside the iteration (see "Chart Performance Optimization")
Issue: Chart Doesn't Update When Data Changes
Problem: Chart data not marked as @State or @Observable
Solution: Ensure data source is observable:
@Observable
class ChartDataManager {
var todayData: [DataPoint] = []
var averageData: [DataPoint] = []
}
struct MyChartView: View {
var dataManager: ChartDataManager
var body: some View {
Chart {
ForEach(dataManager.todayData) { data in
LineMark()
}
}
}
}
Best Practices Summary
- Chart Composition: Extract marks into
@ChartContentBuilder computed properties for clean composition
- Collision Detection: Measure actual text widths, calculate positions mathematically, handle edge cases
- GeometryReader: Use for positioning calculations, pass dimensions to child views, avoid nesting
- Custom Axis Marks: Use
@AxisMarkBuilder for conditional rendering, calculate constants once
- Performance: Calculate outside loops, limit data points, minimize updates
- Data Preparation: Transform to cumulative, interpolate for smoothness, filter/clean before charting
- Styling: Use semantic colors, test light/dark mode, create visual hierarchy through color/weight
- Layering: Order marks from background to foreground for correct visual hierarchy
See Also
- swiftui-advanced skill: For GeometryReader patterns, result builders, performance profiling with Instruments
- healthkit-queries skill: For preparing HealthKit data for charting (cumulative patterns, date/time handling)
References