Pop Navigation view from ViewModel - ios

I'm using swiftUI and combine, I'have some business logic in my VM. Some results have to pop my view in navigation view stack.
I'v used this one in some views to simulate backbutton event :
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
self.presentationMode.wrappedValue.dismiss()
I tried it in view model but it doesn't work. Any ideas ?

This is a follow up question that I answered previously.
You can achieve this by implementing your custom Publisher which will use .send() method to allow you to send specific values to the subscriber (in this case, your View). You will use onReceive(_:perform:) method defined on the View protocol of SwiftUI to subscribe to the output stream of the custom Publisher you defined. Inside the perform action closure where you will have the access to the latest emitted value of your publisher, you will do the actual dismissal of your View.
Enough of the theory, you can look at the code, should not be very hard to follow, below:
import Foundation
import Combine
class ViewModel: ObservableObject {
var viewDismissalModePublisher = PassthroughSubject<Bool, Never>()
private var shouldPopView = false {
didSet {
viewDismissalModePublisher.send(shouldPopView)
}
}
func performBusinessLogic() {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.shouldPopView = true
}
}
}
And the views are:
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
Text("Hello, World!")
NavigationLink(destination: DetailView()) {
Text("Detail")
}
}
.navigationBarTitle(Text("Home"))
}
}
}
struct DetailView: View {
#ObservedObject var viewModel = ViewModel()
#Environment(\.presentationMode) private var presentationMode
var body: some View {
Text("Detail")
.navigationBarTitle("Detail", displayMode: .inline)
.onAppear {
self.viewModel.performBusinessLogic()
}
.onReceive(viewModel.viewDismissalModePublisher) { shouldPop in
if shouldPop {
self.presentationMode.wrappedValue.dismiss()
}
}
}
}

Related

SwiftUI pass by reference a class object to another view

