How to send extra data using NavigationStack with SwiftUI? - ios

I have three views A,B and C. User can navigate from A to B and from A to C. User can navigate from B to C. Now I want to differentiate if the user have come from A to C or from B to C so I was looking in how to pass extra data in NavigationStack which can help me differentiate
Below is my code
import SwiftUI
#main
struct SampleApp: App {
#State private var path: NavigationPath = .init()
var body: some Scene {
WindowGroup {
NavigationStack(path: $path){
A(path: $path)
.navigationDestination(for: ViewOptions.self) { option in
option.view($path)
}
}
}
}
enum ViewOptions {
case caseB
case caseC
#ViewBuilder func view(_ path: Binding<NavigationPath>) -> some View{
switch self{
case .caseB:
B(path: path)
case .caseC:
C(path: path)
}
}
}
}
struct A: View {
#Binding var path: NavigationPath
var body: some View {
VStack {
Text("A")
Button {
path.append(SampleApp.ViewOptions.caseB)
} label: {
Text("Go to B")
}
Button {
path.append(SampleApp.ViewOptions.caseC)
} label: {
Text("Go to C")
}
}
}
}
struct B: View {
#Binding var path: NavigationPath
var body: some View {
VStack {
Text("B")
Button {
path.append(SampleApp.ViewOptions.caseC)
} label: {
Text("Go to C")
}
}
}
}
struct C: View {
#Binding var path: NavigationPath
var body: some View {
VStack {
Text("C")
}
}
}

Instead of "pass extra data in NavigationStack" you can pass data in a NavigationRouter. It gives you much more control
#available(iOS 16.0, *)
//Simplify the repetitive code
typealias NavSource = SampleApp.ViewOptions
#available(iOS 16.0, *)
struct NavigationRouter{
var path: [NavSource] = .init()
///Adds the provided View to the stack
mutating func goTo(view: NavSource){
path.append(view)
}
///Searches the stack for the `View`, if the view is `nil`, the stack returns to root, if the `View` is not found the `View` is presented from the root
mutating func bactrack(view: NavSource?){
guard let view = view else{
path.removeAll()
return
}
//Look for the desired view
while !path.isEmpty && path.last != view{
path.removeLast()
}
//If the view wasn't found add it to the stack
if path.isEmpty{
goTo(view: view)
}
}
///Identifies the previous view in the stack, returns nil if the previous view is the root
func identifyPreviousView() -> NavSource?{
//1 == current view, 2 == previous view
let idx = path.count - 2
//Make sure idx is valid index
guard idx >= 0 else{
return nil
}
//return the view
return path[idx]
}
}
Once you have access to the router in the Views you can adjust accordingly.
#available(iOS 16.0, *)
struct SampleApp: View {
#State private var router: NavigationRouter = .init()
var body: some View {
NavigationStack(path: $router.path){
A(router: $router)
//Have the root handle the type
.navigationDestination(for: NavSource.self) { option in
option.view($router)
}
}
}
//Create an `enum` so you can define your options
//Conform to all the required protocols
enum ViewOptions: Codable, Equatable, Hashable{
case caseB
case caseC
//If you need other arguments add like this
case unknown(String)
//Assign each case with a `View`
#ViewBuilder func view(_ path: Binding<NavigationRouter>) -> some View{
switch self{
case .caseB:
B(router: path)
case .caseC:
C(router: path)
case .unknown(let string):
Text("View for \(string.description) has not been defined")
}
}
}
}
#available(iOS 16.0, *)
struct A: View {
#Binding var router: NavigationRouter
var body: some View {
VStack{
Button {
router.goTo(view: .caseB)
} label: {
Text("To B")
}
Button {
router.goTo(view: .caseC)
} label: {
Text("To C")
}
}.navigationTitle("A")
}
}
#available(iOS 16.0, *)
struct B: View {
#Binding var router: NavigationRouter
var body: some View {
VStack{
Button {
router.goTo(view: .caseC)
} label: {
Text("Hello")
}
}.navigationTitle("B")
}
}
#available(iOS 16.0, *)
struct C: View {
#Binding var router: NavigationRouter
//Identify changes based on previous View
var fromA: Bool{
//nil is the root
router.identifyPreviousView() == nil
}
var body: some View {
VStack{
Text("Welcome\(fromA ? " Back" : "" )")
Button {
//Append to the path the enum value
router.bactrack(view: router.identifyPreviousView())
} label: {
Text("Back")
}
Button {
//Append to the path the enum value
router.goTo(view: .unknown("\"some other place\""))
} label: {
Text("Next")
}
}.navigationTitle("C")
.navigationBarBackButtonHidden(true)
}
}

