I have a multilevel model like:
class TaskDto : Identifiable, Equatable, ObservableObject {
var id: String
#Published var fileName:String
#Published var name: String
.... more stuff
#Published var parameters : JobParametersDto }
And
class JobParametersDto : Identifiable, Equatable, ObservableObject {
var id: String
#Published var blobs: [BlobRequestDto]
#Published var commands:[CommandDto]
#Published var mainTask: String
}
And so on, several embedded levels.
There is a number of views, each for one structure
struct BlobView: View {
#ObservedObject var taskDto: TaskDto
static let blobNames = [
"Product",
"Id",
"Name",
"Version",
"Limit",
"Unzip"]
let columns = Array(repeating: GridItem(.flexible(minimum: 100)), count: filterNames.count)
#State private var scrollViewContentSize: CGSize = .zero
var body: some View {
GridView(title: "Blob Names")
{
LazyVGrid(columns: columns) {
ForEach (Self.blobNames, id: \.self) {
blobName in
Text("\(blobName)").bold()
}
ForEach($taskDto.parameters.blobs ) {
blob in
if(! blob.Removed.wrappedValue) {
TextField("", text: f blob.BlobId)
TextField("", text: blob.BlobName)
TextField("", text: blob.BlobVersion)
Toggle("", isOn: filter.Unzip)
}
}
} //grid
}
}
The problem is when any field in the model changes the views do not reflect the change. For example I have a Toggle at the end of the View code. It does not update when I click on it until I click on another field.
Obviously this arrangement is not very valid. What is the right way to organize a model with embedded models in SiwftUI ?
In Swift and SwiftUI we use structs for the model types. This solves alot of the consistency issues we had with objects in ObjC. See Choosing Between Structures and Classes (Apple Developer Documentation)
We usually use a single object to persist or sync these model structs, usually its an environment object, a singleton, there are 2 singletons: one shared and one preview.
Related
I have an issue with SwiftUI navigation. To show the issue I made a simple example of a list of cars and if the user clicks on it, it shows the car details:
struct ContentView: View {
var cars = [Car(name: "A"), Car(name: "B"), Car(name: "C"), Car(name: "D")]
var body: some View {
NavigationView {
LazyVStack(spacing: 10) {
ForEach(cars, id: \.self) { car in
NavigationLink(destination: {
CarDetailsView(viewModel: CarDetailsViewModel(car: car))
},
label: {
CarRowView(car: car)
})
}
}
}
}
}
struct CarDetailsView: View {
#StateObject var viewModel: CarDetailsViewModel
#Environment(\.presentationMode) private var presentationMode
var body: some View {
Button("Back") {
presentationMode.wrappedValue.dismiss()
}
}
}
class CarDetailsViewModel: ObservableObject {
#Published var car: Car
init(car: Car) {
self.car = car
}
}
struct Car: Hashable {
var name: String
}
struct CarRowView: View {
var car: Car
var body: some View {
Text(car.name)
}
}
This works well when you select one car at the time. Unfortunatly with swiftUI I cannot disabled multi selection. If the user select multiple cars at the same time, and then go back to the car list sometime I get an error log:
SwiftUI encountered an issue when pushing aNavigationLink. Please file a bug.
At that moment, the car list is no longer responsive. It takes few attempt but it does eventually happen. I can only replicate this on iOS 15 so far. I also tried to do this without the viewModel and it still happen.
Ideally I want to keep the NavigationLink inside the VStack because it makes the row dimming when the user selects it.
I can reproduce your issue, and I think this is a bug.
However using the following NavigationLink setup with selection and tag seems to works for me.
struct ContentView: View {
#State var cars = [Car(name: "A"), Car(name: "B"), Car(name: "C"), Car(name: "D")]
#State private var showCar: UUID?
var body: some View {
NavigationView {
LazyVStack(spacing: 30) {
ForEach(cars) { car in
NavigationLink(
destination: CarDetailsView(car: car),
tag: car.id,
selection: $showCar,
label: { CarRowView(car: car) }
)
}
}
}
}
}
struct CarDetailsView: View {
#Environment(\.dismiss) private var dismiss
#State var car: Car
var body: some View {
Button("Back \(car.name)") {
dismiss()
}
}
}
struct Car: Identifiable, Hashable {
let id = UUID()
var name: String
}
struct CarRowView: View {
var car: Car
var body: some View {
Text(car.name)
}
}
I have a LazyVGrid list which need to be updated whenever a data is updated. The data is in a singleton class.
class BluetoothManager: NSObject {
static let shared = BluetoothManager()
#objc dynamic private(set) var stations: [BHStation] = []
}
And the list is
var body: some View {
let columns: [GridItem] = Array(repeating: .init(.flexible()), count: UIDevice.current.userInterfaceIdiom == .pad ? 2 : 1)
ScrollView {
LazyVGrid(columns: columns, alignment: .center, spacing: 10, pinnedViews: [], content: {
ForEach(BluetoothManager.shared.stations, id: \.peripheral.identifier) { item in
NavigationLink(destination: DetailView()) {
MainCell()
}
}
})
}
}
I tried to use ObservableObject with #Published, or to use #State/#Binding but none of them worked.
How can I make the list get updated whenever stations is get updated? #objc dynamic is necessary to be used in the other UIKit classes.
In order for your SwiftUI View to know to update, you should use an ObservableObject with a #Published property and store it as a #ObservedObject or #StateObject:
class BluetoothManager: NSObject, ObservableObject {
static let shared = BluetoothManager()
private override init() { }
#Published var stations: [BHStation] = []
}
struct ContentView : View {
#ObservedObject private var manager = BluetoothManager.shared
var body: some View {
let columns: [GridItem] = Array(repeating: .init(.flexible()), count: UIDevice.current.userInterfaceIdiom == .pad ? 2 : 1)
ScrollView {
LazyVGrid(columns: columns, alignment: .center, spacing: 10, pinnedViews: [], content: {
ForEach(manager.stations, id: \.peripheral.identifier) { item in //<-- Here
NavigationLink(destination: DetailView()) {
MainCell()
}
}
})
}
}
}
The #Published may interfere with your #objc requirement, but since you haven't given any information on how you use it in UIKit or why it's necessary, it's not possible to say directly what the fix is. You may need a different setter to use when interacting with it in UIKit.
Also, be aware that #Published won't update as expected unless the model type it is storing (BHStation) is a struct. If it's a class, you may need to call objectWillChange directly to get the #Published publisher to trigger correctly.
If I have a class:
VehicleListViewModel
class VehicleListViewModel: ObservableObject {
#Published var CarCategoryViewModel = VehicleCategoryViewModel()
#Published var VanCategoryViewModel = VehicleCategoryViewModel()
#Published var BicycleCategoryViewModel = VehicleCategoryViewModel()
...
}
VehicleCategoryViewModel
class VehicleCategoryViewModel: ObservableObject {
#Published var VehicleCellViewModels = [VehicleCellViewModel]()
}
VehicleCellViewModel
class VehicleCellViewModel: ObservableObject, Identifiable {
var id = UUID().uuidString
#Published var vehicle: Vehicle
#Published var backgroundColor = ""
}
Vehicle
struct Vehicle: Identifiable, Decodable, Encodable {
#DocumentID var id: String?
var name: String
var userHas: Bool
}
and several views which use each of those three view models, created in a parent view:
ContentView
#ObservedObject var vehicleListVM = VehicleListViewModel()
ScrollView {
VehicleCategoryView(VehicleCategoryVM: $vehicleListVM.CarCategoryViewModel)
VehicleCategoryView(VehicleCategoryVM: $vehicleListVM.VanCategoryViewModel)
VehicleCategoryView(VehicleCategoryVM: $vehicleListVM.BicycleCategoryViewModel)
...
}
VehicleCategoryView
#Binding var VehicleCategoryVM : VehicleCategoryViewModel
...
If a change is made to one of the bindings in VehicleCategoryView this should propagate to VehicleListViewModel and cause the #ObservedObject var vehicleListVM to refresh the view in ScrollView that was changed. But, would a change to lets say CarCategoryViewModel cause a refresh of all three views inside ScrollView?
If so, how could I prevent this so that each data is independent and doesn't cause refreshes to the others.
Thanks in advance!
You don't need binding in this case, just pass sub-model by reference
ScrollView {
VehicleCategoryView(VehicleCategoryVM: vehicleListVM.CarCategoryViewModel)
// .. other code
and use inside
#ObservedObject var VehicleCategoryVM : VehicleCategoryViewModel
in this case parent view will be rebuilt only if sub-model object will be recreated, and not if just internal properties changed, but corresponding subview will be updated.
I have a model which, for the sake of example, is a tag cloud and a model for items:
struct TagCloud: Identifiable, Hashable{
var id: Int
let tag: String
static func tagCloud() -> [TagCloud]{
return [ TagCloud(id: 01, tag:"Green"),
TagCloud(id: 02, tag:"Blue"),
TagCloud(id: 03, tag:"Red")]
}
}
struct TaggedItem: Identifiable, Hashable{
var id: Int
let tag: String
let item: String
static func taggedItems() -> [TaggedItem]{
return [ TaggedItem(id: 01, tag:"Green", item: "Tree"),
TaggedItem(id: 02, tag:"Blue", item: "Sky"),
TaggedItem(id: 03, tag:"Red", item: "Apple")...]
}
}
I have a class to 'contain' the currently selected items:
class SelectedItems: ObservableObject {
#Published var currentlySelectedItems:[TaggedItem] = []
func changeData(forTag tag: String ){
let currentSelection = TaggedItem. taggedItems()
let filteredList = cardCollection.filter { $0.tag == tag }
currentlySelectedItems = filteredList
}
}
In my parent view, I select one of the tags from the cloud:
struct ParentView: View {
let tagCloud = TagCloud.tagCloud()
#ObservedObject var currentSelection : TaggedItem = TaggedItem()
#State var navigationTag:Int? = nil
var body: some View {
NavigationLink(destination: ChildView(), tag: 1, selection: $tag) {
EmptyView()
}
VStack{
ScrollView(.horizontal, content: {
HStack(spacing: 20){
ForEach( self.tagCloud, id: \.self) { item in
VStack{
Button(action: {
self. currentSelection.changeData(forTag: item.tag )
self.navigationTag = 1
}){
Text(item.tag)
}.buttonStyle(PlainButtonStyle())
..
}
The child view contains the ObservedObject. As a side note, I have also set this as an EnvironmentObject.
struct ChildView: View {
#ObservedObject var currentItems : SelectedItems = SelectedItems()
var body: some View {
VStack{
ScrollView(.horizontal, content: {
HStack(spacing: 20){
ForEach( currentItems.currentlySelectedItems, id: \.self) { item in
...
The problem:
In the parent view, the button calls SelectedItems in order to create a filtered list of items that match the selected tag
The call is made and the list is set (verified by print)
However, the data never "reaches" the child view
I tried a number of things with init(). I tried passing the selected tag to the child, and then doing the filtering within init() itself, but was stymied by the need to bind to a class. I read elsewhere to try having a second model which would be updated by the FIRST model in order to trigger a view refresh.
What is the correct way to create this filtered list via a parent view selection and make the list available in the child view?
This part should be rethink:
let tagCloud = TagCloud.tagCloud()
#ObservedObject var currentSelection : TaggedItem = TaggedItem()
^^ is a struct, it cannot be observed !!!
#ObservedObject must be applied only to #ObservableObject as you did for SelectedItems, so probably another ObservableObject view model wrapper around TaggedItem is expected here.
I'd like to update an UI element on an overview view when data on another view is changed.
I looked into #EnvironmentalObject and #Binding. However, an update to either object does not appear to force a view reload. Only changes to #State force renders.
Also, in the case described below, the ChangeView is not a child of OverviewView. Therefore #Binding is not an option anyway.
Data.swift
struct ExampleData : Hashable {
var id: UUID
var name: String
}
var globalVar: ExampleData = ExampleData(id: UUID(), name:"")
OverviewView.swift
struct OverviewView: View {
#State private var data: ExampleData = globalVar
var body: some View {
Text(data.name)
}
}
ChangeView.swift
struct ChangeView: View {
#State private var data: ExampleData = globalVar
var body: some View {
TextField("Name", text: $data.name, onEditingChanged: { _ in
globalVar = data }, onCommit: { globalVar = data })
}
}
Changes within the ChangeView TextField will update the globalVar. However, this will not update the Text on the OverviewView when switching back to the view.
I am aware that using global variables is "ugly" coding. How do I handle data that will be used in a multitude of unrelated views?
Please advise on how to better handle such a situation.
OverviewView and ChangeView hold different copies of the ExampleData struct in their data variables (When assigning a struct to another variable, you're effectively copying it instead of referencing it like an object.) so changing one won't affect the other.
#EnvironmentObject suits your requirements.
Here's an example:
Since, we're using #EnvironmentObject, you need to either convert ExampleData to
a class, or use a class to store it. I'll use the latter.
class ExampleDataHolder: ObservableObject {
#Published var data: ExampleData = ExampleData(id: UUID(), name:"")
}
struct CommonAncestorOfTheViews: View {
var body: some View {
CommonAncestorView()
.environmentObject(ExampleDataHolder())
}
}
struct OverviewView: View {
#EnvironmentObject var dataHolder: ExampleDataHolder
var body: some View {
Text(dataHolder.data.name)
}
}
struct ChangeView: View {
#EnvironmentObject var dataHolder: ExampleDataHolder
var body: some View {
TextField("Name", text: $dataHolder.data.name)
}
}