I'm trying to learn SwiftUI and I'm going to develop a simple app with tab views and sharing core motion data between those views.
The main idea is to create a motion manager object (like here) and use the sensor values in all views.
ContentView.swift:
import SwiftUI
struct ContentView: View {
#State private var selection = 1
#State private var viewNames : [String] = ["View1", "View2"]
var body: some View {
TabView(selection: $selection){
View1(viewName: $viewNames[0]).tag(0)
View2(viewName: $viewNames[1]).tag(1)
}
}
}
View1.swift:
import SwiftUI
struct View1 : View {
#Binding var viewName : String
var body: some View {
Text("First View")
.font(.title)
.foregroundColor(Color.gray)
.tabItem {
VStack {
Image(systemName: "star")
Text(viewName)
}
}
}
}
View2.swift:
struct View2 : View {
#Binding var viewName : String
var body: some View {
VStack {
Text("Second View")
.font(.title)
.foregroundColor(Color.green)
.padding(.top)
View21(motionManager: MotionManager())
}.tabItem {
VStack {
Image(systemName:"heart")
Text(viewName)
}
}
}
}
View21.swift
struct View21 : View {
#ObservedObject var motionManager : MotionManager
#State private var showDetails = false
var body: some View{
Text(String(format: "%.2f", motionManager.x))
}
With these code I can use the sensor data in View21, but I can't access the data in the views in the hierarchy above.
Furthermore I created #ObservedObject in the ContentView (like here) and passed it through all views. My app work in the simulator, but it doesn't on the real device.
I can see sensor data changing, but I can't switch the tab views.
I've tried to use #EnvironementObject instead of #ObservedObject, but the behavior is the same.
I'll be very thankful for any help and tipps to my issue.
Best wishes
Okay, so Paulw11 is right that the you probably want to inject your ObservableObject into your environment, then in each view that wants to access that instance, you just add a property with the #EnvironmentObject property wrapper. Below I've thrown together the simplest example I could think of, so that you can get the idea of how it works.
import SwiftUI
import Combine
class ManagerPlaceholder: ObservableObject {
#Published var propertyOne: Double = 1.0
#Published var propertyTwo: Double = 2.0
func action() {
propertyOne = Double.random(in: 0.0..<100.00)
propertyTwo = Double.random(in: 0.0..<100.00)
}
}
struct ContentView: View {
#EnvironmentObject var manager: ManagerPlaceholder
var body: some View {
TabView {
Subview()
.tabItem { Label("First", systemImage: "hexagon.fill") }
.tag(1)
Subview()
.tabItem { Label("Second", systemImage: "circle.fill") }
.tag(2)
}
}
}
struct Subview: View {
#EnvironmentObject var manager: ManagerPlaceholder
var body: some View {
VStack {
Text("Prop One: \(manager.propertyOne)").padding()
Text("Prop Two: \(manager.propertyTwo)").padding()
Button("Change", action: manager.action).padding()
}
}
}
So above is
A simple ObservableObject - All it does is set two Doubles with a random value (notice the properties you want to observe are marked as #Published)
A tab view
A simple sub-view
Notice that both of the views have a #EnvironmentObject var manager: ManagerPlaceholder. You don't set that property directly. That line says that you want to reference a ManagerPlaceholder instance that's in the Environment. The environment is a sort of "pool" of storage that SwiftUI manages for you. You can add an instance of an object to it, then reference it in sub-views that need it.
So to make sure that's in the environment you add it when instantiating a view (could be the tab view, or any super-view). So for example in your _NAME_App.swift (for an iOS 14 target) or SceneDelegate.swift (for an iOS 13 target), you'd instantiate your view like this:
ContentView().environmentObject(ManagerPlaceholder())
If you run the code above you'll see when you hit the Change button it randomly sets the two properties, and both subviews will see the exact same values when you switch back and forth, because they're both referencing the same instance.
Feel free to comment if anything is unclear.

SwiftUI - EnvironmentObject Published var to NavigationLink's Selection?

I have a class I use as EnvironmentObject with a screen variable I want to use to control what screen the user is in. I use network calls to change this value, expiration seconds to move to another view, so I need my NavigationView to move to the adequate screen when this value changes.
For the class I have something like this:
class MyClass: NSObject, ObservableObject {
#Published var screen: String? = "main"
}
And for the main view I have something like this:
struct ContentView: View {
#EnvironmentObject var myClass: MyClass
var body: some View {
ZStack {
NavigationView() {
NavigationLink(destination: MainView(), tag: "main", selection: self.$myClass.screen)
{
EmptyView()
}
}
}
}
}
I don't seem to be able to work this way.
As a workaround I have done:
struct ContentView: View {
#EnvironmentObject var myClass: MyClass
var body: some View {
VStack {
if (self.myClass.screen == "main") {
MainView()
} else if (self.myClass.screen == "detail") {
DetailView()
}
}
}
But as you see, is not pretty. And I don't get any animations when changing screens.
Does anyone have an idea how to do this or how should I approach this situation?
Your second approach is correct. You just need animations. Unfortunately they only work when changing #State variables.
For this you need to create a new #State variable and assign it in .onReceive:
struct ContentView: View {
#EnvironmentObject var myClass: MyClass
#State var screen: String?
var body: some View {
VStack {
if screen == "main" {
MainView()
} else if screen == "detail" {
DetailView()
}
}
.onReceive(myClass.$screen) { screen in
withAnimation {
self.screen = screen
}
}
}
}

SwiftUI - memory leak in NavigationView

I am trying to add a close button to the modally presented View's navigation bar. However, after dismiss, my view models deinit method is never called. I've found that the problem is where it captures the self in navigationBarItem's. I can't just pass a weak self in navigationBarItem's action, because View is a struct, not a class. Is this a valid issue or just a lack of knowledge?
struct ModalView: View {
#Environment(\.presentationMode) private var presentation: Binding<PresentationMode>
#ObservedObject var viewModel: ViewModel
var body: some View {
NavigationView {
Text("Modal is presented")
.navigationBarItems(leading:
Button(action: {
// works after commenting this line
self.presentation.wrappedValue.dismiss()
}) {
Text("close")
}
)
}
}
}
You don't need to split the close button out in its own view. You can solve this memory leak by adding a capture list to the NavigationView's closure: this will break the reference cycle that retains your viewModel.
You can copy/paste this sample code in a playground to see that it solves the issue (Xcode 11.4.1, iOS playground).
import SwiftUI
import PlaygroundSupport
struct ModalView: View {
#Environment(\.presentationMode) private var presentation
#ObservedObject var viewModel: ViewModel
var body: some View {
// Capturing only the `presentation` property to avoid retaining `self`, since `self` would also retain `viewModel`.
// Without this capture list (`->` means `retains`):
// self -> body -> NavigationView -> Button -> action -> self
// this is a retain cycle, and since `self` also retains `viewModel`, it's never deallocated.
NavigationView { [presentation] in
Text("Modal is presented")
.navigationBarItems(leading: Button(
action: {
// Using `presentation` without `self`
presentation.wrappedValue.dismiss()
},
label: { Text("close") }))
}
}
}
class ViewModel: ObservableObject { // << tested view model
init() {
print(">> inited")
}
deinit {
print("[x] destroyed")
}
}
struct TestNavigationMemoryLeak: View {
#State private var showModal = false
var body: some View {
Button("Show") { self.showModal.toggle() }
.sheet(isPresented: $showModal) { ModalView(viewModel: ViewModel()) }
}
}
PlaygroundPage.current.needsIndefiniteExecution = true
PlaygroundPage.current.setLiveView(TestNavigationMemoryLeak())
My solution is
.navigationBarItems(
trailing: self.filterButton
)
..........................................
var filterButton: some View {
Button(action: {[weak viewModel] in
viewModel?.showFilter()
},label: {
Image("search-filter-icon").renderingMode(.original)
})
}
I was having a gnarly memory leak due to navigationBarItems and passing my view model to the view I was using as the bar item.
Digging around on this, I learned that navigationBarItems is deprecated
I had
.navigationBarItems(trailing:
AlbumItemsScreenNavButtons(viewModel: viewModel)
)
The replacement is toolbar.
My usage now looks like this:
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
AlbumItemsScreenNavButtons(viewModel: viewModel)
}
}
I recommend design-level solution, ie. decomposing navigation bar item into separate view component breaks that undesired cycle referencing that result in leak.
Tested with Xcode 11.4 / iOS 13.4 - ViewModel destroyed as expected.
Here is complete test module code:
struct CloseBarItem: View { // separated bar item with passed binding
#Binding var presentation: PresentationMode
var body: some View {
Button(action: {
self.presentation.dismiss()
}) {
Text("close")
}
}
}
struct ModalView: View {
#Environment(\.presentationMode) private var presentation
#ObservedObject var viewModel: ViewModel
var body: some View {
NavigationView {
Text("Modal is presented")
.navigationBarItems(leading:
CloseBarItem(presentation: presentation)) // << decompose
}
}
}
class ViewModel: ObservableObject { // << tested view model
init() {
print(">> inited")
}
deinit {
print("[x] destroyed")
}
}
struct TestNavigationMemoryLeak: View {
#State private var showModal = false
var body: some View {
Button("Show") { self.showModal.toggle() }
.sheet(isPresented: $showModal) { ModalView(viewModel: ViewModel()) }
}
}
struct TestNavigationMemoryLeak_Previews: PreviewProvider {
static var previews: some View {
TestNavigationMemoryLeak()
}
}

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
}
)
}
}