You can read the second-to-last item in the path property to learn what the previous screen was.
To do this, it's easier to use an actual array of ViewOptions as the path, instead of a NavigationPath.
For example:
struct SampleApp: App {
// Use your own ViewOptions enum, instead of NavigationPath
#State private var path: [ViewOptions] = []
var body: some Scene {
WindowGroup {
NavigationStack(path: $path){
A(path: $path)
.navigationDestination(for: ViewOptions.self) { option in
option.view($path)
}
}
}
}
}
struct C: View {
#Binding var path: [ViewOptions]
var previousView: ViewOptions? {
path
.suffix(2) // Get the last 2 elements of the path
.first // Get the first of those last 2 elements
}
var body: some View {
VStack {
Text("C")
}
}
}
Remember, a NavigationPath is nothing more than a type-erased array. It can be used to build a NavigationStack quickly without having to worry that all destination values have to match the same type. Since as you're controlling the navigation flow with your own type ViewOptions, it makes no sense to use NavigationPath.

Related

SwiftUI TabView - run code in subview after sequential taps

I am trying to implement the behavior in a TabView when the user taps the same tab multiple times, such as in the iOS AppStore app. First tap: switch to that view, second tap: pop to root, third tap: scroll to the top if needed.
The code below works fine for switching and didTap() is called for every tap.
import SwiftUI
enum Tab: String {
case one
case two
}
struct AppView: View {
#State private var activeTab = Tab.one
var body: some View {
TabView(selection: $activeTab.onChange(didTap)) {
One()
.tabItem {
Label("one", systemImage: "1.lane")
}
.tag(Tab.one)
Two()
.tabItem {
Label("two", systemImage: "2.lane")
}
.tag(Tab.two)
}
}
func didTap(to value: Tab) {
print(value) // this captures every tap
}
}
extension Binding {
func onChange(_ handler: #escaping (Value) -> Void) -> Binding<Value> {
Binding(
get: { self.wrappedValue },
set: { newValue in
self.wrappedValue = newValue
handler(newValue)
}
)
}
}
What I am struggling with, is how to tell either One or Two that it was tapped for a second or third time? (How to pop and scroll is not the issue).
I have seen this: TabView, tabItem: running code on selection or adding an onTapGesture but it doesn't explain how to run code in one of the views.
Any suggestions?
You can record additional taps (of same value) in an array. The array count gives you the number of taps on the same Tab.
EDIT: now with explicit subview struct.
struct ContentView: View {
#State private var activeTab = Tab.one
#State private var tapState: [Tab] = [Tab.one] // because .one is default
var body: some View {
TabView(selection: $activeTab.onChange(didTap)) {
SubView(title: "One", tapCount: tapState.count)
.tabItem {
Label("one", systemImage: "1.lane")
}
.tag(Tab.one)
SubView(title: "Two", tapCount: tapState.count)
.tabItem {
Label("two", systemImage: "2.lane")
}
.tag(Tab.two)
}
}
func didTap(to value: Tab) {
print(value) // this captures every tap
if tapState.last == value {
tapState.append(value) // apped next tap if same value
print("tapped \(tapState.count) times")
} else {
tapState = [value] // reset tap state to new tab selection
}
}
}
struct SubView: View {
let title: String
let tapCount: Int
var body: some View {
VStack {
Text("Subview \(title)").font(.title)
Text("tapped \(tapCount) times")
}
}
}
Although the answer by #ChrisR did answer my question, I couldn't figure out the next step, i.e. the logic when to pop-to-root or scroll-to-the-top based on the number of taps for a SubView. After lots of reading and trial and error, I recently came across this article: https://notificare.com/blog/2022/11/25/a-better-tabview-in-swiftui/
Inspired by this article, but with some modifications, I came up with the following which does exactly what I was looking for.
The two main changes are:
An EmptyView with an id is added as the first (but invisible) row in the List to be used as an anchor by proxy.scrollTo().
Instead of the global #StateObject var appState that stores the navigation paths for the subviews, I added the paths as separate #State properties. This avoids the Update NavigationAuthority bound path tried to update multiple times per frame. warning.
Hopefully this is helpful for someone.
enum Tab: String {
case one
case two
}
struct ContentView: View {
#State var selectedTab = Tab.one
#State var oneNavigationPath = NavigationPath()
#State var twoNavigationPath = NavigationPath()
var body: some View {
ScrollViewReader { proxy in
TabView(selection: tabViewSelectionBinding(proxy: proxy)) {
SubView(title: "One", path: $oneNavigationPath)
.tabItem {
Label("one", systemImage: "1.lane")
}
.tag(Tab.one)
SubView(title: "Two", path: $twoNavigationPath)
.tabItem {
Label("two", systemImage: "2.lane")
}
.tag(Tab.two)
}
}
}
private func tabViewSelectionBinding(proxy: ScrollViewProxy) -> Binding<Tab> {
Binding<Tab>(
get: { selectedTab },
set: { newValue in
if selectedTab == newValue {
switch selectedTab {
case .one:
if oneNavigationPath.isEmpty {
withAnimation {
proxy.scrollTo(Tab.one, anchor: .bottom)
}
} else {
withAnimation {
oneNavigationPath = NavigationPath()
}
}
case .two:
if twoNavigationPath.isEmpty {
withAnimation {
proxy.scrollTo(Tab.two, anchor: .bottom)
}
} else {
withAnimation {
twoNavigationPath = NavigationPath()
}
}
}
}
selectedTab = newValue
}
)
}
}
struct SubView: View {
let title: String
let items = Array(1 ... 100)
#Binding var path: NavigationPath
var body: some View {
NavigationStack(path: $path) {
List {
EmptyView()
.id(Tab(rawValue: title.lowercased()))
ForEach(items, id: \.self) { item in
NavigationLink(value: item) {
Text("Item \(item)")
}
}
}
.navigationTitle(title)
.navigationDestination(for: Int.self) { item in
Text("Item \(item)")
}
}
}
}

