'Understanding Preferences in SwiftUI

iOS 15 Swift 5.5 Trying to understand preferences, but struggling with GeometryReader

I crafted this code reading a tutorial, but it doesn't work correctly. The green box should appear over the red dot, the first one. But it is over the centre one. When I tap on the dot, it moves to the third...it feels like SwiftUI drew the whole thing and then changed its mind about the coordinates. Sure I could change the offsets, but that is a hack. Surely SwiftUI should be updating the preferences if the thing moves.

import SwiftUI

struct ContentView: View {
@State private var activeIdx: Int = 0
@State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 12)
var body: some View {
    ZStack {
        RoundedRectangle(cornerRadius: 15).stroke(lineWidth: 3.0).foregroundColor(Color.green)
            .frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
            .offset(x: rects[activeIdx].minX, y: rects[activeIdx].minY)
        HStack {
            SubtleView(activeIdx: $activeIdx, idx: 0)
            SubtleView(activeIdx: $activeIdx, idx: 1)
            SubtleView(activeIdx: $activeIdx, idx: 2)
        }
        .animation(.easeInOut(duration: 1.0), value: rects)
    }.onPreferenceChange(MyTextPreferenceKey.self) { preferences in
        for p in preferences {
            self.rects[p.viewIdx] = p.rect
            print("p \(p)")
        }
    }.coordinateSpace(name: "myZstack")
}
}

struct SubtleView: View {
@Binding var activeIdx:Int
let idx: Int
var body: some View {
    Circle()
        .fill(Color.red)
        .frame(width: 64, height: 64)
        .background(MyPreferenceViewSetter(idx: idx))
        .onTapGesture {
            activeIdx = idx
        }
}
}

struct MyTextPreferenceData: Equatable {
let viewIdx: Int
let rect: CGRect
}

struct MyTextPreferenceKey: PreferenceKey {
typealias Value = [MyTextPreferenceData]

static var defaultValue: [MyTextPreferenceData] = []

static func reduce(value: inout [MyTextPreferenceData], nextValue: () -> [MyTextPreferenceData]) {
    value.append(contentsOf: nextValue())
}
}

struct MyPreferenceViewSetter: View {
let idx: Int

var body: some View {
    GeometryReader { geometry in
        Rectangle()
            .fill(Color.clear)
            .preference(key: MyTextPreferenceKey.self,
                        value: [MyTextPreferenceData(viewIdx: self.idx, rect: geometry.frame(in: .named("myZstack")))])
    }
}
}

Tried moving the onPreferences up a level, down a level..attaching it to each view... but still doesn't update...what did I miss here=



Solution 1:[1]

You read absolute coordinates in preference so need to use position instead of offset

RoundedRectangle(cornerRadius: 15).stroke(lineWidth: 3.0).foregroundColor(Color.green)
    .frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
    .position(x: rects[activeIdx].midX, y: rects[activeIdx].midY)  // << here !!

Tested with Xcode 13.2 / iOS 15.2

demo

And here is complete fixed body:

var body: some View {
    ZStack {
        HStack {
            SubtleView(activeIdx: $activeIdx, idx: 0)
            SubtleView(activeIdx: $activeIdx, idx: 1)
            SubtleView(activeIdx: $activeIdx, idx: 2)
        }
        RoundedRectangle(cornerRadius: 15).stroke(lineWidth: 3.0).foregroundColor(Color.green)
            .frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
            .position(x: rects[activeIdx].midX, y: rects[activeIdx].midY)
    }.onPreferenceChange(MyTextPreferenceKey.self) { preferences in
        for p in preferences {
            self.rects[p.viewIdx] = p.rect
            print("p \(p)")
        }
    }.coordinateSpace(name: "myZstack")
    .animation(.easeInOut(duration: 0.5), value: activeIdx)

Solution 2:[2]

I think the answer is simply that a ZStack is naturally center aligned, so your initial position, before the offset is over the second circle, but your activeIdx is 0 which is the first circle. If you view it on the Canvas in the static preview. Simply changing the ZStack alignment to .leading lines everything up.

Gif of result

var body: some View {
    ZStack(alignment: .leading) { // Set your alignment here
        RoundedRectangle(cornerRadius: 15).stroke(lineWidth: 3.0).foregroundColor(Color.green)
            .frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
            .offset(x: rects[activeIdx].minX, y: rects[activeIdx].minY)
        HStack {
            SubtleView(activeIdx: $activeIdx, idx: 0)
            SubtleView(activeIdx: $activeIdx, idx: 1)
            SubtleView(activeIdx: $activeIdx, idx: 2)
        }
        // Also change the animation value to activeIdx
        .animation(.easeInOut(duration: 1.0), value: activeIdx)
    }.onPreferenceChange(MyTextPreferenceKey.self) { preferences in
        for p in preferences {
            self.rects[p.viewIdx] = p.rect
            print("p \(p)")
        }
    }.coordinateSpace(name: "myZstack")
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Asperi
Solution 2