Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 050 / 100

Swift Charts: concepts, trade-offs and production lessons

Native charts in a few lines.

Expona Admin8 min read

Introduction

Building visually compelling data visualizations used to mean reaching for third-party charting libraries, wrestling with configuration, and hoping performance stayed acceptable on older devices. Swift Charts: concepts, trade-offs and production lessons shows us that Apple's native charting framework, introduced in iOS 16, handles the most common visualization needs elegantly—and it's worth understanding its design philosophy before reaching for alternatives.

We've shipped charts in production across e-commerce dashboards, trading analytics, and ride-hailing driver earnings breakdowns. This article covers what we've learned: when Swift Charts shines, where it shows limitations, and how to avoid the pitfalls we hit along the way.

BarMark, LineMark and PointMark

Swift Charts gives us three fundamental building blocks. Each one maps data values to visual properties—position, color, size, opacity—and they compose naturally together.

BarMark renders rectangles, one per data point. It's your go-to for categorical comparisons: product sales by region, daily task completion counts, or payment method distributions.

swift
import Charts

struct SalesOverview: View {
    let salesData = [
        (month: "Jan", revenue: 12500),
        (month: "Feb", revenue: 15800),
        (month: "Mar", revenue: 18200),
    ]
    
    var body: some View {
        Chart(salesData, id: \.month) { item in
            BarMark(
                x: .value("Month", item.month),
                y: .value("Revenue", item.revenue)
            )
            .foregroundStyle(.blue)
        }
        .chartYAxis {
            AxisMarks(position: .leading)
        }
    }
}

Notice how we just declare what each bar represents: an x-axis value (the month) and a y-axis value (the revenue). Swift Charts handles scaling, layout, and axis labels. No manual coordinate math.

LineMark connects data points with a path, essential for time-series data: API response times throughout the day, user signup trends over weeks, or ride-hailing demand curves by hour.

swift
struct PerformanceTrend: View {
    let metrics = [
        (hour: "08:00", latencyMs: 45),
        (hour: "09:00", latencyMs: 52),
        (hour: "10:00", latencyMs: 48),
        (hour: "11:00", latencyMs: 61),
    ]
    
    var body: some View {
        Chart(metrics, id: \.hour) { metric in
            LineMark(
                x: .value("Time", metric.hour),
                y: .value("Latency (ms)", metric.latencyMs)
            )
            .lineStyle(StrokeStyle(lineWidth: 2))
            .foregroundStyle(.red)
        }
    }
}

PointMark displays individual points without connecting them. Use it for scatter plots—correlation between user session length and churn probability—or to overlay precise data points on top of smoothed lines.

The real power emerges when you layer them. A common pattern in our dashboards combines LineMark for the trend with PointMark to highlight individual observations:

swift
struct CombinedChart: View {
    let weeklyData = [
        (day: "Mon", target: 100, actual: 95),
        (day: "Tue", target: 100, actual: 108),
    ]
    
    var body: some View {
        Chart(weeklyData, id: \.day) { item in
            LineMark(
                x: .value("Day", item.day),
                y: .value("Target", item.target)
            )
            .foregroundStyle(.gray)
            .lineStyle(StrokeStyle(lineWidth: 1, dash: [5]))
            
            PointMark(
                x: .value("Day", item.day),
                y: .value("Actual", item.actual)
            )
            .foregroundStyle(.green)
            .symbolSize(100)
        }
    }
}

Works with your data models

One of Swift Charts' biggest wins is that it doesn't force you into a rigid data structure. You pass your existing model types directly into the Chart initializer and bind properties using the .value() modifier.

In a CRM dashboard, our user engagement model looked like this:

swift
struct UserEngagement: Identifiable {
    let id: UUID
    let userId: Int
    let date: Date
    let activeMinutes: Int
    let eventsLogged: Int
    let accountStatus: String
}

struct EngagementDashboard: View {
    @State var engagementData: [UserEngagement] = []
    
    var body: some View {
        Chart(engagementData, id: \.id) { record in
            LineMark(
                x: .value("Date", record.date, unit: .day),
                y: .value("Active Minutes", record.activeMinutes)
            )
            .foregroundStyle(
                by: .value("Status", record.accountStatus)
            )
        }
        .onAppear {
            Task {
                engagementData = await fetchEngagementMetrics()
            }
        }
    }
}

Notice how we didn't create a separate data-transformation layer. The chart works directly with our domain model. The foregroundStyle(by:) modifier automatically generates a legend and assigns colors by the account status field.

When filtering or sorting becomes necessary—say, showing only premium users—we filter the data array before passing it to the chart. No proprietary query language. No impedance mismatch.

This design choice reduces boilerplate significantly. In production, we've found it cuts chart implementation time roughly in half compared to earlier third-party solutions where you'd build separate data structures for visualization.

Accessible by default

