SwiftUI EnvironmentObject does not redraw the current view but its parent view - ios

I'm trying to understand how #EnvironmentObject affects redrawing when a property in ObservableObject changes.
As per Apple's doc on EnvironmentObject,
An environment object invalidates the current view whenever the observable object changes.
If the above statement is true, wouldn't the below code invalidate and recreate ChildView when you press the button in ContentView. And that should print the following output.
initializing parent
initializing child
// after pressing the button
initializing child
Contrary to my above assumption, it actually prints
initializing parent
initializing child
// after pressing the button
initializing parent
Can anyone explain why this is the case? Why is the ParentView being recreated even though ParentView is not depending on Library?
class Library: ObservableObject {
#Published var item: Int = 0
}
struct ContentView: View {
#StateObject var library: Library = Library()
var body: some View {
VStack {
ParentView()
.environmentObject(library)
Button {
library.item += 1
} label: {
Text("increment")
}
}
}
}
struct ParentView: View {
init() {
print("initializing parent")
}
var body: some View {
VStack {
Text("Parent view")
ChildView()
}
}
}
struct ChildView: View {
#EnvironmentObject var library: Library
init() {
print("initializing child")
}
var body: some View {
Text("Child view")
}
}

SwiftUI View´s can be a little tricky.
An environment object invalidates the current view whenever the observable object changes.
does not mean the object itself is recreated. It just means the body of the view gets called and the view rebuilds itself.
Remember the struct is not the View itself, it´s just a "description".
I´ve added some print statements to make this more clear:
struct ContentView: View {
#StateObject var library: Library = Library()
init(){
print("initializing content")
}
var body: some View {
VStack {
let _ = print("content body")
ParentView()
.environmentObject(library)
Button {
library.item += 1
} label: {
Text("increment")
}
}
}
}
struct ParentView: View {
init() {
print("initializing parent")
}
var body: some View {
VStack {
let _ = print("parent body")
Text("Parent view")
ChildView()
}
}
}
struct ChildView: View {
#EnvironmentObject var library: Library
init() {
print("initializing child")
}
var body: some View {
let _ = print("child body")
Text("child")
}
}
this initially prints:
initializing content
content body
initializing parent
parent body
initializing child
child body
and after pressing the button:
content body
initializing parent
child body
As you see the body of those View´s depending on Library get their respective body reevaluated.
The ParentView initializer runs because in your ContentView you call ParentView() in the body so a new struct "describing" your View is created. The ParentView´s view itself stays the same so its body var is not called.
This WWDC 2021 video about SwiftUI Views will help you better understand this.

Related

Problems with SwiftUI not redrawing views

Working on my first SwiftUI project, and as I started moving some of my more complex views into their own view structs I started getting problems with the views not being redrawn.
As an example, I have the following superview:
struct ContainerView: View {
#State var myDataObject: MyDataObject?
var body: some View {
if let myDataObject = myDataObject {
TheSmallerView(myDataObject: myDataObject)
.padding(.vertical, 10)
.frame(idealHeight: 10)
.padding(.horizontal, 8)
.onAppear {
findRandomData()
}
}
else {
Text("No random data found!")
.onAppear {
findRandomData()
}
}
}
private func findRandomData() {
myDataObject = DataManager.shared.randomData
}
}
Now when this first gets drawn I get the Text view on screen as the myDataObject var is nil, but the .onAppear from that gets called, and myDataStruct gets set with an actual struct. I've added breakpoints in the body variable, and I see that when this happens it gets called again and it goes into the first if clause and fetches the "TheSmallerView" view, but nothing gets redrawn on screen. It still shows the Text view from before.
What am I missing here?
EDIT: Here's the relevant parts of TheSmallerView:
struct TheSmallerView: View {
#ObservedObject var myDataObject: MyDataObject
EDIT2: Fixed the code to better reflect my actual code.
Try declaring #Binding var myDataStruct: MyDataStruct inside the TheSmallerView view and pass it like this: TheSmallerView(myDataStruct: $myDataStruct) from ContainerView
You are using #ObservedObject in the subview, but that property wrapper is only for classes (and your data is a struct).
You can use #State instead (b/c the data is a struct).
Edit:
The data isn't a struct.
Because it is a class, you should use #StateObject instead of #State.
In lack of complete code I created this simple example based on OPs code, which works fine the way it is expected to. So the problem seems to be somewhere else.
class MyDataObject: ObservableObject {
#Published var number: Int
init() {
number = Int.random(in: 0...1000)
}
}
struct ContentView: View {
#State var myDataObject: MyDataObject?
var body: some View {
if let myDataObject = myDataObject {
TheSmallerView(myDataObject: myDataObject)
.onAppear {
findRandomData()
}
}
else {
Text("No random data found!")
.onAppear {
findRandomData()
}
}
}
private func findRandomData() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
myDataObject = MyDataObject()
}
}
}
struct TheSmallerView: View {
#ObservedObject var myDataObject: MyDataObject
var body: some View {
Text("The number is: \(myDataObject.number)")
}
}

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.

