SwiftUI Struct for Dictionary entry - ios

I have decided after several years of development to restart my project using SwiftUI to future proof as much as I can.
In my current project I have my data in several .CSV's which I then process into dictionaries and then create a list of entries on screen using an Array of keys which are generated programmatically from user input.
All examples I've seen for SwiftUI use JSON. However the structure of these files are identical to an Array of Dictionaries. My question is; is it possible to create a Struct of a dictionary entry to pass in a forEach watching an Array of Keys (data inside the dictionary will never change and I am not looking to iterate or watch the dictionary).
My main goal is to reuse as much as possible but am willing to change what I have to get full benefit of SwiftUI. Obviously if I change the way I store my data almost everything will be useless. If there's a real benefit to converting my data to JSON or even start using something like CoreData I will.

If I'm understanding correctly, you are looking to
Take some user input
Transform that into keys that correspond to your data dictionary
Extract the data for the matching keys into some struct
Display a list of those structs using SwiftUI
Here is a simple implementation of those steps.
import SwiftUI
// A dictionary containing your data
let data: [String: Int] = [
"apples": 5,
"bananas": 3,
"cherries": 12
]
// A struct representing a match from your data
struct Item {
var name: String
var quantity: Int
}
// A view that displays the contents of your struct
struct RowView: View {
var item: Item
var body: some View {
Text("\(item.quantity) \(item.name)")
}
}
struct ContentView: View {
#State private var searchText: String = ""
func items(matching search: String) -> [Item] {
// 2 - split the user input into individual keys
let split = search.split(separator: " ", omittingEmptySubsequences: true).map { $0.lowercased() }
// 3 - turn any matching keys/values in your dictionary to a view model
return split.compactMap { name in
guard let quantity = data[name] else { return nil }
return Item(name: name, quantity: quantity)
}
}
var body: some View {
VStack {
// 1 - get user input
TextField("Search", text: $searchText)
.padding()
// 4 - display the matching values using ForEach (note that the id: \.name is important)
List {
ForEach(items(matching: searchText), id: \.name) { item in
RowView(item: item)
}
}
}
}
}
You'll see that as you type in the text field, if you enter any of the strings "apples", "bananas", or "cherries", a corresponding row will pop into your list.
Depending on the size of your list, and what kind of validation you are performing on your users search queries, you might need to be a little more careful about doing the filtering/searching in an efficient way (e.g. using Combine to only split and search after the user stops typing).

Related

Passing data to subview with Core Data and MVVM

I am using SwiftUI and Core Data with MVVM.
I have a ForEach loop and I want to pass the data to the subview. First I did this using a property like this:
#StateObject var viewModel = ListViewModel()
ForEach(viewModel.items) { item in
NavigationLink {
ItemDetailView() // empty view
} label: {
ItemListRowView(name: item.name!)
}
}
Then in the subview ListRowView would be something like:
let name: String
Text("\(name)")
And the view model where the ForEach is grabbing its data:
#Published var items: [ItemEntity] = []
#Published var name: String = ""
func getItems() {
let request = NSFetchRequest<ItemEntity>(entityName: "ItemEntity")
do {
items = try dataManager.context.fetch(request)
} catch let error {
print("\(error.localizedDescription)")
}
}
That works as expected but now I want to edit the data and pass more properties to the subviews. I think this means I need to use bindings and #ObservedObject in the subviews.
What I see commonly done is one would make a custom Item data type conforming to Identifiable protocol, for example:
struct Item: Identifiable {
let id: UUID
let name: String
}
And then they'd update their ForEach to use the Item type and do something like let items: [Item] = [] but I've already got let items: [ItemEntity] = [] with ItemEntity being the name of the Core Data Item entity.
What I suspect needs to happen is in my getItems method, items needs to be changed to use an Item data type. Is this correct? Or how should I go about this? I'm shiny new to Core Data and MVVM and any input will be super appreciated.
Edit: I've seen this done too but I'm not sure if it's what I'm looking for:
ForEach(viewModel.items.indicies) { index in
SubView(viewModel.items[index])
}
Couple of mistakes:
ForEach is a View, not a loop, if you attempt to use it with indices it will crash when you access an array by index in its closure. In the case of value types you need to supply the ForEach with an id which needs to be a property of the data that is a unique identifier. Or the data can implement Identifiable. However, in the case of objects like in Core Data, it will automatically use the object's pointer as its id, which works because the managed object context ensures there is only one instance of an object that represents a record. So what this all means is you can use ForEach with the array of objects.
We don't need MVVM in SwiftUI because the View struct is already the view model and the property wrappers make it behave like a view model object. Using #StateObject to implement a view model will cause some major issues because state is designed to be a source of truth whereas a traditional view model object is not. #StateObject is designed for when you need a reference type in an #State source of truth, i.e. doing something async with a lifetime you want to associate with something on screen.
The property wrapper for Core Data is #FetchRequest or #SectionedFetchRequest. If you create an app project in Xcode with core data checked the template will demonstrate how to use it.

