NavigationLink freezes when trying to revisit previously clicked NavigationLink in SwiftUI - ios

I am designing an app that includes the function of retrieving JSON data and displaying a list of retrieved items in a FileBrowser type view. In this view, a user should be able to click on a folder to dive deeper into the file tree or click on a file to view some metadata about said file.
I've observed that while this is working, when I click on a file or folder then go back and click on it again, the NavigationLink is not triggered and I am stuck on the view until I click into a different NavigationLink.
Here is a gif demonstrating this problem.
As seen here, when I click on BlahBlah I am activating the NavigationLink and taken to BlahBlah, then when I navigate back and try to renavigate to BlahBlah, it becomes grey, registering that I clicked on it... but then never transports me there. Clicking on TestFile fixes this and allows me to navigate back to BlahBlah.
The list items are made with the following structs
private struct FileCell{
var FileName: String
var FileType: String
var FileID: String = ""
var isContainer: Bool
}
private struct constructedCell: View{
var FileType: String
var FileName: String
var FileID: String
var body: some View {
return
HStack{
VStack(alignment: .center){
Image(systemName: getImage(FileType: FileType)).font(.title).frame(width: 50)
}
Divider()
VStack(alignment: .leading){
Text(FileName).font(.headline)
.multilineTextAlignment(.leading)
Text(FileID)
.font(.caption)
.multilineTextAlignment(.leading)
}
}
}
}
and called into view with navigationLinks as follows
List(cellArray, id: \.FileID) { cell in
if (cell.isContainer) {
NavigationLink(destination: FileView(path: "/\(cell.FileID)", displaysLogin: self.$displaysLogin).navigationBarTitle(cell.FileName)){
constructedCell(FileType: cell.FileType, FileName: cell.FileName, FileID: cell.FileID)
}
} else {
NavigationLink(destination: DetailView(FileID: cell.FileID).navigationBarTitle(cell.FileName)){
constructedCell(FileType: cell.FileType, FileName: cell.FileName, FileID: cell.FileID)
}
}
}
My NavigationView is initialized in the view above (the app has a tab view) this as follows
TabView(selection: $selection){
NavigationView{
FileView(displaysLogin: self.$displaysLogin)
.navigationBarTitle("Home", displayMode: .inline)
.background(NavigationConfigurator { nc in
nc.navigationBar.barTintColor = UIColor.white
nc.navigationBar.titleTextAttributes = [.foregroundColor : UIColor.black]
})
}
.font(.title)
.tabItem {
VStack {
Image(systemName: "folder.fill")
Text("Files")
}
}
.tag(0)
}
The NavigationConfigurator is a struct I use for handling the color of the navigationBar. It is set up like so
struct NavigationConfigurator: UIViewControllerRepresentable {
var configure: (UINavigationController) -> Void = { _ in }
func makeUIViewController(context: UIViewControllerRepresentableContext<NavigationConfigurator>) -> UIViewController {
UIViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<NavigationConfigurator>) {
if let nc = uiViewController.navigationController {
self.configure(nc)
}
}
}
I do not think my NavigationConfigurator is causing this? This bug also happens in other navigationLinks in the app, but it was easiest to demonstrate it here in the FileBrowser view.
This might be a bug in SwiftUI? If it is does anyone know a way to work around this? If it isn't, what am I doing wrong?

