NavigationView causes Unexpected behavior with SwiftUI Picker - swiftui

I have a SwiftUI Picker, and for some reason when I open the app the picker shows the wrong selection in the picker, but has the correct value stored. I can't tell if this is caused by the geometry reader or the navigation view. If anyone has some advice, or can explain the logic for why this is happening it would be great.
Here is a link to the sample if someone wants to test: https://github.com/mazefest/ExampleIssueProject1
Below is an image of what is happening, the picker contains 7 images of number (1-7). You can select to open the picker on the left, it also stores your last value in the button, as you can see below "three" has been selected, so when opening the picker you would expect to see the image of the 3, but no you get the title for the 3 and the number four.
Below is the code for the Picker:
struct NumberPicker: View {
#State var selection: PickerOption
let onSelection: (PickerOption) -> ()
var body: some View {
GeometryReader { geo in
HStack {
Spacer()
Picker(selection.title, selection: $selection) {
ForEach(PickerOption.allCases) { option in
Image(uiImage: option.image)
.resizable()
.aspectRatio(contentMode: .fit)
}
}
.pickerStyle(.wheel)
.defaultWheelPickerItemHeight(geo.size.height)
.frame(width: geo.size.width * 0.7)
Spacer()
}
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
onSelection(selection)
}
}
}
}
}
}
What's weird is I was actually able to fix the issue by putting everything in another navigation view. Which I do not want to do because I lose the "done" button. But as you can see below it did indeed work.
Below is the code with the added navigation view:
struct NumberPicker: View {
#State var selection: PickerOption
let onSelection: (PickerOption) -> ()
var body: some View {
NavigationView { // <-- The added navigation view
GeometryReader { geo in
HStack {
Spacer()
Picker(selection.title, selection: $selection) {
ForEach(PickerOption.allCases) { option in
Image(uiImage: option.image)
.resizable()
.aspectRatio(contentMode: .fit)
}
}
.pickerStyle(.wheel)
.defaultWheelPickerItemHeight(geo.size.height)
.frame(width: geo.size.width * 0.7)
Spacer()
}
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
onSelection(selection)
}
}
}
}
}
}
}
Why is this happening? How can I solve it?
Code for pickerOption:
public enum PickerOption: String, Hashable, CaseIterable, Codable, Identifiable {
case one
case two
case three
case four
case five
case six
case seven
public var id: Self { self }
var title: String {
switch self {
case .one:
return "title for one"
case .two:
return "title for two"
case .three:
return "title for three"
case .four:
return "title for four"
case .five:
return "title for five"
case .six:
return "title for six"
case .seven:
return "title for seven"
}
}
public var image: UIImage {
switch self {
case .one:
return UIImage(named: "one-image")!
case .two:
return UIImage(named: "two-image")!
case .three:
return UIImage(named: "three-image")!
case .four:
return UIImage(named: "four-image")!
case .five:
return UIImage(named: "five-image")!
case .six:
return UIImage(named: "six-image")!
case .seven:
return UIImage(named: "seven-image")!
}
}
}

I rewrote your code (since I didn't have your images) to just use SFSymbol strings. I stripped away a lot of the other stuff you had like the HStack and the NavigationView and GeometryReader (personally I stay away from GR). This might not be what you're looking for exactly, but maybe this will nudge you in the right direction!
Utilizing the defaultWheelPickerItemHeight gives you control over the height so it works as a nice padding to the images.
struct NumberPicker: View {
#State var selection: PickerOption
let onSelection: (PickerOption) -> ()
var body: some View {
Picker(selection.title, selection: $selection) {
ForEach(PickerOption.allCases) { option in
Image(systemName: option.image)
.font(.largeTitle)
}
}
.pickerStyle(.wheel)
.defaultWheelPickerItemHeight(100)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
onSelection(selection)
}
}
}
}
}

Related

SwiftUI migrate NavigationView to NavigationStack or something

