In my iOS 14 SwiftUI app, when user is not logged in, I make him go through a few setup screens before presenting the main logged in screen. At the last setup screen, I use a NavigationLink to present the main logged in screen. How do I clear the entire navigation stack such that the main logged in screen becomes the root / first screen in the navigation stack?
Out of curiosity I started thinking about a solution to clear everything down to my root view and remembered that setting the .id() of a view will force a reload.
I'm posting this as a discussion point in first place not as an actual answer since I'm interested in your opinion if this is a legit approach.
Code is kept to the minimum to demonstrate the general idea and I haven't considered any memory leaks yet
import SwiftUI
// MARK: - SessionManager
class SessionManager: ObservableObject {
var isLoggedIn: Bool = false {
didSet {
rootId = UUID()
}
}
#Published
var rootId: UUID = UUID()
}
// MARK: - ContentView
struct ContentView: View {
#ObservedObject
private var sessionManager = SessionManager()
var body: some View {
NavigationView {
NavigationLink("ContentViewTwo", destination: ContentViewTwo().environmentObject(sessionManager))
}.id(sessionManager.rootId)
}
}
// MARK: - ContentViewTwo
struct ContentViewTwo: View {
#EnvironmentObject
var sessionManager: SessionManager
var body: some View {
NavigationLink("ContentViewTwo", destination: ContentViewThree().environmentObject(sessionManager))
}
}
// MARK: - ContentViewThree
struct ContentViewThree: View {
#EnvironmentObject
var sessionManager: SessionManager
var body: some View {
NavigationLink("ContentViewThree", destination: ContentViewFour().environmentObject(sessionManager))
}
}
// MARK: - ContentViewFour
struct ContentViewFour: View {
#EnvironmentObject
var sessionManager: SessionManager
var body: some View {
Button(action: {
sessionManager.isLoggedIn.toggle()
}, label: {
Text("logout")
})
}
}
You can start with .sheet() or .fullScreenCover() on root / first screen,
and then stack with NaviagtionLink,
and at the end self.presentationMode.wrappedValue.dismiss()
Now that I am aware of .sheet() and .fullscreenCover(), I would prefer that answer, but being new to swiftUI this was my initial approach.
You could use a #State variable to control the behaviour. Create two navigation views, that are enclosed in a if condition based on your bool.
#State var showOnBoarding = true
if $showOnBoarding {
OnboardingNavigationView()
} else {
CoreNavigationView()
}
One with on-boarding links and the other containing your core navigation links. One for your onboarding and then one for logged in.
Each of your navigation links would also be initialized with the isActive Binding parameter.
struct OnboardingNavigationView: View {
var body: some View {
NavigationView {
NavigationLink("Step 1",destination: NextStepView(), isActive: $showOnBoarding)
//etc..
}
}
}
struct CoreNavigationView: View {
var body: some View {
NavigationView {
NavigationLink("Login Screen",destination: AccountView(), isActive: $showOnBoarding)
//etc..
}
}
}
Once the user logs in, you'd toggle that var. Each of your NavigationLinks would use the isActive property bound to showOnBoarding. So once logged in they won't be able go back to the inactive onboarding screens and will be in your 'new' navigation stack where your login screen is the root screen.
See section 'Presenting a Destination View with Programmatic Activation'
SwiftUI NavigationLink
Related
Currently I declare my ProgressView in my main view controller which gets it's value from a #State variable. On a button press i change the value of the variable which then updates the value of my progress bar.
I want to make changes to this ProgressView, but using a button on a separate view controller. I've tried to use Binding, but due to the way I am using WatchTabView, this forces me to declare a #Binding variable within my App struct, which isn't possible.
Not sure if I'm overthinking this, but how can i update my progress bar from another view?
Main View
struct ViewController: View {
#State var progressBarValue = 5.0
var body: some View {
ScrollView{
ProgressView("Effort", value: progressBarValue, total: 20)
VStack {
Button{
progressBarValue += 5.0
}label:{
Text("Click")
}
Other View
struct OtherViewController: View{
...
Button{
//I want to increase progressBarValue by clicking here
}label:{
Text("Click")
}
...
}
First please read this post:
What's the difference between a View and a ViewController?
Then read this: https://developer.apple.com/documentation/combine/observableobject
Also, read this: https://www.hackingwithswift.com/quick-start/swiftui/whats-the-difference-between-observedobject-state-and-environmentobject
Finally, come back to this example:
class MyViewController: ObservableObject {
#Published private(set) progress: Float = 0
func updateProgress(by value: Float) {
progress += value
}
}
Parent View:
struct ParentView: View {
#ObservedObject var myController = MyViewController()
var body: some View {
MainChildView()
.environmentObject(myController) //this is one of the ways of passing down an ObservableObject to all underlying views.
//your progress view code can go here
}
}
MainChildView
struct MainChildView: View {
//no need to pass anything into this view because of the EnvironmentObject.
var body: some View {
ChildView()
}
}
ChildView
struct ChildView: View {
#EnvironmentObject var myController: MyViewController //EnvironmentObject will search for MyViewController declared somewhere in one of the parent views
var body: some View {
//your progress view code can go here
Button("Tap", action: {
myController.updateProgress(by: 5)
})
}
}
I'm using TabView on my home page. Let's just say I have 4 tabs.
On second tab, i can go to another view using NavigationLink and I go to another 2 views using NavigationLink. Then on the latest view, there is a button to present a view and i use .fullScreenCover (since I want to present it full screen).
In the presenting view, I add an X mark on the left side of the navigationBarItems to dismiss. I use #Environment(\.presentationMode) var presentationMode and presentationMode.wrappedValue.dismiss() to dismiss. But it only dismiss the presenting view to the previous view, while actually I want to dismiss it to the root of my view which is the 2nd tab of my TabView.
Is there a way to do this? Because I have looked up to some articles and nothing relevant especially in TabView context.
I also have a question tho:
Is it a right approach to use .fullScreenCover? Or is there another possible solution for example presenting a modal with full screen style (if there's any cause i'm not sure either).
Any suggestions will be very appreciated, thankyou in advance.
The presentationMode is one-level effect value, ie changing it you close one currently presented screen.
Thus to close many presented screens you have to implement this programmatically, like in demo below.
The possible approach is to use custom EnvironmentKey to pass it down view hierarchy w/o tight coupling of every level view (like with binding) and inject/call only at that level where needed.
Demo tested with Xcode 12.4 / iOS 14.4
struct ContentView: View {
var body: some View {
TabView {
Text("Tab1")
.tabItem { Image(systemName: "1.square") }
Tab2RootView()
.tabItem { Image(systemName: "2.square") }
}
}
}
struct Tab2RootView: View {
#State var toRoot = false
var body: some View {
NavigationView {
Tab2NoteView(level: 0)
.id(toRoot) // << reset to root !!
}
.environment(\.rewind, $toRoot) // << inject here !!
}
}
struct Tab2NoteView: View {
#Environment(\.rewind) var rewind
let level: Int
#State private var showFullScreen = false
var body: some View {
VStack {
Text(level == 0 ? "ROOT" : "Level \(level)")
NavigationLink("Go Next", destination: Tab2NoteView(level: level + 1))
Divider()
Button("Full Screen") { showFullScreen.toggle() }
.fullScreenCover(isPresented: $showFullScreen,
onDismiss: { rewind.wrappedValue.toggle() }) {
Tab2FullScreenView()
}
}
}
}
struct RewindKey: EnvironmentKey {
static let defaultValue: Binding<Bool> = .constant(false)
}
extension EnvironmentValues {
var rewind: Binding<Bool> {
get { self[RewindKey.self] }
set { self[RewindKey.self] = newValue }
}
}
struct Tab2FullScreenView: View {
#Environment(\.presentationMode) var mode
var body: some View {
Button("Close") { mode.wrappedValue.dismiss() }
}
}
You have 2 options:
With .fullScreenCover you will have a binding that results in it being presented you can pass this binding through to the content and when the user taps on x set to to false
You can use the #Environment(\.presentationMode) var presentationMode then call presentationMode.wrappedValue.dismiss() in your button body.
Edit:
If you want to unwind all the way you should make the TabView be binding based. I like to use SceneStorage for this take a look at this post then you can access this SceneStorage value anywhere in your app to respond to it but also to update and change the navigation (this also has the benefit of providing you proper state restoration!)
If you make your TabView in this way:
struct ContentView: View {
#SceneStorage("selectedTab") var selectedTab: Tab = .car
var body: some View {
TabView(selection: $selectedTab) {
CarTrips()
.tabItem {
Image(systemName: "car")
Text("Car Trips")
}.tag(Tab.car)
TramTrips()
.tabItem {
Image(systemName: "tram.fill")
Text("Tram Trips")
}.tag(Tab.tram)
AirplaneTrips()
.tabItem {
Image(systemName: "airplane")
Text("Airplane Trips")
}.tag(Tab.airplaine)
}
}
}
enum Tab: String {
case car
case tram
case airplaine
}
Then deep within your app in the place you want to change the navigation you can create a button view.
struct ViewCarButton: View {
#SceneStorage("selectedTab") var selectedTab: Tab = .car
var body: some View {
Button("A Button") {
selectedTab = .car
}
}
}
This will forced the selected tab to be the car tab.
if instead of this you do not want to change tab but rather change what the navigation view is navigated to you can use the same concept for that, NavigationLink that's a binding if this binding is created using a #SceneStorage then in your ViewCarButton you can make changes to it that will change the navigation state.
I want to achieve the function. Like, "Look up" view that is from Apple.
My aim is when the sheet view push another view by navigation, the user can tap the navigation item button to close the sheet view. Like, this below gif.
I try to achieve this function.
I found a problem that is when the user tap the "Done" button. The App doesn't close the sheet view. It only pop the view to parent view. Like, this below gif.
This is my code.
import SwiftUI
struct ContentView: View {
#State var isShowSheet = false
var body: some View {
Button(action: {
self.isShowSheet.toggle()
}) {
Text("Tap to show the sheet")
}.sheet(isPresented: $isShowSheet) {
NavView()
}
}
}
struct NavView: View {
var body: some View {
NavigationView {
NavigationLink(destination: NavSubView()) {
Text("Enter Sub View")
}
} .navigationViewStyle(StackNavigationViewStyle())
}
}
struct NavSubView: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
Text("Hello")
.navigationBarItems(trailing:
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}){
Text("Done")
}
)
}
}
How did I achieve this function? :)
Please help me, thank you. :)
UPDATE: Restored original version - provided below changes should be done, as intended, to the topic starter's code. Tested as worked with Xcode 13 / iOS 15
As navigation in sheet might be long enough and closing can be not in all navigation subviews, I prefer to use environment to have ability to specify closing feature only in needed places instead of passing binding via all navigation stack.
Here is possible approach (tested with Xcode 11.2 / iOS 13.2)
Define environment key to store sheet state
struct ShowingSheetKey: EnvironmentKey {
static let defaultValue: Binding<Bool>? = nil
}
extension EnvironmentValues {
var showingSheet: Binding<Bool>? {
get { self[ShowingSheetKey.self] }
set { self[ShowingSheetKey.self] = newValue }
}
}
Set this environment value to root of sheet content, so it will be available in any subview when declared
}.sheet(isPresented: $isShowSheet) {
NavView()
.environment(\.showingSheet, self.$isShowSheet)
}
Declare & use environment value only in subview where it is going to be used
struct NavSubView: View {
#Environment(\.showingSheet) var showingSheet
var body: some View {
Text("Hello")
.navigationBarItems(trailing:
Button("Done") {
self.showingSheet?.wrappedValue = false
}
)
}
}
I haven't tried SwiftUI ever, but I came from UIKit + RxSwift, so I kinda know how binding works. I read quite a bit of sample codes from a SwiftUI Tutorial, and the way you dismiss a modal is actually correct, but apparently not for a navigation stack.
One way I learned just now is use a #Binding var. This might not be the best solution, but it worked!
So you have this $isShowSheet in your ContentView. Pass that object to your NavView struct by declaring a variable in that NavView.
ContentView
.....
}.sheet(isPresented: $isShowSheet) {
NavView(isShowSheet: self.$isShowSheet)
}
NavView
struct NavView: View {
#Binding var isShowSheet: Bool
var body: some View {
NavigationView {
NavigationLink(destination: NavSubView(isShowSheet: self.$isShowSheet)) {
Text("Enter Sub View")
}
} .navigationViewStyle(StackNavigationViewStyle())
}
}
and finally, do the same thing to your subView.
NavSubView
struct NavSubView: View {
#Environment(\.presentationMode) var presentationMode
#Binding var isShowSheet: Bool
var body: some View {
Text("Hello")
.navigationBarItems(trailing:
Button(action: {
//self.presentationMode.projectedValue.wrappedValue.dismiss()
self.isShowSheet = false
}){
Text("Done")
}
)
}
}
Now as you can see, you just need to send a new signal to that isShowSheet binding var - false.
self.isShowSheet = false
Voila!
Here's an improved version of Asperi's code from above since they won't accept my edit. Main credit goes to them.
As navigation in sheet might be long enough and closing can be not in all navigation subviews, I prefer to use environment to have ability to specify closing feature only in needed places instead of passing binding via all navigation stack.
Here is possible approach (tested with Xcode 13 / iOS 15)
Define environment key to store sheet state
struct ShowingSheetKey: EnvironmentKey {
static let defaultValue: Binding<Bool>? = nil
}
extension EnvironmentValues {
var isShowingSheet: Binding<Bool>? {
get { self[ShowingSheetKey.self] }
set { self[ShowingSheetKey.self] = newValue }
}
}
Set this environment value to root of sheet content, so it will be available in any subview when declared
#State var isShowingSheet = false
...
Button("open sheet") {
isShowingSheet?.wrappedValue = true
}
// note no $ in front of isShowingSheet
.sheet(isPresented: isShowingSheet ?? .constant(false)) {
NavView()
.environment(\.isShowingSheet, self.$isShowingSheet)
}
Declare & use environment value only in subview where it is going to be used
struct NavSubView: View {
#Environment(\.isShowingSheet) var isShowingSheet
var body: some View {
Text("Hello")
.navigationBarItems(trailing:
Button("Done") {
isShowingSheet?.wrappedValue = false
}
)
}
}
I am having some issues with a NavigationLink on an iPad with split view (landscape). Here is an example:
Here is the code to reproduce the issue:
import SwiftUI
final class MyEnvironmentObject: ObservableObject {
#Published var isOn: Bool = false
}
struct ContentView: View {
#EnvironmentObject var object: MyEnvironmentObject
var body: some View {
NavigationView {
NavigationLink("Go to FirstDestinationView", destination: FirstDestinationView(isOn: $object.isOn))
}
}
}
struct FirstDestinationView: View {
#Binding var isOn: Bool
var body: some View {
NavigationLink("Go to SecondDestinationView", destination: SecondDestinationView(isOn: $isOn))
}
}
struct SecondDestinationView: View {
#Binding var isOn: Bool
var body: some View {
Toggle(isOn: $isOn) {
Text("Toggle")
}
}
}
// Somewhere in SceneDelegate
ContentView().environmentObject(MyEnvironmentObject())
Does anyone know a way to fix this? An easy fix is to disable split view, but that is not possible for me.
When something within EnvironmentObject changes, it will render the whole view again including NavigationLink. That's the root cause of automatic pop back.
My research on it:
OK on iOS 15 (seems Apple fixed)
Still broken on iOS 14
The reason why "This bug went away when I dropped the #EnvironmentObject and went with an #ObservedObject instead." #Jon Vogel mentioned is ObservedObject is a local state, which will not be affected by other views while EnvironmentObject is global state and can change from any other remote views.
Ok, here is my investigation results (tested with Xcode 11.2) and below is the code that works.
In iPad NavigationView got into Master/Details style, so ContentView having initial link is active and process bindings update from environmentObject, so refresh, which result in activating link of details view via same binding, thus corrupting navigation stack. (Note: this is absent in iPhone due to stack style, which deactivates root view).
So, probably this is workaround, but works - the idea is not to pass binding from view to view, but use environmentObject directly in final view. Probably this will not always be a case, but anyway in such scenarios it is needed to avoid root view refresh, so it should not have same binding in body. Something like that.
final class MyEnvironmentObject: ObservableObject {
#Published var selection: Int? = nil
#Published var isOn: Bool = false
}
struct ContentView: View {
#EnvironmentObject var object: MyEnvironmentObject
var body: some View {
NavigationView {
List {
NavigationLink("Go to FirstDestinationView", destination: FirstDestinationView())
}
}
}
}
struct FirstDestinationView: View {
var body: some View {
List {
NavigationLink("Go to SecondDestinationView", destination: SecondDestinationView())
}
}
}
struct SecondDestinationView: View {
#EnvironmentObject var object: MyEnvironmentObject
var body: some View {
VStack {
Toggle(isOn: $object.isOn) {
Text("Toggle")
}
}
}
}
You need can use isDetailLink(_:) to fix that, e.g.
struct FirstDestinationView: View {
#Binding var isOn: Bool
var body: some View {
NavigationLink("Go to SecondDestinationView", destination: SecondDestinationView(isOn: $isOn))
.isDetailLink(false)
}
}
I worked to a login view, now I want to present the view after login, but I do not want the user to have the possibility to return to the login view. In UIkit I used present(), but it seems in SwiftUI presentation(_ modal: Modal?) the view does not take the entire screen. Navigation also isn't an option.
Thank you!
I do not want the user to have the possibility to return to the login view
In that case you shouldn't be presenting away from the login view but replacing it entirely.
You could do this by conditionally building the login view or the "app view".
Something like this...
// create the full screen login view
struct LoginView: View {
// ...
}
//create the full screen app veiw
struct AppView: View {
// ...
}
// create the view that swaps between them
struct StartView: View {
#EnvironmentObject var isLoggedIn: Bool // you might not want to use this specifically.
var body: some View {
isLoggedIn ? AppView() : LoginView()
}
}
By using a pattern like this you are not presenting or navigating away from the login view but you are replacing it entirely so it is no longer in the view hierarchy at all.
This makes sure that the user cannot navigate back to the login screen.
Equally... by using an #EnvironmentObject like this you can edit it later (to sign out) and your app will automatically be taken back to the login screen.
struct ContentView: View {
#EnvironmentObject var userAuth: UserAuth
var body: some View {
if !userAuth.isLoggedin {
return AnyView(LoginView())
} else {
return AnyView(HomeView())
}
}
}
Encapsulate the body in a Group to eliminate compiler errors:
struct StartView: View {
#EnvironmentObject var userAuth: UserAuth
var body: some View {
Group {
if userAuth.isLoggedin {
AppView()
} else {
LoginView()
}
}
}
I made this extension for myself. Any feedback/ideas are welcome. :)
https://github.com/klemenkosir/SwiftUI-FullModal
Usage
struct ContentView: View {
#State var isPresented: Bool = false
var body: some View {
NavigationView {
Button(action: {
self.isPresented.toggle()
}) {
Text("Present")
}
.navigationBarTitle("Some title")
}
.present($isPresented, view: ModalView(isPresented: $isPresented))
}
}
struct ModalView: View {
#Binding var isPresented: Bool
var body: some View {
Button(action: {
self.isPresented.toggle()
}) {
Text("Dismiss")
}
}
}