Had the same issue - try this. I would call this a hack to be removed when the bug in swiftUI is corrected.
struct ListView: View {
#State private var destID = 0
...
var body: some View {
...
NavigationLink(destination: FileView(path: "/\(cell.FileID)", displaysLogin: self.$displaysLogin)
.navigationBarTitle(cell.FileName)
.onDisappear() { self.destID = self.destID + 1 }
){
constructedCell(FileType: cell.FileType, FileName: cell.FileName, FileID: cell.FileID)
}.id(destID)
Essentially it seems that in some circumstances (iOS 13.3 - Simulator?) the NavigationLink is not reset when the destination view is removed from the navigation stack. As a work around we need to regenerate the Navigation Link. This is what changing the id does. This corrected my issue.
However if you have NavigationLinks that are chained, that is a link that leads to another list of links, then this solution will create side effects; the stack returns to the origin at the second attempt to show the last view.

Related

How to keep SwiftUI from creating additional StateObjects in this custom page view?

Abstract
I'm creating an app that allows for content creation and display. The UX I yearn for requires the content creation view to use programmatic navigation. I aim at architecture with a main view model and an additional one for the content creation view. The problem is, the content creation view model does not work as I expected in this specific example.
Code structure
Please note that this is a minimal reproducible example.
Suppose there is a ContentView: View with a nested AddContentPresenterView: View. The nested view consists of two phases:
specifying object's name
summary screen
To allow for programmatic navigation with NavigationStack (new in iOS 16), each phase has an associated value.
Assume that AddContentPresenterView requires the view model. No workarounds with #State will do - I desire to learn how to handle ObservableObject in this case.
Code
ContentView
struct ContentView: View {
#EnvironmentObject var model: ContentViewViewModel
var body: some View {
VStack {
NavigationStack(path: $model.path) {
List(model.content) { element in
Text(element.name)
}
.navigationDestination(for: Content.self) { element in
ContentDetailView(content: element)
}
.navigationDestination(for: Page.self) { page in
AddContentPresenterView(page: page)
}
}
Button {
model.navigateToNextPartOfContentCreation()
} label: {
Label("Add content", systemImage: "plus")
}
}
}
}
ContentDetailView (irrelevant)
struct ContentDetailView: View {
let content: Content
var body: some View {
Text(content.name)
}
}
AddContentPresenterView
As navigationDestination associates a destination view with a presented data type for use within a navigation stack, I found no better way of adding a paged view to be navigated using the NavigationStack than this.
extension AddContentPresenterView {
var contentName: some View {
TextField("Name your content", text: $addContentViewModel.contentName)
.onSubmit {
model.navigateToNextPartOfContentCreation()
}
}
var contentSummary: some View {
VStack {
Text(addContentViewModel.contentName)
Button {
model.addContent(addContentViewModel.createContent())
model.navigateToRoot()
} label: {
Label("Add this content", systemImage: "checkmark.circle")
}
}
}
}
ContentViewViewModel
Controls the navigation and adding content.
class ContentViewViewModel: ObservableObject {
#Published var path = NavigationPath()
#Published var content: [Content] = []
func navigateToNextPartOfContentCreation() {
switch path.count {
case 0:
path.append(Page.contentName)
case 1:
path.append(Page.contentSummary)
default:
fatalError("Navigation error.")
}
}
func navigateToRoot() {
path.removeLast(path.count)
}
func addContent(_ content: Content) {
self.content.append(content)
}
}
AddContentViewModel
Manages content creation.
class AddContentViewModel: ObservableObject {
#Published var contentName = ""
func createContent() -> Content {
return Content(name: contentName)
}
}
Page
Enum containing creation screen pages.
enum Page: Hashable {
case contentName, contentSummary
}
What is wrong
Currently, for each page pushed onto the navigation stack, a new StateObject is created. That makes the creation of object impossible, since the addContentViewModel.contentName holds value only for the bound screen.
I thought that, since StateObject is tied to the view's lifecycle, it's tied to AddContentPresenterView and, therefore, I would be able to share it.
What I've tried
The error is resolved when addContentViewModel in AddContentPresenterView is an EnvironmentObject initialized in App itself. Then, however, it's tied to the App's lifecycle and subsequent content creations greet us with stale data - as it should be.
Wraping up
How to keep SwiftUI from creating additional StateObjects in this custom page view?
Should I resort to ObservedObject and try some wizardry? Should I just implement a reset method for my AddContentViewModel and reset the data on entering or quiting the screen?
Or maybe there is a better way of achieving what I've summarized in abstract?
If you declare #StateObject var addContentViewModel = AddContentViewModel() in your AddContentPresenterView it will always initialise new AddContentViewModel object when you add AddContentPresenterView in navigation stack. Now looking at your code and app flow I don't fill you need AddContentViewModel.
First, update your contentSummary of the Page enum with an associated value like this.
enum Page {
case contentName, contentSummary(String)
}
Now update your navigate to the next page method of your ContentViewModel like below.
func navigateToNextPage(_ page: Page) {
path.append(page)
}
Now for ContentView, I think you need to add VStack inside NavigationStack otherwise that bottom plus button will always be visible.
ContentView
struct ContentView: View {
#EnvironmentObject var model: ContentViewViewModel
var body: some View {
NavigationStack(path: $model.path) {
VStack {
List(model.content) { element in
Text(element.name)
}
.navigationDestination(for: Content.self) { element in
ContentDetailView(content: element)
}
.navigationDestination(for: Page.self) { page in
switch page {
case .contentName: AddContentView()
case .contentSummary(let name): ContentSummaryView(contentName: name)
}
}
Button {
model.navigateToNextPage(.contentName)
} label: {
Label("Add content", systemImage: "plus")
}
}
}
}
}
So now it will push destination view on basis of the type of the Page. So you can remove your AddContentPresenterView and add AddContentView and ContentSummaryView.
AddContentView
struct AddContentView: View {
#EnvironmentObject var model: ContentViewViewModel
#State private var contentName = ""
var body: some View {
TextField("Name your content", text: $contentName)
.onSubmit {
model.navigateToNextPage(.contentSummary(contentName))
}
}
}
ContentSummaryView
struct ContentSummaryView: View {
#EnvironmentObject var model: ContentViewViewModel
let contentName: String
var body: some View {
VStack {
Text(contentName)
Button {
model.addContent(Content(name: contentName))
model.navigateToRoot()
} label: {
Label("Add this content", systemImage: "checkmark.circle")
}
}
}
}
So as you can see I have used #State property in AddContentView to bind it with TextField and on submit I'm passing it as an associated value with contentSummary. So this will reduce the use of AddContentViewModel. So now there is no need to reset anything or you want face any issue of data loss when you push to ContentSummaryView.

How to open specific View in SwiftUI app using AppIntents

I'm very new to Intents in Swift. Using the Dive Into App Intents video from WWDC 22 and the Booky example app, I've gotten my app to show up in the Shortcuts app and show an initial shortcut which opens the app to the main view. Here is the AppIntents code:
import AppIntents
enum NavigationType: String, AppEnum, CaseDisplayRepresentable {
case folders
case cards
case favorites
// This will be displayed as the title of the menu shown when picking from the options
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Navigation")
static var caseDisplayRepresentations: [Self:DisplayRepresentation] = [
.folders: DisplayRepresentation(title: "Folders"),
.cards: DisplayRepresentation(title: "Card Gallery"),
.favorites: DisplayRepresentation(title: "Favorites")
]
}
struct OpenCards: AppIntent {
// Title of the action in the Shortcuts app
static var title: LocalizedStringResource = "Open Card Gallery"
// Description of the action in the Shortcuts app
static var description: IntentDescription = IntentDescription("This action will open the Card gallery in the Hello There app.", categoryName: "Navigation")
// This opens the host app when the action is run
static var openAppWhenRun = true
#Parameter(title: "Navigation")
var navigation: NavigationType
#MainActor // <-- include if the code needs to be run on the main thread
func perform() async throws -> some IntentResult {
ViewModel.shared.navigateToGallery()
return .result()
}
static var parameterSummary: some ParameterSummary {
Summary("Open \(\.$navigation)")
}
}
And here is the ViewModel:
import SwiftUI
class ViewModel: ObservableObject {
static let shared = ViewModel()
#Published var path: any View = FavoritesView()
// Clears the navigation stack and returns home
func navigateToGallery() {
path = FavoritesView()
}
}
Right now, the Shortcut lets you select one of the enums (Folders, Cards, and Favorites), but always launches to the root of the app. Essentially no different then just telling Siri to open my app. My app uses a TabView in its ContentView with TabItems for the related Views:
.tabItem {
Text("Folders")
Image(systemName: "folder.fill")
}
NavigationView {
GalleryView()
}
.tabItem {
Text("Cards")
Image(systemName: "rectangle.portrait.on.rectangle.portrait.angled.fill")
}
NavigationView {
FavoritesView()
}
.tabItem {
Text("Favs")
Image(systemName: "star.square.on.square.fill")
}
NavigationView {
SettingsView()
}
.tabItem {
Text("Settings")
Image(systemName: "gear")
}
How can I configure the AppIntents above to include something like "Open Favorites View" and have it launch into that TabItem view? I think the ViewModel needs tweaking... I've tried to configure it to open the FavoritesView() by default, but I'm lost on the proper path forward.
Thanks!
[EDIT -- updated with current code]
You're on the right track, you just need some way to do programmatic navigation.
With TabView, you can do that by passing a selection argument, a binding that you can then update to select a tab. An enum of all your tabs works nicely here. Here's an example view:
struct SelectableTabView: View {
enum Tabs {
case first, second
}
#State var selected = Tabs.first
var body: some View {
// selected will track the current tab:
TabView(selection: $selected) {
Text("First tab content")
.tabItem {
Image(systemName: "1.circle.fill")
}
// The tag is how TabView knows which tab is which:
.tag(Tabs.first)
VStack {
Text("Second tab content")
Button("Select first tab") {
// You can change selected to navigate to a different tab:
selected = .first
}
}
.tabItem {
Image(systemName: "2.circle.fill")
}
.tag(Tabs.second)
}
}
}
So in your code, ViewModel.path could be an enum representing the available tabs, and you could pass a binding to path ($viewModel.path) to your TabView. Then you could simply set path = .favorites to navigate.

Deleting the last item in a list from the detail view in SwiftUI does not pop the detail view

I am working on an app and encountered a strange behavior of List and NavigationLink when removing the last item in the list from the detail view. I am using iOS 13 and Xcode 11 and I made a simplified version that reproduces the behavior:
import SwiftUI
struct ListView: View {
#State private var content = [Int](0..<10) {
didSet {
print(content)
}
}
var body: some View {
NavigationView {
List(content, id: \.self) { element in
NavigationLink(
destination: DetailView(
remove: {
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 1) { // Asynchronous network request
DispatchQueue.main.async {
self.content.removeAll { $0 == element } // When request was successfull remove element from list
}
}
}
)
) {
Text("Element #\(element)")
}
}
}
}
}
struct DetailView: View {
let remove: () -> Void
init(remove: #escaping () -> Void) {
self.remove = remove
}
var body: some View {
VStack {
Text("Hello world!")
Button(
action: {
self.remove()
}
) {
Image(systemName: "trash.circle")
.imageScale(.large)
}
}
}
}
To reconstruct the error select the last item in the list and press the trashcan to delete. As you may notice the view does not disappear as with the other items from the list. But if you press back the list will have correctly removed the last item. This is also shown in this gif. The state change of the list is printed to the console when pressing the trashcan.
I have noticed that the removing of the specific item does not seem to be the problem as it also happens when removing a random item. It does work correctly if I remove the selected item and add new items at the end. So it may be caused by shrinking the size of the array.
I have also found several workarounds. Like modifying the NavigationView with .id(UUID()) but this removes the animations. An other solution is to dismiss the view with PresentationMode and on disappear remove the item from the list but I rather would use a different solution.
To see if it is related to iOS 13 or Xcode 11 I tested it on the newest beta version of iOS 14 and Xcode 12 (currently beta 5). Here the detail view does not get dismissed with any of the selected items.
Has anybody encountered this problem before or at least can explain why this behaves this way?
EDIT: Added mock network request to better illustrate the specific issue.
SwiftUI behaves a bit strange there, I think...
You could tell SwiftUI explicitly to dismiss the DetailView. To do so, you need to modify DetailView a little:
struct DetailView: View {
#Environment(\.presentationMode) var presentationMode
let remove: () -> Void
init(remove: #escaping () -> Void) {
self.remove = remove
}
var body: some View {
VStack {
Text("Hello world!")
Button(
action: {
self.remove()
self.presentationMode.wrappedValue.dismiss()
}
) {
Image(systemName: "trash.circle")
.imageScale(.large)
}
}
}
}
(See also: iOS SwiftUI: pop or dismiss view programmatically)
For me, this seems to work, even with the last row.

