SwiftUI - EnvironmentObject Published var to NavigationLink's Selection? - ios

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

Related

SwiftUI retain cycle in view hierarchy

I'm having the following view hierarchy which has a retain cycle, that's the simplest I could make to reproduce the issue. All viewmodels and properties has to stay as they are needed in the original solution:
import SwiftUI
struct MainView: View {
#StateObject var viewModel = MainViewModel()
var body: some View {
NavigationView { [weak viewModel] in
VStack {
Button("StartCooking") {
viewModel?.show()
}
if viewModel?.isShowingContainerView == true {
ContainerView()
}
Button("StopCooking") {
viewModel?.hide()
}
}
}
.navigationViewStyle(.stack)
}
}
final class MainViewModel: ObservableObject {
#Published var isShowingContainerView = false
func show() {
isShowingContainerView = true
}
func hide() {
isShowingContainerView = false
}
}
struct ContainerView: View {
#Namespace var namespace
var body: some View {
VStack {
SubView(
namespace: namespace
)
}
}
}
struct SubView: View {
#StateObject var viewModel = SubViewModel()
var namespace: Namespace.ID
var body: some View {
Text("5 min")
.matchedGeometryEffect(id: UUID().uuidString, in: namespace)
.onTapGesture {
foo()
}
}
private func foo() {}
}
final class SubViewModel: ObservableObject {}
If I run the app, tap on StartCooking, than on StopCooking and check the memory graph, I still see an instance of SubViewModel, which means that there is a leak in this code.
If I remove:
NavigationView OR
The VStack from ContainerView OR
matchedGeometryEffect OR
tapGesture
The retain cycle is resolved. Unfortunately I need all these. Can you see what the issue might be and how could it be solved?
Looks like a SwiftUI bug. A possible workaround (if sub-view is one or limited set) is to use view model factory to provided instances.
Here is an example for one view:
struct SubView: View {
#StateObject var viewModel = SubViewModel.shared // single instance !!
// .. other code
}
final class SubViewModel: ObservableObject {
static var shared = SubViewModel() // << this !!
}
I could kind of workaround it by making every property optional in the SubViewModel and running a function when the SubViews disappear, which makes them nil. The SubViewModel still stays in the memory, but will not take up that much space.
Interestingly I even tried to make the viewmodel optional, and make it nil when the view disappears, but it still stayed in the memory.

SwiftUI Infinite Subview Hierarchy and Breadcrumbs

what I am trying to achieve is creating a hierarchical view. I understand that iOS simply doesn't like to use breadcrumbs but I need to navigate from a main view in to deeper subviews. they need to be nested and infinite.
you can see what I've done so far in the code and gif below. As I'm a beginner developer I'm not sure if this is the right way to achieve this kind of structure (infinite sub-views nested inside sub-views). Also when I navigate back in views, added buttons(struct A) disappears. What seems to be the problem?
Thanks in advance!
code in action gif
import SwiftUI
struct A: View, Identifiable {
#EnvironmentObject var documentB: classB
var id: Int
var text: String
var destinationLink: B?
var body: some View {
NavigationLink(destination: self.destinationLink) {
VStack{
Rectangle()
.frame(width: 35, height:25)
.background(Color.red)
Text("\(text)")
}
}
}
}
struct B: View, Identifiable {
#EnvironmentObject var documentB: classB
#State var arrayA: [A] = []
var id: Int
var text: String
var mainText: String = "Placeholder"
var body: some View {
NavigationView {
VStack {
Spacer()
ForEach(arrayA){ item in
item
}
Spacer()
Button(action: {
let newB = B(id:self.documentB.arrayB.count+1, text:"B \(self.documentB.arrayB.count+1)")
self.documentB.arrayB.append(newB)
self.arrayA.append(A(id:self.arrayA.count+1, text:"AA \(self.arrayA.count+1)", destinationLink: newB))
}) {
Text("Add A \(self.arrayA.count), B Count: \(self.documentB.arrayB.count)")
}
}
.navigationBarTitle(text)
}
}
}
class classB: ObservableObject {
#Published var arrayB: [B] = [B(id:1, text:"MainView")]
}
struct ContentView: View {
#ObservedObject var documentB = classB()
var body: some View {
VStack {
documentB.arrayB[0]
}
.environmentObject(documentB)
}
}
You just need to move NavigationView into ContentView, because the only one is needed on one view hierarchy, so
struct ContentView: View {
#ObservedObject var documentB = classB()
var body: some View {
NavigationView { // << move it here from B
VStack {
documentB.arrayB[0]
}
}
.environmentObject(documentB)
}
}

SwiftUI binding boolean if statement (Cannot convert value of type 'Binding<Bool>' to expected condition type 'Bool')

I'm trying to conditionally show a View (not using .sheet) and using a binding boolean returns the error above in the title. If I use self.menuactivated, no view will be presented upon the #State variable toggle. Is there a way around this?
struct ContentView: View {
#State private var menuActivated = false
var body: some View {
NavigationView {
... // code
}
if $menuActivated {
menuView()
}
}
}
I assume this was the intention (menu will be shown over NavigationView on toggle)
struct ContentView: View {
#State private var menuActivated = false
var body: some View {
ZStack {
NavigationView {
... // code
}
if menuActivated {
menuView()
}
}
}
}
Found a way around it. Embedded the NavigationView in a VStack and added the menuView to the end of it.
struct ContentView: View {
#State private var menuActivated = false
var body: some View {
ZStack {
NavigationView {
... // view code
}
if menuActivated {
menuView()
}
}
}
}
in case if you are looking for condition based view
func getView() -> AnyView {
return AnyView(
//any business logic and view
)
}
and use it like
self.getView()

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

Pop Navigation view from ViewModel

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

Resources