SwiftUI NavigationView adding extra view onto stack when sheet is dismissed - swift

I have a List with ForEach using a NavigationLink that when tapped displays a detail view. The DetailsView includes a sheet to save the detail information into an array. After the save, the sheet is dismissed but an additional DetailsView is put on the navigation stack, so that I need to tap the back link twice to get back to the listing.
I'm likely doing something incorrect as I'm relatively new to swiftui, but can't determine what.
Three things of interest:
In the ListView, I use .navigationViewStyle(StackNavigationViewStyle()). When removed, the issue goes away but the iPad gets messy for the ListView.
I'm using insert(at: 0) to add data in my array because I want the most recent data at the top of the listing. If I use append instead, the issue does go away. Wanting the most recently saved item at the top of the list, I add a sort, however sorting causes the duplicate issue to reappear.
The issue only seems to occur when selecting the first item created in the list (the last in the array) and then saving a new item into the array.
steps:
click Tap Here First, then tap SAVE, enter a name then click Save.
click tab bar item Saved.
click on the list item from step 1 in the Saved Items listing (nav bar should show "< Saved Items").
click SAVE, enter another name then click Save. At this point, the duplicate view appears with "< Back" as the leading nav bar item, clicking it takes you to the original detail view, then clicking "< Saved Items" takes you to the list view.
What am I doing wrong or what should I be doing better?
xcode 12.4/iOS 14.1
Stripped down code to reproduce:
struct TestModel: Identifiable, Codable {
private(set) var id: UUID
var name: String
}
class AppData: ObservableObject {
#Published var testList = [TestModel]()
}
struct NewView: View {
var body: some View {
NavigationView {
NavigationLink(
destination: DetailView(item: TestModel(id: UUID(), name: ""))) {
Text("Tap here first")
}.navigationBarTitle("Main View", displayMode: .inline)
}.navigationViewStyle(StackNavigationViewStyle())
}
}
struct ListView: View {
#EnvironmentObject var appData: AppData
var body: some View {
NavigationView {
List {
ForEach(appData.testList) { item in
NavigationLink(destination: DetailView(item: item)) {
Text(item.name)
}
}
}
.navigationBarTitle("Saved Items", displayMode: .inline)
}
.navigationViewStyle(StackNavigationViewStyle()) // remove this and issue goes away, but iPad gets "messy".
}
}
struct DetailView: View {
#State private var isSaveShowing = false
#State var item: TestModel
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .center, spacing: 20) {
Text(item.name)
Button(action: {
isSaveShowing = true
}) {
Text("Save".uppercased())
}.sheet(isPresented: $isSaveShowing) {
SaveView(currentItem: item)
}
}
}
}
}
struct SaveView: View {
var currentItem: TestModel
#State private var name = ""
#EnvironmentObject var appData: AppData
#Environment(\.presentationMode) var presentationMode
var body: some View {
NavigationView {
Form {
Section(header: Text("Enter Name ".uppercased())
) {
TextField("Name (required)", text: $name)
}
}
.navigationBarItems(
trailing: Button(action: {
// appData.testList.append(TestModel(id: UUID(), name: name)) // using append instead of insert also resolves issue...
appData.testList.insert(TestModel(id: UUID(), name: name), at: 0)
presentationMode.wrappedValue.dismiss()
}) {
Text("Save")
}
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct ContentView: View {
var appData = AppData()
var body: some View {
TabView {
NewView().tabItem {
Image(systemName: "rectangle.stack.badge.plus")
Text("Calculate")
}
ListView().tabItem {
Image(systemName: "tray.and.arrow.down")
Text("Saved")
}
}
.environmentObject(appData)
}
}

Apparently, this must have been a bug in SwiftUI.
Running the same code using Xcode 12.5 beta 3 with iOS 14.5 the issue no longer occurs.

Related

SwiftUI .searchable new view

I am creating an app and I am using .searchable for my home view, but once it is clicked, I want it to bring up a new view where results of what is being searched is shown, and once the cancel button is clicked goes back to the home view.
I currently have .searchable(text: $searchText) and it is showing the search bar, and I have tried .overlay{SearchView()} but it is just putting it over the home view. Is there a way to do this.
You need to use the #Environment variable isSearching to determine which view to display, e.g.
struct ContentView: View {
#State private var searchText = ""
var body: some View {
NavigationView {
SearchingView(searchText: $searchText)
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search")
.navigationTitle("Title")
}
}
}
struct SearchingView: View {
#Environment(\.isSearching) private var isSearching
#Binding var searchText: String
var body: some View {
if isSearching {
List("This is displayed when searching".components(separatedBy: " "), id: \.self) { word in
Text(word )
}
.listStyle(.plain)
} else {
Text("Here is the normal view")
}
}
}

NavigationLink inside a List and VStack does not work after the first pop

I am trying to use the new Pull to Refresh feature in the latest version of SWiftUI which requires a List. Enclosing the VStack in a List causes the NavigationLink to work only once. Below is a simple version of the code without the Pull To Refresh part.
There is a question that was asked 68144891 on stackoverflow and there was a refrence to a known issue link which takes you to a page not found (https://developer.apple.com/documentation/ios-ipados-release-notes/ios-ipados-15-beta-release-notes)
Steps o reproduce
Tap "Press Me 1" or one of the items
Tap "Show Details"
Tap Back at the top
Tap "Press Me" again will not navigate to the next screen. A grey screen blocks when you tap
The app works without the VStack
struct ContentView: View {
var body: some View {
NavigationView {
List {
VStack { // commenting VStack works
Text("Options").font(.largeTitle).bold()
ForEach(1..<5, id:\.self) { counter in
NavigationLink(destination: SubView(counter: counter)) {
Text("Press Me \(counter)").font(.headline)
}
.buttonStyle(PlainButtonStyle())
}
}
}.listStyle(.grouped)
}
}
}
struct SubView: View {
var counter: Int
#State private var showDetails = false
var body: some View {
VStack(alignment: .leading) {
Button("Show details") {
showDetails.toggle()
}
if showDetails {
Text("Clicked")
.font(.largeTitle)
}
}
}
}
Any help appreciated
Thanks much!
... follow-up to my comment
I assume you wanted this
struct ContentView: View {
var body: some View {
NavigationView {
List {
Section(Text("Options").font(.largeTitle).bold()) {
ForEach(1..<5, id:\.self) { counter in
NavigationLink(destination: SubView(counter: counter)) {
Text("Press Me \(counter)").font(.headline)
}
.buttonStyle(PlainButtonStyle())
}
}
}.listStyle(.grouped)
}
}
}

Why does modifying the label of a NavigationLink change which View is displayed in SwiftUI?

I have an #EnvironmentObject called word (of type Word) whose identifier property I'm using for the label of a NavigationLink in SwiftUI. For the DetailView that is linked to the NavigationLink, all I have put is this:
struct DetailView: View {
#EnvironmentObject var word: Word
var body: some View {
VStack {
Text(word.identifier)
Button(action: {
self.word.identifier += "a"
}) {
Text("Click to add an 'a' to Word's identifier")
}
}
}
}
The ContentView that leads to this DetailView looks like this (I've simplified my actual code to isolate the problem).
struct ContentView: View {
#EnvironmentObject var word: Word
var body: some View {
NavigationView {
NavigationLink(destination: DetailView()) {
Text(word.identifier)
}
}
}
}
When I tap the button on the DetailView, I'd expect it to update the DetailView with a new word.identifier that has an extra "a" appended onto it. When I tap it, however, it takes me back to the ContentView, albeit with an updated word.identifier. I can't seem to find a way to stay on my DetailView when the word.identifier being used by the ContentView's NavigationLink is modified. Also, I am running Xcode 11.3.1 and am currently unable to update, so if this is has been patched, please let me know.
Here is workaround solution
struct DetailView: View {
#EnvironmentObject var word: Word
#State private var identifier: String = ""
var body: some View {
VStack {
Text(self.identifier)
Button(action: {
self.identifier += "a"
}) {
Text("Click to add an 'a' to Word's identifier")
}
}
.onAppear {
self.identifier = self.word.identifier
}
.onDisappear {
self.word.identifier = self.identifier
}
}
}
This works as expected on iOS 13.4, assuming Word is something like:
class Word : ObservableObject {
#Published var identifier = "foo"
}

Insert, update and delete animations with ForEach in SwiftUI

I managed to have a nice insert and delete animation for items displayed in a ForEach (done via .transition(...) on Row). But sadly this animation is also triggered when I just update the name of Item in the observed array. Of course this is because it actually is a new view (you can see that, since onAppear() of Row is called).
As we all know the recommended way of managing lists with cool animations would be List but I think that many people would like to avoid the standard UI or the limitations that come along with this element.
A working SwiftUI example snippet is attached (Build with Xcode 11.4)
So, the question:
Is there a smart way to suppress the animation (or have another one) for just updated items that would keep the same position? Is there a cool possibility to "reuse" the row and just update it?
Or is the answer "Let's wait for the next WWDC and let's see if Apple will fix it..."? ;-)
Cheers,
Orlando šŸ»
Edit
bonky fronks answer is actually a good approach when you can distinguish between edit/add/delete (e.g. by manual user actions). As soon as the items array gets updated in background (for example by synced updates coming from Core Data in your view model) you don't know if this is an update or not. But maybe in this case the answer would be to manually implement the insert/update/delete cases in the view model.
struct ContentView: View {
#State var items: [Item] = [
Item(name: "Tim"),
Item(name: "Steve"),
Item(name: "Bill")
]
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(items, id: \.self) { item in
Row(name: item.name)
}
}
}
.navigationBarItems(leading: AddButton, trailing: RenameButton)
}
}
private var AddButton: some View {
Button(action: {
self.items.insert(Item(name: "Jeff"), at: 0)
}) {
Text("Add")
}
}
private var RenameButton: some View {
Button(action: {
self.items[0].name = "Craigh"
}) {
Text("Rename first")
}
}
}
struct Row: View {
#State var name: String
var body: some View {
HStack {
Text(name)
Spacer()
}
.padding()
.animation(.spring())
.transition(.move(edge: .leading))
}
}
struct Item: Identifiable, Hashable {
let id: UUID
var name: String
init(id: UUID = UUID(), name: String) {
self.id = id
self.name = name
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Luckily this is actually really easy to do. Simply remove .animation(.spring()) on your Row, and wrap any changes in withAnimation(.spring()) { ... }.
So the add button will look like this:
private var AddButton: some View {
Button(action: {
withAnimation(.spring()) {
self.items.insert(Item(name: "Jeff"), at: 0)
}
}) {
Text("Add")
}
}
and your Row will look like this:
struct Row: View {
#State var name: String
var body: some View {
HStack {
Text(name)
Spacer()
}
.padding()
.transition(.move(edge: .leading))
}
}
The animation must be added on the VStack with the modifier animation(.spring, value: items) where items is the value with respect to which you want to animate the view. items must be an Equatable value.
This way, you can also animate values that you receive from your view model.
var body: some View {
NavigationView {
ScrollView {
VStack {
ForEach(items, id: \.self) { item in
Row(name: item.name)
}
}
.animation(.spring(), value: items) // <<< here
}
.navigationBarItems(leading: AddButton, trailing: RenameButton)
}
}

SwiftUI: Dismiss View Within macOS NavigationView

As detailed here (on an iOS topic), the following code can be used to make a SwiftUI View dismiss itself:
#Environment(\.presentationMode) var presentationMode
// ...
presentationMode.wrappedValue.dismiss()
However, this approach doesn't work for a native (not Catalyst) macOS NavigationView setup (such as the below), where the selected view is displayed alongside the List.
Ideally, when any of these sub-views use the above, the list would go back to having nothing selected (like when it first launched); however, the dismiss function appears to do nothing: the view remains exactly the same.
Is this a bug, or expected macOS behaviour?
Is there another approach that can be used instead?
struct HelpView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination:
AboutAppView()
) {
Text("About this App")
}
NavigationLink(destination:
Text("Hereā€™s a User Guide")
) {
Text("User Guide")
}
}
}
}
}
struct AboutAppView: View {
#Environment(\.presentationMode) var presentationMode
public var body: some View {
Button(action: {
self.dismissSelf()
}) {
Text("Dismiss Me!")
}
}
private func dismissSelf() {
presentationMode.wrappedValue.dismiss()
}
}
FYI: The real intent is for less direct scenarios (such as triggering from an Alert upon completion of a task); the button setup here is just for simplicity.
The solution here is simple. Do not use Navigation View where you need to dismiss the view.
Check the example given by Apple https://developer.apple.com/tutorials/swiftui/creating-a-macos-app
If you need dismissable view, there is 2 way.
Create a new modal window (This is more complicated)
Use sheet.
Following is implimenation fo sheet in macOS with SwiftUI
struct HelpView: View {
#State private var showModal = false
var body: some View {
NavigationView {
List {
NavigationLink(destination:
VStack {
Button("About"){ self.showModal.toggle() }
Text("Hereā€™s a User Guide")
}
) {
Text("User Guide")
}
}
}
.sheet(isPresented: $showModal) {
AboutAppView(showModal: self.$showModal)
}
}
}
struct AboutAppView: View {
#Binding var showModal: Bool
public var body: some View {
Button(action: {
self.showModal.toggle()
}) {
Text("Dismiss Me!")
}
}
}
There is also a 3rd option to use ZStack to create a Modal Card in RootView and change opacity to show and hide with dynamic data.