So I am writing a todo list app in SwiftUI in order to get the hang of it, but I am facing a problem.
In my first view (list of items) I have a toolbar with an "add" button which uses a NavigationLink to navigate to the detail view. In the detail view I also have a toolbar button acting as a save button which dismisses this view and also adds the item to a list of items kept in the view model used by both views.
The problem is that if I save the item when tapping the save button it will first navigate back to the first view and then auto navigate to the second view again. If I instead use the built in back button this issue doesn't happen, but obviously I would like to save the item and only when pressing save. This also only happens if I add the item to the item list in the view model before dismissing the view, if I only dismiss the view without saving the item when pressing done then this bug doesn't happen.
Is this not a standard way of saving and closing a view with SwiftUI, or is there some sort of other pattern that is better? In any case I need to resolve this issue.
First view:
struct TodoListView: View {
#EnvironmentObject var viewModel: TodoListViewModel
var body: some View {
NavigationView {
List {
ForEach(viewModel.listOfTodos) { todoItem in
ItemCellView(todoItem: todoItem)
}
}
.navigationTitle("Things to do")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
NavigationLink(
destination: AddEditTodoView(todoItem: TodoListInfo.TodoItem())
) {
Text("Add item") // The navigation bug happens when using this button
}
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct ItemCellView: View {
var todoItem: TodoListInfo.TodoItem
var body: some View {
HStack {
NavigationLink(destination: AddEditTodoView(todoItem: todoItem)) {
Text(todoItem.title) // The navigation bug doesn't happen when editing an existing item
}
}
.padding()
}
}
Second view:
struct AddEditTodoView: View {
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#EnvironmentObject var viewModel: TodoListViewModel
#State var todoItem: TodoListInfo.TodoItem
var body: some View {
Form {
Section(header: Text("Title")) {
TextField("Title", text: $todoItem.title)
}
}
.navigationTitle(Text("Edit task"))
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
viewModel.upsert(item: todoItem) // No bug if I comment out this line
presentationMode.wrappedValue.dismiss()
}
.disabled(todoItem.title == "")
}
}
}
}
View model:
class TodoListViewModel: ObservableObject {
#Published private var todoListInfo: TodoListInfo
private var autoSaveCancellable: AnyCancellable? // even without the autoSaveCancellable part, the bug happens
init(testData: Bool = false) {
todoListInfo = TodoListInfo(testData: testData)
autoSaveCancellable = $todoListInfo.sink {
TodoListInfo.persistTodoList($0)
}
}
var listOfTodos: [TodoListInfo.TodoItem] {
todoListInfo.todos
}
func upsert(item: TodoListInfo.TodoItem) {
if let itemIndex = todoListInfo.todos.firstIndex(where: { $0.id == item.id }) {
todoListInfo.todos[itemIndex] = item
} else {
todoListInfo.todos.append(item) // This gets called when adding an item
}
}
}
The solution for this is to make the navigation be based on a binding state.
NavigationLink(
destination: ExchangeItemSelectedView(exchange: observer),
tag: exchange.id,
selection: $exchangeSelection
) {
Text("Tap Me")
}
then rather than using #State to store exchangeSelection use #SceneStorage this will let you access the binding from anywhere within your app, in the code that creates the new item it should then dispatch async to update the selection value to the new item ID.
Related
I have 3 views within my app where clicking on a button on each view opens a new view. When button is clicked on 3rd view, I wish to dismiss 3rd view and 2nd view should appear. However I am noticing that app navigates back to first view instead of 2nd view.
Note: I have lots of elements, hence lots of code in my app. I removed all of them and adding minimal working code here with which I am still able to repro the problem.
// *** Main App***
#main
struct sample_sampleApp: App {
var body: some SwiftUI.Scene {
WindowGroup {
NavigationView {
ContentView().ignoresSafeArea()
}.navigationViewStyle(.stack)
}
}
}
// *** Content View or First View***
import SwiftUI
struct ContentView: View {
#State private var goToView2 = false
var body: some View {
VStack {
NavigationLink(destination: View2(), isActive: $goToView2) {
Button(action: { goToView2.toggle() }) {
Text("This is first view - Click to go to View 2").foregroundColor(.red).font(.title)
}
}
}
}
}
// *** View2 View***
import SwiftUI
import Combine
struct View2: View {
#ObservedObject var viewModel: View2ViewModel = View2ViewModel()
var body: some View {
VStack {
switch viewModel.state {
case .showView2:
VStack(alignment: .leading, spacing: 8) {
Button(action: { viewModel.navigateToView3() } ) {
Text("Second View - Click to go to View 3").foregroundColor(.blue).font(.title)
}
}
case .showView3:
View3()
}
}
.onAppear() {
viewModel.isViewVisible = true
viewModel.doSomething()
}
.onDisappear() {
viewModel.isViewVisible = false
}
}
}
// *** View model for view 2***
class View2ViewModel: ObservableObject {
enum View2AppState {
case showView2
case showView3
}
// UI changes when state gets updated.
#Published var state: View2AppState = .showView2
var isViewVisible = false
func doSomething() {
self.state = .showView2
}
func navigateToView3() {
self.state = .showView3
}
}
// *** View3***
struct View3: View {
#ObservedObject var viewModel: View3ViewModel = View3ViewModel()
#Environment(\.dismiss) var dismiss
var body: some View {
VStack {
switch viewModel.state {
case .showView3:
VStack(alignment: .leading, spacing: 8) {
Button(action: { dismiss() } ) {
Text("Third View - Click to dismiss this and to go back to view 2").foregroundColor(.green).font(.title)
}
}
}
}
.onAppear() {
viewModel.isViewVisible = true
viewModel.doSomething()
}
.onDisappear() {
viewModel.isViewVisible = false
}.navigationBarBackButtonHidden(true)
}
}
// *** View model for view 3***
class View3ViewModel: ObservableObject {
enum View3AppState {
case showView3
}
// UI changes when state gets updated.
#Published var state: View3AppState = .showView3
var isViewVisible = false
func doSomething() {
self.state = .showView3
}
}
Not sure what am I doing wrong. Sometime back I did use dismiss() while dismissing sheet and it worked fine, but not this this case. I am running this code on iOS 16.0, however my test app is set to iOS 15 as minimum version.
Edit: I tested on iOS 15.0 as well and was able to repro on it too, so something must be wrong with my code then. Not able to figure out what. I am using NavigationView in and navigation view style as Stack.
When button is clicked on 3rd view, I wish to dismiss 3rd view and 2nd view should appear.
Let's first have a look at the code of view2.
struct View2: View {
#ObservedObject var viewModel: View2ViewModel = View2ViewModel()
var body: some View {
VStack {
switch viewModel.state {
case .showView2:
VStack(alignment: .leading, spacing: 8) {
Button(action: { viewModel.navigateToView3() } ) {
Text("Second View - Click to go to View 3").foregroundColor(.blue).font(.title)
}
}
case .showView3:
View3()
}
}
}
}
// here viewModel.navigateToView3() is just changing this state
// func navigateToView3() {
// self.state = .showView3
// }
The current code behavior, when tapping to navigate to view3, replaces the content of view2 with view3 instead of actually navigating to it.
Therefore, when the dismiss function is called, it should not return to view2 as it is already in view2 displaying the content of view3.
So, going back to view1 on the dismiss press is actually the correct behavior as per the code.
If you desire the outcome you are asking, consider modifying the code using a closure passed into the child view to change the state in view2 or explore this answer to actually navigate to it.
I have two views: the list, and the detail.
When I tap into the list item and access the detail view, I can configure settings to that model item.
If I have it update (save to CoreData) .onDisappear the List View's .onAppear doesn't trigger with the new saved data.
However, if I add in a "save" button in the Detail View and then manually unwind to the List View the data updates instantly.
Is there a reason for .onDisappear and .onAppear for different screens not triggering in order?
I would have thought that the unwind of the DetailView would trigger the function before the ListView's onAppear would, unless I'm missing something?
I tried to find some info about running them synchronously but with the new async the results haven't been clear.
Note: I'm not displaying the CoreDataManager but it essentially has 4 functions - create(...all the variables to save...), retrieve(), update(), delete()
Example code
ListView
struct ListView: View {
let coreDM = CoreDataManager()
#State var days: [Int] = []
var body: some View {
NavigationView {
List(0..<5) { item in
NavigationLink {
DetailView()
} label: {
Text("List item: \(item)")
}
}
}.onAppear { coreDM.retrieve() }
}
}
DetailView
This version doesn't work
struct DetailView: View {
let coreDM = CoreDataManager()
#State var isOn: Bool = false
var body: some View {
Form {
Toggle("Turn this on", isOn: $isOn)
}
.onDisappear { coreDM.update() }
}
}
This version does work
struct DetailView: View {
let coreDM = CoreDataManager()
#State var isOn: Bool = false
var body: some View {
Form {
Toggle("Turn this on", isOn: $isOn)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
coreDM.update()
} label: {
Text("Save")
}
}
}
}
}
The reason I am confused is that if I add this portion to the DetailView the update does work correctly:
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
...
Button {
coreDM.update()
presentationMode.wrappedValue.dismiss()
} label: {
...
So it seems that I can unwind the view, update the database, and refresh in one move. But if I want to "automate" it by doing it only on the unwind, the triggers don't work correctly.
I have a view for a list item that displays some news cards within a navigationLink.
I am supposed to add a like/unlike button within each news card of navigationLink, without being took to NavigationLink.destination page.
It seems like a small button inside a big button.
When you click that small one, execute the small one without executing the bigger one.
(note: the click area is covered by the two buttons, smaller one has the priority)
(In javascript, it seems like something called .stopPropaganda)
This is my code:
var body: some View {
NavigationView {
List {
ForEach(self.newsData.newsList, id:\.self) { articleID in
NavigationLink(destination: NewsDetail(articleID: articleID)) {
HStack {
Text(newsTitle)
Button(action: {
self.news.isBookmarked.toggle()
}) {
if self.news.isBookmarked {
Image(systemName: "bookmark.fill")
} else {
Image(systemName: "bookmark")
}
}
}
}
}
}
}
}
Currently, the button action (like/dislike) will not be performed as whenever the button is pressed, the navigationLink takes you to the destination view.
I have tried this almost same question but it cannot solve this problem.
Is there a way that makes this possible?
Thanks.
as of XCode 12.3, the magic is to add .buttonStyle(PlainButtonStyle()) or BorderlessButtonStyle to the button, when said button is on the same row as a NavigationLink within a List.
Without this particular incantation, the entire list row gets activated when the button is pressed and vice versa (button gets activated when NavigationLink is pressed).
This code does exactly what you want.
struct Artcle {
var text: String
var isBookmarked: Bool = false
}
struct ArticleDetail: View {
var article: Artcle
var body: some View {
Text(article.text)
}
}
struct ArticleCell: View {
var article: Artcle
var toggle: () -> ()
#State var showDetails = false
var body: some View {
HStack {
Text(article.text)
Spacer()
Button(action: {
self.toggle()
}) {
Image(systemName: article.isBookmarked ? "bookmark.fill" : "bookmark").padding()
}
.buttonStyle(BorderlessButtonStyle())
}
.overlay(
NavigationLink(destination: ArticleDetail(article: article), isActive: $showDetails) { EmptyView() }
)
.onTapGesture {
self.showDetails = true
}
}
}
struct ContentView: View {
#State var articles: [Artcle]
init() {
_articles = State(initialValue: (0...10).map { Artcle(text: "Article \($0 + 1)") })
}
func toggleArticle(at index: Int) {
articles[index].isBookmarked.toggle()
}
var body: some View {
NavigationView {
List {
ForEach(Array(self.articles.enumerated()), id:\.offset) { offset, article in
ArticleCell(article: article) {
self.toggleArticle(at: offset)
}
}
}
}
}
}
I have a layout that looks like this:
Layout Drawing
There is a main view which is the Feed that would be my NavigationView and then I have views inside: PostList -> Post -> PostFooter and in the PostFooter A Button that would be my NavigationLink
struct Feed: View {
var body: some View {
NavigationView {
PostList()
}
}
}
struct PostList: View {
var body: some View {
List {
ForEach(....) {
Post()
}
}
}
}
struct Post: View {
var body: some View {
PostHeader()
Image()
PostFooter()
}
}
struct PostFooter: View {
var body: some View {
NavigationLink(destination: Comment()) {
Text("comments")
}
}
}
But When when I tap on the comments, it goes to the Comment View then go back to Feed() then back to Comment() then back to Feed() and have weird behaviour.
Is there a better way to handle such a situation ?
Update
The Navigation is now working but the all Post component is Tapeable instead of just the Text in the PostFooter.
Is there any way to disable tap gesture on the cell and add multiple NavigationLink in a cell that go to different pages ?
How about programmatically active the NavigationLink, for example:
struct PostFooter: View {
#State var commentActive: Bool = false
var body: some View {
VStack{
Button("Comments") {
commentActive = true
}
NavigationLink(destination: Comment(), isActive: $commentActive) {
EmptyView()
}
}
}
}
Another benefit of above is, your NavigationLink destination View can accept #ObservedObject or #Binding for comments editing.
I have a list in a Navigation View, with a trailing navigation button to add a list item. The button opens a modal sheet. When I dismiss the sheet (by pulling it down), the sheet pops right back up again automatically and I can't get back to the first screen. Here's my code.
struct ListView: View {
#ObservedObject var listVM: ListViewModel
#State var showNewItemView: Bool = false
init() {
self.listVM = ListViewModel()
}
var body: some View {
NavigationView {
List {
ForEach(listVM.items, id: \.dateCreated) { item in
HStack {
Text(item.name)
Spacer()
Image(systemName: "arrow.right")
}
}
}
.navigationBarTitle("List Name")
.navigationBarItems(trailing: AddNewItemBtn(isOn: $showNewItemView))
}
}
}
struct AddNewItemBtn: View {
#Binding var isOn: Bool
var body: some View {
Button(
action: { self.isOn.toggle() },
label: { Image(systemName: "plus.app") })
.sheet(
isPresented: self.$isOn,
content: { NewItemView() })
}
}
I am getting this error:
Warning: Attempt to present <_TtGC7SwiftUIP13$7fff2c603b7c22SheetHostingControllerVS_7AnyView_: 0x7fc5e0c1f8f0> on which is already presenting (null)
I've tried toggling the bool within "onDismiss" on the button itself, but that doesn't work either. Any ideas?
Turns out putting the button in the navigationBarItems(trailing:) modifier is the problem. I just put the button in the list itself instead of in the nav bar and it works perfectly fine. Must be some kind of bug.