Why are objects still in memory after emptying NavigationStack path?

I'm trying to implement a Coordinator for managing a flow. The state is stored inside the CoordinatorStore. There are 2 #Published properties for managing the flow. The screen property controls which View is currently shown and path controls the navigation stack of the stack view. Details of the implementation can be found below.
With the current implementation and after the following actions: showA -> showB -> showInitial -> Go to Stack
I would expect that StoreA and StoreB would be deallocated from memory since path, which holds StoreA and StoreB via enum associated values, gets emptied.
But that doesn't happen, and if I repeat the actions again there would be 2 StoreA and 2 StoreB in memory and so on. Am I missing something?
I will also attach a screenshot of the memory debugger snapshot after doing the initial set of actions.
enum Path: Hashable {
case a(StoreA)
case b(StoreB)
}
enum Screen {
case initial
case stack
}
final class CoordinatorStore: ObservableObject {
#Published var path: [Path] = []
#Published var screen: Screen = .stack
func showA() {
let store = StoreA()
path.append(.a(store))
}
func showB() {
let store = StoreB()
path.append(.b(store))
}
func showInitial() {
path = []
screen = .initial
}
func showStack() {
screen = .stack
}
}
struct Coordinator: View {
#ObservedObject var store: CoordinatorStore
var body: some View {
switch store.screen {
case .initial: initial
case .stack: stack
}
}
var stack: some View {
NavigationStack(path: $store.path) {
VStack {
Text("Root")
}
.toolbar {
Button(action: self.store.showA) {
Text("Push A")
}
}
.navigationDestination(for: Path.self) { path in
switch path {
case .a(let store):
ViewA(store: store)
.toolbar {
Button(action: self.store.showB) {
Text("Push B")
}
}
case .b(let store):
ViewB(store: store)
.toolbar {
Button(action: self.store.showInitial) {
Text("Show Initial")
}
}
}
}
}
}
var initial: some View {
VStack {
Text("Initial")
Button(action: store.showStack) {
Text("Go to Stack")
}
}
}
}
struct ViewA: View {
#ObservedObject var store: StoreA
var body: some View {
Text("View A")
}
}
final class StoreA: NSObject, ObservableObject {
deinit {
print("Deinit: \(String(describing: self))")
}
}
struct ViewB: View {
#ObservedObject var store: StoreB
var body: some View {
Text("View B")
}
}
final class StoreB: NSObject, ObservableObject {
deinit {
print("Deinit: \(String(describing: self))")
}
}
I believe this is related but not identical to:
Found a strange behaviour of #State when combined to the new Navigation Stack - Is it a bug or am I doing it wrong?
The Navigation api seems to be prioritizing efficiency (inits are expensive) and that SOMETHING must always be on screen. It doesn't seem to de-initialize views that have been disappeared until it has a replacement initialized and appeared.
That can lead to a memory leak (I believe) if you try to manage Navigation framework views with something outside of the Navigation framework, but it appears as long as the Navigation framework stays in charge things will be de-inted eventually, but not until the new view is init-ed.
NEWER VERSION
This version uses one coordinator, but preserves the separate enums and views for the initial vs main app pathways.
import Foundation
import SwiftUI
enum AppSceneTvTe:Hashable {
case setup
case app
}
enum PathTvTeOptions: Hashable {
case optionA(OptionAVM)
case optionB(OptionBVM)
}
struct SplashTVTEView: View {
#StateObject var oneCoordinator = CoordinatorTvTe()
var body: some View {
NavigationStack(path: $oneCoordinator.path) {
splash
.navigationDestination(for: AppSceneTvTe.self) { scene in
switch scene {
case .app:
SplashTvTeAppRootView().environmentObject(oneCoordinator)
default:
splash
}
}
}
}
var splash: some View {
VStack {
Text("Splash Page")
Button(action:navigateToApp) {
Text("Go App Root")
}
}.navigationBarBackButtonHidden(true)
}
func navigateToApp() {
oneCoordinator.showStack()
}
}
final class CoordinatorTvTe: ObservableObject {
#Published var path = NavigationPath()
func showA() {
path.append(PathTvTeOptions.optionA(OptionAVM()))
}
func showB() {
path.append(PathTvTeOptions.optionB(OptionBVM()))
}
func showInitial() {
unwindAll()
//path = NavigationPath()
}
func showStack() {
path = NavigationPath()
path.append(AppSceneTvTe.app)
}
func unwindAll() {
while !path.isEmpty {
path.removeLast()
}
}
}
struct SplashTvTeAppRootView: View {
#EnvironmentObject var navigation: CoordinatorTvTe
var body: some View {
VStack {
Text("Real Root")
}
.navigationBarBackButtonHidden(true)
.toolbar {
Button(action: self.navigation.showA) {
Text("Push A")
}
}
.navigationDestination(for: PathTvTeOptions.self) { path in
switch path {
case .optionA(let vm):
OptionAView(vm: vm)
.toolbar {
Button(action: self.navigation.showB) {
Text("Push B")
}
}
case .optionB(let vm):
OptionBView(vm: vm)
.toolbar {
Button(action: self.navigation.showInitial) {
Text("Show Initial")
}
}
}
}
}
}
OLDER VERSION
Currently the way out of this is to keep it all in the Navigation Stack so no separate Scene vs. Path.
This code uses a boolean to control the Initial screen, but it could be one of the path options - which is the commented out code.
EDITED TO ADD: Tuns out the boolean solution gets weird when you try to make the initial state true. The Stack keeps winning, so I've taken it out.
enum Path: Hashable {
case initial
case a(StoreA)
case b(StoreB)
}
final class CoordinatorStore: ObservableObject {
#Published var path: [Path] = [.initial]
func showA() {
let store = StoreA()
path.append(.a(store))
}
func showB() {
let store = StoreB()
path.append(.b(store))
}
func showInitial() {
path = []
path.append(.inital)
}
func showStack() {
path = []
}
}
struct Coordinator: View {
#ObservedObject var store: CoordinatorStore
var body: some View {
NavigationStack(path: $store.path) {
VStack {
Text("Real Root")
}
.toolbar {
Button(action: self.store.showA) {
Text("Push A")
}
}
.navigationDestination(for: Path.self) { path in
switch path {
case .a(let store):
ViewA(store: store)
.toolbar {
Button(action: self.store.showB) {
Text("Push B")
}
}
case .b(let store):
ViewB(store: store)
.toolbar {
Button(action: self.store.showInitial) {
Text("Show Initial")
}
}
case .initial:
initial
}
}
}
}
var initial: some View {
VStack {
Text("Initial")
Button(action: store.showStack) {
Text("Go to Stack")
}
}.navigationBarBackButtonHidden(true)
}
}