Accessibility isn't bolted on after the fact with Swift Charts. VoiceOver support is built into the framework. Axis labels, value descriptions, and chart types are announced automatically. Our QA team caught fewer accessibility regressions when charts were involved compared to custom view implementations.

By default, VoiceOver users can:

  • Hear chart type and axes description
  • Navigate through data points sequentially
  • Hear the x and y values for each mark
  • Access the legend to understand color encoding

You can enhance this with .accessibilityLabel() and .accessibilityValue() on individual marks:

swift
Chart(salesData, id: \.month) { item in
    BarMark(
        x: .value("Month", item.month),
        y: .value("Revenue", item.revenue)
    )
    .accessibilityLabel("Sales for \(item.month)")
    .accessibilityValue("\(item.revenue) dollars")
}

In our ride-hailing analytics app, driver earnings charts are now accessible to drivers with screen readers. That wasn't trivial with custom views. This is table-stakes functionality, not a nice-to-have, and Swift Charts delivers it without extra work.

Interactive selection in iOS 17+

iOS 17 introduced chart interaction via the chartAngleSelection(), chartXSelection(), and chartPointHoverModifier() modifiers. Users can now tap or hover over marks to see detailed information.

Here's a practical example from our trading analytics system:

swift
struct OrderHistoryChart: View {
    let orders: [Order]
    @State var selectedOrderID: UUID?
    
    var body: some View {
        VStack {
            Chart(orders, id: \.id) { order in
                PointMark(
                    x: .value("Time", order.executedAt),
                    y: .value("Price", order.price)
                )
                .foregroundStyle(order.type == .buy ? .green : .red)
                .opacity(selectedOrderID == nil || selectedOrderID == order.id ? 1 : 0.3)
            }
            .chartPointHoverModifier { index, elementBounds in
                if index < orders.count {
                    selectedOrderID = orders[index].id
                }
            }
            
            if let selectedID = selectedOrderID,
               let selected = orders.first(where: { $0.id == selectedID }) {
                VStack(alignment: .leading) {
                    Text("Price: $\(selected.price, specifier: "%.2f")")
                    Text("Quantity: \(selected.quantity)")
                    Text("Fee: $\(selected.fee, specifier: "%.2f")")
                }
                .padding()
                .background(.gray.opacity(0.1))
            }
        }
    }
}

The chartPointHoverModifier fires when the user's pointer (or finger on iPad) hovers over a region. We use the index to identify which data point the user is interacting with, then conditionally render a details panel. The opacity change gives visual feedback about what's selected.

On older iOS versions (16.x), this interaction isn't available, so we gate it with an @available check if needed. Most of our production apps target iOS 17+, so we haven't had to ship dual implementations.

Common mistakes

Mistake 1: Trying to render too much data at once. Swift Charts performs well with hundreds of points, but thousands of marks become sluggish. If you're visualizing a full month of minutely API metrics (40,000+ points), consider aggregating or using a scrollable timeline instead. We learned this the hard way in an SaaS monitoring dashboard.

Mistake 2: Ignoring chart sizing and aspect ratios. Charts expand to fill their container. In navigation-driven UIs, a chart might be larger on iPad than iPhone, and values can become hard to read. Always test across device sizes and use .frame(height:) to set explicit dimensions. We've seen perfectly fine charts become unusable on split-screen iPad layouts because we didn't constrain them.

Mistake 3: Assuming date/time formatting works automatically. When you use Date values for x-axis, Swift Charts formats them, but not always in the locale or format you expect. Use .value("Label", date, unit: .day) and test with different Locale settings. We once shipped charts showing month-day backwards in European markets.

Mistake 4: Forgetting that foregroundStyle(by:) requires Codable enum or String values. If your color key is a custom object, the chart won't automatically distinguish it. Convert to String first in those cases, or assign colors explicitly with .foregroundStyle().

Mistake 5: Not testing performance with real network delays. Charts render instantly once data arrives, but if your data-fetching code is slow, users see a blank screen. Use @StateObject and combine chart rendering with skeleton screens or loading indicators so the UI feels responsive.

Key takeaways

  • BarMark, LineMark, and PointMark cover the vast majority of charting needs in production apps. Start with these before exploring advanced charting libraries.
  • Swift Charts integrates directly with your data models—no transformation layer required. This keeps views simpler and reduces bugs.
  • Accessibility is not an afterthought. Charts work with VoiceOver by default, meeting compliance requirements without extra effort.
  • Interactive selection in iOS 17+ enables richer user experiences. Combine chartPointHoverModifier with state to build detail panels and drill-down flows.
  • Watch data volume and chart sizing. Hundreds of marks perform well; thousands become sluggish. Always test across device sizes and network conditions.
  • Date/time formatting, color encoding, and locale handling require explicit attention. Don't assume defaults will work globally.
  • For specialized needs—heatmaps, complex multi-axis layouts, or real-time streaming with tens of thousands of points—third-party libraries remain valuable. Understand Swift Charts' boundaries.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles