Why Does #EnvironmentObject Force Re-initialization of View? - ios

In this example, when I drag across the screen why does LabelViewRepresentable get re-initialized before every "updateUIView" call? If I make the counter a #State property instead of an #EnvironmentObject property, it only initializes once like I would expect.
import SwiftUI
class Counter: ObservableObject {
#Published var count = 0
}
struct CounterView: View {
#EnvironmentObject var counter: Counter
var body: some View {
LabelViewRepresentable(count: $counter.count)
.gesture(DragGesture().onChanged({ _ in
self.counter.count += 1
}))
}
}
struct LabelViewRepresentable: UIViewRepresentable {
#Binding var count: Int
private var view: UILabel
init(count: Binding<Int>) {
print("init")
let label = UILabel()
label.text = "0"
self.view = label
self._count = count
}
func makeUIView(context: UIViewRepresentableContext<LabelViewRepresentable>) -> UILabel {
print("makeUIView")
return view
}
func updateUIView(_ uiView: UILabel, context: UIViewRepresentableContext<LabelViewRepresentable>) {
print("updateUIView")
view.text = "\(count)"
}
}

When you look at Apple docs about EnvironmentObject you will find this:
A dynamic view property that uses a bindable object supplied by an
ancestor view to invalidate the current view whenever the bindable
object changes.
That means that each time an EnvironmentObject changes all views that are dependent on it get reinitialised and redrawn.
It works slightly different with State, in Apple docs it is described as follows:
A persistent value of a given type, through which a view reads and
monitors the value.
The view cannot get reinitialised when the State changes as the State value would get discarded. The parts that are influenced by State will get redrawn. On the other hand any children of the view that have the State value passed in as a binding will get reinitialised.

Related

SwiftUI - ObservedObject is never deallocated

My app is leaking model objects because it the objects are keeping closures that are retaining the view itself.
It's better to show by an example.
In the code below, Model is not deallocated after the ContentView disappears.
//
// Content View is an owner of `Model`
// It passes it to `ViewB`
//
// When button is tapped, ContentView executes action
// assigned to Model by the ViewB
//
struct ContentView: View {
#StateObject private var model = Model()
var body: some View {
VStack {
Button(action: {
model.action?()
}) {
Text("Tap")
}
ViewB(model: model)
}
.frame(width: 100, height: 100)
.onDisappear {
print("CONTENT DISAPPEAR")
}
}
}
struct ViewB: View {
#ObservedObject var model: Model
var body: some View {
Color.red.frame(width: 20, height: 20)
.onAppear {
//
// DANGER:
// Assigning this makes a leak and Model is never deallocated.
// This is because the closure is retaining 'self'
// But since it's a struct, how can we break the cycle here?
//
model.action = { bAction() }
}
}
private func bAction() {
print("Hey!")
}
}
class Model: ObservableObject {
var action: (() -> Void)?
deinit {
print("MODEL DEINIT")
}
}
I'm not sure why there's some kind of retain cycle occurring here.
Since View is a struct, referencing it in a closure should be safe, right?
Ahoy #msmialko, while I can't give much reasoning for what I've observed, hopefully this will be a step in the right direction.
I decided to remove SwiftUI's memory management from the equation and tested with simple value and reference types:
private func doMemoryTest() {
struct ContentView {
let model: Model
func pressButton() {
model.action?()
}
}
struct ViewB {
let model: Model
func onAppear() {
model.action = action
// { [weak model] in
// model?.action = action
// }()
}
func onDisappear() {
print("on ViewB's disappear")
model.action = nil
}
private func action() {
print("Hey!")
}
}
class Model {
var action: (() -> Void)?
deinit {
print("*** DEALLOCATING MODEL")
}
}
var contentView: ContentView? = .init(model: Model())
var viewB: ViewB? = .init(model: contentView!.model)
contentView?.pressButton()
viewB?.onAppear()
contentView?.pressButton()
// viewB?.onDisappear()
print("Will remove ViewB's reference")
viewB = nil
print("Removed ViewB's reference")
contentView?.pressButton()
print("Will remove ContentView's reference")
contentView = nil
print("Removed ContentView's reference")
}
When I ran the code above, this was the console output (no deallocation of Model, as you observed):
Hey!
Will remove ViewB's reference
Removed ViewB's reference
Hey!
Will remove ContentView's reference
Removed ContentView's reference
In the above example it looks like I'm in complete control of the reference count on Model, however when I inspected the memory graph in Xcode, I could confirm that Model was retaining itself via action.context (I'm not sure what that means):
To fix the retain cycle with minimal changes, you might want to consider removing Model's action assignment using ViewB.onDisappear as I've done in my example. When I uncommented viewB?.onDisappear() then I saw the following console output:
Hey!
on ViewB's disappear
Will remove ViewB's reference
Removed ViewB's reference
Will remove ContentView's reference
*** DEALLOCATING MODEL
Removed ContentView's reference
Good luck!
Model is not a struct, it is an ObservableObject which is of type AnyObject which is an Object
you should apply weak to in the capture list for .onAppear
.onAppear { [weak model] }
I think you could also just capture model incase its self that the issue is on
.onAppear { [model] }

