10

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")
        }
    }
}
Harshil Patel
  • 1,318
  • 1
  • 7
  • 26
Richard Witherspoon
  • 4,082
  • 3
  • 17
  • 33

4 Answers4

16

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")
                    }
                }
            }
        }
    }
}
rob mayoff
  • 375,296
  • 67
  • 796
  • 848
2

This has been driving me crazy myself for a while. One of those things where I kept approaching it the same incorrect way - by seeing it as some sort of alignment configuration that was inside the black box that is List.

However it appears that it is much simpler. Within the List, Apple is simply applying a ListStyle - seemingly one that is not public.

I created something that does a pretty decent job like this:

public struct ListLabelStyle: LabelStyle {
    @ScaledMetric var padding: CGFloat = 6

    public func makeBody(configuration: Configuration) -> some View {
        HStack {
            Image(systemName: "rectangle")
                .hidden()
                .padding(padding)
                .overlay(
                    configuration.icon
                        .foregroundColor(.accentColor)
                )
            configuration.title
        }
    }
}

This uses a hidden rectangle SFSymbol to set the base size of the icon. This is not the widest possible icon, however visually it seems to work well. In the sample below, you can see that Apple's own ListStyle assumes that the label icon will not be something significantly larger than the SFSymbol with the font being used.

Screenshot from sample code

While the sample here is not pixel perfect with Apple's own List, it's close and with some tweaking, you should be able to achieve what you are after.

By the way, this works with dynamic type as well.

Here is the complete code I used to generate this sample.

public struct ListLabelStyle: LabelStyle {
    @ScaledMetric var padding: CGFloat = 6

    public func makeBody(configuration: Configuration) -> some View {
        HStack {
            Image(systemName: "rectangle")
                .hidden()
                .padding(padding)
                .overlay(
                    configuration.icon
                        .foregroundColor(.accentColor)
                )
            configuration.title
        }
    }
}

struct ContentView: View {
    @ScaledMetric var rowHeightPadding: CGFloat = 6

    var body: some View {
        VStack {
            Text("Lazy VStack Plain").font(.title2)
            LazyVStack(alignment: .leading) {
                ListItem.all
            }

            Text("Lazy VStack with LabelStyle").font(.title2)
            LazyVStack(alignment: .leading, spacing: 0) {
                vStackContent
            }
            
            .labelStyle(ListLabelStyle())

            Text("Built in List").font(.title2)
            List {
                ListItem.all
                labelWithHugeIcon
                labelWithCircle
            }
            .listStyle(PlainListStyle())
        }
    }

    // MARK: List Content

    @ViewBuilder
    var vStackContent: some View {
        ForEach(ListItem.allCases, id: \.rawValue) { item in
            vStackRow {
                item.label
            }
        }
        vStackRow { labelWithHugeIcon }
        vStackRow { labelWithCircle }
    }
    
    func vStackRow<Content>(@ViewBuilder _ content: () -> Content) -> some View where Content : View {
        VStack(alignment: .leading, spacing: 0) {
            content()
                .padding(.vertical, rowHeightPadding)
            Divider()
        }
        .padding(.leading)
    }
    
    // MARK: List Content
    
    var labelWithHugeIcon: some View {
        Label {
            Text("This is HUGE")
        } icon: {
            HStack {
                Image(systemName: "person.3")
                Image(systemName: "arrow.forward")
            }
        }
    }
    
    var labelWithCircle: some View {
        Label {
            Text("Circle")
        } icon: {
            Circle()
        }
    }
    
    enum ListItem: String, CaseIterable {
        case airplane
        case people = "person.3"
        case rectangle
        case chevron = "chevron.compact.right"

        var label: some View {
            Label(self.rawValue, systemImage: self.rawValue)
        }
        
        static var all: some View {
            ForEach(Self.allCases, id: \.rawValue) { item in
                item.label
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
//            .environment(\.sizeCategory, .extraExtraLarge)
    }
}

David Monagle
  • 1,701
  • 16
  • 19
  • I would suggest using the `Label` initializer that takes a title and icon closures instead of `HStack` to keep the icon aligned with the first line in a multiline text. `Label { configuration.title } icon: { Image(systemName: "rectangle").hidden().overlay { configuration.icon } }` – alobaili Feb 13 '23 at 21:25
0

Combining a few of these answers into another simple option (Very similar to some of the other options but thought it was distinct enough that some may find it useful). This has the simplicity of just setting a frame on the icon, and the swiftUI-ness of using LabelStyle but still adapts to dynamic type!

struct StandardizedIconWidthLabelStyle: LabelStyle {
    @ScaledMetric private var size: CGFloat = 25
    
    func makeBody(configuration: Configuration) -> some View {
        Label {
            configuration.title
        } icon: {
            configuration.icon
                .frame(width: size, height: size)
        }
    }
}
Matthew Folbigg
  • 137
  • 1
  • 7
-2

The problem is that the system icons have different standard widths. It's probably easiest to use an HStack as you mentioned. However, if you use the full Label completion, you'll see that the Title is actually just a Text and the icon is just an Image... and you can then add custom modifiers, such as a specific frame for the image width. Personally, I'd rather just use an HStack anyway.

var body: some View {
    VStack(alignment: .leading){
        Label(
            title: {
                Text("People")
            },
            icon: {
                Image(systemName: "person.3")
                    .frame(width: 30)
            })

        Label(
            title: {
                Text("Star")
            },
            icon: {
                Image(systemName: "star")
                    .frame(width: 30)

            })

        Label(
            title: {
                Text("This is a plane")
            },
            icon: {
                Image(systemName: "airplane")
                    .frame(width: 30)
            })
    }
}
nicksarno
  • 3,850
  • 1
  • 13
  • 33
  • This is practically the same solution as using an HStack. By specifying a frame, you lose out on automatic adaptability when the user has dynamic text turned on. – Richard Witherspoon Dec 01 '20 at 18:25
  • Yes, I agree, but your question was asking if there was a way to vertically align Labels and this is the only solution I know of. – nicksarno Dec 01 '20 at 21:58