swiftui

SwiftUI Multiple Labels Vertically Aligned


There are a lot of solutions for trying to align multiple images and text in SwiftUI using a HStacks inside of a VStack. Is there any way to do it for multiple Labels? When added in a list, multiple labels automatically align vertically neatly. Is there a simple way to do this for when they are embedded inside of a VStack?

enter image description here

struct ContentView: View {
    var body: some View {
//        List{
        VStack(alignment: .leading){
            Label("People", systemImage: "person.3")
            Label("Star", systemImage: "star")
            Label("This is a plane", systemImage: "airplane")
        }
    }
}

Solution

  • So, you want this:

    A vertical stack of three SwiftUI labels. The top label says “People” and has the people icon. The middle label says “Star” and has the star icon. The bottom label says “This is a plane” and has the plane icon. The icons are different widths, but their centers are aligned. The leading edges of the titles of the labels are also aligned.

    We're going to implement a container view called EqualIconWidthDomain so that we can draw the image shown above with this code:

    struct ContentView: View {
        var body: some View {
            EqualIconWidthDomain {
                VStack(alignment: .leading) {
                    Label("People", systemImage: "person.3")
                    Label("Star", systemImage: "star")
                    Label("This is a plane", systemImage: "airplane")
                }
            }
        }
    }
    

    You can find all the code in this gist.

    To solve this problem, we need to measure each icon's width, and apply a frame to each icon, using the maximum of the widths.

    SwiftUI provides a system called “preferences” by which a view can pass a value up to its ancestors, and the ancestors can aggregate those values. To use it, we create a type conforming to PreferenceKey, like this:

    fileprivate struct IconWidthKey: PreferenceKey {
        static var defaultValue: CGFloat? { nil }
    
        static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
            switch (value, nextValue()) {
            case (nil, let next): value = next
            case (_, nil): break
            case (.some(let current), .some(let next)): value = max(current, next)
            }
        }
    }
    

    To pass the maximum width back down to the labels, we'll use the “environment” system. For that, we need an EnvironmentKey. In this case, we can use IconWidthKey again. We also need to add a computed property to EnvironmentValues that uses the key type:

    extension IconWidthKey: EnvironmentKey { }
    
    extension EnvironmentValues {
        fileprivate var iconWidth: CGFloat? {
            get { self[IconWidthKey.self] }
            set { self[IconWidthKey.self] = newValue }
        }
    }
    

    Now we need a way to measure an icon's width, store it in the preference, and apply the environment's width to the icon. We'll create a ViewModifier to do those steps:

    fileprivate struct IconWidthModifier: ViewModifier {
        @Environment(\.iconWidth) var width
    
        func body(content: Content) -> some View {
            content
                .background(GeometryReader { proxy in
                    Color.clear
                        .preference(key: IconWidthKey.self, value: proxy.size.width)
                })
                .frame(width: width)
        }
    }
    

    To apply the modifier to the icon of each label, we need a LabelStyle:

    struct EqualIconWidthLabelStyle: LabelStyle {
        func makeBody(configuration: Configuration) -> some View {
            HStack {
                configuration.icon.modifier(IconWidthModifier())
                configuration.title
            }
        }
    }
    

    Finally, we can write the EqualIconWidthDomain container. It needs to receive the preference value from SwiftUI and put it into the environment of its descendants. It also needs to apply the EqualIconWidthLabelStyle to its descendants.

    struct EqualIconWidthDomain<Content: View>: View {
        let content: Content
        @State var iconWidth: CGFloat? = nil
    
        init(@ViewBuilder _ content: () -> Content) {
            self.content = content()
        }
    
        var body: some View {
            content
                .environment(\.iconWidth, iconWidth)
                .onPreferenceChange(IconWidthKey.self) { self.iconWidth = $0 }
                .labelStyle(EqualIconWidthLabelStyle())
        }
    }
    

    Note that EqualIconWidthDomain doesn't just have to be a VStack of Labels, and the icons don't have to be SF Symbols images. For example, we can show this:

    a two-row, two-column grid of labels

    Notice that one of the label “icons” is an emoji in a Text. All four icons are laid out with the same width (across both columns). Here's the code:

    struct FancyView: View {
        var body: some View {
            EqualIconWidthDomain {
                VStack {
                    Text("Le Menu")
                        .font(.caption)
                    Divider()
                    HStack {
                        VStack(alignment: .leading) {
                            Label(
                                title: { Text("Strawberry") },
                                icon: { Text("🍓") })
                            Label("Money", systemImage: "banknote")
                        }
                        VStack(alignment: .leading) {
                            Label("People", systemImage: "person.3")
                            Label("Star", systemImage: "star")
                        }
                    }
                }
            }
        }
    }