swiftswiftuiobservation

Understanding SwiftUI Observable redraw / dependency with Stacks vs custom Views


Im trying to wrap my head around when SwiftUI actually invokes redrawing views using @Observable / property dependencies and random background colors to just get a visual sense of things.

I've read https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app - and my understanding is views which use properties of @Observable objects have a macro mechanism in place to update only if the property changes. Other views in the tree should not update if their property hasn't updated.

From Apples docs:

You can also share an observable model data object with another view. The receiving view forms a dependency if it reads any properties of the object in the its body. For example, in the following code LibraryView shares an instance of Book with BookView, and BookView displays the book’s title. If the book’s title changes, SwiftUI updates only BookView, and not LibraryView, because only BookView reads the title property.

But I can't get this to be consistent for all view types?

In the code below, what I've added some static views which randomly draw a background color, and some 'inline' views which also randomly draw the backgrounds. I have a simple timer based manual animation which is just updating an observed property value as a 'proxy' for things changing to test the observation / dependency tracking

Here is a link to a video of the Xcode preview showing what I see:

https://x.com/_vade/status/1828116344916844806?s=61

What I'd expect to see:

If the above is 'correct' behavior, can any inline parent view used for composition in SwiftUI (ie HStack / VStack / ZStack) be written in a way where subviews redrawing to not trigger a redraw of the parent?

From a graphics perspective I want to limit any overdrawing / fill rate and ensure only the least amount of updates occur per observed updated via Observation.

I am testing on macOS 15.1 Beta 2 fwiw and want to fully opt into any new SwiftUI perf gains I can. I am not targeting older releases. YOLO.

Thanks for any insights! It seems soooo easy to make poor performing SwiftUI code

The test code:


import SwiftUI


public func remap(input:Double, inMin:Double, inMax:Double, outMin:Double, outMax:Double) -> Double
{
    return ((input - inMin) / (inMax - inMin) * (outMax - outMin)) + outMin;
}

public func remap(input:Float, inMin:Float, inMax:Float, outMin:Float, outMax:Float) -> Float
{
    return ((input - inMin) / (inMax - inMin) * (outMax - outMin)) + outMin;
}


@Observable class BPMClock : Identifiable, Hashable, Equatable
{
    var id: UUID = .init()
    
    static func == (lhs: BPMClock, rhs: BPMClock) -> Bool
    {
        return lhs.id == rhs.id
    }
    
    func hash(into hasher: inout Hasher)
    {
        hasher.combine(self.id)
    }
    
    var bpm:Double = 60.0
    var timeValue:Double = 0.0
    
    @ObservationIgnored private var rawTimeValue:Double = 0.0
    @ObservationIgnored private var lastTime:Double = 0.0
    @ObservationIgnored var timer:Timer? = nil

    func startTimer()
    {
        self.timer = Timer.scheduledTimer(timeInterval: 1/60,
                                          target: self,
                                          selector: #selector(calcTimeAbsolute),
                                          userInfo: nil,
                                          repeats: true)
    }
    
    
    @objc func calcTimeAbsolute()
    {
        let now = Date.timeIntervalSinceReferenceDate
       
        self.calcTime(usingNow: now)
    }
    
    func calcTime(usingNow now:Double)
    {
        let delta = now - self.lastTime
        
        self.rawTimeValue = fmod(delta + self.rawTimeValue,  self.bpmToS(self.bpm) )
                                
        self.timeValue = remap(input: self.rawTimeValue, inMin: 0.0, inMax:self.bpmToS(self.bpm), outMin: 0.0, outMax: 1.0)
        self.lastTime = now
    }
    
    private func bpmToS(_ bpm:Double) -> Double
    {
        let bps =  60.0 / bpm
        return bps
    }
    
    private func sToBpm(_ s:Double) -> Double
    {
        let bpm = s * 60.0
        return bpm
    }
}


struct SimpleSliderView : View
{
    var value: Double
//    @Binding var value:Double
    
    private let colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]

    var body: some View
    {
        ZStack
        {
            colors.randomElement()!
            Rectangle().fill(Color.red)
                .frame(width: 200 * value)
            Text("This should obviously redraw")
        }
        .frame(width:200, height: 100 )
    }
}

struct StaticView: View {
    let text:String

    private let colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]

    var body: some View {
        ZStack {
            colors.randomElement()!
            Text(text)
        }
    }
}

struct StaticText: View
{
    private let colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]

    var body: some View {
        Text("Why does the background Redraw?")
            .foregroundStyle(colors.randomElement()!)
    }
}

struct ContentView: View {
    let clock = BPMClock()
//    @State var clock = BPMClock()
//    @Bindable var clock = BPMClock()
    