SwiftUI NavigationLink push in onAppear immediately pops the view when using #ObservableObject

I want to programmatically be able to navigate to a link within a List of NavigationLinks when the view appears (building deep linking from push notification). I have a string -> Bool dictionary which is bound to a custom Binding<Bool> inside my view. When the view appears, I set the bool property, navigation happens, however, it immediately pops back. I followed the answer in SwiftUI NavigationLink immediately navigates back and made sure that each item in the List has a unique identifier, but the issue still persists.
Two questions:
Is my binding logic here correct?
How come the view pops back immediately?
import SwiftUI
class ContentViewModel: ObservableObject {
#Published var isLinkActive:[String: Bool] = [:]
}
struct ContentViewTwo: View {
#ObservedObject var contentViewModel = ContentViewModel()
#State var data = ["1", "2", "3"]
#State var shouldPushPage3: Bool = true
var page3: some View {
Text("Page 3")
.onAppear() {
print("Page 3 Appeared!")
}
}
func binding(chatId: String) -> Binding<Bool> {
return .init(get: { () -> Bool in
return self.contentViewModel.isLinkActive[chatId, default: false]
}) { (value) in
self.contentViewModel.isLinkActive[chatId] = value
}
}
var body: some View {
return
List(data, id: \.self) { data in
NavigationLink(destination: self.page3, isActive: self.binding(chatId: data)) {
Text("Page 3 Link with Data: \(data)")
}.onAppear() {
print("link appeared")
}
}.onAppear() {
print ("ContentViewTwo Appeared")
if (self.shouldPushPage3) {
self.shouldPushPage3 = false
self.contentViewModel.isLinkActive["3"] = true
print("Activating link to page 3")
}
}
}
}
struct ContentView: View {
var body: some View {
return NavigationView() {
VStack {
Text("Page 1")
NavigationLink(destination: ContentViewTwo()) {
Text("Page 2 Link")
}
}
}
}
}
The error is due to the lifecycle of the ViewModel, and is a limitation with SwiftUI NavigationLink itself at the moment, will have to wait to see if Apple updates the pending issues in the next release.
Update for SwiftUI 2.0:
Change:
#ObservedObject var contentViewModel = ContentViewModel()
to:
#StateObject var contentViewModel = ContentViewModel()
#StateObject means that changes in the state of the view model do not trigger a redraw of the whole body.
You also need to store the shouldPushPage3 variable outside the View as the view will get recreated every time you pop back to the root View.
enum DeepLinking {
static var shouldPushPage3 = true
}
And reference it as follows:
if (DeepLinking.shouldPushPage3) {
DeepLinking.shouldPushPage3 = false
self.contentViewModel.isLinkActive["3"] = true
print("Activating link to page 3")
}
The bug got fixed with the latest SwiftUI release. But to use this code at the moment, you will need to use the beta version of Xcode and iOS 14 - it will be live in a month or so with the next GM Xcode release.
I was coming up against this problem, with a standard (not using 'isActive') NavigationLink - for me the problem turned out to be the use of the view modifiers: .onAppear{code} and .onDisappear{code} in the destination view. I think it was causing a re-draw loop or something which caused the view to pop back to my list view (after approx 1 second).
I solved it by moving the modifiers onto a part of the destination view that's not affected by the code in those modifiers.

