Skip to content

Instantly share code, notes, and snippets.

@beader
Last active February 23, 2023 11:52
Show Gist options
  • Save beader/7f3c083a3eba5aec48b4de97523a0f82 to your computer and use it in GitHub Desktop.
Save beader/7f3c083a3eba5aec48b4de97523a0f82 to your computer and use it in GitHub Desktop.
A SwiftUI Demo for retrieving data from Core Data and visualizing them in Charts without using ViewModel

A SwiftUI Demo for retrieving data from Core Data and visualizing them in Charts.

Highlights:

  • Using @FetchRequest to fetch the data.
  • Only re-calculating the chart's data when FetchedResults changed, other irrelevant @State changed will be ignored.
  • Get rid of the ViewModels.

Usage:

List {
    DistributionChart(binnedData: binnedData)
        .frame(height: 200)
    ItemList(items: items)
}
.doFetchRequest(request: request, onChange: { items in
    self.items = items
    updateAveragePrice(items: items)
    updateBinnedData(items: items)
})

updateAveragePrice and updateBinnedData maybe some time-consuming tasks. They will be re-calculated only when FetchedResults changed.

//
// ContentView.swift
// temp
//
// Created by beader on 2023/2/8.
//
import SwiftUI
import CoreData
import Combine
import Charts
struct FirstAppear: ViewModifier {
// The action will be executed the first time the view appears
let action: () -> ()
@State private var hasAppeared = false
func body(content: Content) -> some View {
content.task {
guard !hasAppeared else { return }
hasAppeared = true
action()
}
}
}
extension View {
func onFirstAppear(_ action: @escaping () -> ()) -> some View {
modifier(FirstAppear(action: action))
}
}
struct FetchedResultsView<FetchedEntity: NSManagedObject, Content: View>: View, Equatable {
// FetchedResultsView will be re-rendered only when the sortDescriptors or predicate changed
static func == (lhs: FetchedResultsView<FetchedEntity, Content>, rhs: FetchedResultsView<FetchedEntity, Content>) -> Bool {
(lhs.request.sortDescriptors == rhs.request.sortDescriptors) &&
(lhs.request.predicate == rhs.request.predicate)
}
typealias Request = NSFetchRequest<FetchedEntity>
typealias Results = FetchedResults<FetchedEntity>
let content: (Results) -> Content
let request: Request
@FetchRequest var results: Results
init(
request: Request,
@ViewBuilder content: @escaping (Results) -> Content
) {
self.content = content
self.request = request
_results = FetchRequest(fetchRequest: request, animation: .default)
}
var body: some View {
let _ = print("FetchedResultsView: @self changed.")
content(results)
}
}
struct FetchRequestViewModifier<FetchedEntity: NSManagedObject>: ViewModifier {
typealias Request = NSFetchRequest<FetchedEntity>
typealias Results = FetchedResults<FetchedEntity>
let request: Request
let onChange: (Results) -> Void
func body(content: Content) -> some View {
content.background(
FetchedResultsView(request: request) { results in
Color.clear
.onFirstAppear {
onChange(results)
}
.id(UUID())
}
)
}
}
extension View {
func doFetchRequest<FetchedEntity: NSManagedObject>(
request: NSFetchRequest<FetchedEntity>,
onChange: @escaping (FetchedResults<FetchedEntity>) -> Void
) -> some View {
modifier(FetchRequestViewModifier(request: request, onChange: onChange))
}
}
struct ItemList<Data>: View where Data: RandomAccessCollection, Data.Element == Item, Data.Index == Int {
let items: Data?
@Environment(\.managedObjectContext) private var viewContext
var body: some View {
let _ = print("ItemListView: @self changed.")
if let items {
rows(for: items)
} else {
emptyView
}
}
private var emptyView: some View {
Text("empty")
}
@ViewBuilder
private func rows(for items: Data) -> some View {
Section {
ForEach(items) { item in
ItemRow(item: item)
}
.onDelete(perform: onDelete)
}
}
private func onDelete(_ indexSet: IndexSet) {
guard let items else { return }
indexSet.map { items[$0] }.forEach { item in
viewContext.delete(item)
}
do {
try viewContext.save()
} catch {
print("\(error.localizedDescription)")
}
}
}
struct ItemRow: View {
@ObservedObject var item: Item
var body: some View {
let _ = Self._printChanges()
NavigationLink {
ItemEditView(item: item)
} label: {
VStack(alignment: .leading) {
Text("\(item.timestamp?.formatted() ?? "")")
Text("\(item.price.formatted(.number.precision(.fractionLength(2))))")
}
}
}
}
struct DistributionChart: View {
typealias BinnedDataItem = (index: Int, range: ChartBinRange<Double>, frequency: Int)
let binnedData: [BinnedDataItem]
var body: some View {
let _ = Self._printChanges()
Chart(binnedData, id: \.index) { element in
BarMark(
x: .value("Price", element.range),
y: .value("Frequency", element.frequency)
)
}
.chartXScale(
domain: .automatic(includesZero: false)
)
}
}
struct ContentView: View {
@State var count: Int = 0
@State var minPrice: Double = 0
@State var averagePrice: Double = 0
@State var updating: Bool = false
@State var ascending: Bool = true
@State var items: FetchedResults<Item>? = nil
@State var binnedData: [DistributionChart.BinnedDataItem] = []
var request: NSFetchRequest<Item> {
let request = Item.fetchRequest()
request.predicate = NSPredicate(format: "price >= %f", minPrice)
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Item.timestamp, ascending: ascending)
]
return request
}
private func updateAveragePrice<Data>(items: Data) where Data: Collection, Data.Element == Item {
print("Calculate Average Price...")
updating = true
guard !items.isEmpty else {
averagePrice = 0
return
}
averagePrice = items.map(\.price).reduce(0, +) / Double(items.count)
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
updating = false
}
}
private func updateBinnedData<Data>(items: Data) where Data: Collection, Data.Element == Item {
print("Updating binned data")
let prices = items.map(\.price)
let bins = NumberBins(data: prices, desiredCount: 5)
let groups = Dictionary(grouping: prices, by: bins.index)
let preparedData = groups.map { key, values in
return (
index: key,
range: bins[key],
frequency: values.count
)
}
withAnimation {
binnedData = preparedData
}
}
var body: some View {
let _ = Self._printChanges()
NavigationView {
List {
DistributionChart(binnedData: binnedData)
.frame(height: 200)
ItemList(items: items)
}
.doFetchRequest(request: request, onChange: { items in
self.items = items
updateAveragePrice(items: items)
updateBinnedData(items: items)
})
.navigationTitle("Items")
.toolbar {
toolbar
}
}
}
@ToolbarContentBuilder
var toolbar: some ToolbarContent {
ToolbarItem(placement: .bottomBar) {
VStack {
Slider(value: $minPrice, in: 0...10, step: 1.0) {
Text("minimum price")
} minimumValueLabel: {
Text("0")
} maximumValueLabel: {
Text("10")
}
HStack {
Text("Items with price >= \(minPrice.formatted(.number.precision(.fractionLength(0))))")
Text("Average Price: ")
if updating {
ProgressView()
} else {
Text("\(averagePrice.formatted(.number.precision(.fractionLength(2))))")
}
}
.frame(maxWidth: .infinity)
}
}
ToolbarItem(placement: .navigationBarLeading) {
Button {
count += 1
} label: {
Text("You Tapped \(count) times")
}
}
ToolbarItemGroup {
Button {
ascending = !ascending
} label: {
Image(systemName: "arrow.up.arrow.down")
}
}
ToolbarItem {
NavigationLink {
ItemEditView()
} label: {
Label("Add Item", systemImage: "plus")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
@beader
Copy link
Author

beader commented Feb 23, 2023

fetchrequest_demo.mp4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment