#EnvironmentObject property not working properly in swiftUI - swift

Updating cartArray from ViewModel doesn't append to the current elements, but adds object everytime freshly. I need to maintain cartArray as global array so that it can be accessed from any view of the project. I'm adding elements to cartArray from ViewModel. I took a separate class DataStorage which has objects that can be accessible through out the project
Example_AppApp.swift
import SwiftUI
#main
struct Example_AppApp: App {
var body: some Scene {
WindowGroup {
ContentView().environmentObject(DataStorage())
}
}
}
DataStorage.swift
import Foundation
class DataStorage: ObservableObject {
#Published var cartArray = [Book]()
}
ContentView.swift
import SwiftUI
struct ContentView: View {
#State var showSheetView = false
var body: some View {
NavigationView{
ListViewDisplay()
.navigationBarItems(trailing:
Button(action: {
self.showSheetView.toggle()
}) {
Image(systemName: "cart.circle.fill")
.font(Font.system(.title))
}
)
}.sheet(isPresented: $showSheetView) {
View3()
}
}
}
struct ListViewDisplay: View{
var book = [
Book(bookId: 1 ,bookName: "Catch-22"),
Book(bookId: 2 ,bookName: "Just-Shocking" ),
Book(bookId: 3 ,bookName: "Stephen King" ),
Book(bookId: 4,bookName: "A Gentleman in Moscow"),
]
var body: some View {
List(book, id: \.id) { book in
Text(book.bookName)
NavigationLink(destination: View1(book: book)) {
}
}
}
}
View1Modal.swift
import Foundation
struct Book: Codable, Identifiable {
var id:String{bookName}
var bookId : Int
var bookName: String
}
struct BookOption: Codable{
var name: String
var price: Int
}
View1ViewModel.swift
import Foundation
import Combine
class View1ViewModel : ObservableObject{
var dataStorage = DataStorage()
func addBook (bookId:Int ,bookName : String){
dataStorage.cartArray.append(Book(bookId:bookId, bookName: bookName)) // Adding to global array
print(dataStorage.cartArray)
}
}
View1.swift
import SwiftUI
struct View1: View {
#ObservedObject var vwModel = View1ViewModel()
#EnvironmentObject var datastrg: DataStorage
var book:Book
var body: some View {
Text(book.bookName).font(.title)
Spacer()
Button(action: {
vwModel.addBook(bookId: book.bookId, bookName: book.bookName)
}, label: {
Text("Add Book to Cart")
.frame(maxWidth: .infinity, minHeight: 60)
.background(Color.red)
.foregroundColor(Color.white)
.font(.custom("OpenSans-Bold", size: 24))
})
}
}
View3.swift
import SwiftUI
struct View3: View {
#EnvironmentObject var datastorage : DataStorage
var body: some View {
NavigationView {
List(datastorage.cartArray,id:\.id){book in
VStack{
Text(book.bookName)
.font(.custom("OpenSans-Bold", size: 20))
}
}
.navigationBarTitle(Text("Cart"), displayMode: .inline)
}
}
}
When addBook func is called for the first time it prints as
[Example_App.Book(bookId: 1, bookName: "Catch-22")]
When I go back and come back to this View1 and add another book by calling addBook func it adds as new object to cartArray
[Example_App.Book(bookId: 3, bookName: "Stephen King")]
Printing number of elements in cartArray gives as 1 element instead of 2 elements. When I go to View3 and display the Books in list, cartArray shows as empty(0 elements)
I think there is something wrong with var dataStorage = DataStorage() in ViewModel class. Everytime this is being created freshly, so the prevoius values are not stored. But I couldn't understand how to preserve its state
How to display List in View3 ? Any ideas/ suggestions will be helpful