Passing binding back to parent's parent view

I have 4 views.
Grandparent
Parent
Child
EditView
Grandparent has a navigation link to Parent, and Parent a navigation link to Child. Child has a button which initializes a #State variable, location (a class), from Grandparent, via a binding in Parent and a binding in Child. That same button also updates a #State variable, showEditView, from Grandparent (via bindings again), which shows the EditView.
There are 11 lines which are currently commented out. If they are commented out, the app throws a "Fatal error: Unexpectedly found nil..." error when I tap the button in the Child view. If either section is uncommented, the button does not throw an error.
It also works if I pass the bound location to the EditView, not property itself like I'm currently doing, where it is then wrapped as an #ObservedObject.
I don't understand what is going on here. The only thing I can of is that, when it's working, SwiftUI is updating the location property because it's used in the body. If that is the case, that seems to indicate that I have to include a hidden Text view of this property every time I want to do have properties passed around this way.
Grandparent
import SwiftUI
struct Grandparent: View {
#State var location: Location!
#State var showEditView: Bool = false
var body: some View {
NavigationView {
VStack{
NavigationLink(
destination: Parent(location: $location, showEditView: $showEditView)) {
Text("Navigate")
}
// // section 1
// if location != nil {
// Text(location.name)
// } else {
// Text("No location yet")
// }
}
// // section 2
// .navigationBarItems(trailing:
// Button("Edit"){
// showEditView = true
// }
// .disabled(location == nil)
// )
}
.padding()
.sheet(isPresented: $showEditView) {
EditView(placemark: location, dismiss: { showEditView = false })
}
}
}
Parent
import SwiftUI
struct Parent: View {
#Binding var location: Location!
#Binding var showEditView: Bool
var body: some View {
NavigationLink(
destination: Child(location: $location, showEditView: $showEditView),
label: {
Text("Child")
})
}
}
Child
import SwiftUI
struct Child: View {
#Binding var location: Location!
#Binding var showEditView: Bool
var body: some View {
Button("Make location") {
location = Location(name: "Lebanon")
showEditView = true
}
}
}
EditView
import SwiftUI
struct EditView: View {
#ObservedObject var placemark: Location
var dismiss: () -> Void
var body: some View {
NavigationView {
Text(placemark.name)
.navigationTitle("Edit place")
.navigationBarItems(trailing: Button("Done") { dismiss() })
}
}
}
Location
import Foundation
class Location: ObservableObject {
init(name: String) {
self.name = name
}
#Published var name: String
}
You probably shouldn't declare your Location as ! and then do nil checking on it. Using ! is sort of asking for a crash to happen. I think what you're encountering is a the sheet getting rendered before location is set. There aren't any guarantees about when in the run loop a #State variable gets set, so it's better to account for scenarios where it is nil (and definitely not using ! to force unwrap it).
Secondly, at least given the scenario you have here, you probably shouldn't be using a class for Location -- it should just be a struct.
Eventually, you are going to run into a little bit of complexity, because judging by your View's name, you want to edit the Location at some point. This becomes a little more tricky with an Optional, since things like TextField want non-optional values, but this can be solved in various was (see where I used nonNilBinding).
Something like this is definitely a more safe approach than what you're currently doing. It may not be exactly what you want, but hopefully it can get you on the right path.
struct Location {
var name : String
}
struct Grandparent: View {
#State var location: Location?
#State var showEditView: Bool = false
var body: some View {
NavigationView {
VStack{
NavigationLink(
destination: Parent(location: $location, showEditView: $showEditView)) {
Text("Navigate")
}
if let location = location {
Text(location.name)
} else {
Text("No location yet")
}
}
.padding()
.sheet(isPresented: $showEditView) {
EditView(placemark: $location, dismiss: { showEditView = false })
}
}
}
}
struct Parent: View {
#Binding var location: Location?
#Binding var showEditView: Bool
var body: some View {
NavigationLink(
destination: Child(location: $location, showEditView: $showEditView),
label: {
Text("Child")
})
}
}
struct Child: View {
#Binding var location: Location?
#Binding var showEditView: Bool
var body: some View {
Button("Make location") {
location = Location(name: "Lebanon")
showEditView = true
}
}
}
struct EditView: View {
#Binding var placemark: Location?
var dismiss: () -> Void
var nonNilBinding : Binding<Location> {
.init { () -> Location in
placemark ?? Location(name:"Default")
} set: { (newValue) in
placemark = newValue
}
}
var body: some View {
NavigationView {
TextField("Name", text: nonNilBinding.name)
.navigationTitle("Edit place")
.navigationBarItems(trailing: Button("Done") { dismiss() })
}
}
}

