Aligning views across stacks when nested (within a overlay, background, or ZStack)

289 Views Asked by At

A typical problem in SwiftUI is aligning two texts with their baselines in different VStacks, when the content of these VStacks differs in height.

Apple has a nice article on how to solve this problem with a custom alignment guide. However, this approach doesn't work when the VStacks that contain the texts to be aligned are nested inside of an overlay (or background) as you can see in the following image.

Preview of misaligned texts using an overlay

That makes sense when you assume that overlays and backgrounds do not affect the layout at all which is (to my knowledge) their main purpose. This is the code for the view:

struct SaveOptionsView: View {
    var body: some View {
        HStack(alignment: .bucket, spacing: 0) {  // ← use custom alignment guide
            BucketView(imageSystemName: "brain", name: "Remember")
                .foregroundColor(.cyan)
            BucketView(imageSystemName: "hand.thumbsup.fill", name: "Like")
                .foregroundColor(.orange)
        }
        .ignoresSafeArea()
        .frame(width: 300, height: 300)
    }
}

struct BucketView: View {
    let imageSystemName: String
    let name: String

    var body: some View {
        Rectangle()
            .opacity(0.2)
            .overlay {
                VStack { // ← VStack nested inside of an overlay
                    Image(systemName: imageSystemName)
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                    Text(name) // ← this is the Text to be aligned
                        .font(.headline)
                        .alignmentGuide(.bucket) { context in
                            context[.firstTextBaseline]
                        }
                }
                .padding()
            }
    }
}

extension VerticalAlignment {
    /// A custom alignment for buckets.
    private struct BucketAlignment: AlignmentID {
        static func defaultValue(in context: ViewDimensions) -> CGFloat {
            context[VerticalAlignment.bottom]
        }
    }

    static let bucket = VerticalAlignment(
        BucketAlignment.self
    )
}

Now I was able to solve this alignment problem using a ZStack instead of an overlay, but as you can see in the rendered view below, a new problem arises:

Preview of misaligned texts using a ZStack

The backgrounds (colored Rectangles) are also shifted accordingly, so the exceed the container at the top or bottom.

How do I fix this properly?*

Here's the code for the modified BucketView using a ZStack:

struct BucketView: View {
    let imageSystemName: String
    let name: String

    var body: some View {
        ZStack {
            Rectangle()
                .opacity(0.2)
            VStack {
                Image(systemName: imageSystemName)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                Text(name)
                    .font(.headline)
                    .alignmentGuide(.bucket) { context in
                        context[.firstTextBaseline]
                    }
            }
            .padding()
        }
    }
}

* Properly = ?

I'm aware that I could remove the colored Rectangle from the BucketView and just put another HStack with two differently colored Rectangles behind the HStack that contains the BucketViews.

However, this approach doesn't scale and it doesn't follow proper encapsulation: If I were to add another BucketView, I would have to modify the code two places:

  1. add a BucketView to the HStack in the front,
  2. add a Rectangle with a matching color to the HStack in the background. In other words: It creates a second source of truth and is hard to maintain.

So by properly, I mean that I can have all the configuration of (colored panel + icon + text) including the colors encapsulated in one view (that might have nested subviews of course), so there is only a single point in code that I need to touch in order to add or remove a "bucket".

1

There are 1 best solutions below

9
Benzy Neez On

EDIT: improved the way the alignment position is estimated.
EDIT2: added more screenshots and conclusion.

Here's a summary of the conclusions I came to while trying to find a solution here:

  • it doesn't work to use alignment guides inside the HStack, this is what is causing the offset backgrounds in your second screenshot
  • therefore, just let the HStack use default vertical alignment
  • alignment needs to be used for the overlays instead.

I couldn't find a way to have the overlays align at a natural position. But given that the alignment is only used for buckets, it is possible to estimate the alignment position quite reliably, as follows:

struct SaveOptionsView: View {
    var body: some View {
        HStack(spacing: 0) { // No alignment needed here
            BucketView(imageSystemName: "brain", name: "Remember")
                .foregroundColor(.cyan)
            BucketView(imageSystemName: "hand.thumbsup.fill", name: "Like")
                .foregroundColor(.orange)
        }
        .ignoresSafeArea()
        .frame(width: 300, height: 300)
    }
}

struct BucketView: View {
    let imageSystemName: String
    let name: String

    var body: some View {
        Rectangle()
            .opacity(0.2)
            .overlay(alignment: .centerBucket) { // Align the overlay
                VStack { // ← VStack nested inside of an overlay
                    Image(systemName: imageSystemName)
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                    Text(name) // ← this is the Text to be aligned
                        .font(.headline)
                        .alignmentGuide(.bucket) { context in
                            context[.bottom]
                        }
                }
                .padding()
            }
    }
}

extension VerticalAlignment {
    /// A custom alignment for buckets.
    private struct BucketAlignment: AlignmentID {
        static func defaultValue(in context: ViewDimensions) -> CGFloat {

            // This alignment is only used for buckets and is based
            // on a knowledge of the usual content. The images are
            // usually quite square and they are scaled to fit the
            // space available, with padding. So use the smallest
            // dimension as an estimate of the typical overlay height
            let approxOverlayHeight = min(context.width, context.height)

            // Compute the position for guidelines to align to
            let pos = context[VerticalAlignment.center] + approxOverlayHeight / 2

            // Don't go over the bottom edge
            return min(pos, context[.bottom])
        }
    }

    static let bucket = VerticalAlignment(
        BucketAlignment.self
    )
}

extension Alignment {
    static let centerBucket = Alignment(horizontal: .center, vertical: .bucket)
}

AlignedOverlays
Here are some more screenshots to illustrate the cases discussed in the comments:

AlignmentExamples

Conclusion
What this case has shown is that if the overlays you are trying to align have similar content and belong to views that have similar geometry (such as same height or same width), then it might be possible to align the overlays in a satisfactory way using a computed alignment position. However, if the views are very different to each other then this approach probably won't work.