    private let colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]

    var body: some View {
        ZStack
        {
            colors.randomElement()!

            VStack {
                
                Text("Why do I change color?")
                    .foregroundStyle(colors.randomElement()!)
                
                StaticText()
                
                StaticView(text: "Start Clock \n (this does not redraw)")
                    .frame(width: 100, height: 100)
                    .onTapGesture {
                        self.clock.startTimer()
                    }
                
                StaticView(text: "I should not redraw")
                    .frame(width: 100, height: 100)
                
                SimpleSliderView(value: clock.timeValue)
                    .frame(height: 100)
            }
            .frame(width: 400, height: 400)
        }
    }
}

#Preview {
    ContentView()
}

Solution

  • The correct formulation of the view stack is below.

    The solution is that the current view actually accesses the dynamically changing property ( clock.timeValue) when passed to the initializer of a subview. That isnt 'isolated', it's seen by the @Observable macro and the entire ContentView is marked dirty.

    The solution is to have the StaticSlider take in the clock as an instance, and not the double value.

    Ugh

    Here is a slightly cleaned up 'working as expected' solution

    import SwiftUI
    
    
    public func remap(input:Double, inMin:Double, inMax:Double, outMin:Double, outMax:Double) -> Double
    {
        return ((input - inMin) / (inMax - inMin) * (outMax - outMin)) + outMin;
    }
    
    public func remap(input:Float, inMin:Float, inMax:Float, outMin:Float, outMax:Float) -> Float
    {
        return ((input - inMin) / (inMax - inMin) * (outMax - outMin)) + outMin;
    }
    
    
    @Observable class BPMClock
    {
        
        var bpm:Double = 60.0
        var timeValue:Double = 0.0
        
        @ObservationIgnored private var rawTimeValue:Double = 0.0
        @ObservationIgnored private var lastTime:Double = 0.0
        @ObservationIgnored var timer:Timer? = nil
    
        func startTimer()
        {
            self.timer = Timer.scheduledTimer(timeInterval: 1/60,
                                              target: self,
                                              selector: #selector(calcTimeAbsolute),
                                              userInfo: nil,
                                              repeats: true)
        }
        
        
        @objc func calcTimeAbsolute()
        {
            let now = Date.timeIntervalSinceReferenceDate
           
            self.calcTime(usingNow: now)
        }
        
        func calcTime(usingNow now:Double)
        {
            let delta = now - self.lastTime
            
            self.rawTimeValue = fmod(delta + self.rawTimeValue,  self.bpmToS(self.bpm) )
                                    
            self.timeValue = remap(input: self.rawTimeValue, inMin: 0.0, inMax:self.bpmToS(self.bpm), outMin: 0.0, outMax: 1.0)
            self.lastTime = now
        }
        
        private func bpmToS(_ bpm:Double) -> Double
        {
            let bps =  60.0 / bpm
            return bps
        }
        
        private func sToBpm(_ s:Double) -> Double
        {
            let bpm = s * 60.0
            return bpm
        }
    }
    
    
    struct SimpleSliderView : View
    {
        @State var clock: BPMClock
    //    @Binding var value:Double
        
        private let colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]
    
        var body: some View
        {
            ZStack
            {
                colors.randomElement()!
                    
                Rectangle().fill(Color.red)
                    .frame(width: 200 * clock.timeValue)
                
                Text("This should obviously redraw")
            }
            .frame(width:200, height: 100 )
        }
    }
    
    struct StaticView: View {
        let text:String
    
        private let colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]
    
        var body: some View {
            ZStack {
                colors.randomElement()!
                Text(text)
            }
        }
    }
    
    struct StaticText: View
    {
        private let colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]
    
        var body: some View {
            Text("Why do I not change color?")
                .foregroundStyle(colors.randomElement()!)
        }
    }
    
    
    // Doesnt matter if @State or @Bindable, local or global var / let
    
    struct ContentView: View {
        
        @State var clock:BPMClock
        
        private let colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple]
    
        var body: some View {
                VStack {
                    
                    ZStack {
                        colors.randomElement()!
                        Text("Why do I change color?")
                    }
    
                    Text("Why do I change color?")
                        .foregroundStyle(colors.randomElement()!)
    
                    StaticText()
                    
                    StaticView(text: "Start Clock \n (this does not redraw)")
                        .frame(width: 100, height: 100)
                        .onTapGesture {
                            clock.startTimer()
                        }
                    
                    StaticView(text: "I should not redraw")
                        .frame(width: 100, height: 100)
                    
                    SimpleSliderView(clock: clock)
                        .frame(height: 100)
                }
                // uncomment me for even more epillepsy
                // .background( colors.randomElement()! )
                .frame(width: 400, height: 400)
            
        }
    }
    
    #Preview {
        let bpmClock = BPMClock()
        ContentView(clock: bpmClock)
    }