Setting a Stateobject Value from Child View Causes Navigationview to Pop All Views

Setting a StateObject value from child view causes NavigationView to pop all views

In mocking up a Store where dispatches mutates a root @Published state that directly fires ObjectWillChange, I can replicate your popping behavior. If I separate state mutation from ObjectWillChange by using CurrentValueSubject or nesting an ObservableObject, NavigationLink performs exactly as desired.

I'd suggest decoupling a global root state firing from mandatorily marking all views as dirty. That way you can use a diffing mechanism to trigger that yourself.

That said, maybe there's a workaround re: @loremipsum comment. I am unfamiliar with that as I work with NavigationView in macOS multi-pane only, where I find it very reliable.

Edit: Example

Given your Q in the comment, here is a rough demo that keeps your root state on an @Published property.

That lets you pass your old store into the environment and use it as normal for views that you're OK with diffing for every possible state mutation.

Mock store & container

class Store: ObservableObject {
@Published var state = RootState()
}

struct RootState {
var nav: NavTag = .page1
var other: Int = 1
}

class Container: ObservableObject {
let store = Store()

func makeNavVM() -> NavVM {
NavVM(store)
}
}

struct RootView: View {

@StateObject private var container = Container()

var body: some View {
ContentView(vm: container.makeNavVM())
.environmentObject(container.store)
.environmentObject(container)
}
}

Navigation


class NavVM: ObservableObject {

init(_ root: Store) {
self.root = root
root.$state
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self,
state.nav != self.tag else { return }
self.tag = state.nav
}
.store(in: &subs)
}

@Published private(set) var tag: NavTag = .page1
private var subs = Set<AnyCancellable>()
private weak var root: Store?

func navigate(to tag: NavTag?) {
guard let tag = tag else { return }
root?.state.nav = tag
}
}

enum NavTag: String {
case page1
case page2

var color: Color {
switch self {
case .page1: return .pink
case .page2: return .yellow
}
}

var label: String {
switch self {
case .page1: return "Page 1"
case .page2: return "Page 2"
}
}
}

Usage


struct ContentView: View {

@StateObject var vm: NavVM

var body: some View {
NavigationView {
VStack(alignment: .center) {
Text("Your navigation view")
NavigationButton(tag: .page1)
NavigationButton(tag: .page2)

}
}
.environmentObject(vm)
}
}

struct Child: View {
@EnvironmentObject var store: Store

var color: Color
var body: some View {
VStack {
Button { store.state.other += 1 } label: {
ZStack {
color
Text("Press to mutate state \(store.state.other)")
}
}
NavigationLink("Go to page not in NavTag or state", destination: Child(color: .green))
}
}
}

/// On its own the Link-in-own-view-encapsulation
/// worked on first screen w/o navigation,
/// but once navigated the view hierarchy will pop
/// unless you stop state updates at the top of the hierarchy
struct NavigationButton: View {
@EnvironmentObject var navVM: NavVM
var tag: NavTag

var neveractuallynil: Binding<NavTag?> {
Binding<NavTag?> { navVM.tag }
set: { navVM.navigate(to: $0) }
}

var body: some View {
NavigationLink(destination: Child(color: tag.color),
tag: tag,
selection: neveractuallynil )
{ Text(tag.label) }

}
}

The demo uses a view model object to partition updates, but you can use a Combine pipeline in a view, too. You may want to do other variations on this general theme, from the publisher you use for root state and a UI factory object.

SwiftUI List data update from destination view causes unexpected behaviour

You're controlling lifetimes and objects in a way that's not a pattern in this UI framework. The VieModel is not going to magically republish a singleton value type; it's going to instantiate with that value and then mutate its state without ever checking in with the shared instance again, unless it is rebuilt.

class Store: ObservableObject {
static var shared: Store = .init()

struct ContentView: View {
@ObservedObject var store = Store.shared
struct ItemDetailView: View {
@ObservedObject var viewModel = ItemDetailViewModel()

class ViewModel: ObservableObject {
@Published var items: [String] = Store.shared.items

There are lots of potential viable patterns. For example:

  1. Create class RootStore: ObservableObject and house it as an @StateObject in App. The lifetime of a StateObject is the lifetime of that view hierarchy's lifetime. You can expose it (a) directly to child views by .environmentObject(store) or (b) create a container object, such as Factory that vends ViewModels and pass that via the environment without exposing the store to views, or (c) do both a and b.