I'm trying to take my app and migrate my opening NavigationView which has been deprecated to the new NavigationStack.
My opening screen looks like:
and the code that presents it is:
import SwiftUI
struct MainAppView: View {
#State var listSelection: Int = 1
#Binding var isLoggedIn: Bool
var body: some View {
NavigationView {
if DBManager.shared.checkDatabaseFileExists()
{
SideListView(listSelection: $listSelection)
HStack {
if listSelection == 1
{
AccountsListView()
}
if listSelection == 2
{
SetAsideBalanceListView()
}
if listSelection == 3
{
BankBalanceListView()
}
if listSelection == 4
{
SetupMenuView()
}
if listSelection == 5
{
ReportsListView()
}
} // END HSTACK
} else {
Text("NO DATABASE FOUND!!!!")
.font(.system(size: 60))
} // END OUTTER IF/ELSE DATABASE FOUND
} // END NAVIGATION VIEW
} // END BODY VIEW
} // END STRUCT VIEW
I did the simple thing of changing NavigationView to NavigationStack and while it compiles, it looks wrong:
Chris... the solution that I implemented using your suggestion is working and would be acceptable, except that the navigation behavior seems to have changed with NavigationSplitView and NavigationStack. When a selection is made in the left pane the corresponding view appears in the right pane. The view in the right pane has a NavigationLink to a subview. This works and the subview has the back button. What I noticed is that with NavigationView if the user in a subview clicks on the selection in the left pane, the view immediately pops the appropriate selected view to the right pane clearing the subview of some other selection that is showing. But in this model, using NavigationStack on the selected view, if the subview is showing for a given selection, clicking on the left pane selection has no effect until the existing subview is back buttoned to the parent view at which time the selected view is presented.
Like this:
And then selected the sub view looks OK like this:
But when I select in the left pane another selection like this:
Using NavigationView the subview for SetAside would immediately pop but instead only shows after I use the back button on the sub view...
UPDATES:
Here is the code I've implemented for the MainAppView.swift
import SwiftUI
struct MainAppView: View {
#State var listSelection: Int? = 1
#Binding var isLoggedIn: Bool
var body: some View {
if DBManager.shared.checkDatabaseFileExists()
{
NavigationSplitView {
SideListView(listSelection: $listSelection)
} detail: {
NavigationStack {
switch listSelection {
case 1: AccountsListView()
case 2: SetAsideBalanceListView()
case 3: BankBalanceListView()
case 4: SetupMenuView()
case 5: ReportsListView()
default: Text("Select View")
}
} // END NAVIGATION STACK
} // END NAVIGATION SPLIT VIEW DETAIL
} else {
Text("NO DATABASE FOUND!!!!")
.font(.system(size: 60))
} // END OUTTER IF/ELSE DATABASE FOUND
} // END BODY VIEW'
} // END STRUCT VIEW
Here is the view code for the SetAsideBalanceListView you can comment out the DB function calls and get an idea of the code. They are all somewhat clones of each other
import SwiftUI
struct SetAsideBalanceListView: View {
var accounts: [Accounts.AccountRecord] {
var acctRec = [Accounts.AccountRecord]()
acctRec = Accounts.shared.selectAllAccounts()
return acctRec
}
var body: some View {
// NavigationStack {
VStack {
CustomDivider(horizontalpadding: 0, thickness: 1)
ForEach (accounts, id: \.self) { accountRec in
let result = Budget.shared.getBudgetMonthAndYearForView(withAccountCode: accountRec.account_code)
NavigationLink(destination: SetAsideBalanceView(account_code: accountRec.account_code, currentBudgetYear: result.viewYear)){
HStack {
Image(systemName: "dollarsign.circle")
.foregroundColor(.white)
.background(Color("iconBlue"))
.cornerRadius(25)
.padding(.leading, 15)
Text(accountRec.account_name)
Spacer()
}
}
.foregroundColor(.primary)
Divider().background(Color.primary)
}// END FOR EACH
} // END VSTACK
.font(.title2)
.frame(minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity,
alignment: .topLeading
)
.padding(.top, 20)
.navigationTitle("Managing Your Money - Set Aside")
// } // END NAVIGATION STACK
} // END BODY VIEW
} // END STRUCT VIEW
It looks like this:
And here is the subview that the view above displays.. This is the one that needs a full "back button" to see the selected view.. If you clone these to make the other subviews you'll get the results (I hope:-))
import SwiftUI
struct SetAsideBalanceView: View {
#State var account_code: Int
#State var currentBudgetYear: Int
#State var totalBalances: Double = 0.00
// Array of SetAside Balance records for View List
var setAsideBalances: [SetAsideBalances.SetAsideBalancesRecord]
{
return SetAsideBalances.shared.selectSetAsideBalancesForAccount(withAccountCode: self.account_code)
}
var body: some View {
GeometryReader { gr in
let viewWidth = gr.size.width * 1
let columns = [
GridItem(.fixed(viewWidth * 0.60), alignment: .leading),
GridItem(.fixed(viewWidth * 0.30), alignment: .trailing)
]
VStack {
if setAsideBalances.count > 0 {
SetAsideBalanceHeader(accountName: setAsideBalances[0].account_name!, budgetYear: currentBudgetYear)
ScrollView {
ForEach (setAsideBalances, id: \.self) { setAsideRecord in
SetAsideBalancesRow(accountCode: account_code, setAsideBalanceCode: setAsideRecord.set_aside_code, description: setAsideRecord.description, set_aside_balance: setAsideRecord.set_aside_balance, currentBudgetYear: currentBudgetYear)
.frame(height: 45)
}
}
.frame(height: CGFloat((setAsideBalances.count + 1) * 45))
LazyVGrid(columns: columns, spacing: 0) {
Text("TOTAL BALANCES")
.padding(.leading, 20)
Text("\(NumberFormatter.formatAsCurrency(value: totalBalances))")
.foregroundColor((totalBalances < 0 ) ? Color("negative") : nil)
}
.frame(maxWidth: .infinity)
.frame(height: 55)
.border(Color.primary)
.foregroundColor(.black)
.background(Rectangle().fill(Color("lightBlue")))
.font(.title2)
Spacer()
}
} // END VSTACK
.font(.title2)
.frame(minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity,
alignment: .topLeading
)
.padding(.top, 5)
.onAppear {
self.totalBalances = SetAsideBalances.shared.sumSetAsideBalancesForAccount(withAccountCode: self.account_code)
}
.font(.title2)
.navigationTitle("Managing Your Money - Set Aside")
.navigationBarTitleDisplayMode(.inline)
.ignoresSafeArea(edges: .bottom)
}
} // END BODY VIEW
} // END STRUCT VIEW
This is what the subview looks like:
I think I found it. We have to update the NavigationLink in the subview also to the new logic.
In SetAsideBalanceListView replace this:
ForEach (accounts, id: \.self) { accountRec in
let result = Budget.shared.getBudgetMonthAndYearForView(withAccountCode: accountRec.account_code)
NavigationLink(destination: SetAsideBalanceView(account_code: accountRec.account_code, currentBudgetYear: result.viewYear)){
HStack {
Image(systemName: "dollarsign.circle")
.foregroundColor(.white)
.background(Color("iconBlue"))
.cornerRadius(25)
.padding(.leading, 15)
Text(accountRec.account_name)
Spacer()
}
}
.foregroundColor(.primary)
Divider().background(Color.primary)
}// END FOR EACH
with this:
ForEach (accounts, id: \.self) { accountRec in
let result = Budget.shared.getBudgetMonthAndYearForView(withAccountCode: accountRec.account_code)
NavigationLink(value: accountRec) { // HERE
HStack {
Image(systemName: "dollarsign.circle")
.foregroundColor(.white)
.background(Color("iconBlue"))
.cornerRadius(25)
.padding(.leading, 15)
Text(accountRec.account_name)
Spacer()
}
}
.foregroundColor(.primary)
Divider().background(Color.primary)
}// END FOR EACH
// HERE
.navigationDestination(for: AccountRecord.self) { accountRec in
SetAsideBalanceView(account_code: accountRec.account_code, currentBudgetYear: result.viewYear)
}
You want to switch to NavigationSplitView(sidebar: content:). As the name implies it has two elements: 1 the sidebar, and 2 the content area.
I also would like to suggest to exchange the many if statements for the view selection with a switch statement on listSelection.
#State var listSelection: Int = 1
#Binding var isLoggedIn: Bool
var body: some View {
if DBManager.shared.checkDatabaseFileExists()
{
NavigationSplitView {
SideListView(listSelection: $listSelection)
} detail: {
switch listSelection {
case 1: AccountsListView()
case 2: SetAsideBalanceListView()
case 3: BankBalanceListView()
case 4: SetupMenuView()
case 5: ReportsListView()
default: Text("Select View")
}
}
} else {
Text("NO DATABASE FOUND!!!!")
.font(.system(size: 60))
} // END OUTTER IF/ELSE DATABASE FOUND
} // END BODY VIEW
extended version including detail links.
struct ContentView: View {
#State var listSelection: Int? = 1
// #Binding var isLoggedIn: Bool
var body: some View {
// if DBManager.shared.checkDatabaseFileExists()
// {
NavigationSplitView {
List(selection: $listSelection) {
Label("Accounts", systemImage: "dollarsign.circle").tag(1)
Label("Set Aside", systemImage: "folder").tag(2)
Label("Bank Bal.", systemImage: "line.2.horizontal.decrease.circle").tag(3)
Label("Setup", systemImage: "gear").tag(4)
Label("Reports", systemImage: "gear").tag(5)
}
} detail: {
switch listSelection {
case 1: SubView(title: "Accounts")
case 2: SubView(title: "Set Aside")
case 3: SubView(title: "Bank Balance")
case 4: SubView(title: "Setup")
case 5: SubView(title: "Reports")
default: Text("Select View")
}
}
// } else {
// Text("NO DATABASE FOUND!!!!")
// .font(.system(size: 60))
// } // END OUTTER IF/ELSE DATABASE FOUND
} // END BODY VIEW
} // END STRUCT VIEW
struct SubView: View {
let title: String
var body: some View {
NavigationStack {
List(0..<6, id: \.self) { nr in
NavigationLink("\(title) Subview \(nr)", value: nr) // select
}
.navigationDestination(for: Int.self) { nr in // define the destination (Int.self refers to type of selection)
DetailView(item: nr)
}
}
.navigationBarTitle(title)
}
}
struct DetailView: View {
let item: Int
var body: some View {
Text("This is the detail view for item \(item)")
}
}

