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?
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")
}
}
}
So, you want this:
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 Label
s, and the icons don't have to be SF Symbols images. For example, we can show this:
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")
}
}
}
}
}
}
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.
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)
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With