You need to have one instance of DataStorage that gets passed around. Any time you write DataStorage() that creates a new instance.
.environmentObject will let you inject that one instance into the view hierarchy. Then, you can use the #EnvironmentObject property wrapper to access it within a View.
Inside View1, I used onAppear to set the dataStorage property on View1ViewModel -- that means that it has to be an optional on View1ViewModel since it will not be set in init. The reason I'm avoiding setting it in init is because an #EnvironmentObject is not set as of the init of the View -- it gets injected at render time.
#main
struct Example_AppApp: App {
var dataStorage = DataStorage()
var body: some Scene {
WindowGroup {
ContentView().environmentObject(dataStorage)
}
}
}
class DataStorage: ObservableObject {
#Published var cartArray = [Book]()
}
struct ContentView: View {
#State var showSheetView = false
var body: some View {
NavigationView{
ListViewDisplay()
.navigationBarItems(trailing:
Button(action: {
self.showSheetView.toggle()
}) {
Image(systemName: "cart.circle.fill")
.font(Font.system(.title))
}
)
}.sheet(isPresented: $showSheetView) {
View3()
}
}
}
struct ListViewDisplay: View {
var book = [
Book(bookId: 1 ,bookName: "Catch-22"),
Book(bookId: 2 ,bookName: "Just-Shocking" ),
Book(bookId: 3 ,bookName: "Stephen King" ),
Book(bookId: 4,bookName: "A Gentleman in Moscow"),
]
var body: some View {
List(book, id: \.id) { book in
Text(book.bookName)
NavigationLink(destination: View1(book: book)) {
}
}
}
}
struct Book: Codable, Identifiable {
var id:String{bookName}
var bookId : Int
var bookName: String
}
struct BookOption: Codable{
var name: String
var price: Int
}
class View1ViewModel : ObservableObject{
var dataStorage : DataStorage?
func addBook (bookId:Int ,bookName : String) {
guard let dataStorage = dataStorage else {
fatalError("DataStorage not set")
}
dataStorage.cartArray.append(Book(bookId:bookId, bookName: bookName)) // Adding to global array
print(dataStorage.cartArray)
}
}
struct View1: View {
#ObservedObject var vwModel = View1ViewModel()
#EnvironmentObject var datastrg: DataStorage
var book:Book
var body: some View {
Text(book.bookName).font(.title)
Spacer()
Button(action: {
vwModel.addBook(bookId: book.bookId, bookName: book.bookName)
}, label: {
Text("Add Book to Cart")
.frame(maxWidth: .infinity, minHeight: 60)
.background(Color.red)
.foregroundColor(Color.white)
.font(.custom("OpenSans-Bold", size: 24))
})
.onAppear {
vwModel.dataStorage = datastrg
}
}
}
struct View3: View {
#EnvironmentObject var datastorage : DataStorage
var body: some View {
NavigationView {
List(datastorage.cartArray,id:\.id){book in
VStack{
Text(book.bookName)
.font(.custom("OpenSans-Bold", size: 20))
}
}
.navigationBarTitle(Text("Cart"), displayMode: .inline)
}
}
}

You are not calling your function addBook anywhere, add an onappear to your view3 calling the function and your list will populate with data.

Related

SwiftUI: Pass an ObservableObject's property into another class's initializer