Overlay views one by one in SwiftUI

I have the following code with a struct and two views. On tap of the firstScreenOverlay button i want to show the secondScreenOverlay and hide the previous one and so on. Any help appreciated!
import SwiftUI
struct ContentView: View {
var body: some View {
Text("hello there")
.overlay(firstScreenOverlay, alignment: .center)
}
}
private var firstScreenOverlay: some View {
ZStack {
Color.blue
.opacity(0.5)
Button {} label: {
Text("Next")
.fullWidth()
}
}
}
private var secondScreenOverlay: some View {
ZStack {
Color.red
.opacity(0.5)
}
}
You just need a way to keep track of what is showing. You can use a variable and an enum
import SwiftUI
struct DynamicOverlay: View {
//Keeps track of what is showing
#State var selectedOverlay: OverlayViews = .none
var body: some View {
VStack{
Text("hello there")
//Button changes what is being displayed
Button("first", action: {
selectedOverlay = .first
})
}
//Displays the selected view
.overlay(selectedOverlay.view($selectedOverlay), alignment: .center)
}
}
enum OverlayViews{
case first
case second
case none
//Holds all the options for the views
#ViewBuilder func view(_ selectedView: Binding<OverlayViews>) -> some View{
switch self{
case .first:
ZStack {
Color.blue
.opacity(0.5)
Button {
selectedView.wrappedValue = .second
} label: {
Text("Next")
}
}
case .second:
ZStack {
Color.red
.opacity(0.5)
Button("home") {
selectedView.wrappedValue = .none
}
}
case .none:
EmptyView()
}
}
}
struct DynamicOverlay_Previews: PreviewProvider {
static var previews: some View {
DynamicOverlay()
}
}