Reload view when #Published is changed

I want to be able to scan barcodes, then show the barcode at the bottom of the screen in a sheet, or a separate view.
When I update the 'barcode' variable which is #Published and accessed in other methods with #ObserveableObject, it doesn't update the view with the data from the barcode.
Content View
class ScannedCode: ObservableObject {
#Published var barcode = ""
}
struct ContentView: View {
#ObservedObject var barcode = ScannedCode()
var body: some View {
ZStack {
ScannerView()
FoundItemSheet()
}
}
}
Scanner View - scannedCode is linked to the #Published variable, so when this changes, I want it to reload FoundItemSheet() as a barcode has been found by the scanner
class Coordinator: BarcodeScannerCodeDelegate, BarcodeScannerErrorDelegate {
#ObservedObject var scannedCode = ScannedCode()
private var scannerView: ScannerView
init(_ scannerView: ScannerView) {
self.scannerView = scannerView
}
func scanner(_ controller: BarcodeScannerViewController, didCaptureCode code: String, type: String) {
self.scannedCode.barcode = code
controller.resetWithError(message: "Error message")
}
func scanner(_ controller: BarcodeScannerViewController, didReceiveError error: Error) {
print(error)
}
}
FoundItemSheet() Loads BottomSheetView which is shown at the bottom of the screen in a box over the camera. I want this to update with the barcode data when it's found.
struct FoundItemSheet: View {
#State private var bottomSheetShown = false
var body: some View {
GeometryReader { geometry in
BottomSheetView(
isOpen: self.$bottomSheetShown,
maxHeight: geometry.size.height * 0.7
) {
Color.blue
}
}.edgesIgnoringSafeArea(.all)
}
}
BottomSheetView - I have declared the #Published barcode variable in here, so when it changes, I want contentsInSlide to reload with the new barcode.
struct BottomSheetView<Content: View>: View {
#Binding var isOpen: Bool
#State var showingDetail = false
#ObservedObject var scannedCode = ScannedCode()
....
private var contentInSlide: some View {
VStack {
Text("Value is: \(scannedCode.barcode)") //Doesn't show any value for the barcode
.foregroundColor(ColorManager.beautytruthGreen)
.font(.system(size: 22, weight: .medium))
Button(action: {
self.showingDetail.toggle()
}
}
The problem is that all of your views create their own ScannedCode instances. You need to create it only on the type that creates all other views and inject the same instance into each one of them.
From your code snippets it isn't really clear which view is the parent of which, so it's hard to give you a definitive answer with a code example, but in general, you should never create an #ObservableObject in a view itself, since that object will be recreated as soon as the view is reloaded. Instead, you should be creating your #ObservableObject on the view model or the parent view and injecting it into the child view that needs reloading.
Using below code, whenever scannedCode on the ParentView is updated, it reloads its ChildView with the updated ScannedCode.
struct ParentView: View {
#Published var scannedCode = ScannedCode()
var body: some View {
ChildView(scannedCode: scannedCode)
}
}
struct ChildView: View {
#ObservedObject var scannedCode: ScannedCode
var body: some View {
Text(scannedCode.barCode)
}
}

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

PresentationLink always use the same destination object

I was surprised that whenever I tap on the button embedded in PresentationLink, the same view reference shows up all the time. What I mean is that we don't create another instance of the view.
This makes sense as the destination object is created within the body property and will therefore not be recreated unless a change occur.
Do you guys know if we have a trivial way to recreate a new view every time we hit the button? Or is it by design and should be use like this?
Thank you!
EDIT
After #dfd 's comment, it seems to be by designed.
Now how to handle this use case:
Let's say I present a NavigationView and I pushed one view. If I
dismiss and re present, I will go back on the view I previously
pushed. In this case, I believe it's wrong as I'd like the user to go
through the complete flow every single time. How can I make sure that
I go back on the first screen everytime?
Thank you (again)!
EDIT 2
Here's some code:
struct PresenterExample : View {
var body: some View {
VStack {
PresentationLink(destination: CandidateCreateProfileJobView()) {
Text("Present")
}
}
}
}
struct StackFirstView : View {
var body: some View {
NavigationLink(destination: StackSecondView()) {
Text("Got to view 2")
}
}
}
struct StackSecondView : View {
var body: some View {
Text("View 2")
}
}
In this case that would be PresenterExample presents StackFirstView which will push StackSecondView from the NavigationLink.
From there, let's say the user swipe down and therefore dismiss the presentation. When it clicks back on the PresentationLink in PresenterExample it will open back on StackSecondView, which is not what I want. I want to display StackFirstView again.
Makes more sense? :)
First Try: Failure
I tried using the id modifier to tell SwiftUI to treat each presentation of StackFirstView as a completely new view unrelated to prior views:
import SwiftUI
struct PresenterExample : View {
var body: some View {
VStack {
PresentationLink("Present", destination:
StackFirstView()
.onDisappear {
print("onDisappear")
self.presentationCount += 1
}
)
}
}
#State private var presentationCount = 0
}
struct StackFirstView : View {
var body: some View {
NavigationView {
NavigationLink(destination: StackSecondView()) {
Text("Go to view 2")
}.navigationBarTitle("StackFirstView")
}
}
}
struct StackSecondView : View {
var body: some View {
Text("View 2")
.navigationBarTitle("StackSecondView")
}
}
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(rootView: PresenterExample())
This should do the following:
It should identify the StackFirstView by presentationCount. SwiftUI should consider each StackFirstView with a different identifier to be a completely different view. I've used this successfully with animated transitions.
It should increment presentationCount when the StackFirstView is dismissed, so that the next StackFirstView gets a different identifier.
The problem is that SwiftUI never calls the onDisappear closure for the presented view or any of its subviews. I'm pretty sure this is a SwiftUI bug (as of Xcode 11 beta 3). I filed FB6687752.
Second Try: FailureSuccess
Next I tried managing the presentation myself, using the presentation(Modal?) modifier, so I wouldn't need the onDisappear modifier:
import SwiftUI
struct PresenterExample : View {
var body: some View {
VStack {
Button("Present") {
self.presentModal()
}.presentation(modal)
}
}
#State private var shouldPresent = false
#State private var presentationCount = 0
private func presentModal() {
presentationCount += 1
shouldPresent = true
}
private var modal: Modal? {
guard shouldPresent else { return nil }
return Modal(StackFirstView().id(presentationCount), onDismiss: { self.shouldPresent = false })
}
}
struct StackFirstView : View {
var body: some View {
NavigationView {
NavigationLink(destination: StackSecondView()) {
Text("Go to view 2")
}.navigationBarTitle("StackFirstView")
}
}
}
struct StackSecondView : View {
var body: some View {
Text("View 2")
}
}
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(rootView: PresenterExample())
This fails in a different way. The second and later presentations of StackFirstView simply present a blank view instead. Again, I'm pretty sure this is a SwiftUI bug. I filed FB6687804.
I tried passing the presentationCount down to StackFirstView and then applying the .id(presentationCount) modifier to the NavigationView's content. That crashes the playground if the modal is dismissed and presented again while showing StackSecondView. I filed FB6687850.
Update
This tweet from Ryan Ashcraft showed me a workaround that gets this second attempt working. It wraps the Modal's content in a Group, and applies the id modifier to the Group's content:
import SwiftUI
struct PresenterExample : View {
var body: some View {
VStack {
Button("Present") {
self.presentModal()
}.presentation(modal)
}
}
#State private var shouldPresent = false
#State private var presentationCount = 0
private func presentModal() {
presentationCount += 1
shouldPresent = true
}
private var modal: Modal? {
guard shouldPresent else { return nil }
return Modal(Group { StackFirstView().id(presentationCount) }, onDismiss: { self.shouldPresent = false })
}
}
struct StackFirstView : View {
var body: some View {
NavigationView {
NavigationLink(destination: StackSecondView()) {
Text("Go to view 2")
}.navigationBarTitle("StackFirstView")
}
}
}
struct StackSecondView : View {
var body: some View {
Text("View 2")
}
}
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(rootView: PresenterExample())
This revised second try successfully resets the state of the Modal on each presentation. Note that the id must be applied to the content of the Group, not to the Group itself, to work around the SwiftUI bug.
Third Try: Success
I modified the second try so that, instead of using the id modifier, it wraps the StackFirstView inside a ZStack when presentationCount is an odd number.
import SwiftUI
struct PresenterExample : View {
var body: some View {
VStack {
Button("Present") {
self.presentModal()
}.presentation(modal)
}
}
#State private var shouldPresent = false
#State private var presentationCount = 0
private func presentModal() {
presentationCount += 1
shouldPresent = true
}
private var modal: Modal? {
guard shouldPresent else { return nil }
if presentationCount.isMultiple(of: 2) {
return Modal(presentationContent, onDismiss: { self.shouldPresent = false })
} else {
return Modal(ZStack { presentationContent }, onDismiss: { self.shouldPresent = false })
}
}
private var presentationContent: some View {
StackFirstView()
}
}
struct StackFirstView : View {
var body: some View {
NavigationView {
NavigationLink(destination: StackSecondView()) {
Text("Go to view 2")
}.navigationBarTitle("StackFirstView")
}
}
}
struct StackSecondView : View {
var body: some View {
Text("View 2")
}
}
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(rootView: PresenterExample())
This works. I guess SwiftUI sees that the modal's content is a different type each time (StackFirstView vs. ZStack<StackFirstView>) and that is sufficient to convince it that these are unrelated views, so it throws away the prior presented view instead of reusing it.

Resources