Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI Multiple Labels Vertically Aligned

Tags:

swiftui

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")
        }
    }
}
like image 388
Richard Witherspoon Avatar asked Dec 01 '20 03:12

Richard Witherspoon


Video Answer


2 Answers

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")
                    }
                }
            }
        }
    }
}
like image 95
rob mayoff Avatar answered Oct 11 '22 13:10

rob mayoff


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)
    }
}

like image 30
David Monagle Avatar answered Oct 11 '22 11:10

David Monagle