SwiftUI - Allow a List item to have both cell tap and button tap

I am trying to have a List that each item supports both cell tap and button tap
Currently, I have the following code, but for some reason, when I tap the hello button in each cell, although I do receive the action, but it also triggers the highlight from the background button, when i m tapping the hello button, I only want the highlight on the hello button, not on the whole cell
I searched everywhere can't really find an answer
Thanks in advance!
private enum MenuItem: String, Identifiable, CaseIterable {
var id: String {
self.rawValue
}
case option1
case option2
case option3
case option4
}
private struct MainView: View {
let onSelect: (MenuItem) -> Void
var body: some View {
VStack(spacing: 0) {
Text("Demo")
.padding(16)
List {
Section("hello") {
ForEach(MenuItem.allCases) { item in
ZStack {
Button("") {
print("yes", item)
}
VStack(alignment: .leading, spacing: 5) {
Text(item.rawValue).font(.title2)
Text("something here").font(.body)
Button("Hello") {
print("no", item)
}.buttonStyle(BorderedButtonStyle())
}
.contentShape(Rectangle())
.padding(.top, 10)
.padding(.bottom, 10)
}
}
}
}.listStyle(.plain)
}.onAppear {
UITableViewCell.appearance().selectedBackgroundView = {
let view = UIView()
view.backgroundColor = .blue
return view
}()
}
}
}
struct MainView_Previews: PreviewProvider {
static var previews: some View {
MainView(onSelect: { _ in })
}
}
Basically we need a custom button style in this situation (because automatic one is handled by List and gives observed behavior).
Here is main part. Tested with Xcode 13.4 / iOS 15.5
Button("") {
print("yes", item)
}
.buttonStyle(MyStyle()) // << here !!
// ...
struct MyStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.background( // highlight on row tap
configuration.isPressed ? Color.secondary : Color.clear
)
}
}
Complete test code on GitHub