Deep link onChange not triggered in SwiftUI sheet

I have an issue with deep links in my SwiftUI app.
In my app class I have declared deepLink as an environment variable for every View under ContentView() in the hierarchy:
...
#main
struct TestApp: App {
var userSettings: UserSettings
var dataFetcher: DataFetcher
var dataUpdater: DataUpdater
#State var deepLink = ""
init() {
userSettings = UserSettings()
dataFetcher = DataFetcher(userSettings: userSettings)
dataUpdater = DataUpdater(userSettings: userSettings)
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(userSettings)
.environmentObject(dataFetcher)
.environmentObject(dataUpdater)
.onOpenURL { url in
deepLink = url.absoluteString
}
.environment(\.deepLink, deepLink)
}
}
}
In my ContentView() I've declared deepLink as an environment variable
struct ContentView: View {
...
#State var isTestSheetViewPresented = false
#Environment(\.deepLink) var deepLink: String
...
var body: some View {
Button(action: {
self.isTestSheetViewPresented = true
}, label: {
HStack {
Spacer()
Image(systemName: "plus")
Text("Add")
Spacer()
}
})
.sheet(isPresented: $isTestSheetViewPresented, content: {
TestSheetView(isPresented: self.$isTestSheetViewPresented)
})
.onChange(of: self.deepLink) { _ in
self.isTestSheetViewPresented = true
}
}
}
And the TestSheetView is like this
struct TestSheetView: View {
#Environment(\.deepLink) var deepLink: String
#State var url: String = ""
var body: some View {
Text(url)
.onChange(of: deepLink) { _ in
if deepLink != "" {
self.url = deepLink
}
}
}
}
The problem is that when I click on a link, and my app opens, the TestSheetView is correctly presented but the onChange is not triggered unless I scroll a little bit down the sheet.
Instead if I put the same code of the TestSheetView in the ContentView then the text is correctly shown
Seems like a timing issue. While TestSheetView is being initialized, the body is created after deepLink changed, so it won't be able to detect it.
The solution is to use onAppear in TestSheetView and read from there, like so:
struct TestSheetView: View {
#Environment(\.deepLink) var deepLink: String
#State var url: String = ""
var body: some View {
Text(url)
.onAppear {
if deepLink != "" {
self.url = deepLink
}
}
}
}
It's unergonomic to handle both the case where the view is yet to appear, and the case where a link is being navigated within the view. The following view modifier handles both cases. It assumes an .onOpenURL() handler in the top level navigating view that sets both the current tab selection, along with the currentDeepLink environment value.
struct DeepLinkViewModifier: ViewModifier {
#Environment(\.currentDeepLink) private var currentDeepLink
let action: ((URL) -> Bool)
func body(content: Content) -> some View {
content
.onAppear {
if let url = currentDeepLink.wrappedValue,
action(url) {
currentDeepLink.wrappedValue = nil
}
}
.onOpenURL { url in
_ = action(url)
}
}
}
extension View {
func onDeepLink(perform action: #escaping ((URL) -> Bool)) -> some View {
return self.modifier(DeepLinkViewModifier(action: action))
}
}
Use:
struct SomeView: View {
#State urlString: String = ""
var body: some View {
Text(urlString).onDeepLink { url
self.string = url.absoluteString
return true // return false if another handler should consume
}
}
}
struct ContentView: View {
#State private var tabSelection: TabSelection = .something
#State private var currentDeepLink: URL? = nil
var body: some View {
TabView(selection: self.$tabSelection) {
...
}
.onOpenURL { url in
self.tabSelection = ... // determine selection from URL
self.currentDeepLink = url
}
.environment(\.tabSelection, self.$tabSelection)
.environment(\.currentDeepLink, self.$currentDeepLink)
}
}