SwiftUI some view to its root view

I want to trigger the update of a subview in SwiftUI by changing the state of a #State variable, since it does not update alone when I change the Wallet Object since it is defined as EnvironmentObject. The thing is that I have to initialize the view with environmentObject, and it returns Some View, and cannot cast it to WalletView as it should seem that it should.
var walletView = WalletView().environmentObject(Wallet(cards: reminders))
if walletView = walletView as? WalletView{
walletView.isPresented = !walletView.isPresented
}
How can I access the WalletView object?
I've tried:
var walletView = WalletView()
let someWalletView = walletView.environmentObject(Wallet(cards: reminders))
walletView.isPresented = !walletView.isPresented
but the walletView doesn't seem to update. Any clue?
The SwiftUI approach is to change view state inside view, so as far as I understood what your going to do with WalletView it could be achieved like in the following (scratchy):
struct WalletView: View {
...
var body: some View {
_some_internal_view
.onAppear { self.isPresented = true }
.onDisappear { self.isPresented = false }
}
}
I've solved this by changing the variable isPresented to #Binding, and modifying it then triggers an update on the subclass.

In a UIViewControllerRepresentable, how can I pass an ObservedObject's value to the view controller and update it every time the value changes?

I have a UIViewControllerRepresentable struct that is subscribed to an ObservableObject, like this:
struct ViewControllerWrapper: UIViewControllerRepresentable {
#ObservedObject var chartVM = ChartViewModel()
typealias UIViewControllerType = ViewController
func makeUIViewController(context: Context) -> ViewController {
let lineChartView = LineChartView()
let vc = ViewController(lineChartView: lineChartView)
return vc
}
func updateUIViewController(_ uiViewController: ViewController, context: Context) {
uiViewController.metrics = chartVM.metrics
uiViewController.setChartValues()
}
}
I would like that, when the ObservedObject changes, either updateUIViewController is called, or another function that updates the view controller's metrics array and calls its setChartValues() method.
Is there a way I can do that? I can't find one.
I can always do it as we used to using only UIKit, but it would be much better to do it using that MVVM pattern.
Help would be much appreciated, thanks!
updateUIViewController should be called when the view model is updated, however there is a bug in SwiftUI UIViewControllerRepresentable and UIViewRepresentable where it is not called.
There is a work around to this by defining a class and creating an instance of it inside the representable.
Just add the following under chartVM and it should start to work:
class RandomClass { }
let x = RandomClass()

SwiftUI ScrollView with content frame update closure

