Programmatic navigation with NavigationView/NavigationLink delay jumps back - swiftui

I created a simple showcase where my problem can be reproduced; what I'm doing is navigating from the initial view => View1 => View2.
The navigation from the initial to View1 happens via button tap, nothing special here.
My View1 looks the following:
struct View1: View {
#ObservedObject private var viewModel = ViewModel()
private let includeDelay = true
var body: some View {
NavigationLink(
destination: View2(),
isActive: $viewModel.foo,
label: {
Text("View 1")
})
.onAppear(perform: {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(includeDelay ? 500 : 0)) {
viewModel.doSomething()
}
})
}
}
class ViewModel: ObservableObject {
#Published var foo = false
func doSomething() {
foo = true
}
}
If I include the delay in onAppear, it works as expected; after the delay, I get navigated to View2 and stay there.
But if I remove the delay (or set it to e.g. 300ms) I get navigated to View2, but immediately get navigated back. I don't understand what's happening here; why is my $viewModel.foo set to false after my setting to true?

Related

Navigate to another view when async/await task finishes

I'm pretty new to SwiftUI, so I'm wondering how to navigate to a new view only when an async method returns with a value. Here is my view:
import SwiftUI
struct FollowersView: View {
#State var followers: [Follower]
#State var user: User?
#State private var selection: String? = nil
#StateObject private var viewModel = GitHubUsersViewModel()
let columns = [
GridItem(.flexible(minimum: 150, maximum: 175)),
GridItem(.flexible(minimum: 150, maximum: 175)),
]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 20) {
ForEach(followers, id: \.self) { follower in
FollowerCardView(follower: follower)
.onTapGesture {
Task {
self.user = try await self.viewModel.manager.getUser(for: follower.login)
self.selection = NavigationTags.userFound
}
}
}
}
.padding(.horizontal)
}
.frame(maxHeight: .infinity)
}
}
When self.user gets populated, I want to be able to navigate to another view. But, I can't figure out where to put the NavigationLink.
This FollowersView is already embedded in a NavigationView from within it's parent view.
Please advise?
For iOS 15 you can still use NavigationLink as it isn't deprecated until iOS 16 when the new NavigationStack stuff comes in.
Anyway...
struct SomeView: View {
#State var shouldNavigate: Bool = false
var body: some View {
NavigationLink(
isActive: $shouldNavigate,
destination: navDestination
) {
Text("Press me")
.onTapGesture {
let result = await someAsyncFunction()
shouldNavigate = true
}
}
}
#ViewBuilder
var navDestination: some View {
Text("Ta dah!")
}
}
This will navigate to your destination when the async function completes and sets shouldNavigate to true.
There are nmany ways that you could do this. This is just one of them. Good luck
As a side note, you need to make sure your view is part of a NavigationView too.

SwiftUI: Updating ui when view is not present causes "Unable to present view. Please file a bug."

I get the following error: Unable to present view. Please file a bug whenever I make an asynchronous call on a view and leave the view (e.g. navigate to another view in the navigation stack) before it can make changes to the ui. Consequently, the next view in the navigation stack is unable to update its view. How can I fix this problem?
An example of the problem occurring is when I switch from view1 to view2 before my GetIoTThingIndex() call finishes and makes an update to the ui.
GetIoTThingIndex.query(device) { error in
DispatchQueue.main.async { [self] in
...
}
}
EDIT:
After doing more investigating, I found that this problem is due to the fact that I am implementing my logic in an MVVM pattern. When I moved my logic directly into the the view and called the functions and state variables inside the view, everything worked fine. It's interesting because when I started building my app with just a few pages with minimal logic and dependencies, this MVVM pattern worked fine without any bugs. However, when my project grew to 20+ pages with more logic and dependencies, the MVVM pattern causes this bug. Is this just a problem I see or has anyone seen anything like this before and have any recommendations for fixing it?
This is the way I had things with MVVM.
View
struct DeviceView: View {
#ObservedObject var viewModel = DeviceViewModel()
var body: some View {
Text(viewModel.name)
...
}
}
View Model
class DeviceViewModel: ObservableObject {
#Published var name = ""
public func updateUI() {
...
}
...
}
This is the way I have things now (which works without this bug).
View
struct DeviceView: View {
var body: some View {
Text(name)
...
}
#State var name = ""
public func updateUI() {
...
}
...
}
Are you sure this is what is happening?
I've tested the idea of navigating to another view
before the parent can make a change to its view. And all works well.
This is the code I used for the test, click on the button first, then within 3 sec click on the NavigationLink.
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#State var thingToUpdate = ""
var body: some View {
NavigationView {
VStack (spacing: 40) {
Text("text \(thingToUpdate)")
Button("click me first") {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
thingToUpdate = " is updated now"
}
}
NavigationLink(destination: Text("the detail view")) {
Text("then to DetailView")
}
}
}
}
}
Edit update using ObservableObject that works for me:
class DeviceViewModel: ObservableObject {
#Published var name = "no name"
public func updateUI() {
// simulated delay on the main thread
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.name = "success"
}
}
}
struct ContentView: View {
#ObservedObject var viewModel = DeviceViewModel()
var body: some View {
NavigationView {
VStack (spacing: 40) {
Text("viewModel name is \(viewModel.name)")
Button("click me first") {
viewModel.updateUI()
}
NavigationLink(destination: Text("DetailView")) {
Text("then to DetailView")
}
}
}
}
}

