SwiftUI Observable object doesn't update UI on first attempt - swiftui

Issue
The #ObservableObject ViewModel only updates the UI after closing and re-opening the view. Once this update has happened, every subsequent change in the #Published var inside the #ObservableObject triggers an automatic update.
Context
I have a model called 'Stack' in my project.
struct Stack: Identifiable, Equatable {
var id = UUID()
var title: String
var isPinned = false
}
I have a view (NavigationMenuView) which presents all of the stacks in a scroll view
import SwiftUI
struct NavigationMenuView: View {
#ObservedObject var viewModel = NavigationMenuViewModel()
#EnvironmentObject var environmentObjects: EnvironmentObjects
var body: some View {
ZStack{
NavigationView {
VStack(alignment: .leading){
ScrollView {
ForEach(viewModel.stacks){ stack in
StackRow(
stack: stack,
lftButtonAction: {
self.viewModel.toggleIsPinned(for: stack)
},
rhtButtonAction: {
self.viewModel.deleteStack(stack: stack)
}
)
}
}
}
.navigationBarTitle(Text("Home"))
.navigationBarItems(trailing: addNewStackButton)
.background(Color(UIColor.systemGray6).edgesIgnoringSafeArea(.all))
}.zIndex(1)
if environmentObjects.newStackViewIsShown {
VStack{
Spacer()
NewStackView(viewModel: NewStackViewModel())
}
.onDisappear{
self.viewModel.fetchStacks()
}
.zIndex(2)
}
}
.onAppear{
self.viewModel.fetchStacks()
}
}
}
The view model for this view has a #Published var stacks = [Stack]() array which gets updates by fetchStacks().
The NavigationMenuView has a subview (NewStackView) which creates a new 'stack' (using the ViewModel) using this button:
Button(action: {
if !self.isAddButtonDisabled{
self.viewModel.addNewStack(title: self.title)
self.environmentObjects.newStackViewIsShown = false
}
}) {
Text("Add")
.padding(.vertical, 16.0)
.frame(minWidth: 0, maxWidth: .infinity)
.foregroundColor(Color(UIColor.systemBackground))
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.foregroundColor(addButtonColor)
)
}
.disabled(isAddButtonDisabled)
My own progress
I have found out that this only occurs when the NewStackView is presented as a subview of the NavigationMenuView. When presenting it as a modal view using .sheet() the NewStackView updates straight away.

Related

Dismissing view using ObservableObject class provokes Publishing changes from within view updates is not allowed