I want to have a ScrollView where you can be aware of the content frame changes as the user scrolls (similar to didScroll delegate in UIKit UIScrollView).
With this, you can then perform layout changes based on the scroll behavior.
I managed to come with a nice solution for this problem by making use of View Preferences as a method to notify layout information upstream in the View Hierarchy.
For a very detail explanation of how View Preferences work, I will suggest reading this 3 articles series on the topic by kontiki
For my solution, I implemented two ViewModifiers: one to make a view report changes on its layout using anchor preferences, and the second to allow a View to handle updates to frames on views on its subtree.
To do this, we first define a Struct to carry the identifiable frame information upstream:
/// Represents the `frame` of an identifiable view as an `Anchor`
struct ViewFrame: Equatable {
/// A given identifier for the View to faciliate processing
/// of frame updates
let viewId : String
/// An `Anchor` representation of the View
let frameAnchor: Anchor<CGRect>
// Conformace to Equatable is required for supporting
// view udpates via `PreferenceKey`
static func == (lhs: ViewFrame, rhs: ViewFrame) -> Bool {
// Since we can currently not compare `Anchor<CGRect>` values
// without a Geometry reader, we return here `false` so that on
// every change on bounds an update is issued.
return false
}
}
and we define a Struct conforming to PreferenceKey protocol to hold the view tree preference changes:
/// A `PreferenceKey` to provide View frame updates in a View tree
struct FramePreferenceKey: PreferenceKey {
typealias Value = [ViewFrame] // The list of view frame changes in a View tree.
static var defaultValue: [ViewFrame] = []
/// When traversing the view tree, Swift UI will use this function to collect all view frame changes.
static func reduce(value: inout [ViewFrame], nextValue: () -> [ViewFrame]) {
value.append(contentsOf: nextValue())
}
}
Now we can define the ViewModifiers I mentioned:
Make a view report changes on its layout:
This just adds a transformAnchorPreference modifier to the View with a handler that simply constructs a ViewFrame instance with current frame Anchor value and appends it to the current value of the FramePreferenceKey:
/// Adds an Anchor preference to notify of frame changes
struct ProvideFrameChanges: ViewModifier {
var viewId : String
func body(content: Content) -> some View {
content
.transformAnchorPreference(key: FramePreferenceKey.self, value: .bounds) {
$0.append(ViewFrame(viewId: self.viewId, frameAnchor: $1))
}
}
}
extension View {
/// Adds an Anchor preference to notify of frame changes
/// - Parameter viewId: A `String` identifying the View
func provideFrameChanges(viewId : String) -> some View {
ModifiedContent(content: self, modifier: ProvideFrameChanges(viewId: viewId))
}
}
Provide an update handler to a view for frame changes on its subtree:
This adds a onPreferenceChange modifier to the View, where the list of frame Anchors changes are transformed into frames(CGRect) on the view's coordinate space and reported as a dictionary of frame updates keyed by the view ids:
typealias ViewTreeFrameChanges = [String : CGRect]
/// Provides a block to handle internal View tree frame changes
/// for views using the `ProvideFrameChanges` in own coordinate space.
struct HandleViewTreeFrameChanges: ViewModifier {
/// The handler to process Frame changes on this views subtree.
/// `ViewTreeFrameChanges` is a dictionary where keys are string view ids
/// and values are the updated view frame (`CGRect`)
var handler : (ViewTreeFrameChanges)->Void
func body(content: Content) -> some View {
GeometryReader { contentGeometry in
content
.onPreferenceChange(FramePreferenceKey.self) {
self._updateViewTreeLayoutChanges($0, in: contentGeometry)
}
}
}
private func _updateViewTreeLayoutChanges(_ changes : [ViewFrame], in geometry : GeometryProxy) {
let pairs = changes.map({ ($0.viewId, geometry[$0.frameAnchor]) })
handler(Dictionary(uniqueKeysWithValues: pairs))
}
}
extension View {
/// Adds an Anchor preference to notify of frame changes
/// - Parameter viewId: A `String` identifying the View
func handleViewTreeFrameChanges(_ handler : #escaping (ViewTreeFrameChanges)->Void) -> some View {
ModifiedContent(content: self, modifier: HandleViewTreeFrameChanges(handler: handler))
}
}
LET'S USE IT:
I will illustrate the usage with an example:
Here I will get notifications of a Header View frame changes inside a ScrollView. Since this Header View is on the top of the ScrollView content, the reported frame changes on the frame origin are equivalent to the contentOffset changes of the ScrollView
enum TestEnum : String, CaseIterable, Identifiable {
case one, two, three, four, five, six, seven, eight, nine, ten
var id: String {
rawValue
}
}
struct TestView: View {
private let _listHeaderViewId = "testView_ListHeader"
var body: some View {
ScrollView {
// Header View
Text("This is some Header")
.provideFrameChanges(viewId: self._listHeaderViewId)
// List of test values
ForEach(TestEnum.allCases) {
Text($0.rawValue)
.padding(60)
}
}
.handleViewTreeFrameChanges {
self._updateViewTreeLayoutChanges($0)
}
}
private func _updateViewTreeLayoutChanges(_ changes : ViewTreeFrameChanges) {
print(changes)
}
}
There is an elegant solution to this problem, Soroush Khanlou already posted a Gist of it so I won't copy-paste it. You can find it here and yeah...Shame that it isn't a part of the framework yet!

Activating an animation in SwiftUI automatically based on saved state

