Search code examples
alignmentswiftui

SwiftUI custom alignment pushes views wider than parent


I start with this. Some UI with two sliders.

enter image description here

Defined by this code:

struct ContentView: View {
    @State private var num1: Double = 0.5
    @State private var num2: Double = 0.5
    let blueGreen = Color(red: 0.2, green: 0.6, blue: 0.6)
    var body: some View {
        VStack {
            Circle().fill(blueGreen).border(Color.blue, width: 1.0).padding(4.0)
            VStack {
                HStack {
                    Text("Value:")
                    Slider(value: $num1, in: 0...1)
                }
                HStack {
                    Text("Opacity:")
                    Slider(value: $num2, in: 0...1)
                }
            }
            Spacer()
        }.border(Color.green, width: 1.0).padding()
    }
}

I want the "Value:" and "Opacity:" labels to be aligned on their trailing edge, leaving the sliders lined-up and the same width. I've heard that custom alignments can align Views like this, Views that are not siblings.

So I add the custom alignment:

extension HorizontalAlignment {
    private enum MyAlignment : AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            return d[.trailing]
        }
    }
    static let myAlignment = HorizontalAlignment(MyAlignment.self)
}

struct ContentView: View {
    @State private var num1: Double = 0.5
    @State private var num2: Double = 0.5
    let blueGreen = Color(red: 0.2, green: 0.6, blue: 0.6)
    var body: some View {
        VStack {
            Circle().fill(blueGreen).border(Color.blue, width: 1.0).padding(4.0)
            VStack(alignment: .myAlignment) {
                HStack {
                    Text("Value:").alignmentGuide(.myAlignment) { d in d[.trailing] }
                    Slider(value: $num1, in: 0...1)
                }
                HStack {
                    Text("Opacity:").alignmentGuide(.myAlignment) { d in d[.trailing] }
                    Slider(value: $num2, in: 0...1)
                }
            }
            Spacer()
        }.border(Color.green, width: 1.0).padding()
    }
}

It partly works. The trailing edges of the labels are aligned, but now the UI has moved partly off screen to the right.

enter image description here

With UIKit and auto layout, I would constrain the trailing edges of those two labels to be equal. That would not push anything off screen (assuming things were constrained to screen edges, as usual). What is going wrong here? And how do you achieve this with alignments, or by some better way, with SwiftUI?


Solution

  • One way to do it, is using Preferences (learn more here).

    enter image description here

    struct ContentView: View {
        @State private var num1: Double = 0.5
        @State private var num2: Double = 0.5
        @State private var sliderWidth: CGFloat = 0
    
        private let spacing: CGFloat = 5
        let blueGreen = Color(red: 0.2, green: 0.6, blue: 0.6)
    
        var body: some View {
            VStack {
                Circle().fill(blueGreen).border(Color.blue, width: 1.0).padding(4.0)
    
                VStack(alignment: .trailing) {
                    HStack(spacing: self.spacing) {
                        Text("Value:")
                            .fixedSize()
                            .anchorPreference(key: MyPrefKey.self, value: .bounds, transform: { [$0] })
    
                        Slider(value: $num1, in: 0...1)
                            .frame(width: sliderWidth)
                    }
                    .frame(maxWidth: .infinity, alignment: .trailing)
    
    
                    HStack(spacing: self.spacing) {
                        Text("Opacity:")
                            .fixedSize()
                            .anchorPreference(key: MyPrefKey.self, value: .bounds, transform: { [$0] })
    
                        Slider(value: $num2, in: 0...1)
                            .frame(width: sliderWidth)
                    }
                    .frame(maxWidth: .infinity, alignment: .trailing)
    
                }
                .backgroundPreferenceValue(MyPrefKey.self) { prefs -> GeometryReader<AnyView> in
    
                   GeometryReader { proxy -> AnyView in
                        let vStackWidth = proxy.size.width
    
                        let maxAnchor = prefs.max {
                            return proxy[$0].size.width < proxy[$1].size.width
    
                        }
    
                        DispatchQueue.main.async {
                            if let a = maxAnchor {
                                self.sliderWidth = vStackWidth - (proxy[a].size.width + self.spacing)
    
                            }
                        }
    
                        return AnyView(EmptyView())
    
                    }
                }
    
                Spacer()
    
            }.border(Color.green, width: 1.0).padding(20)
        }
    
    }
    
    struct MyPrefKey: PreferenceKey {
        typealias Value = [Anchor<CGRect>]
    
        static var defaultValue: [Anchor<CGRect>] = []
    
        static func reduce(value: inout [Anchor<CGRect>], nextValue: () -> [Anchor<CGRect>]) {
            value.append(contentsOf: nextValue())
        }
    }