swiftuiswiftui-layoutswiftui-zstackswiftui-alignment-guide

In SwiftUI, what's the best way to anchor one view to the containers center, while anchoring an additional view to its edge?


I'm newish to SwiftUI and trying to understand how I can keep one view horizontally centered on the device screen, while anchoring an additional view to its trailing edge (without uncentering the original view).

I have a prototype that works, but it's a bit ugly and relies on "onGeometryChange" to track the size of the centered view in order to offset the adjacent view the correct amount. The reason this works is that using offset does not change the geometry of the containing ZStack, which thinks the adjacent view occupies the same centered position as the first.

While my approach seems to work, it seems a bit smelly as it relies on multiple layout cycles (one to layout the ZStack and centered view, then read the geometry change, then update the position of the adjacent view). Further, the ZStack container's dimensions do not include offset of the adjacent view.

struct ContentView: View {
    
    @State private var centeredLabelWidth: CGFloat = .zero
    @State private var offsetLabelWidth: CGFloat = .zero
    
    var body: some View {
        ZStack {
            Text("Centered Label")
            .border(Color.blue)
            .padding(1)
            .onGeometryChange(for: CGFloat.self) { $0.size.width } action: {
                centeredLabelWidth = $0
            }
            
            Text("Adjacent Label")
            .border(Color.red)
            .padding(1)
            .onGeometryChange(for: CGFloat.self) { $0.size.width } action: {
                offsetLabelWidth = $0
            }
            .offset(x: centeredLabelWidth / 2 + offsetLabelWidth / 2)
        }
    }
}

Playground Screenshot

Greatly appreciate any insights from the community!


Solution

  • In general, this can be done by putting the view you want to move as the overlay of the view you want to stay fixed, with alignment X, then change the X alignmentGuide of the overlay view to a different alignment guide Y.

    "Adjacent Label" can be overlaid on top of "Centered Label" with alignment .trailing. This overlaps the trailing sides of those labels together. You then change the trailing alignment guide of "Adjacent Label" to be its leading side instead. So now the leading side of "Adjacent Label" overlaps with the trailing side of "Centered Label".

    Text("Centered Label")
        .border(Color.blue)
        .padding(1)
        .overlay(alignment: .trailing) {
            Text("Adjacent Label")
                .border(Color.red)
                .padding(1)
                .alignmentGuide(.trailing) { $0[.leading] }
        }
    

    Note that the overlay view ("Adjacent Label") will be proposed a size that is the same as the view at the bottom ("Centered Label"). If this causes undesirable effects to how the overlay view is laid out, you can apply modifiers to the overlay view, that causes it to ignore the size proposal in one way or another, such as fixedSize, or a fixed frame.