Chapter 9: Data Aggregation & Charting in SwiftUI
Understanding Data Aggregation
- In-Memory Aggregation (The Swift Way): Fetch all relevant
Transactionentities into memory and use Swift's native collection operations (likeDictionary(grouping:by:)andreduce) to calculate the totals. - Database-Level Aggregation (The Core Data Pro Way): Delegate the mathematical heavy lifting to SQLite before returning the results to Swift. We achieve this using
NSExpressionDescriptionand setting the fetch request's result type toNSDictionaryResultType.
graph TD
A[Raw Transactions in SQLite] --> B{Aggregation Strategy}
B -->|"In-Memory"| C[Fetch all NSManagedObjects]
C -->|"Memory Spike"| D[Swift Dictionary Grouping & Reduce]
D -->|"CPU Overhead"| G[Chart Data Models]
B -->|"Database-Level"| E[NSExpressionDescription + GROUP BY]
E -->|"Optimized SQL Execution"| F[Fetch lightweight NSDictionary]
F -->|"Low Memory Footprint"| G
G --> H[Swift Charts UI]
The Architecture: MVVM and Core Data
sequenceDiagram
participant View as ChartView (SwiftUI)
participant VM as ChartViewModel (@Observable)
participant Repo as ExpenseRepository
participant Context as NSManagedObjectContext
participant Store as NSPersistentStore (SQLite)
View->>VM: onAppear / fetchChartData()
VM->>VM: Spawn Task (Concurrency)
VM->>Repo: fetchCategoryTotalsAsync()
Repo->>Context: performBackgroundTask
Context->>Store: SELECT category, SUM(amount) ... GROUP BY category
Store-->>Context: Raw SQLite Rows
Context-->>Repo: [[String: Any]] (Lightweight Dictionaries)
Repo-->>VM: [CategoryTotal] (Domain Models)
VM-->>View: @Observable chartData updated (Main Thread)
View->>View: Swift Charts renders UI & Animations
- View: Only knows about
ChartViewModeland the simpleCategoryTotalstruct. It handles UI and Swift Charts configuration. - ViewModel: Manages the state, handles date filtering logic, and coordinates asynchronous data fetching.
- Repository: The boundary layer. It translates domain requirements into Core Data
NSFetchRequestobjects and maps Core Data results back into pure Swift structs.
Approach 1: In-Memory Aggregation
Defining the Domain Model
import Foundation
import CoreData
import SwiftUI
import Observation
/// A pure Swift domain model representing aggregated spending for a category.
struct CategoryTotal: Identifiable, Hashable {
let id = UUID()
let categoryName: String
let totalAmount: Double
let categoryColorHex: String
}
// Dummy Color extension to allow compilation
extension Color {
init(hex: String) {
self.init(UIColor.gray)
}
}
Implementing the Repository Method
import Foundation
import CoreData
import SwiftUI
import Observation
/// A pure Swift domain model representing aggregated spending for a category.
struct CategoryTotal: Identifiable, Hashable {
let id = UUID()
let categoryName: String
let totalAmount: Double
let categoryColorHex: String
}
// Dummy Color extension to allow compilation
extension Color {
init(hex: String) {
self.init(UIColor.gray)
}
}
// Dummy entities to allow code to compile
@objc(TransactionEntity)
class TransactionEntity: NSManagedObject {
@NSManaged var amount: Double
@NSManaged var date: Date?
@NSManaged var category: CategoryEntity?
}
@objc(CategoryEntity)
class CategoryEntity: NSManagedObject {
@NSManaged var name: String?
@NSManaged var colorHex: String?
}
class ExpenseRepository {
static let shared = ExpenseRepository()
let persistentContainer: NSPersistentContainer
// Mock expensesStream for ChartViewModel compilation
var expensesStream: AsyncStream<[TransactionEntity]> {
AsyncStream { continuation in
continuation.finish()
}
}
init() {
persistentContainer = NSPersistentContainer(name: "ExpenseTracker")
}
/// Fetches transactions for a date range and aggregates totals by category in-memory.
/// - Warning: Can cause memory spikes and faulting overhead for large datasets.
func fetchCategoryTotalsInMemory(startDate: Date, endDate: Date) -> [CategoryTotal] {
let context = persistentContainer.viewContext
let request: NSFetchRequest = NSFetchRequest(entityName: "TransactionEntity")
// 1. Filter by date to minimize fetched data
request.predicate = NSPredicate(format: "date >= %@ AND date <= %@", startDate as NSDate, endDate as NSDate)
// 2. Optimization: Prefetch relationships to avoid faulting loops later
request.relationshipKeyPathsForPrefetching = ["category"]
do {
// 3. Fetch all matching entities into memory
let transactions = try context.fetch(request)
// 4. Group by Category Name
// If category is nil, we default to "Uncategorized"
let grouped = Dictionary(grouping: transactions) { transaction in
transaction.category?.name ?? "Uncategorized"
}
// 5. Map and reduce to our domain model
let totals: [CategoryTotal] = grouped.compactMap { (categoryName, txs) in
// Reduce the array of transactions into a single sum
let total = txs.reduce(0.0) { $0 + $1.amount }
// Exclude categories with zero spending if desired
guard total > 0 else { return nil }
// Grab color from the first transaction's category, or default to gray
let colorHex = txs.first?.category?.colorHex ?? "#808080"
return CategoryTotal(
categoryName: categoryName,
totalAmount: total,
categoryColorHex: colorHex
)
}
// 6. Sort by amount descending for a better visual chart presentation
return totals.sorted { $0.totalAmount > $1.totalAmount }
} catch {
print("Error fetching for in-memory aggregation: \(error)")
return []
}
}
}
The Hidden Cost of In-Memory: Faulting and Memory Spikes
- Easy to read and debug.
- You have full access to the
NSManagedObjectinstances if you need to calculate complex, non-mathematical logic based on multiple properties. - Memory Intensive: Instantiating thousands of objects just to read one or two properties is highly inefficient.
- Slower: Converting database rows to full object graphs takes CPU time.
Approach 2: Database-Level Aggregation (The Pro Way)
Step-by-Step Implementation
import Foundation
import CoreData
import SwiftUI
import Observation
/// A pure Swift domain model representing aggregated spending for a category.
struct CategoryTotal: Identifiable, Hashable {
let id = UUID()
let categoryName: String
let totalAmount: Double
let categoryColorHex: String
}
// Dummy Color extension to allow compilation
extension Color {
init(hex: String) {
self.init(UIColor.gray)
}
}
// Dummy entities to allow code to compile
@objc(TransactionEntity)
class TransactionEntity: NSManagedObject {
@NSManaged var amount: Double
@NSManaged var date: Date?
@NSManaged var category: CategoryEntity?
}
@objc(CategoryEntity)
class CategoryEntity: NSManagedObject {
@NSManaged var name: String?
@NSManaged var colorHex: String?
}
class ExpenseRepository {
static let shared = ExpenseRepository()
let persistentContainer: NSPersistentContainer
// Mock expensesStream for ChartViewModel compilation
var expensesStream: AsyncStream<[TransactionEntity]> {
AsyncStream { continuation in
continuation.finish()
}
}
init() {
persistentContainer = NSPersistentContainer(name: "ExpenseTracker")
}
/// Fetches transactions for a date range and aggregates totals by category in-memory.
/// - Warning: Can cause memory spikes and faulting overhead for large datasets.
func fetchCategoryTotalsInMemory(startDate: Date, endDate: Date) -> [CategoryTotal] {
let context = persistentContainer.viewContext
let request: NSFetchRequest = NSFetchRequest(entityName: "TransactionEntity")
// 1. Filter by date to minimize fetched data
request.predicate = NSPredicate(format: "date >= %@ AND date <= %@", startDate as NSDate, endDate as NSDate)
// 2. Optimization: Prefetch relationships to avoid faulting loops later
request.relationshipKeyPathsForPrefetching = ["category"]
do {
// 3. Fetch all matching entities into memory
let transactions = try context.fetch(request)
// 4. Group by Category Name
// If category is nil, we default to "Uncategorized"
let grouped = Dictionary(grouping: transactions) { transaction in
transaction.category?.name ?? "Uncategorized"
}
// 5. Map and reduce to our domain model
let totals: [CategoryTotal] = grouped.compactMap { (categoryName, txs) in
// Reduce the array of transactions into a single sum
let total = txs.reduce(0.0) { $0 + $1.amount }
// Exclude categories with zero spending if desired
guard total > 0 else { return nil }
// Grab color from the first transaction's category, or default to gray
let colorHex = txs.first?.category?.colorHex ?? "#808080"
return CategoryTotal(
categoryName: categoryName,
totalAmount: total,
categoryColorHex: colorHex
)
}
// 6. Sort by amount descending for a better visual chart presentation
return totals.sorted { $0.totalAmount > $1.totalAmount }
} catch {
print("Error fetching for in-memory aggregation: \(error)")
return []
}
}
/// Aggregates category totals efficiently at the database level using a background context.
func fetchCategoryTotalsAsync(startDate: Date, endDate: Date) async -> [CategoryTotal] {
return await withCheckedContinuation { continuation in
// performBackgroundTask ensures this runs off the main thread
persistentContainer.performBackgroundTask { context in
// 1. Create the base fetch request targeting NSDictionary
let request = NSFetchRequest(entityName: "TransactionEntity")
request.resultType = .dictionaryResultType
// 2. Set the predicate (Date Range)
request.predicate = NSPredicate(format: "date >= %@ AND date <= %@", startDate as NSDate, endDate as NSDate)
// 3. Define the SUM expression
let amountKeyPath = #keyPath(TransactionEntity.amount)
let amountExpression = NSExpression(forKeyPath: amountKeyPath)
// Use the 'sum:' SQL function to add up the values
let sumExpression = NSExpression(forFunction: "sum:", arguments: [amountExpression])
// 4. Create the Expression Description
// This acts as a virtual property in our resulting dictionary
let sumDescription = NSExpressionDescription()
sumDescription.name = "totalAmount" // The key in the resulting dictionary
sumDescription.expression = sumExpression
sumDescription.expressionResultType = .doubleAttributeType
// Mock expensesStream for ChartViewModel compilation
var expensesStream: AsyncStream<[TransactionEntity]> {
AsyncStream { continuation in
continuation.finish()
}
}
init() {
persistentContainer = NSPersistentContainer(name: "ExpenseTracker")
}
/// Fetches transactions for a date range and aggregates totals by category in-memory.
/// - Warning: Can cause memory spikes and faulting overhead for large datasets.
func fetchCategoryTotalsInMemory(startDate: Date, endDate: Date) -> [CategoryTotal] {
let context = persistentContainer.viewContext
let request: NSFetchRequest = NSFetchRequest(entityName: "TransactionEntity")
// 1. Filter by date to minimize fetched data
request.predicate = NSPredicate(format: "date >= %@ AND date <= %@", startDate as NSDate, endDate as NSDate)
// 2. Optimization: Prefetch relationships to avoid faulting loops later
request.relationshipKeyPathsForPrefetching = ["category"]
do {
// 3. Fetch all matching entities into memory
let transactions = try context.fetch(request)
// 4. Group by Category Name
// If category is nil, we default to "Uncategorized"
let grouped = Dictionary(grouping: transactions) { transaction in
transaction.category?.name ?? "Uncategorized"
}
// 5. Map and reduce to our domain model
let totals: [CategoryTotal] = grouped.compactMap { (categoryName, txs) in
// Reduce the array of transactions into a single sum
let total = txs.reduce(0.0) { $0 + $1.amount }
// Exclude categories with zero spending if desired
guard total > 0 else { return nil }
// Grab color from the first transaction's category, or default to gray
let colorHex = txs.first?.category?.colorHex ?? "#808080"
return CategoryTotal(
categoryName: categoryName,
totalAmount: total,
categoryColorHex: colorHex
)
}
// 6. Sort by amount descending for a better visual chart presentation
return totals.sorted { $0.totalAmount > $1.totalAmount }
} catch {
print("Error fetching for in-memory aggregation: \(error)")
return []
}
}
}
import Foundation
import CoreData
@objc(TransactionEntity)
class TransactionEntity: NSManagedObject {
@NSManaged var amount: Double
}
class ExpenseRepository {
func advancedFetch(request: NSFetchRequest, dict: [String: Any], categoryNameKeyPath: String, categoryColorKeyPath: String) {
// 1. Create Count Expression
let countExpression = NSExpression(forFunction: "count:", arguments: [NSExpression(forKeyPath: #keyPath(TransactionEntity.amount))])
let countDescription = NSExpressionDescription()
countDescription.name = "transactionCount"
countDescription.expression = countExpression
countDescription.expressionResultType = .integer32AttributeType
// Mock sumDescription for compilation
let sumDescription = NSExpressionDescription()
// 2. Add to propertiesToFetch
request.propertiesToFetch = [categoryNameKeyPath, categoryColorKeyPath, sumDescription, countDescription]
// 3. Map in results loop
let count = dict["transactionCount"] as? Int ?? 0
}
}
// 5. Define grouping and properties to fetch
// We want to group by the category's name and color.
let categoryNameKeyPath = #keyPath(TransactionEntity.category.name)
let categoryColorKeyPath = #keyPath(TransactionEntity.category.colorHex)
request.propertiesToFetch = [categoryNameKeyPath, categoryColorKeyPath, sumDescription]
request.propertiesToGroupBy = [categoryNameKeyPath, categoryColorKeyPath]
do {
// 6. Execute the fetch (this translates to SELECT ... GROUP BY in SQL)
let results = try context.fetch(request)
import SwiftUI
import Charts
import Observation
import CoreData
struct CategoryTotal: Identifiable, Hashable {
let id = UUID()
let categoryName: String
let totalAmount: Double
let categoryColorHex: String
}
extension Color {
init(hex: String) { self.init(UIColor.gray) }
}
@objc(TransactionEntity)
class TransactionEntity: NSManagedObject {
@NSManaged var amount: Double
@NSManaged var date: Date?
@NSManaged var category: CategoryEntity?
}
@objc(CategoryEntity)
class CategoryEntity: NSManagedObject {
@NSManaged var name: String?
@NSManaged var colorHex: String?
}
class ExpenseRepository {
static let shared = ExpenseRepository()
let persistentContainer = NSPersistentContainer(name: "ExpenseTracker")
var expensesStream: AsyncStream<[TransactionEntity]> { AsyncStream { $0.finish() } }
func fetchCategoryTotalsAsync(startDate: Date, endDate: Date) async -> [CategoryTotal] { [] }
}
@MainActor
@Observable class ChartViewModel {
var chartData: [CategoryTotal] = []
var selectedMonth: Date = Date()
var isLoading: Bool = false
var totalSpent: Double = 0
}
struct ExpenseBarChartView: View {
@Bindable var viewModel: ChartViewModel
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Monthly Breakdown")
.font(.title2)
.bold()
if viewModel.isLoading && viewModel.chartData.isEmpty {
ProgressView()
.frame(height: 300)
.frame(maxWidth: .infinity)
} else if viewModel.chartData.isEmpty {
Text("No data for this month.")
.foregroundColor(.secondary)
.frame(height: 300)
.frame(maxWidth: .infinity, alignment: .center)
} else {
Chart(viewModel.chartData) { item in
BarMark(
x: .value("Amount", item.totalAmount),
y: .value("Category", item.categoryName)
)
// Customize the bar color based on our domain model
.foregroundStyle(Color(hex: item.categoryColorHex))
// Add annotations to the end of each bar
.annotation(position: .trailing) {
Text(item.totalAmount, format: .currency(code: "USD"))
.font(.caption)
.foregroundColor(.secondary)
}
// Corner radius for a polished look
.cornerRadius(4)
}
.frame(height: 300)
// Animate changes when the user switches months
.animation(.easeInOut, value: viewModel.chartData)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2)
}
}
import SwiftUI
import Charts
import Observation
import CoreData
struct CategoryTotal: Identifiable, Hashable {
let id = UUID()
let categoryName: String
let totalAmount: Double
let categoryColorHex: String
}
extension Color {
init(hex: String) { self.init(UIColor.gray) }
}
@objc(TransactionEntity)
class TransactionEntity: NSManagedObject {
@NSManaged var amount: Double
@NSManaged var date: Date?
@NSManaged var category: CategoryEntity?
}
@objc(CategoryEntity)
class CategoryEntity: NSManagedObject {
@NSManaged var name: String?
@NSManaged var colorHex: String?
}
class ExpenseRepository {
static let shared = ExpenseRepository()
let persistentContainer = NSPersistentContainer(name: "ExpenseTracker")
var expensesStream: AsyncStream<[TransactionEntity]> { AsyncStream { $0.finish() } }
func fetchCategoryTotalsAsync(startDate: Date, endDate: Date) async -> [CategoryTotal] { [] }
}
@MainActor
@Observable class ChartViewModel {
var chartData: [CategoryTotal] = []
var selectedMonth: Date = Date()
var isLoading: Bool = false
var totalSpent: Double = 0
}
struct ExpenseDonutChartView: View {
@Bindable var viewModel: ChartViewModel
var body: some View {
VStack {
Text("Spending Distribution")
.font(.headline)
Chart(viewModel.chartData) { item in
SectorMark(
angle: .value("Amount", item.totalAmount),
innerRadius: .ratio(0.6), // Makes it a donut instead of a pie
angularInset: 1.5 // Adds a small gap between slices for visual clarity
)
.foregroundStyle(Color(hex: item.categoryColorHex))
.cornerRadius(4)
}
.frame(height: 250)
// chartBackground allows us to overlay a view perfectly in the plot area
.chartBackground { chartProxy in
GeometryReader { geometry in
// We use the proxy to get the exact frame of the pie area
if let plotFrame = chartProxy.plotFrame {
let frame = geometry[plotFrame]
VStack {
Text("Total")
.font(.caption)
.foregroundColor(.secondary)
Text(viewModel.totalSpent, format: .currency(code: "USD"))
.font(.title2.bold())
.foregroundColor(.primary)
// Scale text to fit if the total gets too large
.minimumScaleFactor(0.5)
}
// Position exactly in the center of the donut hole
.position(x: frame.midX, y: frame.midY)
}
}
}
.animation(.spring(), value: viewModel.chartData)
// Custom Legend since standard charts legend can look cluttered with many categories
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))], alignment: .leading, spacing: 8) {
ForEach(viewModel.chartData) { item in
HStack {
Circle()
.fill(Color(hex: item.categoryColorHex))
.frame(width: 8, height: 8)
Text(item.categoryName)
.font(.caption)
.lineLimit(1)
}
}
}
.padding(.top, 10)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2)
}
}
import SwiftUI
import Charts
import Observation
import CoreData
struct CategoryTotal: Identifiable, Hashable {
let id = UUID()
let categoryName: String
let totalAmount: Double
let categoryColorHex: String
}
extension Color {
init(hex: String) { self.init(UIColor.gray) }
}
@objc(TransactionEntity)
class TransactionEntity: NSManagedObject {
@NSManaged var amount: Double
@NSManaged var date: Date?
@NSManaged var category: CategoryEntity?
}
@objc(CategoryEntity)
class CategoryEntity: NSManagedObject {
@NSManaged var name: String?
@NSManaged var colorHex: String?
}
class ExpenseRepository {
static let shared = ExpenseRepository()
let persistentContainer = NSPersistentContainer(name: "ExpenseTracker")
var expensesStream: AsyncStream<[TransactionEntity]> { AsyncStream { $0.finish() } }
func fetchCategoryTotalsAsync(startDate: Date, endDate: Date) async -> [CategoryTotal] { [] }
}
@MainActor
@Observable class ChartViewModel {
var chartData: [CategoryTotal] = []
var selectedMonth: Date = Date()
var isLoading: Bool = false
var totalSpent: Double = 0
init(repository: ExpenseRepository) {}
}
struct MonthPickerView: View {
@Binding var selectedDate: Date
var body: some View { EmptyView() }
}
struct ExpenseDonutChartView: View {
var viewModel: ChartViewModel
var body: some View { EmptyView() }
}
struct ExpenseBarChartView: View {
var viewModel: ChartViewModel
var body: some View { EmptyView() }
}
struct StatisticsDashboardView: View {
// In a real app with proper DI, inject the repository into the view environment
@State private var viewModel = ChartViewModel(repository: ExpenseRepository.shared)
var body: some View {
NavigationView {
ScrollView {
VStack(spacing: 24) {
// Month Selector
MonthPickerView(selectedDate: $viewModel.selectedMonth)
// Charts
ExpenseDonutChartView(viewModel: viewModel)
ExpenseBarChartView(viewModel: viewModel)
}
.padding()
}
.navigationTitle("Insights")
.background(Color(.systemGroupedBackground))
}
}
}
Advanced Aggregation Considerations
Multiple Expressions (Count, Average)
import Foundation
import CoreData
@objc(TransactionEntity)
class TransactionEntity: NSManagedObject {
@NSManaged var amount: Double
}
class ExpenseRepository {
func advancedFetch(request: NSFetchRequest, dict: [String: Any], categoryNameKeyPath: String, categoryColorKeyPath: String) {
// 1. Create Count Expression
let countExpression = NSExpression(forFunction: "count:", arguments: [NSExpression(forKeyPath: #keyPath(TransactionEntity.amount))])
let countDescription = NSExpressionDescription()
countDescription.name = "transactionCount"
countDescription.expression = countExpression
countDescription.expressionResultType = .integer32AttributeType
// Mock sumDescription for compilation
let sumDescription = NSExpressionDescription()
// 2. Add to propertiesToFetch
request.propertiesToFetch = [categoryNameKeyPath, categoryColorKeyPath, sumDescription, countDescription]
// 3. Map in results loop
let count = dict["transactionCount"] as? Int ?? 0
}
}
The "Gotcha": Grouping by Date
Reviewing the "UIKit Way" in a SwiftUI World
- Separation of Concerns: Our SwiftUI views remain pure UI components. They don't know where the data comes from, making them easier to preview, test, and reuse.
- Performance Control:
@FetchRequest triggers UI updates whenever any relevant data changes. While convenient, it can cause severe over-rendering. Our ViewModel approach allows us to debounce updates, aggregate data off the main thread, and precisely control when the UI redraws.
- Complex Queries: Try writing a complex
GROUP BY and SUM query using just @FetchRequest parameters. It's often impossible or incredibly convoluted. Hand-rolling the NSFetchRequest gives us access to the full power of SQLite while keeping the UI layer clean.
graph LR
subgraph UI_Layer__SwiftUI_View_ ["UI Layer (SwiftUI View)"]
Chart[Chart View]
end
subgraph Presentation_Layer__ViewModel_ ["Presentation Layer (ViewModel)"]
VM[ChartViewModel]
Model[Domain Models]
end
subgraph Data_Access_Layer__Repository_ ["Data Access Layer (Repository)"]
Repo[ExpenseRepository]
Fetch[NSFetchRequest]
Expr[NSExpression]
end
subgraph Storage__Core_Data_ ["Storage (Core Data)"]
Context[NSManagedObjectContext]
SQL[(SQLite Store)]
end
Chart -->|"Observes @Observable"| VM
VM -->|"Maps to"| Model
VM -->|"Spawns Task"| Repo
Repo -->|"Constructs"| Fetch
Fetch -->|"Contains"| Expr
Repo -->|"Executes async"| Context
Context --> SQL
Conclusion