How do I pass a property from an ObservedObject in a View, to another class's initializer in the same View? I get an error with my ObservedObject:
Cannot use instance member 'project' within property initializer; property initializers run before 'self' is available
The reason I want to do this is I have a class which has properties that depend on a value from the ObservedObject.
For example, I have an ObservedObject called project. I want to use the property, project.totalWordsWritten, to change the session class's property, session.totalWordCountWithSession:
struct SessionView: View {
#Binding var isPresented: Bool
#ObservedObject var project: Project
// How to pass in project.totalWordsWritten from ObservedObject project to totalWordCount?
#StateObject var session:Session = Session(startDate: Date(), sessionWordCount: 300, totalWordCount: 4000)
var body: some View {
NavigationView {
VStack(alignment: .leading) {
Form {
Section {
Text("Count")
HStack {
Text("Session word count")
TextField("", value: $session.sessionWordCount, formatter: NumberFormatter())
.textFieldStyle(.roundedBorder)
}
HStack {
// Changing text field here should change the session count above
Text("Total word count")
TextField("", value: $session.totalWordCountWithSession, formatter: NumberFormatter())
.textFieldStyle(.roundedBorder)
}
}
}
}.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
// Save this session into the project
project.addSession(newSession: session)
isPresented = false
}
}
}
}
}
}
struct SessionView_Previews: PreviewProvider {
static var previews: some View {
SessionView(isPresented: .constant(true), project: Project(title: "TestProject", startWordCount: 0))
}
}
Below is the rest of the example:
HomeView.swift
import SwiftUI
struct HomeView: View {
#State private var showingSessionPopover:Bool = false
#StateObject var projectItem:Project = Project(title: "Test Project", startWordCount: 4000)
var body: some View {
NavigationView {
VStack(alignment: .leading) {
Text(projectItem.title).font(Font.custom("OpenSans-Regular", size: 18))
.fontWeight(.bold)
Text("Count today: \(projectItem.wordsWrittenToday)")
Text("Total: \(projectItem.totalWordsWritten)")
}
.toolbar {
ToolbarItem {
Button(action: {
showingSessionPopover = true
}, label: {
Image(systemName: "calendar").imageScale(.large)
}
)
}
}
}.popover(isPresented: $showingSessionPopover) {
SessionView(isPresented: $showingSessionPopover, project: projectItem)
}
}
}
Session.swift:
import Foundation
import SwiftUI
class Session: Identifiable, ObservableObject {
init(startDate:Date, sessionWordCount:Int, totalWordCount: Int) {
self.startDate = startDate
self.endDate = Calendar.current.date(byAdding: .minute, value: 30, to: startDate) ?? Date()
self.sessionWordCount = sessionWordCount
self.totalWordCount = totalWordCount
self.totalWordCountWithSession = self.totalWordCount + sessionWordCount
}
var id: UUID = UUID()
#Published var startDate:Date
#Published var endDate:Date
var totalWordCount: Int
var sessionWordCount:Int
#Published var totalWordCountWithSession:Int {
didSet {
sessionWordCount = totalWordCountWithSession - totalWordCount
}
}
}
Project.swift
import SwiftUI
class Project: Identifiable, ObservableObject {
var id: UUID = UUID()
#Published var title:String
var sessions:[Session] = []
#Published var wordsWrittenToday:Int = 0
#Published var totalWordsWritten:Int = 0
#Published var startWordCount:Int
init(title:String,startWordCount:Int) {
self.title = title
self.startWordCount = startWordCount
self.calculateDailyAndTotalWritten()
}
// Create a new session
func addSession(newSession:Session) {
sessions.append(newSession)
calculateDailyAndTotalWritten()
}
// Re-calculate how many
// today and in total for the project
func calculateDailyAndTotalWritten() {
wordsWrittenToday = 0
totalWordsWritten = startWordCount
for session in sessions {
if (Calendar.current.isDateInToday(session.startDate)) {
wordsWrittenToday += session.sessionWordCount
}
totalWordsWritten += session.sessionWordCount
}
}
}
You can use the StateObject initializer in init:
struct SessionView: View {
#Binding var isPresented: Bool
#ObservedObject var project: Project
#StateObject var session:Session = Session(startDate: Date(), sessionWordCount: 300, totalWordCount: 4000)
init(isPresented: Binding<Bool>, project: Project, session: Session) {
_isPresented = isPresented
_session = StateObject(wrappedValue: Session(startDate: Date(), sessionWordCount: 300, totalWordCount: project.totalWordsWritten))
self.project = project
}
var body: some View {
Text("Hello, world")
}
}
Note that the documentation says:
You don’t call this initializer directly
But, it has been confirmed by SwiftUI engineers in WWDC labs that this is a legitimate technique. What runs in wrappedValue is an autoclosure and only runs on the first init of StateObject, so you don't have to be concerned that every time your View updates that it will run.
In general, though, it's a good idea to try to avoid doing things in the View's init. You could consider instead, for example, using something like task or onAppear to set the value and just put a placeholder value in at first.