SwiftUI NavigationLink with constant binding for isActive

I don't understand why SwiftUI NavigationLink's isActive behaves as if it has it's own state. Even though I pass a constant to it, the back button overrides the value of the binding once pressed.
Code:
import Foundation
import SwiftUI
struct NavigationLinkPlayground: View {
#State
var active = true
var body: some View {
NavigationView {
VStack {
Text("Navigation Link playground")
Button(action: { active.toggle() }) {
Text("Toggle")
}
Spacer()
.frame(height: 40)
FixedNavigator(active: active)
}
}
}
}
fileprivate struct FixedNavigator: View {
var active: Bool = true
var body: some View {
return VStack {
Text("Fixed navigator is active: \(active)" as String)
NavigationLink(
destination: SecondScreen(),
// this is technically a constant!
isActive: Binding(
get: { active },
set: { newActive in print("User is setting to \(newActive), but we don't let them!") }
),
label: { Text("Go to second screen") }
)
}
}
}
fileprivate struct SecondScreen: View {
var body: some View {
Text("Nothing to see here")
}
}
This is a minimum reproducible example, my actual intention is to handle the back button press manually. So when the set inside the Binding is called, I want to be able to decide when to actually proceed. (So like based on some validation or something.)
And I don't understand what is going in and why the back button is able to override a constant binding.
Your use of isActive is wrong. isActive takes a binding boolean and whenever you set that binding boolean to true, the navigation link gets activated and you are navigated to the destination.
isActive does not control whether the navigation link is clickable/disbaled or not.
Here's an example of correct use of isActive. You can manually trigger the navigation to your second view by setting activateNavigationLink to true.
EDIT 1:
In this new sample code, you can disable and enable the back button at will as well:
struct ContentView: View {
#State var activateNavigationLink = false
var body: some View {
NavigationView {
VStack {
// This isn't visible and should take 0 space from the screen!
// Because its `label` is an `EmptyView`
// It'll get programmatically triggered when you set `activateNavigationLink` to `true`.
NavigationLink(
destination: SecondScreen(),
isActive: $activateNavigationLink,
label: EmptyView.init
)
Text("Fixed navigator is active: \(activateNavigationLink)" as String)
Button("Go to second screen") {
activateNavigationLink = true
}
}
}
}
}
fileprivate struct SecondScreen: View {
#State var backButtonActivated = false
var body: some View {
VStack {
Text("Nothing to see here")
Button("Back button is visible: \(backButtonActivated)" as String) {
backButtonActivated.toggle()
}
}
.navigationBarBackButtonHidden(!backButtonActivated)
}
}

Reset NagivationView stack in TabView