SwiftUI NavigationLink loads destination view immediately, without clicking

With following code:
struct HomeView: View {
var body: some View {
NavigationView {
List(dataTypes) { dataType in
NavigationLink(destination: AnotherView()) {
HomeViewRow(dataType: dataType)
}
}
}
}
}
What's weird, when HomeView appears, NavigationLink immediately loads the AnotherView. As a result, all AnotherView dependencies are loaded as well, even though it's not visible on the screen yet. The user has to click on the row to make it appear.
My AnotherView contains a DataSource, where various things happen. The issue is that whole DataSource is loaded at this point, including some timers etc.
Am I doing something wrong..? How to handle it in such way, that AnotherView gets loaded once the user presses on that HomeViewRow?
The best way I have found to combat this issue is by using a Lazy View.
struct NavigationLazyView<Content: View>: View {
let build: () -> Content
init(_ build: #autoclosure #escaping () -> Content) {
self.build = build
}
var body: Content {
build()
}
}
Then the NavigationLink would look like this. You would place the View you want to be displayed inside ()
NavigationLink(destination: NavigationLazyView(DetailView(data: DataModel))) { Text("Item") }
EDIT: See #MwcsMac's answer for a cleaner solution which wraps View creation inside a closure and only initializes it once the view is rendered.
It takes a custom ForEach to do what you are asking for since the function builder does have to evaluate the expression
NavigationLink(destination: AnotherView()) {
HomeViewRow(dataType: dataType)
}
for each visible row to be able to show HomeViewRow(dataType:), in which case AnotherView() must be initialized too.
So to avoid this a custom ForEach is necessary.
import SwiftUI
struct LoadLaterView: View {
var body: some View {
HomeView()
}
}
struct DataType: Identifiable {
let id = UUID()
var i: Int
}
struct ForEachLazyNavigationLink<Data: RandomAccessCollection, Content: View, Destination: View>: View where Data.Element: Identifiable {
var data: Data
var destination: (Data.Element) -> (Destination)
var content: (Data.Element) -> (Content)
#State var selected: Data.Element? = nil
#State var active: Bool = false
var body: some View {
VStack{
NavigationLink(destination: {
VStack{
if self.selected != nil {
self.destination(self.selected!)
} else {
EmptyView()
}
}
}(), isActive: $active){
Text("Hidden navigation link")
.background(Color.orange)
.hidden()
}
List{
ForEach(data) { (element: Data.Element) in
Button(action: {
self.selected = element
self.active = true
}) { self.content(element) }
}
}
}
}
}
struct HomeView: View {
#State var dataTypes: [DataType] = {
return (0...99).map{
return DataType(i: $0)
}
}()
var body: some View {
NavigationView{
ForEachLazyNavigationLink(data: dataTypes, destination: {
return AnotherView(i: $0.i)
}, content: {
return HomeViewRow(dataType: $0)
})
}
}
}
struct HomeViewRow: View {
var dataType: DataType
var body: some View {
Text("Home View \(dataType.i)")
}
}
struct AnotherView: View {
init(i: Int) {
print("Init AnotherView \(i.description)")
self.i = i
}
var i: Int
var body: some View {
print("Loading AnotherView \(i.description)")
return Text("hello \(i.description)").onAppear {
print("onAppear AnotherView \(self.i.description)")
}
}
}
I had the same issue where I might have had a list of 50 items, that then loaded 50 views for the detail view that called an API (which resulted in 50 additional images being downloaded).
The answer for me was to use .onAppear to trigger all logic that needs to be executed when the view appears on screen (like setting off your timers).
struct AnotherView: View {
var body: some View {
VStack{
Text("Hello World!")
}.onAppear {
print("I only printed when the view appeared")
// trigger whatever you need to here instead of on init
}
}
}
For iOS 14 SwiftUI.
Non-elegant solution for lazy navigation destination loading, using view modifier, based on this post.
extension View {
func navigate<Value, Destination: View>(
item: Binding<Value?>,
#ViewBuilder content: #escaping (Value) -> Destination
) -> some View {
return self.modifier(Navigator(item: item, content: content))
}
}
private struct Navigator<Value, Destination: View>: ViewModifier {
let item: Binding<Value?>
let content: (Value) -> Destination
public func body(content: Content) -> some View {
content
.background(
NavigationLink(
destination: { () -> AnyView in
if let value = self.item.wrappedValue {
return AnyView(self.content(value))
} else {
return AnyView(EmptyView())
}
}(),
isActive: Binding<Bool>(
get: { self.item.wrappedValue != nil },
set: { newValue in
if newValue == false {
self.item.wrappedValue = nil
}
}
),
label: EmptyView.init
)
)
}
}
Call it like this:
struct ExampleView: View {
#State
private var date: Date? = nil
var body: some View {
VStack {
Text("Source view")
Button("Send", action: {
self.date = Date()
})
}
.navigate(
item: self.$date,
content: {
VStack {
Text("Destination view")
Text($0.debugDescription)
}
}
)
}
}
I was recently struggling with this issue (for a navigation row component for forms), and this did the trick for me:
#State private var shouldShowDestination = false
NavigationLink(destination: DestinationView(), isActive: $shouldShowDestination) {
Button("More info") {
self.shouldShowDestination = true
}
}
Simply wrap a Button with the NavigationLink, which activation is to be controlled with the button.
Now, if you're to have multiple button+links within the same view, and not an activation State property for each, you should rely on this initializer
/// Creates an instance that presents `destination` when `selection` is set
/// to `tag`.
public init<V>(destination: Destination, tag: V, selection: Binding<V?>, #ViewBuilder label: () -> Label) where V : Hashable
https://developer.apple.com/documentation/swiftui/navigationlink/3364637-init
Along the lines of this example:
struct ContentView: View {
#State private var selection: String? = nil
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: Text("Second View"), tag: "Second", selection: $selection) {
Button("Tap to show second") {
self.selection = "Second"
}
}
NavigationLink(destination: Text("Third View"), tag: "Third", selection: $selection) {
Button("Tap to show third") {
self.selection = "Third"
}
}
}
.navigationBarTitle("Navigation")
}
}
}
More info (and the slightly modified example above) taken from https://www.hackingwithswift.com/articles/216/complete-guide-to-navigationview-in-swiftui (under "Programmatic navigation").
Alternatively, create a custom view component (with embedded NavigationLink), such as this one
struct FormNavigationRow<Destination: View>: View {
let title: String
let destination: Destination
var body: some View {
NavigationLink(destination: destination, isActive: $shouldShowDestination) {
Button(title) {
self.shouldShowDestination = true
}
}
}
// MARK: Private
#State private var shouldShowDestination = false
}
and use it repeatedly as part of a Form (or List):
Form {
FormNavigationRow(title: "One", destination: Text("1"))
FormNavigationRow(title: "Two", destination: Text("2"))
FormNavigationRow(title: "Three", destination: Text("3"))
}
In the destination view you should listen to the event onAppear and put there all code that needs to be executed only when the new screen appears. Like this:
struct DestinationView: View {
var body: some View {
Text("Hello world!")
.onAppear {
// Do something important here, like fetching data from REST API
// This code will only be executed when the view appears
}
}
}

Resources