SwiftUI doesn't update UI with ObservedObject in nested NavigationLink destination

I currently have an app that's fetching data from an API, In the root view (let's call it Home) everything works as expected, in the second view (let's call it User View) everything works as expected but now on the third view (Team View) the ObservedObject for this view only is not working.
The strangest part is that if the user navigates directly to the Team View, again every thing works as expected.
Each view has it's own ObservedObject has the data being loaded belongs only to that view
The navigation between each view is made by the NavigationLink
Heres an exemple of how I'm doing the loading and navigation.
struct HomeView: View {
#ObservedObject var viewModel = HomeViewModel()
var body: some View {
VStack {
NavigationLink(destination: UserView(userId: viewModel.userId))
NavigationLink(destination: TeamView(teamId: viewModel.teamId))
}
}
}
struct TeamView: View {
#ObservedObject var viewModel = TeamViewModel()
#State var teamId: String = ""
var body: some View {
Text(viewModel.name)
.onAppear() { viewModel.loadData(id: teamId) }
}
}
struct UserView: View {
#ObservedObject var viewModel = UserViewModel()
#State var userId: String = ""
var body: some View {
VStack {
Text(viewModel.name)
NavigationLink(destination: TeamView(teamId: viewModel.teamId))
}
.onAppear() { viewModel.loadData(id: userId) }
}
}
From the example you can see that the function to load the data is in the view model and is loaded when the view appears
Everything works just fine but when I reach the 3rd level in the stack the data does not get updated in the view. I thought It might be the thread but I'm using DispatchQueue.main.async when the fetch is complete.
All necessary variables on the Model are marked as #Published
In sum the following flows work
HomeView -> TeamView
HomeView -> UserView
But this one on the last view it does load the data but it does not update the view
HomeView -> UserView -> TeamView
I replicated your code behaviour and the issue is due to fast navigation. Here is what's going on
if you would do
HomeView [tap] -> UserView -> [wait for user loaded] -> TeamView // no issue
but you do
HomeView [tap] -> UserView [tap] -> TeamView // got defect
The defect is because UserView is updated in background when the data got loaded, so body rebuilt, so link is recreated, so TeamView is reconstructed, but .onAppear is not called, because such kind of view is already on screen.
(I'm not sure if this is SwiftUI bug, because there is logic in such behaviour).
So here is a solution for this case. Tested with Xcode 11.5b.
struct TeamView: View {
#ObservedObject var viewModel = TeamViewModel()
var teamId: String // << state is not needed
init(teamId: String) {
self.teamId = teamId
viewModel.loadData(id: teamId) // initiate load here !!
}
var body: some View {
Text(viewModel.name)
}
}
struct UserView: View {
#ObservedObject var viewModel = UserViewModel()
#State var userId: String = ""
var body: some View {
VStack {
Text(viewModel.name)
// used DeferView to avoid in-advance constructions
NavigationLink(destination: DeferView { TeamView(teamId: viewModel.teamId) })
}
.onAppear() { viewModel.loadData(id: userId) }
}
}
DeferView is taken from this my post.

Resources