NavigationLink destination immediately disappear after loading

I'm quite new to Swift and I'm struggling with this implementation. I moved my code to playground so it's easier for you to debug if you copy/paste on your Xcode.
Basically once loading View3 the view immediately disappear and you are pushed back to View2.
I identify the issue to be with the categories array on the View2. If I separate the 3 categories in 3 NavigationLink (instead of looping) the code works just fine.
I just don't understand why this doesn't work and I'd love if you help me find out what's wrong with this implementation?
import UIKit
import Foundation
import SwiftUI
import PlaygroundSupport
struct Article: Identifiable {
var id = UUID()
var title : String
}
struct Category: Identifiable {
var id = UUID()
var name : String
}
class DataManager: ObservableObject{
#Published var articles : [Article] = []
func getArticles(){
let article = Article(title: "Just a test")
if !articles.contains(where: {$0.title == article.title}) {
articles.append(article)
}
}
func clearArticles(){
articles.removeAll()
}
}
struct View1: View{
#StateObject var dataManager = DataManager()
var body: some View {
NavigationView{
List(){
NavigationLink(destination: View2(), label: { Text("View 2") })
}
.navigationBarTitle("View 1")
.listStyle(.insetGrouped)
}
.navigationViewStyle(StackNavigationViewStyle())
.environmentObject(dataManager)
}
}
struct View2: View{
let categories = [
Category(name: "Category 1"),
Category(name: "Category 2"),
Category(name: "Category 3")
]
var body: some View {
List(categories){ category in
NavigationLink(destination: View3(category: category), label: { Text(category.name) })
}
.navigationBarTitle("View 2")
.navigationBarTitleDisplayMode(.inline)
}
}
struct View3: View{
#EnvironmentObject var dataManager: DataManager
let category: Category
var body: some View {
List(dataManager.articles) { article in
HStack {
Text(article.title)
}
}.task{
dataManager.getArticles()
}.onDisappear(){
dataManager.clearArticles()
}
.navigationTitle("View 3")
.navigationBarTitleDisplayMode(.inline)
}
}
PlaygroundPage.current.setLiveView(View1())
This is the resulting behaviour:
The Category id is not stable so every time the View2 is init, a new array with different ids is created so the ForEach is creating new NavigationLinks which are not active so the previously active one is gone and it reacts by popping it off the nav stack, try this:
struct Category: Identifiable {
var id: String {
name
}
var name : String
}
But it would be better if you put them in the data store object so you aren't creating them every time View2 is init.

Load different data by ID in the same View

I want to load different data in the same View using an ObservableObject with an Index.
As you can see in the simple demo example attached when I go through the views changing the id in order to load different data it works.
But when in any point I change another data from another observable object (in this case AnotherObservable) then my view breaks and any data is found at all.
Navigation works...
Once I press Change Title button...
To test de issue just simple copy and paste the code and navigate through the data with the <- -> and then press Change title button
class DemoItemsLoader: ObservableObject {
#Published var items = [1, 2, 3, 4]
}
class DemoArticleLoader: ObservableObject {
#Published var items = [1 : "One", 2 : "Two" , 3 : "Three", 4 : "Four"]
#Published var realData: String?
func loadItemForID(id: Int) {
realData = items[id] ?? "Not found"
}
}
class AnotherObservable: ObservableObject {
#Published var title: String = "Title"
}
struct Class1: View {
#StateObject var anotherObservable = AnotherObservable()
#StateObject var itemsLoader = DemoItemsLoader()
var body: some View {
NavigationView{
VStack {
ForEach(itemsLoader.items, id: \.self) { item in
NavigationLink(
destination: Class2(anotherObservable: anotherObservable, articleLoader: DemoArticleLoader(), id: item)) {
Text("\(item)")
}
}
}
}
}
}
struct Class2: View {
#ObservedObject var anotherObservable: AnotherObservable
#ObservedObject var articleLoader: DemoArticleLoader
#State var id: Int
var body: some View {
VStack {
Text(anotherObservable.title)
Text(articleLoader.realData ?? "Not found")
HStack {
Spacer()
Button(action: {
if id > 1 {
id = id - 1
articleLoader.loadItemForID(id: id)
}
}) {
Text("<-")
}
Button(action: {
if id < articleLoader.items.count {
id = id + 1
articleLoader.loadItemForID(id: id)
}
}) {
Text("->")
}
Button(action: {
anotherObservable.title = "Another Title"
}) {
Text("Change title")
}
Spacer()
}
}
.onAppear { articleLoader.loadItemForID(id: id)}
}
}
The issue is that you're calling loadItemForID in onAppear only:
.onAppear { articleLoader.loadItemForID(id: id)}
And when you change title, the above function is not called again.
The easiest solution is probably to store DemoArticleLoader() as a #StateObject, so it's not recreated every time:
struct Class1: View {
#StateObject var anotherObservable = AnotherObservable()
#StateObject var itemsLoader = DemoItemsLoader()
#StateObject var articleLoader = DemoArticleLoader() // create once
var body: some View {
NavigationView {
VStack {
ForEach(itemsLoader.items, id: \.self) { item in
NavigationLink(
// pass here
destination: Class2(anotherObservable: anotherObservable, articleLoader: articleLoader, id: item)) {
Text("\(item)")
}
}
}
}
}
}

