WWDC24 SALE: Save 50% on all my Swift books and bundles! >>

SOLVED: Is there a way to constrain or inform a view size?

Forums > SwiftUI

I'm fiddling with a view layout - a graph - where I'm seeing how far I can get within an all-SwiftUI layout. Each of the component pieces are fine, but assembling the whole isn't working quite as I'd like. What I've found is that I can easily constrain sizing along a single stack axis - but not two axis at once: (both vertical and horizontal).

I started to reach for AlignmentGuides, as I noticed you can align non-siblings with a custom guide. That will help my goal, but it doesn't solve the sizing part, which is the heart of this question:

Is there a way to constrain a view's size based on another, non-sibling, view?

A simplification of the structure is:

HStack {
   CellOneView {
   }
   CellTwoView {
   }
}
HStack {
   CellThreeView {
   }
   CellFourView {
   }
}

Which maps out to:

+-----+-----+
|  1  |  2  |
+-----+-----+
|  3  |  4  |
+-----+-----+

Is there a way to tell CellFour (which isn't in the same HStack as cell's 1 and 2) that I want it to constrain itself (and align) to the width of cell CellTwo?

This does not need to strictly be a grid view (per https://www.hackingwithswift.com/quick-start/swiftui/how-to-position-views-in-a-grid) - there are really only three views that I care about in this case. The areas that roughly map to cell 1, cell 2, and cell 4. I want the heights of Cell 1 and Cell 2 to be the same (accomplished easily with the current HStack), and the widths of Cell 2 and Cell 4 to be the same - that's where I'm struggling.

(I've also inquired on StackOverflow)

3      

Since your cells 2 and 4 are not part of the same view hierarchy, you would have to use PreferenceKeys to push information from one of them up the chain and then down the chain to the other one.

Two great resources for learning about this method are:

3      

Here is my quick attempt to use the PreferenceKey protocol to push the cell frame info up the view hierarchy.

import SwiftUI
struct ContentView: View {
    @State var cellFrame: CGRect?
        var body: some View {
            Form {
                HStack {
                    CellView(txt: "CELL #1")
                        .background(Color.green)
                        .frame(width: 150)
                    CellView(txt: "CELL #2")
                        .background(Color.red)
                }
                ZStack {
                    HStack {
                        Text("TEXT #1")
                            .frame(height: 32)
                            .background(Color.blue)
                        Spacer()
                    }
                    HStack() {
                        Spacer()
                        Text("TEXT #2")
                            .frame(width: cellFrame?.width, height: cellFrame?.height)
                            .background(Color.red)
                    }
                }
            }
            .onPreferenceChange(CellFramePreferenceKey.self) { preferences in
                if preferences.count > 1 {
                    self.cellFrame = preferences[1].cellFrame
                }
            }
        }
}
struct CellView: View {
    var txt: String
    var body: some View {
        GeometryReader { geometry in
            Text(self.txt)
            .preference(key: CellFramePreferenceKey.self,
                        value: [CellFrame(cellFrame: geometry.frame(in: CoordinateSpace.global))])
        }
    }
}
struct CellFrame: Equatable {
    let cellFrame: CGRect
}
struct CellFramePreferenceKey: PreferenceKey {
    typealias Value = [CellFrame]
    static var defaultValue: [CellFrame] = []
    static func reduce(value: inout [CellFrame], nextValue: () -> [CellFrame]) {
        value.append(contentsOf: nextValue())
    }
}

4      

@Pyroh  

Using the preference/environment value propagation technique I went with this solution that can size multiple columns at once :

struct PropagatedWidthEnvironmentKey: EnvironmentKey {
    static var defaultValue: [Int: CGFloat] { [:] }
}

struct PropagatedHeightEnvironmentKey: EnvironmentKey {
    static var defaultValue: [Int: CGFloat] { [:] }
}

struct PropagatedWidthPreferenceKey: PreferenceKey {
    static var defaultValue: [Int: CGFloat] = [:]

    static func reduce(value: inout [Int : CGFloat], nextValue: () -> [Int : CGFloat]) {
        guard let next = nextValue().first else { return }
        let key = next.key
        if next.value > value[key] ?? 0 { value[key] = next.value }
    }
}

struct PropagatedHeightPreferenceKey: PreferenceKey {
    static var defaultValue: [Int: CGFloat] = [:]

    static func reduce(value: inout [Int : CGFloat], nextValue: () -> [Int : CGFloat]) {
        guard let next = nextValue().first else { return }
        let key = next.key
        if next.value > value[key] ?? 0 { value[key] = next.value }
    }
}

extension EnvironmentValues {
    var propagatedWidth: [Int: CGFloat] {
        get { self[PropagatedWidthEnvironmentKey.self] }
        set { self[PropagatedWidthEnvironmentKey.self] = newValue }
    }

    var propagatedHeight: [Int: CGFloat] {
        get { self[PropagatedHeightEnvironmentKey.self] }
        set { self[PropagatedHeightEnvironmentKey.self] = newValue }
    }
}

struct PropagatedWidthProvider: ViewModifier {
    @Environment(\.propagatedWidth) var propagatedWidth
    private let index: Int
    private let alignment: Alignment

    init(_ index: Int = 0,  alignment: Alignment = .trailing) {
        self.index = index
        self.alignment = alignment
    }

    func body(content: Content) -> some View {
        content
            .frame(width: propagatedWidth[index], alignment: alignment)
            .overlay(
                GeometryReader { geometry in
                    Color.clear
                        .preference(key: PropagatedWidthPreferenceKey.self, value: geometry.size.width > 0 ? [self.index: geometry.size.width] : [:])
                }
            )
    }
}

struct PropagatedHeightProvider: ViewModifier {
    @Environment(\.propagatedHeight) var propagatedHeight
    private let index: Int
    private let alignment: Alignment

    init(_ index: Int = 0,  alignment: Alignment = .center) {
        self.index = index
        self.alignment = alignment
    }

    func body(content: Content) -> some View {
        content
            .frame(height: propagatedHeight[index], alignment: alignment)
            .overlay(
                GeometryReader { geometry in
                    Color.clear
                        .preference(key: PropagatedHeightPreferenceKey.self, value: geometry.size.height > 0 ? [self.index: geometry.size.height] : [:])
                }
            )
    }
}

struct SizePropagator: ViewModifier {
    @State var propagatedWidth: [Int: CGFloat] = [:]
    @State var propagatedHeight: [Int: CGFloat] = [:]

    func body(content: Content) -> some View {
        content
            .environment(\.propagatedWidth, propagatedWidth)
            .environment(\.propagatedHeight, propagatedHeight)
            .onPreferenceChange(PropagatedWidthPreferenceKey.self) { width in
                DispatchQueue.main.async {
                    self.propagatedWidth = width
                }
            }
            .onPreferenceChange(PropagatedHeightPreferenceKey.self) { (height) in
                DispatchQueue.main.async {
                    self.propagatedHeight = height
                }
            }
    }
}

extension View {
    func equalizeWidth(_ index: Int = 0, alignment: Alignment = .trailing) -> some View {
        self.modifier(PropagatedWidthProvider(index, alignment: alignment))
    }

    func equalizeHeight(_ index: Int = 0, alignment: Alignment = .center) -> some View {
        self.modifier(PropagatedHeightProvider(index, alignment: alignment))
    }

    func sizeEqualizer() -> some View {
        self.modifier(SizePropagator())
    }
}

I've also included the part that propagates the height among the views —although less useful.

You can test it with this preview :

struct SizeEqualizer_Previews: PreviewProvider {
    static var previews: some View {
        VStack {
            HStack {
                Text("Lorem Lorem").equalizeWidth().background(Color.orange)
                Divider().background(Color.black).padding(.vertical, -12)
                Text("Dolor sit").equalizeWidth(1, alignment: .leading).background(Color.pink)
                Divider().background(Color.black).padding(.vertical, -12)
                Text("9999").equalizeWidth(2, alignment: .center).background(Color.green)
            }
            Divider().background(Color.black)
            HStack {
                Text("Ipsum").equalizeWidth().background(Color.orange)
                Divider().background(Color.black).padding(.vertical, -12)
                Text("Amet").equalizeWidth(1, alignment: .leading).background(Color.pink)
                Divider().background(Color.black).padding(.vertical, -12)
                Text("0").equalizeWidth(2, alignment: .center).background(Color.green)
            }
        }
        .sizeEqualizer()
        .fixedSize()
    }
}

Too bad we can't attach image, you won't see the result without running it yourself.

PS: If someone have a better idea regarding the components' names it'll be much appreciated :)

4      

Save 50% in my WWDC sale.

SAVE 50% To celebrate WWDC24, all our books and bundles are half price, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.

Save 50% on all our books and bundles!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.