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:
The top most 'inline' created Text should not change its foreground color - it has no dependencies on the observed property. It does in fact change. Why?
Similarly, the StaticText view below it should not change its foreground color. It does not, and works as expected. Why is this different than the view above?
I expect the StaticView Start Clock button not to change its background. It'd does not, I presume for the same reason as the above StaticText does not change. That said, the view does interact with the clock, but not in the main draw body. Yay.
Similarly, the StaticView below does not change as expected. Yay.
I'd expect the SimpleSliderView to update as it's directly bound to the clock and observing the property that is updating based on the timer.
I do not expect the outer ZStacks background color to update, although I can be convinced that because its needs to re-draw a child view it may need to redraw.
What is the reason the entire ZStack recalculates its background but some of its children do not?
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()
}
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)
}