Pull unique items & calculations from an array in SwiftUI

I'm trying to create a Metrics view in my SwiftUI app. I'm building this so I can track my poker sessions I play. Each Session model looks like this:
struct PokerSession: Hashable, Codable, Identifiable {
var id = UUID()
let location: String
let game: String
let stakes: String
let date: Date
let profit: Int
let notes: String
let imageName: String
let startTime: Date
let endTime: Date
In my Metrics View I would like to iterate through all of the sessions which are stored in an array of type: [PokerSession] that displays a List of all the unique locations and their corresponding profit totals. This is how my code looks right now, obviously not working because I'm getting duplicate locations:
List {
ForEach(viewModel.sessions) { location in
HStack {
Text(location.location)
Spacer()
Text("$500")
.bold()
.foregroundColor(.green)
}
}
.navigationBarTitle(Text("Profit by Location"))
}
Does anyone know how I can grab unique locations and calculate their total profit? Many thanks!
I'd define a new type to store your totals:
struct LocationProfit: Identifiable {
let id = UUID()
let location: String
let profit: Int
}
Then you can group all your sessions by location in a dictionary, transform the sessions into a sum of profits, then transform the location and profit totals into our LocationProfit structure.
let locationProfits = Dictionary(grouping: sessions) { element in
element.location
}.mapValues { sessionsGroupedByLocation -> Int in
sessionsGroupedByLocation
.map { $0.profit }
.reduce(0, +)
}.map { locationProfitPair in
LocationProfit(location: locationProfitPair.key, profit: locationProfitPair.value)
}
Just stuff the whole conversion into your viewModel and iterate over the locationProfits in your View.
You need to filter which will return a filtered array of your PokerSessions by location then you reduce the filtered array to get the sum of your profit like this:
viewModel.sessions.filter( { $0.location == location}).reduce(0) { $0 + $1.profit})
edit with use case assuming it is in USD:
Text("$\(viewModel.sessions.filter( { $0.location == location}).reduce(0) { $0 + $1.profit}))")
You could also turn it into a string by add .description to the end of it. If you only need to display the data to the user, and don't need it generally available to the app, this is the simplest way of doing it.
You can generate an array of unique locations like this:
Array(Set(viewModel.sessions.map { $0.location }))
Use this in your ForEach to iterate over the location strings.
EDIT
To calculate the total per location, you can simply query your existing data (as described in Yrb's answer):
viewModel.sessions.filter({ $0.location == location }).reduce(0) { $0 + $1.profit }
Since you already use a view model, I suggest to hide both the creation of the unique locations list and the total profit lookup inside the view model, which will make your UI code much cleaner and more readable.
Also, if you have a lot of PokerSession entries, I suggest to generate the data only once and cache it inside the view model, e.g. using a custom data model as described in Rob's answer (or even simpler by generating a dictionary which maps from location string to total profit). The good thing about abstracting the data access away inside the view model is that you can introduce the caching approach later, without changing the UI layer, since you will be able to keep the same access methods and just change their implementation.
My solution was borrowed from most of your responses, thank you for the feedback. Below is the final code that worked, was able to avoid creating a dictionary and instead relied on map and reduce.
List {
ForEach(viewModel.uniqueLocations, id: \.self) { location in
HStack {
Text(location)
Spacer()
Text("$" + "\(viewModel.sessions.filter({$0.location == location}).reduce(0) { $0 + $1.profit})")
.bold()
.foregroundColor(.green)
}
}
.navigationBarTitle(Text("Profit by Location"))
}

displays values of array in ForEach Swift UI

I am using SwiftUI and I want to simply display array values in a Text form.
Here is my code:
ForEach(0..<returnCont().count) {
Text("\(returnCont()[$0]),")
}
I also tried this:
ForEach(returnCont().indices) {
Text("\(return()[index]),")
}
Where returnCont() is a function returning an array.
The array displays elements that are initialised, but when the array is empty and then appended through user inputs, it only displays values in the terminal, but not in the Text form on the View.
No error is displayed either, just empty text.
Try something like below-:
import SwiftUI
class Model:ObservableObject{
var dataArray = ["1","2","3"]
func returnCont() -> Int{
return dataArray.count
}
}
struct myView:View{
#ObservedObject var model = Model()
var body: some View{
ForEach(0..<model.returnCont()) { index in
Text("\(model.dataArray[index]),")
}
}
}
I don’t know how your model class looks like, so this is just a demo to give you correct idea.

List not binding to filtered collection

This is my list...
List {
ForEach(viewModel.flight.flightEvents, id: \.id) { flightEvent in
...
}
}
The view model has this...
#Published var flight: Flight
Flight looks like this...
struct Flight: Identifiable {
let flightEvents: [FlightEvent]
let id = UUID()
}
FlightEvent...
struct FlightEvent: Identifiable {
let id = UUID()
let dateComponents: DateComponents
}
This code results in the items in the flightEvents array immediately binding to the list.
If, however, I change the collection bound to the List to this...
viewModel.flight.flightEvents.filter { $0.dateComponents.date! <= Date() }
... then the items never appear in the list - the filter is false for every item to begin with. I have a workaround where I copy the filtered flight events to a different array when the user refreshes and bind this array instead.
Is there a way to have SwiftUI continuously evaluate the filter expression? I think it's not binding because the base collection hasn't changed.
Is there a way to have SwiftUI continuously evaluate the filter expression?
Actually, no. That would cause unstoppable view redrawing, which is very undesirable. But you can set up timer (say for 1 sec, or better more) and in callback force
viewModel.objectWillCange.send()

Creating a dynamic List with section headers in SwiftUI?

I'm experimenting with SwiftUI and I'm trying to build a workout tracker app that I've already sketched in UIKit. I am trying to build an exercise list for the user to consult, so when the app starts I load some exercises in CoreData, and the Exercise has the following properties.
#NSManaged public var name: String
#NSManaged public var muscleGroup: String
#NSManaged public var exerciseDescription: String
#NSManaged public var type: String
#NSManaged public var id: UUID
When I'm building the list view, I retrieve an array of Exercise from CoreData and load them in a list. This work fine with a basic list, the thing is I would like to create a List with section headers in alphabetical order. In UIKit I did this by building a dictionary of the form [ "Prefix" : [Exercise]], and using the keys as section headers. This is practical because I could give the user sorting option just by changing the dictionary and reloading the data. In SwiftUI, I can't seem to make it work because I can't work on the fetch request object before view creation, and I can't iterate over dictionaries. Here is my code:
import SwiftUI
import CoreData
struct ExerciseListUIView: View {
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(
entity: Exercise.entity(),
sortDescriptors: [
NSSortDescriptor(keyPath: \Exercise.name, ascending: true)
]
) var exerciseList: FetchedResults<Exercise>
#State private var prefixList = [String]()
var body: some View {
return NavigationView {
VStack{
List(exerciseList, id: \.self) { exercise in
Text(exercise.name)
}
}
.navigationBarTitle("Exercises")
}
}
}
I tried a bunch of things but nothing seem to work. The most promising solution seems to store exercises directly in a different data structure, ExercisesByLetter(prefix: "String", exercises: [Exercise]), retrieve an array of [ExercisesByLetter] and iterate on that for building the list, but that would mean changing the way I store data, adding more work in the data storage functions and being forced to add different storages for each sorting option, like ExercisesByMuscleGroup, ExercisesByEquipment, and so on.
Let me know what you think,
Thanks.
For anyone interested in the solution, I came up with the following. I created a ViewModel for my main View to manipulate the data. In that view model I retrieve all the Exercise as an array of [Exercise] from CoreData, and store them in a property of the ViewModel. I created then an helper function to iterate through the list of [Exercise] and create an array of [ExercisesBy]. This type contains a property which stores the sorting criterion (first letter, muscle group, equipment, etc) and another property which stores the array of Exercise which adhere to that criterion.
This array of ExercisesBy is then iterated from my view to construct the sectioned list.

Resources