When dismissing a fullScreenCover using a variable inside an ObservableObject (lines commented with 1.-) it shows the "Publishing changes from within view updates is not allowed, this will cause undefined behavior." message in the console, but using a #State variable (lines commented with 2.-) does not show the warning. I do not understand why.
Here is the code:
import SwiftUI
final class DismissWarningVM: ObservableObject {
#Published var showAnotherView = false
}
struct DismissWarningView: View {
#StateObject private var dismissWarningVM = DismissWarningVM()
#State private var showAnotherView = false
var body: some View {
VStack {
HStack {
Spacer()
Button {
// 1.- This line provokes the warning
dismissWarningVM.showAnotherView = true
// 2.- This line DO NOT provokes the warning
//showAnotherView = true
} label: {
Text("Show")
}
}
.padding(.trailing, 20)
Spacer()
Text("Main view")
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.white)
// 1.- This line provokes the warning
.fullScreenCover(isPresented: $dismissWarningVM.showAnotherView) {
// 2.- This line DO NOT provokes the warning
//.fullScreenCover(isPresented: $showAnotherView) {
AnotherView()
}
}
}
struct AnotherView: View {
#Environment(\.dismiss) var dismiss
var body: some View {
VStack(spacing: 30) {
Text("Another view")
Button {
dismiss()
} label: {
Text("Dismiss")
.foregroundColor(.red)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
}
}
struct DismissWarningView_Previews: PreviewProvider {
static var previews: some View {
DismissWarningView()
}
}
Fixed. It was a problem of the XCode version. I was trying with 14.0.1 version but after updating to 14.1 version the warning is no longer shown
#StateObject isn't designed for view model objects. In SwiftUI the View struct is the view model already you don't another one. Remember SwiftUI is diffing these structs and creating/updating/removing UIView objects automatically for us. If you use view model objects then you'll have viewModel object -> View struct -> UIView object which is a big mess and will lead to bugs. #StateObject is designed for when you need a reference type in an #State which isn't very often nowadays given we have .task and .task(id:) for asynchronous features.
You can achieve what you need like this:
struct WarningConfig {
var isPresented = false
// mutating func someLogic() {}
}
struct SomeView: View {
#State private var config = WarningConfig()
...
.fullScreenCover(isPresented: $config.isPresented) {
WarningView(config: $config)

SwiftUI AppStorage, UserDefaults and ObservableObject

I have two different views, ContentView and CreateView.
In CreateView, I get user's inputs by textfield, and once user clicks on Save button, the inputs will be stored in AppStorage.
Then, I want to display the saved inputs on ContentView.
Here, I tried to use State & Binding but it didn't work out well.
How would I use the variable, that is created in CreateView, in ContentView?
what property should I use..
Thanks
Here's the updated questions with the code...
struct ContentView: View {
// MARK: - PROPERTY
#ObservedObject var appData: AppData
let createpage = CreatePage(appData: AppData())
var body: some View {
HStack {
NavigationLink("+ create a shortcut", destination: CreatePage(appData: AppData()))
.padding()
Spacer()
} //: HStack - link to creat page
VStack {
Text("\(appData.shortcutTitle) - \(appData.shortcutOption)")
}
}
struct CreatePage: View {
// MARK: - PROPERTY
#AppStorage("title") var currentShortcutTitle: String?
#AppStorage("option") var currentOption: String?
#Environment(\.presentationMode) var presentationMode
#ObservedObject var appData: AppData
var body: some View {
NavigationView{
ScrollView{
Text("Create a ShortCut")
.padding()
HStack {
TextField("what is the title?", text: $appData.titleInput)
.textFieldStyle(RoundedBorderTextFieldStyle())
//.frame(width: 150, height: 60, alignment: .center)
.border(Color.black)
.padding()
} //: HStack - Textfield - title
.padding()
HStack (spacing: 10) {
TextField("options?", text: $appData.optionInput)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(width: 80, height: 40, alignment: .leading)
.padding()
} //: HStack - Textfield - option
.padding()
Button(action: {
self.appData.shortcutTitle = self.appData.titleInput
self.appData.shortcutOption = self.appData.optionInput
UserDefaults.standard.set(appData.shortcutTitle, forKey: "title")
UserDefaults.standard.set(appData.shortcutOption, forKey: "option")
presentationMode.wrappedValue.dismiss()
}, label: {
Text("Save")
.padding()
.frame(width: 120, height: 80)
.border(Color.black)
}) //: Button - save
.padding(.top, 150)
} //: Scroll View
} //: Navigation View
} //: Body
class AppData: ObservableObject {
#Published var shortcutTitle : String = "Deafult Shortcut"
#Published var shortcutOption : String = "Default Option"
#Published var titleInput : String = ""
#Published var optionInput : String = ""
}
So the problem here is that
when I put new inputs on CreatePage and tab the save button, the new inputs do not appear on ContentView page.The output keeps showing the default values of title and option, not user inputs.
If user makes a new input and hit the save button, I want to store them in AppStorage, and want the data to be kept on ContentView (didn't make the UI yet). Am I using the AppStorage and UserDefaults in a right direction?
If anyone have insights on these issues.. would love to take your advice or references.
You're creating instances of AppData in multiple places. In order to share data, you have to share one instance of AppData.
I'm presuming that you create AppData in a parent view of ContentView since you have #ObservedObject var appData: AppData defined at the top of the view (without = AppData()). This is probably in your WindowGroup where you also must have a NavigationView.
I removed the next (let createpage = CreatePage(appData: AppData())) because it does nothing. And in the NavigationLink, I passed the same instance of AppData.
struct ContentView: View {
// MARK: - PROPERTY
#StateObject var appData: AppData = AppData() //Don't need to have `= AppData()` if you already create it in a parent view
var body: some View {
// I'm assuming there's a NavigationView in a parent view
VStack { //note that I've wrapped the whole view in a VStack to avoid having two root nodes (which can perform differently in NavigationView depending on the platform)
HStack {
NavigationLink("+ create a shortcut", destination: CreatePage(appData: appData))
.padding()
Spacer()
} //: HStack - link to creat page
VStack {
Text("\(appData.shortcutTitle) - \(appData.shortcutOption)")
}
}
}
}
struct CreatePage: View {
// MARK: - PROPERTY
#AppStorage("title") var currentShortcutTitle: String?
#AppStorage("option") var currentOption: String?
#Environment(\.presentationMode) var presentationMode
#ObservedObject var appData: AppData
var body: some View {
NavigationView{
ScrollView{
Text("Create a ShortCut")
.padding()
HStack {
TextField("what is the title?", text: $appData.titleInput)
.textFieldStyle(RoundedBorderTextFieldStyle())
//.frame(width: 150, height: 60, alignment: .center)
.border(Color.black)
.padding()
} //: HStack - Textfield - title
.padding()
HStack (spacing: 10) {
TextField("options?", text: $appData.optionInput)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(width: 80, height: 40, alignment: .leading)
.padding()
} //: HStack - Textfield - option
.padding()
Button(action: {
appData.shortcutTitle = appData.titleInput
appData.shortcutOption = appData.optionInput
presentationMode.wrappedValue.dismiss()
}, label: {
Text("Save")
.padding()
.frame(width: 120, height: 80)
.border(Color.black)
}) //: Button - save
.padding(.top, 150)
} //: Scroll View
} //: Navigation View
} //: Body
}
Regarding #AppStorage and UserDefaults, it's a little hard to tell what your intent is at this point with those. But, you shouldn't need to declare AppStorage and call UserDefaults on the same key -- #AppStorage writes to UserDefaults for you. Read more at https://www.hackingwithswift.com/quick-start/swiftui/what-is-the-appstorage-property-wrapper
you can use a singleton ObservableObject that conforms to NSObject so you can observe everything even older apple objects like progress.
class appData : NSObject , ObservableObject {
static let shared = appData()
#Published var localItems = Array<AVPlayerItem>()
#Published var fractionCompleted : Double = 0
#Published var downloaded : Bool = false
#Published var langdentifier = UserDefaults.standard.value(forKey: "lang") as? String ?? "en" {
didSet {
print("AppState isLoggedIn: \(langIdentifier)")
}
}
var progress : Progress?
override init() {
}
}
then u can use it anywhere in your code like this:
appData.shared.langIdentifier == "en" ? .leading : .trailing
You should be able to simply put AppStorage objects in the ObservableClass and call them from there. There should be no need to put AppStorage in the View and then read from UserDefaults in the class.
class AppData: ObservableObject {
#AppStorage(keyForValue) var valueForKey: ValueType = defaultValue
...
}
Of course, you could make #Published property in the class and define the getter and setter for it so it reads and writes directly to the UserDefaults but at that point, you're just creating more work than simply using AppStorage from the beginning directly in the class.

SWIFTUI Button or NavigationLink?

I have a button called "save" that saves the user inputs.
But, I want to make it like, if the user tap on Button "Save", then the screen automatically goes back to the previous view. Can I do that by just adding a code to an action in Button? or do I have to use NavigationLink instead of Button?
Button(action: {
let title = shortcutTitle
currentShortcutTitle = title
UserDefaults.standard.set(title, forKey: "title")
}, label: {
Text("Save")
.padding()
.frame(width: 120, height: 80)
.border(Color.black)
}) //: Button - save
If you're just trying to go back to the previous view and already inside a NavigationView stack, you can use #Environment(\.presentationMode):
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination: Screen2()) {
Text("Go to screen 2")
}
}.navigationViewStyle(StackNavigationViewStyle())
}
}
struct Screen2 : View {
#Environment(\.presentationMode) var presentationMode //<-- Here
var body: some View {
Button("Dismiss") {
presentationMode.wrappedValue.dismiss() //<-- Here
}
}
}

SwiftUI List rows with INFO button

UIKit used to support TableView Cell that enabled a Blue info/disclosure button. The following was generated in SwiftUI, however getting the underlying functionality to work is proving a challenge for a beginner to SwiftUI.
Generated by the following code:
struct Session: Identifiable {
let date: Date
let dir: String
let instrument: String
let description: String
var id: Date { date }
}
final class SessionsData: ObservableObject {
#Published var sessions: [Session]
init() {
sessions = [Session(date: SessionsData.dateFromString(stringDate: "2016-04-14T10:44:00+0000"),dir:"Rhubarb", instrument:"LCproT", description: "brief Description"),
Session(date: SessionsData.dateFromString(stringDate: "2017-04-14T10:44:00+0001"),dir:"Custard", instrument:"LCproU", description: "briefer Description"),
Session(date: SessionsData.dateFromString(stringDate: "2018-04-14T10:44:00+0002"),dir:"Jelly", instrument:"LCproV", description: " Description")
]
}
static func dateFromString(stringDate: String) -> Date {
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX") // set locale to reliable US_POSIX
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
return dateFormatter.date(from:stringDate)!
}
}
struct SessionList: View {
#EnvironmentObject private var sessionData: SessionsData
var body: some View {
NavigationView {
List {
ForEach(sessionData.sessions) { session in
SessionRow(session: session )
}
}
.navigationTitle("Session data")
}
// without this style modification we get all sorts of UIKit warnings
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct SessionRow: View {
var session: Session
#State private var presentDescription = false
var body: some View {
HStack(alignment: .center){
VStack(alignment: .leading) {
Text(session.dir)
.font(.headline)
.truncationMode(.tail)
.frame(minWidth: 20)
Text(session.instrument)
.font(.caption)
.opacity(0.625)
.truncationMode(.middle)
}
Spacer()
// SessionGraph is a place holder for the Graph data.
NavigationLink(destination: SessionGraph()) {
// if this isn't an EmptyView then we get a disclosure indicator
EmptyView()
}
// Note: without setting the NavigationLink hidden
// width to 0 the List width is split 50/50 between the
// SessionRow and the NavigationLink. Making the NavigationLink
// width 0 means that SessionRow gets all the space. Howeveer
// NavigationLink still works
.hidden().frame(width: 0)
Button(action: { presentDescription = true
print("\(session.dir):\(presentDescription)")
}) {
Image(systemName: "info.circle")
}
.buttonStyle(BorderlessButtonStyle())
NavigationLink(destination: SessionDescription(),
isActive: $presentDescription) {
EmptyView()
}
.hidden().frame(width: 0)
}
.padding(.vertical, 4)
}
}
struct SessionGraph: View {
var body: some View {
Text("SessionGraph")
}
}
struct SessionDescription: View {
var body: some View {
Text("SessionDescription")
}
}
The issue comes in the behaviour of the NavigationLinks for the SessionGraph. Selecting the SessionGraph, which is the main body of the row, propagates to the SessionDescription! hence Views start flying about in an un-controlled manor.
I've seen several stated solutions to this issue, however none have worked using XCode 12.3 & iOS 14.3
Any ideas?
When you put a NavigationLink in the background of List row, the NavigationLink can still be activated on tap. Even with .buttonStyle(BorderlessButtonStyle()) (which looks like a bug to me).
A possible solution is to move all NavigationLinks outside the List and then activate them from inside the List row. For this we need #State variables holding the activation state. Then, we need to pass them to the subviews as #Binding and activate them on button tap.
Here is a possible example:
struct SessionList: View {
#EnvironmentObject private var sessionData: SessionsData
// create state variables for activating NavigationLinks
#State private var presentGraph: Session?
#State private var presentDescription: Session?
var body: some View {
NavigationView {
List {
ForEach(sessionData.sessions) { session in
SessionRow(
session: session,
presentGraph: $presentGraph,
presentDescription: $presentDescription
)
}
}
.navigationTitle("Session data")
// put NavigationLinks outside the List
.background(
VStack {
presentGraphLink
presentDescriptionLink
}
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
#ViewBuilder
var presentGraphLink: some View {
// custom binding to activate a NavigationLink - basically when `presentGraph` is set
let binding = Binding<Bool>(
get: { presentGraph != nil },
set: { if !$0 { presentGraph = nil } }
)
// activate the `NavigationLink` when the `binding` is `true`
NavigationLink("", destination: SessionGraph(), isActive: binding)
}
#ViewBuilder
var presentDescriptionLink: some View {
let binding = Binding<Bool>(
get: { presentDescription != nil },
set: { if !$0 { presentDescription = nil } }
)
NavigationLink("", destination: SessionDescription(), isActive: binding)
}
}
struct SessionRow: View {
var session: Session
// pass variables as `#Binding`...
#Binding var presentGraph: Session?
#Binding var presentDescription: Session?
var body: some View {
HStack {
Button {
presentGraph = session // ...and activate them manually
} label: {
VStack(alignment: .leading) {
Text(session.dir)
.font(.headline)
.truncationMode(.tail)
.frame(minWidth: 20)
Text(session.instrument)
.font(.caption)
.opacity(0.625)
.truncationMode(.middle)
}
}
.buttonStyle(PlainButtonStyle())
Spacer()
Button {
presentDescription = session
print("\(session.dir):\(presentDescription)")
} label: {
Image(systemName: "info.circle")
}
.buttonStyle(PlainButtonStyle())
}
.padding(.vertical, 4)
}
}

Pushing multiple navigation links from a parent view in SwiftUI

I want to implement a wizard whereby the user has to go through multiple screens in order to complete a signup process.
In SwiftUI the easiest way to do this is to have each view when it's finished push the next view on the navigation stack, but this codes the entire navigation between views in the views themselves, and I would like to avoid it.
What I want to do is have a parent view show the navigation view and then push the different steps on that navigation view.
I have something working already that looks like this:
struct AddVehicleView: View {
#ObservedObject var viewModel: AddVehicleViewModel
var body: some View {
NavigationView {
switch viewModel.state {
case .description:
AddDescriptionView(addDescriptionViewModel: AddVehicleDescriptionViewModel(), addVehicleViewModel: viewModel)
case .users:
AddUsersView(viewModel: AddUsersViewModel(viewModel.vehicle), addVehicleViewModel: viewModel)
}
}
}
}
This works fine. In the first step the AddVehicleViewModel is updated with the necessary info, the AddVehicleView is re-evaluated, the switch case jumps to the next option and the next view is presented to complete the wizard.
The issue with this however is that there are no navigation stack animations. Views simply get replaced. How can I change this to a system whereby the views are pushed, without implementing the push inside the AddDescriptionView object?
Should I write wrapper views that do the navigation stack handling on top of those views, and get rid of the switch case?
Ok so if you want to go from view a to b you should implement this not in your NavigationView but the view after the NavigationView, this way you wont break the animations. Why? Good question, I really don't know. When possible I keep my NavigationView always in the App struct under WindowGroup.
To get back to the point. Basically there should be an intermediate view between your steps and NavigationView. This view (StepperView) will contain the navigation logic of your steps. This way you keep the animations intact.
import SwiftUI
class AddVehicleViewModel: ObservableObject {
enum StateType {
case description
case users1
case users2
}
#Published var state: StateType? = nil
}
struct AddDescriptionView: View {
#ObservedObject var viewModel: AddVehicleViewModel
#State var text: String = ""
var body: some View {
GeometryReader {proxy in
VStack {
TextField("test", text: self.$text).background(RoundedRectangle(cornerRadius: 10).fill(Color.white).frame(width: 150, height: 40)).padding()
Button("1") {
viewModel.state = .users1
}
}.frame(width: proxy.size.width, height: proxy.size.height, alignment: .center).background(Color.orange)
}
}
}
struct AddUsersView: View {
#ObservedObject var viewModel: AddVehicleViewModel
var body: some View {
GeometryReader {proxy in
ZStack {
Button("2") {
viewModel.state = .users2
}
}.frame(width: proxy.size.width, height: proxy.size.height, alignment: /*#START_MENU_TOKEN#*/.center/*#END_MENU_TOKEN#*/).background(Color.orange)
}
}
}
struct AddUsersView2: View {
#ObservedObject var viewModel: AddVehicleViewModel
var body: some View {
GeometryReader {proxy in
ZStack {
Button("3") {
viewModel.state = .description
}
}.frame(width: proxy.size.width, height: proxy.size.height, alignment: /*#START_MENU_TOKEN#*/.center/*#END_MENU_TOKEN#*/).background(Color.orange)
}
}
}
struct StepperView: View {
#ObservedObject var viewModel: AddVehicleViewModel = AddVehicleViewModel()
var body: some View {
VStack {
NavigationLink(
destination: AddDescriptionView(viewModel: viewModel),
isActive: .constant(viewModel.state == .description),
label: {EmptyView()})
if viewModel.state == .users1 {
NavigationLink(
destination: AddUsersView(viewModel: viewModel),
isActive: .constant(true),
label: {EmptyView()})
}
if viewModel.state == .users2 {
NavigationLink(
destination: AddUsersView2(viewModel: viewModel),
isActive: .constant(true),
label: {EmptyView()})
}
}.onAppear {
viewModel.state = .description
}
}
}
class BackBarButtonItem: UIBarButtonItem {
#available(iOS 14.0, *)
override var menu: UIMenu? {
set {
// Don't set the menu here
// super.menu = menu
}
get {
return super.menu
}
}
}
struct AddVehicleView: View {
#ObservedObject var viewModel: AddVehicleViewModel = AddVehicleViewModel()
var body: some View {
NavigationView {
NavigationLink(
destination: StepperView(),
isActive: .constant(true),
label: {EmptyView()})
}
}
}