onAppear not updating instantly in SwiftUI - ios

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.

Related

SwiftUI auto navigates to detail view after saving and dismissing the view

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.

SwiftUI transition from modal sheet to regular view with Navigation Link

I'm working with SwiftUI and I have a starting page. When a user presses a button on this page, a modal sheet pops up.
In side the modal sheet, I have some code like this:
NavigationLink(destination: NextView(), tag: 2, selection: $tag) {
EmptyView()
}
and my modal sheet view is wrapped inside of a Navigation View.
When the value of tag becomes 2, the view does indeed go to NextView(), but it's also presented as a modal sheet that the user can swipe down from, and I don't want this.
I'd like to transition from a modal sheet to a regular view.
Is this possible? I've tried hiding the navigation bar, etc. but it doesn't seem to make a difference.
Any help with this matter would be appreciated.
You can do this by creating an environmentObject and bind the navigationLink destination value to the environmentObject's value then change the value of the environmentObject in the modal view.
Here is a code explaining what I mean
import SwiftUI
class NavigationManager: ObservableObject{
#Published private(set) var dest: AnyView? = nil
#Published var isActive: Bool = false
func move(to: AnyView) {
self.dest = to
self.isActive = true
}
}
struct StackOverflow6: View {
#State var showModal: Bool = false
#EnvironmentObject var navigationManager: NavigationManager
var body: some View {
NavigationView {
ZStack {
NavigationLink(destination: self.navigationManager.dest, isActive: self.$navigationManager.isActive) {
EmptyView()
}
Button(action: {
self.showModal.toggle()
}) {
Text("Show Modal")
}
}
}
.sheet(isPresented: self.$showModal) {
secondView(isPresented: self.$showModal).environmentObject(self.navigationManager)
}
}
}
struct StackOverflow6_Previews: PreviewProvider {
static var previews: some View {
StackOverflow6().environmentObject(NavigationManager())
}
}
struct secondView: View {
#EnvironmentObject var navigationManager: NavigationManager
#Binding var isPresented: Bool
#State var dest: AnyView? = nil
var body: some View {
VStack {
Text("Modal view")
Button(action: {
self.isPresented = false
self.dest = AnyView(thirdView())
}) {
Text("Press me to navigate")
}
}
.onDisappear {
// This code can run any where but I placed it in `.onDisappear` so you can see the animation
if let dest = self.dest {
self.navigationManager.move(to: dest)
}
}
}
}
struct thirdView: View {
var body: some View {
Text("3rd")
.navigationBarTitle(Text("3rd View"))
}
}
Hope this helps, if you have any questions regarding this code, please let me know.

SwiftUI: detecting the NavigationView back button press

In SwiftUI I couldn't find a way to detect when the user taps on the default back button of the navigation view when I am inside DetailView1 in this code:
struct RootView: View {
#State private var showDetails: Bool = false
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView1(), isActive: $showDetails) {
Text("show DetailView1")
}
}
.navigationBarTitle("RootView")
}
}
}
struct DetailView1: View {
#State private var showDetails: Bool = false
var body: some View {
NavigationLink(destination: DetailView2(), isActive: $showDetails) {
Text("show DetailView2")
}
.navigationBarTitle("DetailView1")
}
}
struct DetailView2: View {
var body: some View {
Text("")
.navigationBarTitle("DetailView2")
}
}
Using .onDisappear doesn't solve the problem as its closure is called when the view is popped off or a new view is pushed.
The quick solution is to create a custom back button because right now the framework have not this possibility.
struct DetailView : View {
#Environment(\.presentationMode) var mode: Binding<PresentationMode>
var body : some View {
Text("Detail View")
.navigationBarBackButtonHidden(true)
.navigationBarItems(leading: Button(action : {
self.mode.wrappedValue.dismiss()
}){
Image(systemName: "arrow.left")
})
}
}
As soon as you press the back button, the view sets isPresented to false, so you can use an observer on that value to trigger code when the back button is pressed. Assume this view is presented inside a navigation controller:
struct MyView: View {
#Environment(\.isPresented) var isPresented
var body: some View {
Rectangle().onChange(of: isPresented) { newValue in
if !newValue {
print("detail view is dismissed")
}
}
}
}
An even nicer (SwiftUI-ier?) way of observing the published showDetails property:
struct RootView: View {
class ViewModel: ObservableObject {
#Published var showDetails = false
}
#ObservedObject var viewModel = ViewModel()
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView1(), isActive: $viewModel.showDetails) {
Text("show DetailView1")
}
}
.navigationBarTitle("RootView")
.onReceive(self.viewModel.$showDetails) { isShowing in
debugPrint(isShowing)
// Maybe do something here?
}
}
}
}
Following up on my comment, I would react to changes in the state of showDetails. Unfortunately didSet doesn't appear to trigger with #State variables. Instead, we can use an observable view model to hold the state, which does allow us to do intercept changes with didSet.
struct RootView: View {
class ViewModel: ObservableObject {
#Published var showDetails = false {
didSet {
debugPrint(showDetails)
// Maybe do something here?
}
}
}
#ObservedObject var viewModel = ViewModel()
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView1(), isActive: $viewModel.showDetails) {
Text("show DetailView1")
}
}
.navigationBarTitle("RootView")
}
}
}