  2. If you reference the store in another class, hold a weak reference weak var store: Store?. If you keep state in that RootStore on @Published, then you can subscribe directly to that publisher or to the RootStore's own ObjectWillChangePublisher. You could also use other Combine publishers like CurrentValueSubject to achieve the same as @Published.

For code examples, see

Setting a StateObject value from child view causes NavigationView to pop all views

SwiftUI @Published Property Being Updated In DetailView

SwiftUI - ObservableObject created multiple times

Latest SwiftUI updates have brought solution to this problem. (iOS 14 onwards)

@StateObject is what we should use instead of @ObservedObject, but only where that object is created and not everywhere in the sub-views where we are passing the same object.

For eg-

class User: ObservableObject {
var name = "mohit"
}

struct ContentView: View {
@StateObject var user = User()

var body: some View {
VStack {
Text("name: \(user.name)")
NameCount(user: self.user)
}
}
}

struct NameCount: View {
@ObservedObject var user

var body: some View {
Text("count: \(user.name.count)")
}
}

In the above example, only the view responsible (ContentView) for creating that object is annotating the User object with @StateObject and all other views (NameCount) that share the object is using @ObservedObject.

By this approach whenever your parent view(ContentView) is re-created, the User object will not be re-created and it will persist its @State, while your child views just observing to the same User object doesn't have to care about its re-creation.

How can you switch views without having a navigationView or an popover?

If you just want to hide the navigation bar it's easy:

import SwiftUI

struct View2: View {
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

var body: some View {
VStack {
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("POP")
}
}
.navigationBarTitle("")
.navigationBarBackButtonHidden(true)
.navigationBarHidden(true)
}
}

struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination: View2()) {
Text("PUSH")
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

If you, instead, want to get rid of the NavigationView and NavigationLink views you have to implement your own custom navigation. It's a little more complicated. The following is just a simple example of a push/pop transition between two views.

import SwiftUI

struct View2: View {
@Binding var push: Bool

var body: some View {
ZStack {
Color.yellow
Button(action: {
withAnimation(.easeOut(duration: 0.3)) {
self.push.toggle()
}
}) {
Text("POP")
}
}
.edgesIgnoringSafeArea(.all)
}
}

struct View1: View {
@Binding var push: Bool

var body: some View {
ZStack {
Color.green
Button(action: {
withAnimation(.easeOut(duration: 0.3)) {
self.push.toggle()
}
}) {
Text("PUSH")
}
}
.edgesIgnoringSafeArea(.all)
}
}

struct ContentView: View {
@State private var push = false

var body: some View {
ZStack {
if !push {
View1(push: $push)
.transition(.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .leading)))
}

if push {
View2(push: $push)
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .trailing)))
}
}
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

How can I pop to the Root view using SwiftUI?

Setting the view modifier isDetailLink to false on a NavigationLink is the key to getting pop-to-root to work. isDetailLink is true by default and is adaptive to the containing View. On iPad landscape for example, a Split view is separated and isDetailLink ensures the destination view will be shown on the right-hand side. Setting isDetailLink to false consequently means that the destination view will always be pushed onto the navigation stack; thus can always be popped off.

Along with setting isDetailLink to false on NavigationLink, pass the isActive binding to each subsequent destination view. At last when you want to pop to the root view, set the value to false and it will automatically pop everything off:

import SwiftUI

struct ContentView: View {
@State var isActive : Bool = false

var body: some View {
NavigationView {
NavigationLink(
destination: ContentView2(rootIsActive: self.$isActive),
isActive: self.$isActive
) {
Text("Hello, World!")
}
.isDetailLink(false)
.navigationBarTitle("Root")
}
}
}

struct ContentView2: View {
@Binding var rootIsActive : Bool

var body: some View {
NavigationLink(destination: ContentView3(shouldPopToRootView: self.$rootIsActive)) {
Text("Hello, World #2!")
}
.isDetailLink(false)
.navigationBarTitle("Two")
}
}

struct ContentView3: View {
@Binding var shouldPopToRootView : Bool

var body: some View {
VStack {
Text("Hello, World #3!")
Button (action: { self.shouldPopToRootView = false } ){
Text("Pop to root")
}
}.navigationBarTitle("Three")
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Screen capture

Swift UI how to retrieve state from child view

In SwiftUI, you want a single source of truth for your data, and then all views read and write to that data.

@State is a bit like private, in that it creates a new source of truth that can only be used by that view (and its subviews).

What you want is to have the data in the Parent (ContentView in this case) and then pass the data that you want the children to update to them.

You can do this using @Binding:

public struct ChildPickerView : View {

var selectedText: Binding<String>

var body: some View { }
}

And then passing that binding from the parent using $:

public struct ContentView : View {
@State var selectedText = ""

var body: some View {
ChildViewItem($selectedText)
}
}

You can also use EnvironmentObject to avoid passing data from view to view.

SwiftUI Navigation immediately dismisses views? iOS 15

No need for .onTapGesture or .background ...

It should look something like this:

struct ProfileView: View {

var body: some View {
NavigationView {
List {
Text("other content ...")
//other content {
NavigationLink("Show settings menu") {
Settings()
}
}
.listStyle(PlainListStyle())
.navigationViewStyle(StackNavigationViewStyle())
}
}
}

struct Settings: View {

var body: some View {
VStack(spacing: 30) {

NavigationLink("Account Menu") {
AccountTab()
}
NavigationLink("Security Menu") {
// SecuritySettings()
}
NavigationLink("Notification Menu") {
// UserNotificationsView()
}
}
}
}

struct AccountTab: View {

var body: some View {
VStack {
NavigationLink("Email") {
// EditEmail()
Text("Edit Mail")
}
NavigationLink("Phone") {
// EditGender()
Text("Edit Phone")
}
NavigationLink("Gender") {
// EditPhone()
Text("Edit Gender")
}
}
}
}


Related Topics



Leave a reply



Submit