I have a tabview with two tabs (tabs A and B).
Clicking tab A opens a master View. In that master view there is a navigation link to Page 1. Within Page 1 there is also a link to Page 2.
When the user is on Page 1 or 2, and I tap Tab A, it doesn’t revert to master View. Similarly if the user clicks Tab B and then Tab A again, it returns to Page 1 or 2 (whichever the user was on), rather than master View.
How to I make the navigation stack reset in both cases?
Thanks!
That's because the View won't be rerendered. Here is a possible approach how to achieve your behavior:
You can use ProxyBinding for the TabView to detect changes and then reset the NavigationLink by changing the internal State variable.
struct ContentView: View {
#State var activeView: Int = 0
#State var showNavigation: Bool = false
var body: some View {
TabView(selection: Binding<Int>(
get: {
activeView
}, set: {
activeView = $0
showNavigation = false //<< when pressing Tab Bar Reset Navigation View
}))
{
NavigationView {
NavigationLink("Click", destination: Text("Page A"), isActive: $showNavigation)
}
.tabItem {
Image(systemName: "1.circle")
Text("First")
}
.tag(0)
Text("Second View")
.padding()
.tabItem {
Image(systemName: "2.circle")
Text("Second")
}
.tag(1)
}
}
}
You can create RootView with MainView
import SwiftUI
struct RootView: View {
#ObservedObject var viewModel = RootViewModel()
init(){
viewModel.prepare()
}
var body: some View {
MainView(tab: viewModel.mainTab)
.id(UUID().uuidString)
}
}
Create RootViewModel with listeners to screen updating
import SwiftUI
class RootViewModel: ObservableObject{
#Published var mainTab: SelectedTab = .firstTab
let mainScreenNotification = NSNotification.Name("mainScreenNotification")
private var observerMain: Any?
func prepare(){
observerMain = NotificationCenter.default.addObserver(forName: mainScreenNotification, object: nil, queue: nil, using: { [unowned self] notification in
self.mainTab = (notification.userInfo?["selectedTab"])! as! SelectedTab
})
}
}
enum SelectedTab {
case firstTab, secondTab
}
Run this to inflating new tab screen from tab child:
NotificationCenter.default.post(name:
NSNotification.Name("mainScreenNotification"),
object: nil,
userInfo: ["selectedTab": SelectedTab.firstTab]
)

SwiftUI NavigationLink isActive from view model

I have a MVVM SwiftUI app that will navigate to another view based on the value of a #Published property of a view model:
class ViewModel: ObservableObject {
#Published public var showView = false
func doShowView() {
showView = true
}
}
struct MyView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
NavigationView {
MySubView().environmentObject(viewModel)
}
}
}
struct MySubView: View {
#EnvironmentObject private var viewModel: ViewModel
var body: some View {
VStack {
Button(action: {
viewModel.doShowView()
}) {
Text("Button")
}
NavigationLink(
destination: SomeOtherView(),
isActive: $viewModel.showView,
label: {
EmptyView()
})
}
}
}
The problem is sometimes when I run the app it will work only every other time and sometimes it works perfectly as expected.
The cause seems to be that sometimes when the property is set in the view model (in doShowView()) SwiftUI will immediately render my view with the old value of showView and in the working case the view is rendered on the next event cycle with the updated value.
Is this a feature (due to the fact #Published is calling objectWillChange under the hood and the view is rendering due to that) or a bug?
If it is a feature (and I just happen to get lucky when it works as I want it to) what is the best way to guarantee it renders my view after the new value is set?
Note this is only a simple example, I cannot use a #State variable in the button action since in the real code the doShowView() method may or may not set the showView property in the view model.
The issue here is that SwiftUI creates the SomeOtherView beforehand. Then, this view is not related with the viewModel in any way, so it's not re-created when viewModel.showView changes.
A possible solution is to make SomeOtherView depend on the viewModel - e.g. by explicitly injecting the environmentObject:
struct MySubView: View {
#EnvironmentObject private var viewModel: ViewModel
var body: some View {
VStack {
Button(action: {
viewModel.doShowView()
}) {
Text("Button")
}
NavigationLink(
destination: SomeOtherView().environmentObject(viewModel),
isActive: $viewModel.showView,
label: {
EmptyView()
}
)
}
}
}
I came upon a working solution. I did add a #State variable and set it by explictly watching for changes of showView:
class ViewModel: ObservableObject {
#Published public var showView = false
var disposables = Set<AnyCancellable>()
func doShowView() {
showView = true
}
}
struct MyView: View {
#StateObject var viewModel = ViewModel()
var body: some View {
NavigationView {
MySubView().environmentObject(viewModel)
}
}
}
struct MySubView: View {
#EnvironmentObject private var viewModel: ViewModel
#State var showViewLink = false
var body: some View {
VStack {
Button(action: {
viewModel.doShowView()
}) {
Text("Button")
}
NavigationLink(
destination: SomeOtherView(),
isActive: $showViewLink,
label: {
EmptyView()
})
}
.onAppear {
viewModel.$showView
.sink(receiveValue: { showView in
showViewLink = showView
})
.store(in: &viewModel.disposables)
}
}
}