Is there a way to carry a variable/bool up and into a View's SuperView?

Say I were to have the follow sudocode as a View named Item
Item{
Rectangle()
.frame(width: 100, height: 50)
.onTapGesture{
isTapped.toggle()
}
}
And there were multiple Items in it's superview, Content
Content{
VStack{
Item()
Item()
Item()
}
}
Would there be a way for me to relay the variable/bool isTapped from Item, up and into it's superview with it still being Item specific? (So I know which Item has what isTapped value) so that for example, this would be possible?...
Content{
VStack{
Item().padding(.bottom, Item.isTapped ? 20 : 0)
Item().padding(.bottom, Item.isTapped ? 20 : 0)
Item().padding(.bottom, Item.isTapped ? 20 : 0)
}
}
edit: A key detail of note is that the number of Items would be generated dynamically by the user, so I can't for instance make a variable for each item
Declare the variable as #State in the parent and pass it into the child as #Binding.
A possible solution is:
First define a "Source of truth" Model
import SwiftUI
import Combine
import Foundation
enum itemType{
case itemTypeA
case itemTypeB
case itemTypeC
}
struct ItemModel: Identifiable {
var id: Int
var type: itemType
var isTapped: Bool = false
}
class ObserverModel: ObservableObject {
var didChange = PassthroughSubject<Void, Never>()
#Published var itemA: ItemModel = ItemModel(id: 1, type: .itemTypeA)
#Published var itemB: ItemModel = ItemModel(id: 2, type: .itemTypeB)
#Published var itemC: ItemModel = ItemModel(id: 3, type: .itemTypeC)
func toggleItem(itemType: itemType) {
switch itemType {
case .itemTypeA:
itemA.isTapped.toggle()
case .itemTypeB:
itemB.isTapped.toggle()
case .itemTypeC:
itemC.isTapped.toggle()
}
}
}
Your single ItemView should look like this:
import SwiftUI
struct ItemView: View {
#EnvironmentObject var observer: ObserverModel
#Binding var itemBinding: ItemModel
#State private var toggleColor = Color.red
var body: some View {
Rectangle()
.fill(toggleColor)
.frame(width: 100, height: 50)
.onTapGesture{
self.observer.toggleItem(itemType: self.itemBinding.type)
self.toggleColor = self.itemBinding.isTapped ? Color.green : Color.red
print("Item Bool: \(self.itemBinding.isTapped)")
}
}
}
struct ItemView_Previews: PreviewProvider {
static var previews: some View {
ItemView(itemBinding: .constant(ItemModel(id: 1, type: .itemTypeA))).environmentObject(ObserverModel())
}
}
Finally you put it all together:
import SwiftUI
struct ItemsView: View {
#EnvironmentObject var observer: ObserverModel
var body: some View {
HStack {
ItemView(itemBinding: $observer.itemA)
ItemView(itemBinding: $observer.itemB)
ItemView(itemBinding: $observer.itemC)
}
}
}
struct ItemsView_Previews: PreviewProvider {
static var previews: some View {
ItemsView().environmentObject(ObserverModel())
}
}
I uploaded this to github: https://github.com/ppoh71/SwiftUIButtonTest
(Built with GM2)
And I would recommend this video about the data flow in SwiftUI.
this should explain everything: https://developer.apple.com/videos/play/wwdc2019/226/