I'm trying to write a view that displays 3 buttons, I cannot get the animation to start on load.
When a button is tapped, I want it to animate until either:
it is tapped a second time
another of the 3 buttons is tapped
I have got the code working using a #Environment object to store the running state. It toggles between the 3 buttons nicely:
The code for this is here:
struct ContentView : View {
#EnvironmentObject var model : ModelClockToggle
var body: some View {
VStack {
ForEach(0...2) { timerButton in
ActivityBreak(myId: timerButton)
.padding()
}
}
}
}
import SwiftUI
struct ActivityBreak : View {
var myId: Int
#EnvironmentObject var model : ModelClockToggle
let anim1 = Animation.basic(duration: 1.0, curve: .easeInOut).repeatCount(Int.max)
let noAni = Animation.basic(duration: 0.2, curve: .easeInOut).repeatCount(0)
var body: some View {
return Circle()
.foregroundColor(.red)
.scaleEffect(self.model.amIRunning(clock: self.myId) ? 1.0 : 0.6)
.animation( self.model.amIRunning(clock: self.myId) ? anim1 : noAni )
.tapAction {
self.model.toggle(clock: self.myId)
}
}
}
For completeness, the model is:
import Foundation
import SwiftUI
import Combine
class ModelClockToggle: BindableObject {
let didChange = PassthroughSubject<ModelClockToggle, Never>()
private var clocksOn: [Bool] = [false,false,false]
init() {
clocksOn = []
clocksOn.append(UserDefaults.standard.bool(forKey: "toggle1"))
clocksOn.append(UserDefaults.standard.bool(forKey: "toggle2"))
clocksOn.append(UserDefaults.standard.bool(forKey: "toggle3"))
debugPrint(clocksOn)
}
func toggle(clock: Int) {
debugPrint(#function)
if clocksOn[clock] {
clocksOn[clock].toggle()
} else {
clocksOn = [false,false,false]
clocksOn[clock].toggle()
}
saveState()
didChange.send(self)
}
func amIRunning(clock: Int) -> Bool {
debugPrint(clocksOn)
return clocksOn[clock]
}
private func saveState() {
UserDefaults.standard.set(clocksOn[0], forKey: "toggle1")
UserDefaults.standard.set(clocksOn[1], forKey: "toggle2")
UserDefaults.standard.set(clocksOn[2], forKey: "toggle3")
}
}
How do I make the repeating animation start at load time based on the #Environment object I have passed into the View? Right now SwiftUI only seems to consider state change once the view is loaded.
I tried adding an .onAppear modifier, but that meant I had to use a different animator - which had very strange effects.
help gratefully received.
In your example, you are using an implicit animation. Those are animations that will look for changes on any animatable parameter such as size, position, opacity, color, etc. When SwiftUI detects any change, it will animate it.
In your specific case, Circles are normally scaled to 0.6 while not active, and 1.0 when active. Changes between inactive and active states, make your Circle to alter the scale, and this changes are animated in a loop.
However, your problem is that a Circle that is initially loaded at a 1.0 scale (because the model says it is active), will not detect a change: It starts at 1.0 and remains at 1.0. So there is nothing to animate.
In your comments you mention a solution, that involves having the model postpone loading the state of the Circle states. That way, your view is created first, then you ask the model to load states and then there is a change in your view that can be animated. That works, however, there is a problem with that.
You are making your model's behaviour dependent on the view. When it should really be the other way around. Suppose you have two instances of your view on the screen. Depending on timing, one will start fine, but the other will not.
The way to solve it, is making sure the entire logic is handle by the view itself. What you want to accomplish, is that your Circle always gets created with a scale of 0.6. Then, you check with the model to see if the Circel should be active. If so, you immediately change it to 1.0. This way you guarantee the view's animation.
Here is a possible solution, that uses a #State variable named booted to keep track of this. Your Circles will always be created with a scale of 0.6, but once the onAppear() method is call, the view will scale to 1.0 (if active), producing the corresponding animation.
struct ActivityBreak : View {
var myId: Int
#EnvironmentObject var model : ModelClockToggle
#State private var booted: Bool = false
// Beta 4
let anim1 = Animation.easeInOut(duration: 1.0).repeatCount(Int.max)
let noAni = Animation.easeInOut(duration: 0.2).repeatCount(0)
// Beta 3
// let anim1 = Animation.basic(duration: 1.0, curve: .easeInOut).repeatCount(Int.max)
// let noAni = Animation.basic(duration: 0.2, curve: .easeInOut).repeatCount(0)
var body: some View {
return Circle()
.foregroundColor(.red)
.scaleEffect(!booted ? 0.6 : self.model.amIRunning(clock: self.myId) ? 1.0 : 0.6)
.animation( self.model.amIRunning(clock: self.myId) ? anim1 : noAni )
.tapAction {
self.model.toggle(clock: self.myId)
}
.onAppear {
self.booted = true
}
}
}

Resources