SwiftUI - Pop back in navigation stack does not deallocate a view - ios

I would like to start by highlighting my views hierarchy. I just have FindUserView and WelcomeView.
FindUserView is used for retrieving users from the server if the entered email exists. If so, then it automatically redirects me to the next WelcomeView where I can enter password and login.
I've created a repo here and a video SwiftUI - Pop back does not deallocate view
My FindUserView: ---------------------------- and WelcomeView:
-----------------
By pressing NEXT button on FindUserView I fetch a user from the database:
func fetchUser(with email: String) {
userService.getUser(with: email) { (result) in
switch result {
case .success(_):
self.showActivityIndicator = false
self.showingAlert = false
self.showWelcomeView = true
break
case .failure:
self.showingAlert = true
break
}
}
}
I use NavigationView and programatically show WelcomeView by changing showWelcomeView state above:
NavigationLink(destination: WelcomeView(), isActive: $showWelcomeView) { EmptyView() }
Now I am on welcome view WelcomeView.
But when I press this button and pop back, my WelcomeView still exists.
As I use #EnvironmentObject with observable property state I see how it reflects to the view which is already dismissed. Is this correct behaviour? Or do I need to dealloc WelcomeView somehow? Does it lead to memory leaks?
I am a bit worry as in UIKit when you pop back in navigation stack the view controller it is deallocated by UINavigationController by removing view controller from the array automatically. How to pop back correctly in SwiftUI?

Actually it is not clear if it is defect or not - SwiftUI views are values, so there is no dealloc thing for them. It looks like NavigationView just keeps something like lastView variable until it is replaced with another one. Maybe worth submitting feedback to Apple.
Meanwhile here is solution that allows to defer real destination view creation until exactly NavigationLink tapped and cleanup it (w/ any related resources, like view model) when view is removed from stack by Back button.
Tested with Xcode 11.4 / iOS 13.4
Helper proxy view presenter:
struct LinkPresenter<Content: View>: View {
let content: () -> Content
#State private var invlidated = false
init(#ViewBuilder _ content: #escaping () -> Content) {
self.content = content
}
var body: some View {
Group {
if self.invlidated {
EmptyView()
} else {
content()
}
}
.onDisappear { self.invlidated = true }
}
}
Usage:
NavigationLink(destination: LinkPresenter { WelcomeView() },
isActive: $showWelcomeView) { EmptyView() }

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.

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.

NavigationLink freezes when trying to revisit previously clicked NavigationLink in SwiftUI

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.

SwiftUI: How can I get the selected row in a List before pushing another view

Another SwiftUI struggle!
I have a view that contains a list. When user taps on a row, I want to first save the selected item in my VM then push another view.
The only way I can think of to solve that issue is to first save the selected row and have another button to push the next view. It seems impossible to do this with only one tap.
Anyone have a clue?
Here's the code by the way:
struct AnotherView : View {
#State var viewModel = AnotherViewModel()
var body: some View {
NavigationView {
VStack {
List(viewModel.items.identified(by: \.id)) { item in
NavigationLink(destination: DestinationView()) {
Text(item)
}
// Before the new view is open, I want to save the selected item in my VM, which will write to a global store.
self.viewModel.selectedItem = item
}
}
}
}
}
Thank you!
Alright, I found a not too shady solution.
I used this article https://ryanashcraft.me/swiftui-programmatic-navigation shout out to him!
Instead of using a NavigationLink button, I use a regular button, save the selected item when the user tap then use NavigationDestinationLink to push the new view as is self.link.presented?.value = true.
Works like a charm as of beta 3!
I'll update my post if something change in the next betas.
Here's how it could look like:
struct AnotherView : View {
private let link: NavigationDestinationLink<AnotherView2>
#State var viewModel = AnotherViewModel()
init() {
self.link = NavigationDestinationLink(
AnotherView2(),
isDetail: true
)
}
var body: some View {
NavigationView {
VStack {
List(viewModel.items.identified(by: \.id)) { item in
Button(action: {
// Save the object into a global store to be used later on
self.viewModel.selectedItem = item
// Present new view
self.link.presented?.value = true
}) {
Text(value: item)
}
}
}
}
}
}
You can add simple TapGesture
NavigationLink(destination: ContentView() ) {
Text("Row")
.gesture(TapGesture()
.onEnded({ _ in
//your action here
}))
}
This can also be accomplished by using a Publisher and the OnReceive hook in any View inheritor.
Swift 5.1
In case you would like to apply it to a static List. You may try this.
NavigationView{
List{
NavigationLink("YourView1Description", destination: YourView1())
NavigationLink("YourView2Description", destination: YourView2())
NavigationLink("YourView3Description", destination: YourView3())
NavigationLink("YourView4Description", destination: YourView4())
}
.navigationBarTitle(Text("Details"))
}

Resources