SwiftUI NavigationLink pops automatically which is unexpected

I am having some issues with a NavigationLink on an iPad with split view (landscape). Here is an example:
Here is the code to reproduce the issue:
import SwiftUI
final class MyEnvironmentObject: ObservableObject {
#Published var isOn: Bool = false
}
struct ContentView: View {
#EnvironmentObject var object: MyEnvironmentObject
var body: some View {
NavigationView {
NavigationLink("Go to FirstDestinationView", destination: FirstDestinationView(isOn: $object.isOn))
}
}
}
struct FirstDestinationView: View {
#Binding var isOn: Bool
var body: some View {
NavigationLink("Go to SecondDestinationView", destination: SecondDestinationView(isOn: $isOn))
}
}
struct SecondDestinationView: View {
#Binding var isOn: Bool
var body: some View {
Toggle(isOn: $isOn) {
Text("Toggle")
}
}
}
// Somewhere in SceneDelegate
ContentView().environmentObject(MyEnvironmentObject())
Does anyone know a way to fix this? An easy fix is to disable split view, but that is not possible for me.
When something within EnvironmentObject changes, it will render the whole view again including NavigationLink. That's the root cause of automatic pop back.
My research on it:
OK on iOS 15 (seems Apple fixed)
Still broken on iOS 14
The reason why "This bug went away when I dropped the #EnvironmentObject and went with an #ObservedObject instead." #Jon Vogel mentioned is ObservedObject is a local state, which will not be affected by other views while EnvironmentObject is global state and can change from any other remote views.
Ok, here is my investigation results (tested with Xcode 11.2) and below is the code that works.
In iPad NavigationView got into Master/Details style, so ContentView having initial link is active and process bindings update from environmentObject, so refresh, which result in activating link of details view via same binding, thus corrupting navigation stack. (Note: this is absent in iPhone due to stack style, which deactivates root view).
So, probably this is workaround, but works - the idea is not to pass binding from view to view, but use environmentObject directly in final view. Probably this will not always be a case, but anyway in such scenarios it is needed to avoid root view refresh, so it should not have same binding in body. Something like that.
final class MyEnvironmentObject: ObservableObject {
#Published var selection: Int? = nil
#Published var isOn: Bool = false
}
struct ContentView: View {
#EnvironmentObject var object: MyEnvironmentObject
var body: some View {
NavigationView {
List {
NavigationLink("Go to FirstDestinationView", destination: FirstDestinationView())
}
}
}
}
struct FirstDestinationView: View {
var body: some View {
List {
NavigationLink("Go to SecondDestinationView", destination: SecondDestinationView())
}
}
}
struct SecondDestinationView: View {
#EnvironmentObject var object: MyEnvironmentObject
var body: some View {
VStack {
Toggle(isOn: $object.isOn) {
Text("Toggle")
}
}
}
}
You need can use isDetailLink(_:) to fix that, e.g.
struct FirstDestinationView: View {
#Binding var isOn: Bool
var body: some View {
NavigationLink("Go to SecondDestinationView", destination: SecondDestinationView(isOn: $isOn))
.isDetailLink(false)
}
}

Resources