Я пытаюсь понять, когда SwiftUI на самом деле вызывает перерисовку представлений с использованием зависимостей @Observable/property и случайных цветов фона, чтобы просто получить визуальное представление о вещах.
Я прочитал https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app - и насколько я понимаю, представления, которые используют свойства объектов @Observable, имеют механизм макросов в место для обновления только в случае изменения свойства. Другие представления в дереве не должны обновляться, если их свойство не обновилось.
Из документов Apple:
Вы также можете поделиться объектом данных наблюдаемой модели с другим представлением. Принимающее представление формирует зависимость, если оно считывает какие-либо свойства объект в своем теле. Например, в следующем коде LibraryView использует экземпляр Book совместно с BookView и BookView. отображает название книги. Если название книги изменится, SwiftUI обновляет только BookView, а не LibraryView, потому что только BookView читает свойство title.
Но я не могу добиться единообразия для всех типов представлений?
В приведенном ниже коде я добавил несколько статических представлений, которые случайным образом рисуют цвет фона, и несколько «встроенных» представлений, которые также случайным образом рисуют фон. У меня есть простая ручная анимация на основе таймера, которая просто обновляет наблюдаемое значение свойства в качестве «прокси» для изменений, чтобы проверить отслеживание наблюдений/зависимостей.
Вот ссылка на видео предварительного просмотра Xcode, показывающее то, что я вижу:
https://x.com/_vade/status/1828116344916844806?s=61
Что я ожидал увидеть:
Самый верхний «встроенный» созданный текст не должен менять цвет переднего плана — он не зависит от наблюдаемого свойства. На самом деле оно меняется. Почему?
Аналогично, представление StaticText под ним не должно менять цвет переднего плана. Это не так, и работает так, как ожидалось. Почему это отличается от представленного выше?
Я ожидаю, что кнопка StaticView Start Clock не изменит свой фон. Это не так, я полагаю, по той же причине, что и приведенный выше StaticText не меняется. Тем не менее, представление взаимодействует с часами, но не с основным телом рисования. Ура.
Аналогично, StaticView ниже не меняется должным образом. Ура.
Я ожидаю, что SimpleSliderView будет обновляться, поскольку он напрямую привязан к часам и наблюдает за свойством, которое обновляется на основе таймера.
Я не ожидаю обновления внешнего цвета фона ZStacks, хотя я могу быть убежден, что, поскольку необходимо перерисовать дочернее представление, возможно, потребуется перерисовать его.
В чем причина того, что весь ZStack пересчитывает свой фон, а некоторые его дочерние элементы - нет?
Если вышеизложенное является «правильным» поведением, может ли любое встроенное родительское представление, используемое для композиции в SwiftUI (например, HStack/VStack/ZStack), быть написано таким образом, чтобы перерисовка подпредставлений не вызывала перерисовку родителя?
С точки зрения графики я хочу ограничить любую скорость перерисовки/заполнения и обеспечить минимальное количество обновлений за каждое наблюдаемое обновление через наблюдение.
Я тестирую бета-версию macOS 15.1 Beta 2 и хочу полностью использовать любые новые возможности повышения производительности SwiftUI, какие только смогу. Я не ориентируюсь на старые версии. ЙОЛО.
Спасибо за любую информацию! Кажется, очень легко создать плохо работающий код SwiftUI.
Тестовый код:
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()
}
Правильная формулировка стека представлений приведена ниже.
Решение состоит в том, что текущее представление фактически обращается к динамически изменяющемуся свойству ( clock.timeValue) при передаче инициализатору подпредставления. Это не «изолировано», его видит макрос @Observable, и весь ContentView помечен как «грязный».
Решение состоит в том, чтобы StaticSlider принимал в качестве экземпляра часы, а не двойное значение.
Фу
Вот слегка очищенное решение, работающее как положено.
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)
}