ObservedObject not working on NavigationLink's destination if there are updates on parent

I have two screens, a master and a detail, detail has an ObservedObject that has it's state. I also want to hide the navigation bar on master and show it on detail. To do that, I have the navigation bar hidden status as a #State property on master view and send it back to the detail view as a Binding variable.
The problem I'm having is that whenever I update that variable inside the detail screen, the ObservedObject stops working.
Here's a sample code that reproduces the issue:
struct ContentView: View {
#State var navigationBarHidden = true
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView(navigationBarHidden: $navigationBarHidden)) {
Text("Go Forward")
}
}
.navigationBarTitle("", displayMode: .inline)
.navigationBarHidden(navigationBarHidden)
.onAppear { self.navigationBarHidden = true }
}
}
}
class DetailViewModel: ObservableObject {
#Published var text = "Didn't work"
}
struct DetailView: View {
#Binding var navigationBarHidden: Bool
#ObservedObject var viewModel = DetailViewModel()
var body: some View {
VStack {
Text(viewModel.text)
}.onAppear {
self.navigationBarHidden = false
self.viewModel.text = "Worked"
}
}
}
If I leave it as is, the text will not update to "Worked". If I remove the line self.navigationBarHidden = false, the ObservedObject will work properly and the text will update.
How can I achieve the expected behavior, update the navigation bar while keeping my observed object working?
The reason is, that
NavigationLink(destination: DetailView(navigationBarHidden: $navigationBarHidden)) {
Text("Go Forward")
}
create new DetailView and so on new DetailViewModel when activating
try
import SwiftUI
struct ContentView: View {
#State var navigationBarHidden = true
#ObservedObject var viewModel = DetailViewModel()
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: DetailView(navigationBarHidden: $navigationBarHidden).environmentObject(viewModel)) {
Text("Go Forward")
}
}
.navigationBarTitle("", displayMode: .inline)
.navigationBarHidden(navigationBarHidden)
.onAppear { self.navigationBarHidden = true }
}
}
}
class DetailViewModel: ObservableObject {
#Published var text = "Didn't work"
}
struct DetailView: View {
#Binding var navigationBarHidden: Bool
#EnvironmentObject var viewModel: DetailViewModel
var body: some View {
VStack {
Text(viewModel.text)
}.onAppear {
self.navigationBarHidden = false
self.viewModel.text = "Worked"
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Now you share the model with DetailView and it works as expected (written)
If I remove the line self.navigationBarHidden = false, the
ObservedObject will work properly and the text will update.
If you remove this line, the DetailView in not recreated (there is nothing changed in View) State is not part of View state, it is reference type, so SwiftUI don't see any changes until some values which are wrapped by them change.

SwiftUI - How to close the sheet view, while dismissing that view

I want to achieve the function. Like, "Look up" view that is from Apple.
My aim is when the sheet view push another view by navigation, the user can tap the navigation item button to close the sheet view. Like, this below gif.
I try to achieve this function.
I found a problem that is when the user tap the "Done" button. The App doesn't close the sheet view. It only pop the view to parent view. Like, this below gif.
This is my code.
import SwiftUI
struct ContentView: View {
#State var isShowSheet = false
var body: some View {
Button(action: {
self.isShowSheet.toggle()
}) {
Text("Tap to show the sheet")
}.sheet(isPresented: $isShowSheet) {
NavView()
}
}
}
struct NavView: View {
var body: some View {
NavigationView {
NavigationLink(destination: NavSubView()) {
Text("Enter Sub View")
}
} .navigationViewStyle(StackNavigationViewStyle())
}
}
struct NavSubView: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
Text("Hello")
.navigationBarItems(trailing:
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}){
Text("Done")
}
)
}
}
How did I achieve this function? :)
Please help me, thank you. :)
UPDATE: Restored original version - provided below changes should be done, as intended, to the topic starter's code. Tested as worked with Xcode 13 / iOS 15
As navigation in sheet might be long enough and closing can be not in all navigation subviews, I prefer to use environment to have ability to specify closing feature only in needed places instead of passing binding via all navigation stack.
Here is possible approach (tested with Xcode 11.2 / iOS 13.2)
Define environment key to store sheet state
struct ShowingSheetKey: EnvironmentKey {
static let defaultValue: Binding<Bool>? = nil
}
extension EnvironmentValues {
var showingSheet: Binding<Bool>? {
get { self[ShowingSheetKey.self] }
set { self[ShowingSheetKey.self] = newValue }
}
}
Set this environment value to root of sheet content, so it will be available in any subview when declared
}.sheet(isPresented: $isShowSheet) {
NavView()
.environment(\.showingSheet, self.$isShowSheet)
}
Declare & use environment value only in subview where it is going to be used
struct NavSubView: View {
#Environment(\.showingSheet) var showingSheet
var body: some View {
Text("Hello")
.navigationBarItems(trailing:
Button("Done") {
self.showingSheet?.wrappedValue = false
}
)
}
}
I haven't tried SwiftUI ever, but I came from UIKit + RxSwift, so I kinda know how binding works. I read quite a bit of sample codes from a SwiftUI Tutorial, and the way you dismiss a modal is actually correct, but apparently not for a navigation stack.
One way I learned just now is use a #Binding var. This might not be the best solution, but it worked!
So you have this $isShowSheet in your ContentView. Pass that object to your NavView struct by declaring a variable in that NavView.
ContentView
.....
}.sheet(isPresented: $isShowSheet) {
NavView(isShowSheet: self.$isShowSheet)
}
NavView
struct NavView: View {
#Binding var isShowSheet: Bool
var body: some View {
NavigationView {
NavigationLink(destination: NavSubView(isShowSheet: self.$isShowSheet)) {
Text("Enter Sub View")
}
} .navigationViewStyle(StackNavigationViewStyle())
}
}
and finally, do the same thing to your subView.
NavSubView
struct NavSubView: View {
#Environment(\.presentationMode) var presentationMode
#Binding var isShowSheet: Bool
var body: some View {
Text("Hello")
.navigationBarItems(trailing:
Button(action: {
//self.presentationMode.projectedValue.wrappedValue.dismiss()
self.isShowSheet = false
}){
Text("Done")
}
)
}
}
Now as you can see, you just need to send a new signal to that isShowSheet binding var - false.
self.isShowSheet = false
Voila!
Here's an improved version of Asperi's code from above since they won't accept my edit. Main credit goes to them.
As navigation in sheet might be long enough and closing can be not in all navigation subviews, I prefer to use environment to have ability to specify closing feature only in needed places instead of passing binding via all navigation stack.
Here is possible approach (tested with Xcode 13 / iOS 15)
Define environment key to store sheet state
struct ShowingSheetKey: EnvironmentKey {
static let defaultValue: Binding<Bool>? = nil
}
extension EnvironmentValues {
var isShowingSheet: Binding<Bool>? {
get { self[ShowingSheetKey.self] }
set { self[ShowingSheetKey.self] = newValue }
}
}
Set this environment value to root of sheet content, so it will be available in any subview when declared
#State var isShowingSheet = false
...
Button("open sheet") {
isShowingSheet?.wrappedValue = true
}
// note no $ in front of isShowingSheet
.sheet(isPresented: isShowingSheet ?? .constant(false)) {
NavView()
.environment(\.isShowingSheet, self.$isShowingSheet)
}
Declare & use environment value only in subview where it is going to be used
struct NavSubView: View {
#Environment(\.isShowingSheet) var isShowingSheet
var body: some View {
Text("Hello")
.navigationBarItems(trailing:
Button("Done") {
isShowingSheet?.wrappedValue = false
}
)
}
}

Resources