Passing filtered #Bindable objects to multiple views in SwiftUI

I’m trying to pass a filter array to multiple views, but the filtering is not working. If I remove the filter, you can pass the array to the next view, but that leads to another error during the ForEach loop. I've posted all the code below.
Does anyone know how you can pass a filter version of a #Bindable array? Also why can't I print sport.name and sport.isFavorite.description in the ForEach loop?
I’m using swiftUI on Xcode 11.0 beta 5.
import SwiftUI
import Combine
struct Sport: Identifiable{
var id = UUID()
var name : String
var isFavorite = false
}
final class SportData: ObservableObject {
#Published var store =
[
Sport(name: "soccer", isFavorite: false),
Sport(name: "tennis", isFavorite: false),
Sport(name: "swimming", isFavorite: true),
Sport(name: "running", isFavorite: true)
]
}
struct Testing: View {
#ObservedObject var sports = SportData()
var body: some View {
VStack {
TestingTwo(sports: $sports.store.filter({$0.isFavorite}))
}
}
}
struct TestingTwo: View {
#Binding var sports : [Sport]
var body: some View {t
NavigationView {
VStack(spacing: 10){
ForEach($sports) { sport in
NavigationLink(destination: TestingThree(sport: sport)){
HStack {
Text(sport.name)
Spacer()
Text(sport.isFavorite.description)
}
.padding(.horizontal)
.frame(width: 200, height: 50)
.background(Color.blue)
}
}
}
}
}
}
struct TestingThree: View {
#Binding var sport : Sport
var body: some View {
VStack {
Text(sport.isFavorite.description)
.onTapGesture {
self.sport.isFavorite.toggle()
}
}
}
}
#if DEBUG
struct Testing_Previews: PreviewProvider {
static var previews: some View {
Testing()
}
}
#endif
Filtering in your case might be better placed in the navigation view, due to your binding requirements.
struct Testing: View {
#ObservedObject var sports = SportData()
var body: some View {
VStack {
TestingTwo(sports: $sports.store)
}
}
}
struct TestingTwo: View {
#Binding var sports : [Sport]
#State var onlyFavorites = false
var body: some View {t
NavigationView {
VStack(spacing: 10){
ForEach($sports) { sport in
if !self.onlyFavorites || sport.value.isFavorite {
NavigationLink(destination: TestingThree(sport: sport)){
HStack {
Text(sport.value.name)
Spacer()
Text(sport.value.isFavorite.description)
}
.padding(.horizontal)
.frame(width: 200, height: 50)
.background(Color.blue)
}
}
}
}
}
}
}
Now you can switch the isFavorite state either within the action implementation of a button, or while specifying the integration of you TestingTwo view.
struct Testing: View {
#ObservedObject var sports = SportData()
var body: some View {
VStack {
TestingTwo(sports: $sports.store, onlyFavorites: true)
}
}
}
Regarding the second part of your question: Note the value addendum in the ForEach loop. You're dealing with as binding here (as ForEach($sports) indicates), hence sport is not an instance of Sport.
You can't get a #Binding from a computed property, since the computed property is computed dynamically. A typical way to avoid this is to pass in ids of the sports objects and the data store itself, whereby you can access the sports items via id from the store.
If you really want to pass a #Binding in you have to remove the filter (pass in an actually backed array) and modfy the ForEach like the following:
ForEach($sports.store) { (sport: Binding<Sport>) in