swiftuilayout

SwiftUI aligning a view by percentage


Using SwiftUI, I have a rectangle that takes up a percentage of the device's width, with an inner rectangle in an overlay.

struct ContentView: View {
    var body: some View {
        Rectangle()
            .fill(.purple)
            .containerRelativeFrame([.horizontal, .vertical]) { myWidth, axis in
                if axis == .horizontal {
                    return myWidth * 0.8
                } else {
                    return 40.0
                }
            }
            .overlay(CandyView(theWidth: 0.25), alignment: .leading)
            .cornerRadius(8.0)
    }
}

struct CandyView: View {
    var theWidth: CGFloat
    
    var body: some View {
        Rectangle()
            .fill(.white)
            .containerRelativeFrame([.horizontal, .vertical]) { myWidth, axis in
                if axis == .horizontal {
                    return myWidth * theWidth
                } else {
                    return 35.0
                }
            }
            .cornerRadius(7.0)
            .padding([.leading, .trailing], 3.0)
    }
}

This will give me something like this:

screenshot of overlaid rectangles

I can place the inner rectangle on the other side, or in the middle, by changing the alignment:

.overlay(CandyView(theWidth: 0.25), alignment: .leading)

But what if I wanted to place the inner rectangle 20% along the outer rectangle? Is there an easy way to specify a percentage to place it where I'd like it to be? I'd prefer not to use GeometryReader, but I'm open to suggestions.


Solution

  • You could always change the overlay to an HStack containing a spacer + the marker shape.

    You might notice, that the white marker in your example has a width that is 25% of the screen, not 25% of the underlying view. This is because, the reference container for .containerRelativeFrame is actually the screen in this case. See the documentation for more details.

    ā†’ To correct the width, the base fraction needs to be applied to all overlay fractions too.

    Btw, the .overlay modifier you are using is deprecated, suggest using overlay(alignment:content:) instead. Also, .cornerRadius is deprecated, suggest applying a clip shape instead, or just use a RoundedRectangle as the base shape:

    private let baseWidthFraction: CGFloat = 0.8
    
    RoundedRectangle(cornerRadius: 8)
        .fill(.purple)
        .containerRelativeFrame([.horizontal, .vertical]) { width, axis in
            axis == .horizontal ? width * baseWidthFraction : 40.0
        }
        .overlay(alignment: .leading) {
            HStack(spacing: 0) {
                Color.clear
                    .containerRelativeFrame(.horizontal) { width, axis in
                        width * 0.2 * baseWidthFraction
                    }
                CandyView(theWidth: 0.25 * baseWidthFraction)
            }
        }
    

    Screenshot


    An alternative way to split the view is to use a custom Layout. For the case here, there is only one child to position, so the layout implementation can be greatly simplified:

    struct FractionalLayout: Layout {
        var spacerFraction: CGFloat = 0.0
        let contentFraction: CGFloat
    
        func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
            proposal.replacingUnspecifiedDimensions()
        }
        
        func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
            if let child = subviews.first {
                let x = bounds.minX + (bounds.width * spacerFraction)
                let w = bounds.width * contentFraction
                child.place(
                    at: CGPoint(x: x, y: bounds.minY),
                    proposal: .init(width: w, height: bounds.height)
                )
            }
        }
    }
    

    Example use:

    .overlay {
        CandyView(spacerWidth: 0.2, theWidth: 0.25)
    }
    
    struct CandyView: View {
        var spacerWidth = CGFloat.zero
        let theWidth: CGFloat
    
        var body: some View {
            FractionalLayout(spacerFraction: spacerWidth, contentFraction: theWidth) {
                RoundedRectangle(cornerRadius: 7)
                    .fill(.white)
            }
            .padding(.horizontal, 3)
            .padding(.vertical, 2.5)
        }
    }
    

    The result is very similar to the previous result, except that the width of the marker is now determined after allowing for the horizontal padding, which means it is actually slightly more precise than before:

    Screenshot


    For a more generic Layout implementation that supports multiple subviews, see the answer to SwiftUI How to Set Specific Width Ratios for Child Elements in an HStack (it was my answer). In that implementation, the available width is split according to the weights given to the subviews. Here's how it could be used to give the same result here:

    .overlay {
        WeightedHStack {
            Color.clear.layoutWeight(20)
            RoundedRectangle(cornerRadius: 7)
                .fill(.white)
                .frame(height: 35)
                .layoutWeight(25)
            Color.clear.layoutWeight(55)
        }
        .padding(.horizontal, 3)
    }