2

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".

Mischa
  • 15,816
  • 8
  • 59
  • 117

1 Answers1

1

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.

Benzy Neez
  • 1,546
  • 2
  • 3
  • 10
  • That's a very interesting approach. I didn't know you can pass an alignment parameter to overlays as well. Like you, I'm not happy with the estimated vertical positioning within the bucket views. However, it's a good direction and worth exploring. – Mischa Aug 04 '23 at 08:17
  • After exploring this a bit further, I came to the conclusion that this (most likely) doesn't work the way you believe it works. As I understand it, an overlay (or any kind of stack) only aligns its _children_ with each other. When you use the same alignment guide in different overlays in the view hierarchy (in a different stack), they are independent of each other and don't magically relate. It might appear as if we only use the guide in one place, but we added the `BucketView` to the same `HStack` twice, so effectively we're having two independent overlays. – Mischa Aug 04 '23 at 08:57
  • The reason your solution kinda works is because you align both overlays with the bottom of their container whose size is determined by the equally sized colored rectangles. So you're just pushing everything down to the bottom edge and then manually move them back up in the middle. You'll see that this won't work anymore when you modify the `BucketView` adding another (differently sized) icon below the text. So the texts are not equally aligned because a single layout guide goes "through" both overlays, but because both overlays have the same height and you bottom-align their contents. – Mischa Aug 04 '23 at 09:06
  • @Mischa Actually it still works if BucketView has an image above and below the text, because the alignmentGuide is being applied to the Text item inside the bucket. Of course, the estimated alignment position needs adjusting accordingly, but just returning ```context[VerticalAlignment.center]``` works pretty well as a first approximation. By the same reasoning, the alignment also works when the items in the HStack have different heights too (although the backgrounds are then no longer the same size, of course). – Benzy Neez Aug 04 '23 at 11:26
  • So the biggest flaw with this solution is the estimated alignment position, even though it might actually be fit-for-purpose in the real-world. But if you wanted to compute the position precisely then I guess it might be possible to pass around a binding to a state variable which can be used to capture the height of the largest overlay. I think the other answer is doing something like this. – Benzy Neez Aug 04 '23 at 11:27
  • Screenshots added to illustrate the cases discussed above. – Benzy Neez Aug 04 '23 at 11:41
  • Actually I was a bit surprised that it worked for different heights too. The reason is because the HStack is now using (default) alignment of ```VerticalAlignment.center``` and the estimated alignment position is an offset of half the width from the vertical center. So height doesn't matter. – Benzy Neez Aug 04 '23 at 11:55
  • You are correct that it works with icons (or any kind of content) below the text as well. I need to rectify that claim of mine. I fully understand your idea now and agree that it's a good approximation in this concrete scenario. However, as documented in your code, it heavily relies on outside knowledge, i.e. the approximate dimensions of the icon as well as the padding. In this case, it's the default padding of 16pt and barely noticeable. But increase it to 32pt and it's already clearly off, so you'll need to touch that alignment guide again (→ 2 sources of truth → again not very scalable). – Mischa Aug 07 '23 at 23:20
  • You're sacrificing SwiftUI's ability to (perfectly) vertically center views in a container for the ability to align two labels (perfectly) that are not in the same direct (parent) container. As a result, we're then forced to manually center the views again, but we don't have the necessary information and thus need to rely on assumptions about the outside world (approximate aspect ratio of the views, padding size). It's a great idea that will certainly work for specific scenarios (thanks for the idea!), but it's not a general "proper" solution for this problem category. – Mischa Aug 08 '23 at 07:27