Add border to only 1 button

I have a simple setup where I have 2 buttons and when a user clicks on one of them I want a border to show around it so that they know which one they clicked
I only ever want 1 button to have a border at once
I came up with this
import SwiftUI
struct TestView: View {
#State var isBorder:Bool = false
var body: some View {
VStack{
Button {
isBorder.toggle()
} label: {
Label("Sports", systemImage: "sportscourt")
}
.foregroundColor(.black)
.padding()
.background(Color(hex: "00bbf9"))
.cornerRadius(8)
.overlay(isBorder ? RoundedRectangle(cornerRadius: 8).stroke(Color.black, lineWidth:2) : nil)
Button {
isBorder.toggle()
} label: {
Label("Firends", systemImage: "person")
}
.foregroundColor(.black)
.padding()
.background(Color(hex: "fee440"))
.cornerRadius(8)
.overlay(isBorder ? RoundedRectangle(cornerRadius: 8).stroke(Color.black, lineWidth:2) : nil)
}
}
}
struct TestView_Previews: PreviewProvider {
static var previews: some View {
TestView()
}
}
But a border shows around both buttons because I am using 1 variable "isBorder"
How could I adapt my solution so that I can accommodate for more buttons
As the comments allready stated this should be encapsulated in its own view. But here you also have the additional problem with the shared state of what button is clicked.
First create an enum that holds all properties a button has that distinguishes it from others:
enum ButtonEnum: Int, CaseIterable{
case sports, friends
var systemIcon: String{
switch self{
case .sports:
return "sportscourt"
case .friends:
return "person"
}
}
var label: String{
switch self{
case .sports:
return "Sports"
case .friends:
return "Friends"
}
}
var backgroundColor: Color{
switch self{
case .sports:
return Color("00bbf9")
case .friends:
return Color("fee440")
}
}
}
Then create a View that represents that button:
struct BorderedButton: View{
#Binding var selected: ButtonEnum?
var buttonType: ButtonEnum
var action: () -> Void
var body: some View{
Button {
selected = buttonType
action()
} label: {
Label(buttonType.label, systemImage: buttonType.systemIcon)
}
.foregroundColor(.black)
.padding()
.background(buttonType.backgroundColor)
.cornerRadius(8)
.overlay(selected == buttonType ? RoundedRectangle(cornerRadius: 8).stroke(Color.black, lineWidth:2) : nil)
}
}
and usage example:
struct TestView: View {
#State private var selected: ButtonEnum? = nil
var body: some View {
VStack{
BorderedButton(selected: $selected, buttonType: .friends) {
print("friends pressed")
}
BorderedButton(selected: $selected, buttonType: .sports) {
print("sports selected")
}
}
}
}
This solution can be easily expanded by adding new cases to the enum.

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?
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 Labels, 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)
}
}
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)
}
}
}
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)
})
}
}