SwiftUI - How to vertically align a Text view with another Text view to make something similar to a Table with proper alignment? - ios

So I basically need to make a table similar to this in SwiftUI:
As you can see on the screenshot, Im having a hard time aligning the fields to their corresponding columns, as in "John" should be exactly centered to "Name" and "EUR" should be centered to "Currency", etc. As you can see on the code, Im currently eye-balling it with the stacks spacing parameter, but thats just plain awful and doesnt even work on all devices plus data is dynamic and comes from an API call so it just wont work. How can I align this on SwiftUI? I need to support iOS 13.
Current code:
struct ContentView: View {
var body: some View {
VStack(spacing: 0) {
ZStack {
TitleBackground()
Text("Clients")
.foregroundColor(.white)
}
HStack(spacing: 30) {
Text("Name")
Text("Balance")
Text("Currency")
Spacer()
}
.foregroundColor(Color(#colorLiteral(red: 0.4783782363, green: 0.4784628153, blue: 0.4783664346, alpha: 1)))
.frame(maxWidth:.infinity)
.padding(12)
.background(Color(#colorLiteral(red: 0.9332349896, green: 0.9333916306, blue: 0.9332130551, alpha: 1)))
RowView()
RowView()
RowView()
RowView()
}
}
}
struct RowView : View {
var body: some View {
HStack(spacing: 30) {
Text("John")
Text("$5300")
Text("EUR")
Spacer()
}
.padding(12)
}
}
struct TitleBackground: View {
var body: some View {
ZStack(alignment: .bottom){
Rectangle()
.frame(maxWidth: .infinity, maxHeight: 60)
.foregroundColor(.red)
.cornerRadius(15)
//We only need the top corners rounded, so we embed another rectangle to the bottom.
Rectangle()
.frame(maxWidth: .infinity, maxHeight: 15)
.foregroundColor(.red)
}
}
}

I see that Yrb posted a similar answer before me, but I'm going to post this anyway as my solution is a bit more modular/composable than theirs.
You want this layout:
We're going to implement an API so that we can get your layout with this code for the table view:
enum ColumnId: Hashable {
case name
case balance
case currency
}
struct TableView: View {
var body: some View {
WidthPreferenceDomain {
VStack(spacing: 0) {
HStack(spacing: 30) {
Text("Name")
.widthPreference(ColumnId.name)
Text("Balance")
.widthPreference(ColumnId.balance)
Text("Currency")
.widthPreference(ColumnId.currency)
Spacer()
}
.frame(maxWidth:.infinity)
.padding(12)
.background(Color(#colorLiteral(red: 0.9332349896, green: 0.9333916306, blue: 0.9332130551, alpha: 1)))
ForEach(0 ..< 4) { _ in
RowView()
}
}
}
}
}
In the code above:
I've defined a ColumnId type to identify each table column.
I've inserted a WidthPreferenceDomain container view, which we will implement below.
I've added a widthPreference modifier to each table row, which we will implement below.
We also need to use the widthPreference modifier in RowView like this:
struct RowView : View {
var body: some View {
HStack(spacing: 30) {
Text("John")
.widthPreference(ColumnId.name)
Text("$5300")
.widthPreference(ColumnId.balance)
Text("EUR")
.widthPreference(ColumnId.currency)
Spacer()
}
.padding(12)
}
}
I've placed the full code for this answer in a gist for easy copying.
So, how do we implement WidthPreferenceDomain and widthPreference?
We need to measure the width of all the items in the Name column, including the Name title itself, so that we can then set all of the Name column's items to have the same width. We need to repeat the procedure for the Balance and Currency columns.
To measure the items, we can use GeometryReader. Because GeometryReader is a greedy view that expands to fill as much space as its parent offers, we don't want to wrap any table items in a GeometryReader. Instead, we want to put a GeometryReader in a background on each table item. A background view is given the same frame as its parent.
To collect the measured widths, we can use a “preference“ by defining a type conforming to the PreferenceKey protocol. Since we want to collect the widths of all the columns, we'll store the widths in a Dictionary, keyed by a column id. To merge two widths for the same column, we take the maximum of the widths, so that each column expands to fit all its items.
fileprivate struct WidthPreferenceKey: PreferenceKey {
static var defaultValue: [AnyHashable: CGFloat] { [:] }
static func reduce(value: inout [AnyHashable : CGFloat], nextValue: () -> [AnyHashable : CGFloat]) {
value.merge(nextValue(), uniquingKeysWith: { max($0, $1) })
}
}
To distribute the collected widths back down to the table items, we can use the “environment” by defining a type conforming to the EnvironmentKey protocol. EnvironmentKey only has one requirement, a static var defaultValue property, which matches the PreferenceKey's defaultValue property, so we can re-use the WidthPreferenceKey type we already defined:
extension WidthPreferenceKey: EnvironmentKey { }
To use the environment, we also need to add a property to EnvironmentValues:
extension EnvironmentValues {
fileprivate var widthPreference: WidthPreferenceKey.Value {
get { self[WidthPreferenceKey.self] }
set { self[WidthPreferenceKey.self] = newValue }
}
}
Now we can define the widthPreference modifier:
extension View {
public func widthPreference<Domain: Hashable>(_ key: Domain) -> some View {
return self.modifier(WidthPreferenceModifier(key: key))
}
}
fileprivate struct WidthPreferenceModifier: ViewModifier {
#Environment(\.widthPreference) var widthPreference
var key: AnyHashable
func body(content: Content) -> some View {
content
.fixedSize(horizontal: true, vertical: false)
.background(GeometryReader { proxy in
Color.clear
.preference(key: WidthPreferenceKey.self, value: [key: proxy.size.width])
})
.frame(width: widthPreference[key])
}
}
This modifier measures the modified view's width using a background GeometryReader and stores it in the preference. It also sets the view's width, if the widthPreference from the environment has a width for it. Note that widthPreference[key] will be nil on the first layout pass, but that's okay, because frame(width: nil) is an “inert” modifier; it has no effect.
And now we can implement WidthPreferenceDomain. It needs to receive the preference and store it in the environment:
public struct WidthPreferenceDomain<Content: View>: View {
#State private var widthPreference: WidthPreferenceKey.Value = [:]
private var content: Content
public init(#ViewBuilder content: () -> Content) {
self.content = content()
}
public var body: some View {
content
.environment(\.widthPreference, widthPreference)
.onPreferenceChange(WidthPreferenceKey.self) { widthPreference = $0 }
}
}

There are two ways you could solve this. First, you could use two LazyVGrids with the same column set up, but you would have to fix some of your sizes. The second way isYou can solve this with with PreferenceKeys.
LazyVGrid:
struct LazyVGridView: View {
let columns = [
GridItem(.fixed(100)),
GridItem(.fixed(100)),
GridItem(.adaptive(minimum: 180))
]
var body: some View {
VStack {
LazyVGrid(columns: columns) {
Text("Name")
.padding()
Text("Balance")
.padding()
Text("Currency")
.padding()
}
.foregroundColor(Color(#colorLiteral(red: 0.4783782363, green: 0.4784628153, blue: 0.4783664346, alpha: 1)))
.background(Color(#colorLiteral(red: 0.9332349896, green: 0.9333916306, blue: 0.9332130551, alpha: 1)))
LazyVGrid(columns: columns) {
ForEach(clients) { client in
Text(client.name)
.padding()
Text(client.balance.description)
.padding()
Text(client.currency)
.padding()
}
}
}
}
}
`PreferenceKeys`:
struct PrefKeyAlignmentView: View {
let clients: [ClientInfo] = [
ClientInfo(name: "John", balance: 300, currency: "EUR"),
ClientInfo(name: "Jennifer", balance: 53000, currency: "EUR"),
ClientInfo(name: "Jo", balance: 30, currency: "EUR"),
ClientInfo(name: "Jill", balance: 530000, currency: "EUR")
]
#State private var col1Width: CGFloat = 100
#State private var col2Width: CGFloat = 100
#State private var col3Width: CGFloat = 110
var body: some View {
VStack {
HStack() {
Text("Name")
.padding()
// The background view takes on the size of the Text + the padding, and the GeometryReader reads that size
.background(GeometryReader { geometry in
Color.clear.preference(
//This sets the preference key value with the width of the background view
key: Col1WidthPrefKey.self,
value: geometry.size.width)
})
// This set the frame to the column width variable defined above
.frame(width: col1Width)
Text("Balance")
.padding()
.background(GeometryReader { geometry in
Color.clear.preference(
key: Col2WidthPrefKey.self,
value: geometry.size.width)
})
.frame(width: col2Width)
Text("Currency")
.padding()
.background(GeometryReader { geometry in
Color.clear.preference(
key: Col3WidthPrefKey.self,
value: geometry.size.width)
})
.frame(width: col3Width)
Spacer()
}
ForEach(clients) { client in
HStack {
Text(client.name)
.padding()
.background(GeometryReader { geometry in
Color.clear.preference(
key: Col1WidthPrefKey.self,
value: geometry.size.width)
})
.frame(width: col1Width)
Text(client.balance.description)
.padding()
.background(GeometryReader { geometry in
Color.clear.preference(
key: Col2WidthPrefKey.self,
value: geometry.size.width)
})
.frame(width: col2Width)
Text(client.currency)
.padding()
.background(GeometryReader { geometry in
Color.clear.preference(
key: Col3WidthPrefKey.self,
value: geometry.size.width)
})
.frame(width: col3Width)
Spacer()
}
}
}
//This sets the various column widths whenever a new entry as found.
//It keeps the larger of the new item's width, or the last largest width.
.onPreferenceChange(Col1WidthPrefKey.self) {
col1Width = max($0,col1Width)
}
.onPreferenceChange(Col2WidthPrefKey.self) {
col2Width = max($0,col2Width)
}
.onPreferenceChange(Col3WidthPrefKey.self) {
col3Width = max($0,col3Width)
}
}
}
struct ClientInfo: Identifiable {
let id = UUID()
let name: String
let balance: Int
let currency: String
}
private extension PrefKeyAlignmentView {
struct Col1WidthPrefKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat,
nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct Col2WidthPrefKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat,
nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct Col3WidthPrefKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat,
nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
}
I don't think using alignmentGuides would work as you would have to handle multiple views, and alignment guides only align one set of views, and not the views internally.
Both of these solutions will work. The PreferenceKeys will automatically adapt everything, but theoretically could push past the bounds of the screen with too much information. The LazyVGrid is more rigid, but could cause truncation/wrapping if the data is too long. I don't think you could use an adaptive layout for the LazyVGrid for all columns, if you have two different grids. It may be possible to mix both and set the LazyVGrid widths with PreferenceKeys, but I haven't personally tried it.
DataSource for both for testing:
struct ClientInfo: Identifiable {
let id = UUID()
let name: String
let balance: Int
let currency: String
}
let clients: [ClientInfo] = [
ClientInfo(name: "John", balance: 300, currency: "EUR"),
ClientInfo(name: "Jennifer", balance: 53000, currency: "EUR"),
ClientInfo(name: "Jo", balance: 30, currency: "EUR"),
ClientInfo(name: "Jill", balance: 530000, currency: "EUR")
]
Edit: While working on the answer, I forgot you were restricted to iOS 13. LazyVGrids aren't available. I left the LazyVGrid answer up as you could use if #available(os:) if you wanted to use it for later OS's, or for someone else looking for a more current solution.
Second Edit: I was thinking about how this view could be made more reusable, and I came up with a third way that is also iOS 13 compatible, and only uses one PreferenceKey to help set the background color on the headers. This also allows unlimited columns. I think this is the simplest way of the bunch:
struct TableView: View {
let headers: [String]
let tableData: [String:[String]]
let dataAlignment: [String:HorizontalAlignment]
let headerColor: Color
#State var headerHeight: CGFloat = 30
init(headers: [String], headerColor: Color = Color.clear, columnData: [[String]], alignment: [HorizontalAlignment]) {
self.headers = headers
self.headerColor = headerColor
if headers.count == columnData.count,
headers.count == alignment.count {
var data: [String:[String]] = [:]
var align: [String:HorizontalAlignment] = [:]
for i in 0..<headers.count {
let key = headers[i]
let value = columnData[i]
data[key] = value
align[key] = alignment[i]
}
dataAlignment = align
tableData = data
} else {
tableData = [:]
dataAlignment = [:]
}
}
var body: some View {
ScrollView {
HStack(spacing: 30) {
ForEach(headers, id: \.self) { header in
VStack {
Text(header)
.padding()
.background( GeometryReader { geometry in
Color.clear.preference(
key: HeaderHeightPrefKey.self,
value: geometry.size.height)
})
VStack(alignment: dataAlignment[header] ?? .center) {
ForEach(tableData[header] ?? [], id: \.self) { entry in
Text(entry)
}
}
}
}
}
.background(
VStack {
Rectangle()
.fill(headerColor)
.frame(height: headerHeight, alignment: .top)
Spacer()
}
)
.onPreferenceChange(HeaderHeightPrefKey.self) {
headerHeight = max($0,headerHeight)
}
}
}
}
private extension TableView {
struct HeaderHeightPrefKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat,
nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
}
And it is used like this:
TableView(headers: ["Name", "Balance", "Currency"], headerColor: Color(uiColor: .systemGray5), columnData: [
["John", "Jennifer", "Jo", "Jill"],
["300", "53000", "30", "530000"],
["EUR", "EUR", "EUR", "EUR"]
], alignment: [.center, .trailing, .center])
and produces this:

Related

Using a List instead of a ScrollView and ForEach

I have the following code to show some Cards which expand to fullscreen if the user taps on one of them:
import SwiftUI
struct ContentView: View {
#State private var cards = [
Card(title: "testA", subtitle: "subtitleA"),
Card(title: "testB", subtitle: "subtitleB"),
Card(title: "testC", subtitle: "subtitleC"),
Card(title: "testD", subtitle: "subtitleD"),
Card(title: "testE", subtitle: "subtitleE")
]
#State private var showDetails: Bool = false
#State private var heights = [Int: CGFloat]()
var body: some View {
ScrollView {
VStack {
if(!cards.isEmpty) {
ForEach(self.cards.indices, id: \.self) { index in
GeometryReader { reader in
CardView(card: self.$cards[index], isDetailed: self.$showDetails)
.offset(y: self.cards[index].showDetails ? -reader.frame(in: .global).minY : 0)
.fixedSize(horizontal: false, vertical: !self.cards[index].showDetails)
.background(GeometryReader {
Color.clear
.preference(key: ViewHeightKey.self, value: $0.frame(in: .local).size.height)
})
.onTapGesture {
withAnimation(.spring()) {
self.cards[index].showDetails.toggle()
self.showDetails.toggle()
}
}
}
.frame(height: self.cards[index].showDetails ? UIScreen.main.bounds.height : self.heights[index], alignment: .center)
.onPreferenceChange(ViewHeightKey.self) { value in
self.heights[index] = value
}
}
} else {
ActivityIndicator(style: UIActivityIndicatorView.Style.medium).frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height / 2)
}
}
}
.onAppear() {
// load data
}
}
}
struct CardView : View {
#Binding var card : Card
#Binding var isDetailed : Bool
var body: some View {
VStack(alignment: .leading){
ScrollView(showsIndicators: isDetailed && card.showDetails) {
HStack (alignment: .center){
VStack (alignment: .leading){
HStack(alignment: .top){
Text(card.subtitle).foregroundColor(Color.gray)
Spacer()
}
Text(card.title).fontWeight(Font.Weight.bold).fixedSize(horizontal: false, vertical: true)
}
}
.padding([.top, .horizontal]).padding(isDetailed && card.showDetails ? [.top] : [] , 34)
Image("placeholder-image").resizable().scaledToFit().frame(width: UIScreen.main.bounds.width - 60).padding(.bottom)
if isDetailed && card.showDetails {
Text("Lorem ipsum ... ")
}
}
}
.background(Color.green)
.cornerRadius(16)
.shadow(radius: 12)
.padding(isDetailed && card.showDetails ? [] : [.top, .horizontal])
.opacity(isDetailed && card.showDetails ? 1 : (!isDetailed ? 1 : 0))
.edgesIgnoringSafeArea(.all)
}
}
struct Card : Identifiable {
public var id = UUID()
public var title: String
public var subtitle : String
public var showDetails : Bool = false
}
struct ViewHeightKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
Now i want to change both (or at least one) Scrollviews in ContentView and CardView to Lists, because of the lazy loading for better performance. But changing the ScrollView in ContentView results in glitched animations. And if i change it to a List in the CardView, no card is even showing up anymore.
Any idea how I can change the code to use Lists?
So as noted in the comments, the flickering is a bug on Apple's end. The problem is you want to first resize then change offset because if you change offset and then resize you will get the flickering issue when cells overlap. I wrote a simple test that can be found in this pastebin
Because of my time constraint I was able to solve the weird scrolling behavior where the 2nd/3rd/etc... card would jump to the top by changing the animations order. I will come back to this post hopefully tonight and fix the flickering by fixing the order in which animations happens first. They have to be chained.
Here is my code where I
init() {
UITableView.appearance().separatorStyle = .none
UITableView.appearance().backgroundColor = .clear
UITableView.appearance().layoutMargins = .zero
UITableViewCell.appearance().backgroundColor = .clear
UITableViewCell.appearance().layoutMargins = .zero
}
...
List {
VStack {
if(!cards.isEmpty) {
ForEach(self.cards.indices, id: \.self) { index in
GeometryReader { reader in
CardView(card: self.$cards[index], isDetailed: self.$showDetails)
// 1. Note I switched the order of offset and fixedsize (not that it will make difference in this step but its good practice
// 2. I added animation in between
.fixedSize(horizontal: false, vertical: !self.cards[index].showDetails)
.animation(Animation.spring())
.offset(y: self.cards[index].showDetails ? -reader.frame(in: .global).minY : 0)
.animation(Animation.spring())
.background(GeometryReader {
Color.clear
.preference(key: ViewHeightKey.self, value: $0.frame(in: .local).size.height)
})
.onTapGesture {
// Note I commented this out, it's the source of the bug.
// withAnimation(Animation.spring().delay()) {
self.cards[index].showDetails.toggle()
self.showDetails.toggle()
// }
}
.zIndex(self.cards[index].showDetails ? 100 : -1)
}
.frame(height: self.cards[index].showDetails ? UIScreen.main.bounds.height : self.heights[index], alignment: .center)
.onPreferenceChange(ViewHeightKey.self) { value in
self.heights[index] = value
}
}
} else {
Text("Loading")
}
}
// I added this for styling
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
}
// I added this for styling
.listStyle(PlainListStyle())
.onAppear() {
// load data
}
NOTE: If however, you wait a second or two between opening and closing, you wouldn't notice that flickering behavior.
NOTE2: This chaining behavior can also be observed in the App Store But they blend the animations perfectly. If you pay attention you will find some delay
EDIT 1
In this edit I would like to point out that the previous solutions does enhance the animations, it does still have the flickering issue, unfortunately, not much can be done to that except we can have a delay between presenting and hiding. Even Apple has done so. If you pay attention to the App Store on iOS you will notice that when you try to open one of their cards you wont be able to press "X" right away, you would first notice that it is unresponsive and that is because even Apple had to add some delay between presenting and being able to hide. The delay is basically a work around. Furthermore, you can also notice that when you tap the card, it won't be presented right away. It would actually take some time to execute animations which is again just a trick to add delay.
So to solve the flickering issue you can add a simple timer that counts down before allowing the user to be able to close the opened card or open the closed card again. This delay can be the duration of some of your animations so the user won't feel any lag in your app.
here I wrote a simple code to demonstrate how can that be done. Notice though, the blending of animations or delays is no where perfect and can be tweaked even further. I only wanted to demonstrate that it is possible to get rid of the flickering or at least reduce it by a lot. There might be far better solutions than this, but that's what I came up with :)
EDIT 2: On a second test I was able to point out that this flickering is actually happening because your list doesn't have animations. So a quick solution can be done by applying animations to the List; Keep in mind the above solution in Edit 1: would works just as good but it would require a lot of trials and errors, without further waiting, here is my solution.
import SwiftUI
struct StackOverflow22: View {
#State private var cards = [
Card(title: "testA", subtitle: "subtitleA"),
Card(title: "testB", subtitle: "subtitleB"),
Card(title: "testC", subtitle: "subtitleC"),
Card(title: "testD", subtitle: "subtitleD"),
Card(title: "testE", subtitle: "subtitleE")
]
#State private var showDetails: Bool = false
#State private var heights = [Int: CGFloat]()
init() {
UITableView.appearance().separatorStyle = .none
UITableView.appearance().backgroundColor = .clear
UITableView.appearance().layoutMargins = .zero
UITableViewCell.appearance().backgroundColor = .clear
UITableViewCell.appearance().layoutMargins = .zero
}
var body: some View {
List {
VStack {
if(!cards.isEmpty) {
ForEach(self.cards.indices, id: \.self) { index in
GeometryReader { reader in
CardView(card: self.$cards[index], isDetailed: self.$showDetails)
.fixedSize(horizontal: false, vertical: !self.cards[index].showDetails)
.offset(y: self.cards[index].showDetails ? -reader.frame(in: .global).minY : 0)
.background(GeometryReader {
Color.clear
.preference(key: ViewHeightKey.self, value: $0.frame(in: .local).size.height)
})
.onTapGesture {
withAnimation(.spring()) {
self.cards[index].showDetails.toggle()
self.showDetails.toggle()
}
}
}
.frame(height: self.cards[index].showDetails ? UIScreen.main.bounds.height : self.heights[index], alignment: .center)
.onPreferenceChange(ViewHeightKey.self) { value in
self.heights[index] = value
}
}
} else {
Text("Loading")
}
}
.animation(Animation.linear)
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
}
.listStyle(PlainListStyle())
.onAppear() {
// load data
}
}
}
struct CardView : View {
#Binding var card : Card
#Binding var isDetailed : Bool
var body: some View {
VStack(alignment: .leading){
ScrollView(showsIndicators: isDetailed && card.showDetails) {
HStack (alignment: .center){
VStack (alignment: .leading){
HStack(alignment: .top){
Text(card.subtitle).foregroundColor(Color.gray)
Spacer()
}
Text(card.title).fontWeight(Font.Weight.bold).fixedSize(horizontal: false, vertical: true)
}
}
.padding([.top, .horizontal]).padding(isDetailed && card.showDetails ? [.top] : [] , 34)
Image("placeholder-image").resizable().scaledToFit().frame(width: UIScreen.main.bounds.width - 60).padding(.bottom)
if isDetailed && card.showDetails {
Text("Lorem ipsum ... ")
}
}
}
.background(Color.green)
.cornerRadius(16)
.shadow(radius: 12)
.padding(isDetailed && card.showDetails ? [] : [.top, .horizontal])
.opacity(isDetailed && card.showDetails ? 1 : (!isDetailed ? 1 : 0))
.edgesIgnoringSafeArea(.all)
}
}
struct Card : Identifiable, Hashable {
public var id = UUID()
public var title: String
public var subtitle : String
public var showDetails : Bool = false
}
struct StackOverflow22_Previews: PreviewProvider {
static var previews: some View {
StackOverflow22()
}
}
struct ViewHeightKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}

SwiftUI Create a Custom Segmented Control also in a ScrollView

Below is my code to create a standard segmented control.
struct ContentView: View {
#State private var favoriteColor = 0
var colors = ["Red", "Green", "Blue"]
var body: some View {
VStack {
Picker(selection: $favoriteColor, label: Text("What is your favorite color?")) {
ForEach(0..<colors.count) { index in
Text(self.colors[index]).tag(index)
}
}.pickerStyle(SegmentedPickerStyle())
Text("Value: \(colors[favoriteColor])")
}
}
}
My question is how could I modify it to have a customized segmented control where I can have the boarder rounded along with my own colors, as it was somewhat easy to do with UIKit? Has any one done this yet.
I prefect example is the Uber eats app, when you select a restaurant you can scroll to the particular portion of the menu by selecting an option in the customized segmented control.
Included are the elements I'm looking to have customized:
* UPDATE *
Image of the final design
Is this what you are looking for?
import SwiftUI
struct CustomSegmentedPickerView: View {
#State private var selectedIndex = 0
private var titles = ["Round Trip", "One Way", "Multi-City"]
private var colors = [Color.red, Color.green, Color.blue]
#State private var frames = Array<CGRect>(repeating: .zero, count: 3)
var body: some View {
VStack {
ZStack {
HStack(spacing: 10) {
ForEach(self.titles.indices, id: \.self) { index in
Button(action: { self.selectedIndex = index }) {
Text(self.titles[index])
}.padding(EdgeInsets(top: 16, leading: 20, bottom: 16, trailing: 20)).background(
GeometryReader { geo in
Color.clear.onAppear { self.setFrame(index: index, frame: geo.frame(in: .global)) }
}
)
}
}
.background(
Capsule().fill(
self.colors[self.selectedIndex].opacity(0.4))
.frame(width: self.frames[self.selectedIndex].width,
height: self.frames[self.selectedIndex].height, alignment: .topLeading)
.offset(x: self.frames[self.selectedIndex].minX - self.frames[0].minX)
, alignment: .leading
)
}
.animation(.default)
.background(Capsule().stroke(Color.gray, lineWidth: 3))
Picker(selection: self.$selectedIndex, label: Text("What is your favorite color?")) {
ForEach(0..<self.titles.count) { index in
Text(self.titles[index]).tag(index)
}
}.pickerStyle(SegmentedPickerStyle())
Text("Value: \(self.titles[self.selectedIndex])")
Spacer()
}
}
func setFrame(index: Int, frame: CGRect) {
self.frames[index] = frame
}
}
struct CustomSegmentedPickerView_Previews: PreviewProvider {
static var previews: some View {
CustomSegmentedPickerView()
}
}
If I'm following the question aright the starting point might be something like the code below. The styling, clearly, needs a bit of attention. This has a hard-wired width for segments. To be more flexible you'd need to use a Geometry Reader to measure what was available and divide up the space.
struct ContentView: View {
#State var selection = 0
var body: some View {
let item1 = SegmentItem(title: "Some Way", color: Color.blue, selectionIndex: 0)
let item2 = SegmentItem(title: "Round Zip", color: Color.red, selectionIndex: 1)
let item3 = SegmentItem(title: "Multi-City", color: Color.green, selectionIndex: 2)
return VStack() {
Spacer()
Text("Selected Item: \(selection)")
SegmentControl(selection: $selection, items: [item1, item2, item3])
Spacer()
}
}
}
struct SegmentControl : View {
#Binding var selection : Int
var items : [SegmentItem]
var body : some View {
let width : CGFloat = 110.0
return HStack(spacing: 5) {
ForEach (items, id: \.self) { item in
SegmentButton(text: item.title, width: width, color: item.color, selectionIndex: item.selectionIndex, selection: self.$selection)
}
}.font(.body)
.padding(5)
.background(Color.gray)
.cornerRadius(10.0)
}
}
struct SegmentButton : View {
var text : String
var width : CGFloat
var color : Color
var selectionIndex = 0
#Binding var selection : Int
var body : some View {
let label = Text(text)
.padding(5)
.frame(width: width)
.background(color).opacity(selection == selectionIndex ? 1.0 : 0.5)
.cornerRadius(10.0)
.foregroundColor(Color.white)
.font(Font.body.weight(selection == selectionIndex ? .bold : .regular))
return Button(action: { self.selection = self.selectionIndex }) { label }
}
}
struct SegmentItem : Hashable {
var title : String = ""
var color : Color = Color.white
var selectionIndex = 0
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
None of the above solutions worked for me as the GeometryReader returns different values once placed in a Navigation View that throws off the positioning of the active indicator in the background. I found alternate solutions, but they only worked with fixed length menu strings. Perhaps there is a simple modification to make the above code contributions work, and if so, I would be eager to read it. If you're having the same issues I was, then this may work for you instead.
Thanks to inspiration from a Reddit user "End3r117" and this SwiftWithMajid article, https://swiftwithmajid.com/2020/01/15/the-magic-of-view-preferences-in-swiftui/, I was able to craft a solution. This works either inside or outside of a NavigationView and accepts menu items of various lengths.
struct SegmentMenuPicker: View {
var titles: [String]
var color: Color
#State private var selectedIndex = 0
#State private var frames = Array<CGRect>(repeating: .zero, count: 5)
var body: some View {
VStack {
ZStack {
HStack(spacing: 10) {
ForEach(self.titles.indices, id: \.self) { index in
Button(action: {
print("button\(index) pressed")
self.selectedIndex = index
}) {
Text(self.titles[index])
.foregroundColor(color)
.font(.footnote)
.fontWeight(.semibold)
}
.padding(EdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5))
.modifier(FrameModifier())
.onPreferenceChange(FramePreferenceKey.self) { self.frames[index] = $0 }
}
}
.background(
Rectangle()
.fill(self.color.opacity(0.4))
.frame(
width: self.frames[self.selectedIndex].width,
height: 2,
alignment: .topLeading)
.offset(x: self.frames[self.selectedIndex].minX - self.frames[0].minX, y: self.frames[self.selectedIndex].height)
, alignment: .leading
)
}
.padding(.bottom, 15)
.animation(.easeIn(duration: 0.2))
Text("Value: \(self.titles[self.selectedIndex])")
Spacer()
}
}
}
struct FramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
struct FrameModifier: ViewModifier {
private var sizeView: some View {
GeometryReader { geometry in
Color.clear.preference(key: FramePreferenceKey.self, value: geometry.frame(in: .global))
}
}
func body(content: Content) -> some View {
content.background(sizeView)
}
}
struct NewPicker_Previews: PreviewProvider {
static var previews: some View {
VStack {
SegmentMenuPicker(titles: ["SuperLongValue", "1", "2", "Medium", "AnotherSuper"], color: Color.blue)
NavigationView {
SegmentMenuPicker(titles: ["SuperLongValue", "1", "2", "Medium", "AnotherSuper"], color: Color.red)
}
}
}
}

SwiftUI Custom PickerStyle

I'm trying to write a custom PickerStyle that looks similar to the SegmentedPickerStyle(). This is my current status:
import SwiftUI
public struct FilterPickerStyle: PickerStyle {
public static func _makeView<SelectionValue>(value: _GraphValue<_PickerValue<FilterPickerStyle, SelectionValue>>, inputs: _ViewInputs) -> _ViewOutputs where SelectionValue : Hashable {
}
public static func _makeViewList<SelectionValue>(value: _GraphValue<_PickerValue<FilterPickerStyle, SelectionValue>>, inputs: _ViewListInputs) -> _ViewListOutputs where SelectionValue : Hashable {
}
}
I created a struct that conforms to the PickerStyle protocol. Xcode then added the required protocol methods, but I don't know how to use them. Could someone explain how to deal with these methods, if I for example want to achieve something similar to the SegmentedPickerStyle()?
I haven't finished it yet since other stuff came up, but here is my (unfinished attempt to implement a SegmentedPicker):
struct SegmentedPickerElementView<Content>: View where Content : View {
#Binding var selectedElement: Int
let content: () -> Content
#inlinable init(_ selectedElement: Binding<Int>, #ViewBuilder content: #escaping () -> Content) {
self._selectedElement = selectedElement
self.content = content
}
var body: some View {
GeometryReader { proxy in
self.content()
.fixedSize(horizontal: true, vertical: true)
.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
.contentShape(Rectangle())
}
}
}
struct SegmentedPickerView: View {
#Environment (\.colorScheme) var colorScheme: ColorScheme
var elements: [(id: Int, view: AnyView)]
#Binding var selectedElement: Int
#State var internalSelectedElement: Int = 0
private var width: CGFloat = 620
private var height: CGFloat = 200
private var cornerRadius: CGFloat = 20
private var factor: CGFloat = 0.95
private var color = Color(UIColor.systemGray)
private var selectedColor = Color(UIColor.systemGray2)
init(_ selectedElement: Binding<Int>) {
self._selectedElement = selectedElement
self.elements = [
(id: 0, view: AnyView(SegmentedPickerElementView(selectedElement) {
Text("4").font(.system(.title))
})),
(id: 1, view: AnyView(SegmentedPickerElementView(selectedElement) {
Text("5").font(.system(.title))
})),
(id: 2, view: AnyView(SegmentedPickerElementView(selectedElement) {
Text("9").font(.system(.title))
})),
(id: 3, view: AnyView(SegmentedPickerElementView(selectedElement) {
Text("13").font(.system(.title))
})),
(id: 4, view: AnyView(SegmentedPickerElementView(selectedElement) {
Text("13").font(.system(.title))
})),
(id: 5, view: AnyView(SegmentedPickerElementView(selectedElement) {
Text("13").font(.system(.title))
})),
]
self.internalSelectedElement = selectedElement.wrappedValue
}
func calcXPosition() -> CGFloat {
var pos = CGFloat(-self.width * self.factor / 2.4)
pos += CGFloat(self.internalSelectedElement) * self.width * self.factor / CGFloat(self.elements.count)
return pos
}
var body: some View {
ZStack {
Rectangle()
.foregroundColor(self.selectedColor)
.cornerRadius(self.cornerRadius * self.factor)
.frame(width: self.width * self.factor / CGFloat(self.elements.count), height: self.height - self.width * (1 - self.factor))
.offset(x: calcXPosition())
.animation(.easeInOut(duration: 0.2))
HStack(alignment: .center, spacing: 0) {
ForEach(self.elements, id: \.id) { item in
item.view
.gesture(TapGesture().onEnded { _ in
print(item.id)
self.selectedElement = item.id
withAnimation {
self.internalSelectedElement = item.id
}
})
}
}
}
.frame(width: self.width, height: self.height)
.background(self.color)
.cornerRadius(self.cornerRadius)
.padding()
}
}
struct SegmentedPickerView_Previews: PreviewProvider {
static var previews: some View {
SegmentedPickerView(.constant(1))
}
}
I haven't figured out the formula where the value 2.4 sits... it depends on the number of elements... her is what I have learned:
2 Elements = 4
3 Elements = 3
4 Elements = 2.6666
5 Elements = ca. 2.4
If you figure that out and fix the alignment of the content in the pickers its basically fully adjustable ... you could also pass the width and height of the hole thing ore use GeometryReader
Good Luck!
P.S.: I will update this when its finished but at the moment it is not my number one priority so don't expect me to do so.
The following code simplifies the design of the SegmentPickerElementView and the maintenance of selection state. Also, it fixes the selection indicator’s size (width & height) calculation in the original posting. Note that the indicator in this solution is in the foreground, effectively “sliding” across the surface of the HStack of choices (segments). Finally, this was developed on an iPad, using Swift Playgrounds. If you are using XCode on a Mac, you would want to comment out the PlaygroundSupport code, and uncomment the SegmentedPickerView_Previews struct code.
Code updated for iOS 15
import Foundation
import Combine
import SwiftUI
import PlaygroundSupport
struct SegmentedPickerElementView<Content>: Identifiable, View where Content : View {
var id: Int
let content: () -> Content
#inlinable init(id: Int, #ViewBuilder content: #escaping () -> Content) {
self.id = id
self.content = content
}
var body: some View {
/*
By simply wrapping “content” in a GeometryReader
you get a view which will flexibly take up the available
width in the parent container. As "Hacking Swift" put it:
"GeometryReader has an interesting side effect that might
catch you out at first: the view that gets returned has a
flexible preferred size, which means it will expand to
take up more space as needed."
(https://www.hackingwithswift.com/books/ios-swiftui/understanding-frames-and-coordinates-inside-geometryreader)
Interesting side effect, indeed. (Don't know about you,
but I don't like side effects, interesting or not.) As
suggested in the cited article, uncomment the
“background()“ modifiers to see this side effect.
*/
GeometryReader { proxy in
self.content()
// Sizing seems to have changed in iOS 14 or 15
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white)
}
}
}
struct SegmentedPickerView: View {
#Environment (\.colorScheme) var colorScheme: ColorScheme
#State var selectedIndex: Int = 0
#State var elementWidth: CGFloat = 0
// The values for width and height are arbitrary, and this part
// of the implementation can be improved (left to the reader).
private let width: CGFloat = 380
private let height: CGFloat = 72
private let cornerRadius: CGFloat = 8
private let selectorStrokeWidth: CGFloat = 4
private let selectorInset: CGFloat = 6
private let backgroundColor = Color(UIColor.lightGray)
private let choices: [String]
private var elements: [SegmentedPickerElementView<Text>] = [SegmentedPickerElementView<Text>]()
init(choices: [String]) {
self.choices = choices
for i in choices.indices {
self.elements.append(SegmentedPickerElementView(id: i) {
Text(choices[i]).font(.system(.title))
})
}
self.selectedIndex = 0
}
#State var selectionOffset: CGFloat = 0
func updateSelectionOffset(id: Int) {
let widthOfElement = self.width/CGFloat(self.elements.count)
self.selectedIndex = id
selectionOffset = CGFloat((widthOfElement * CGFloat(id)) + widthOfElement/2.0)
}
var body: some View {
VStack {
ZStack(alignment: .leading) {
HStack(alignment: .center, spacing: 0) {
ForEach(self.elements) { item in
(item as SegmentedPickerElementView )
.onTapGesture(perform: {
withAnimation {
self.updateSelectionOffset(id: item.id)
}
})
}
}
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(Color.gray, lineWidth: selectorStrokeWidth)
.foregroundColor(Color.clear)
// add color highlighting (optional)
.background(.yellow.opacity(0.25))
.frame(
width: (width/CGFloat(elements.count)) - 2.0 * selectorInset,
height: height - 2.0 * selectorInset)
.position(x: selectionOffset, y: height/2.0)
.animation(.easeInOut(duration: 0.2))
}
.frame(width: width, height: height)
.background(backgroundColor)
.cornerRadius(cornerRadius)
.padding()
Text("selected element: \(selectedIndex) -> \(choices[selectedIndex])")
}.onAppear(perform: { self.updateSelectionOffset(id: 0) })
}
}
// struct SegmentedPickerView_Previews: PreviewProvider {
// static var previews: some View {
// SegmentedPickerView(choices: ["A", "B", "C", "D", "E", "F" ])
// }
// }
PlaygroundPage.current.setLiveView(SegmentedPickerView(choices: ["A", "B", "C", "D", "E", "F" ]))

How can I create a text with Checkbox in SwiftUI?

I have a requirement of Checkbox (✅ as in to-do list) with textfield. Currently I have created button object like below :
Button(action: {
// do when checked / unchecked
//...
}) {
HStack(alignment: .top, spacing: 10) {
Rectangle()
.fill(Color.white)
.frame(width:20, height:20, alignment: .center)
.cornerRadius(5)
Text("Todo item 1")
}
}
I need to preserve checked and unchecked state in SwiftUI.
Here is a simple, re-usable checkmark component I created that follows a color scheme similar to other checkmarks on iOS (e.g. selecting messages in the Messages app):
import SwiftUI
struct CheckBoxView: View {
#Binding var checked: Bool
var body: some View {
Image(systemName: checked ? "checkmark.square.fill" : "square")
.foregroundColor(checked ? Color(UIColor.systemBlue) : Color.secondary)
.onTapGesture {
self.checked.toggle()
}
}
}
struct CheckBoxView_Previews: PreviewProvider {
struct CheckBoxViewHolder: View {
#State var checked = false
var body: some View {
CheckBoxView(checked: $checked)
}
}
static var previews: some View {
CheckBoxViewHolder()
}
}
You can use it in other views like this:
...
#State private var checked = true
...
HStack {
CheckBoxView(checked: $checked)
Spacer()
Text("Element that requires checkmark!")
}
...
The best way for iOS devices is to create a CheckboxStyle struct and conform to the ToggleStyle protocol. That allows you to then use the built-in Toggle component provided by Apple.
// CheckboxStyle.swift
import SwiftUI
struct CheckboxStyle: ToggleStyle {
func makeBody(configuration: Self.Configuration) -> some View {
return HStack {
Image(systemName: configuration.isOn ? "checkmark.square" : "square")
.resizable()
.frame(width: 24, height: 24)
.foregroundColor(configuration.isOn ? .blue : .gray)
.font(.system(size: 20, weight: .regular, design: .default))
configuration.label
}
.onTapGesture { configuration.isOn.toggle() }
}
}
// Example usage in a SwiftUI view
Toggle(isOn: $checked) {
Text("The label")
}
.toggleStyle(CheckboxStyle())
On macOS, Apple already has created a CheckboxToggleStyle() that you can use for macOS 10.15+. But it isn't available for iOS - yet.
Toggle seems to work for both macOS and iOS, using the native control on each.
https://developer.apple.com/documentation/swiftui/toggle
A control that toggles between on and off states.
#State var isOn: Bool = true
var body: some View {
Toggle("My Checkbox Title", isOn: $isOn)
.padding()
}
macOS:
iOS:
We can take help of the #State from Apple, which persists value of a given type, through which a view reads and monitors the value.
Working example :
struct CheckboxFieldView: View {
#State var checkState: Bool = false
var body: some View {
Button(action:
{
//1. Save state
self.checkState = !self.checkState
print("State : \(self.checkState)")
}) {
HStack(alignment: .top, spacing: 10) {
//2. Will update according to state
Rectangle()
.fill(self.checkState ? Color.green : Color.red)
.frame(width:20, height:20, alignment: .center)
.cornerRadius(5)
Text("Todo item ")
}
}
.foregroundColor(Color.white)
}
}
Now, you can add CheckboxFieldView()
You'll want something like this:
struct TodoCell: View {
var todoCellViewModel: TodoCellViewModel
var updateTodo: ((_ id: Int) -> Void)
var body: some View {
HStack {
Image(systemName: (self.todoCellViewModel.isCompleted() ? "checkmark.square" : "square")).tapAction {
self.updateTodo(self.todoCellViewModel.getId())
}
Text(self.todoCellViewModel.getTitle())
}
.padding()
}
}
Your list could look something like this:
struct TodoList: View {
var todos: Todos
var updateTodo: ((_ id: Int) -> Void)
var body: some View {
List(self.todos) { todo in
TodoCell(todoCellViewModel: TodoCellViewModel(todo: todo), updateTodo: { (id) in
self.updateTodo(id)
})
}
}
}
Your model might look something like this:
public class TodoCellViewModel {
private var todo: Todo
public init(todo: Todo) {
self.todo = todo
}
public func isCompleted() -> Bool {
return self.todo.completed
}
public func getTitle() -> String {
return self.todo.title
}
public func getId() -> Int {
return self.todo.id
}
}
And finally a Todo class:
public class Todo: Codable, Identifiable {
public let id: Int
public var title: String
public var completed: Bool
}
None of this has actually been tested and not all of the code has been implemented but this should get you on the right track.
Here’s my take on it. I’m actually doing this for MacOS, but the process should be the same.
First, I had to fake the checkbox by creating two png images: and , calling them checkbox-on.png and checkbox-off.png respectively. These I put into Assets.xcassets.
I believe that for iOS, the images are already available.
Second, the view includes a state variable:
#State var checked = false
The rest is to implement a Button with an action, an image, some text, and some modifiers:
Button(action: {
checked.toggle()
}) {
Image(checked ? "checkbox-on" : "checkbox-off")
.renderingMode(.original)
.resizable()
.padding(0)
.frame(width: 14.0, height: 14.0)
.background(Color(NSColor.controlBackgroundColor))
Text("Choose me … !").padding(0)
}
.buttonStyle(PlainButtonStyle())
.background(Color(red: 0, green: 0, blue: 0, opacity: 0.02))
.cornerRadius(0)
checked is the boolean variable you want to toggle
The image depends on the value of the boolean, using the condition operator to choose between the two
renderingMode() ensures that the image appears correctly and resizable() is used to enable frame().
The rest of the modifiers are there to tweak the appearance.
Obviously, if you are going to make a habit of this, you can create a struct:
struct Checkbox: View {
#Binding var toggle: Bool
var text: String
var body: some View {
Button(action: {
self.toggle.toggle()
}) {
Image(self.toggle ? "checkbox-on" : "checkbox-off")
.renderingMode(.original)
.resizable()
.padding(0)
.frame(width: 14.0, height: 14.0)
.background(Color(NSColor.controlBackgroundColor))
Text(text).padding(0)
}
.buttonStyle(PlainButtonStyle())
.background(Color(red: 0, green: 0, blue: 0, opacity: 0.02))
.cornerRadius(0)
}
}
and then use:
Checkbox(toggle: self.$checked, text: "Choose me … !")
(Note that you need to use self.$checked on this one).
Finally, if you prefer to use a common alternative appearance, that of a filled in square for the check box, you can replace Image with:
Rectangle()
.fill(self.autoSave ? Color(NSColor.controlAccentColor) : Color(NSColor.controlColor))
.padding(4)
.border(Color(NSColor.controlAccentColor), width: 2)
.frame(width: 14, height: 14)
I learned a lot doing this, and hopefully, this will help.
Here is my way:
import SwiftUI
extension ToggleStyle where Self == CheckBoxToggleStyle {
static var checkbox: CheckBoxToggleStyle {
return CheckBoxToggleStyle()
}
}
// Custom Toggle Style
struct CheckBoxToggleStyle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View {
Button {
configuration.isOn.toggle()
} label: {
Label {
configuration.label
} icon: {
Image(systemName: configuration.isOn ? "checkmark.square.fill" : "square")
.foregroundColor(configuration.isOn ? .accentColor : .secondary)
.accessibility(label: Text(configuration.isOn ? "Checked" : "Unchecked"))
.imageScale(.large)
}
}
.buttonStyle(PlainButtonStyle())
}
}
struct ContentView: View {
#State var isOn = false
var body: some View {
Toggle("Checkmark", isOn: $isOn).toggleStyle(.checkbox)
}
}
Unchecked:
Checked:
I found this solution here to be much better than using a completely custom made View:
https://swiftwithmajid.com/2020/03/04/customizing-toggle-in-swiftui/
He uses the ToggleStyle protocol to simply change the look of the toggle, instead of rebuilding it:
struct CheckboxToggleStyle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View {
return HStack {
configuration.label
Spacer()
Image(systemName: configuration.isOn ? "checkmark.square" : "square")
.resizable()
.frame(width: 22, height: 22)
.onTapGesture { configuration.isOn.toggle() }
}
}
}
You can use the following code and change the color etc. This is an individual component and I used a callback method to get informed when the checkbox is selected or not.
Step 1: Create a customizable and reusable checkbox view
Step 2: Let use the component in the main view
Use the checkboxSelected() callback function to know which checkbox is selected or not.
import SwiftUI
//MARK:- Checkbox Field
struct CheckboxField: View {
let id: String
let label: String
let size: CGFloat
let color: Color
let textSize: Int
let callback: (String, Bool)->()
init(
id: String,
label:String,
size: CGFloat = 10,
color: Color = Color.black,
textSize: Int = 14,
callback: #escaping (String, Bool)->()
) {
self.id = id
self.label = label
self.size = size
self.color = color
self.textSize = textSize
self.callback = callback
}
#State var isMarked:Bool = false
var body: some View {
Button(action:{
self.isMarked.toggle()
self.callback(self.id, self.isMarked)
}) {
HStack(alignment: .center, spacing: 10) {
Image(systemName: self.isMarked ? "checkmark.square" : "square")
.renderingMode(.original)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: self.size, height: self.size)
Text(label)
.font(Font.system(size: size))
Spacer()
}.foregroundColor(self.color)
}
.foregroundColor(Color.white)
}
}
enum Gender: String {
case male
case female
}
struct ContentView: View {
var body: some View {
HStack{
Text("Gender")
.font(Font.headline)
VStack {
CheckboxField(
id: Gender.male.rawValue,
label: Gender.male.rawValue,
size: 14,
textSize: 14,
callback: checkboxSelected
)
CheckboxField(
id: Gender.female.rawValue,
label: Gender.female.rawValue,
size: 14,
textSize: 14,
callback: checkboxSelected
)
}
}
.padding()
}
func checkboxSelected(id: String, isMarked: Bool) {
print("\(id) is marked: \(isMarked)")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Selectable Circle, Customizable
struct SelectableCircle: View {
#Binding var isSelected: Bool
var selectionColor: Color = Color.green
var size: CGFloat = 20
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .circular)
.stroke(Color.gray, lineWidth: 2)
.background(isSelected ? selectionColor : Color.clear)
.frame(width: size, height: size, alignment: .center)
.clipShape(Circle())
.onTapGesture {
withAnimation {
isSelected.toggle()
}
}
}
}
}
You can use like this:
struct CircleChooseView_Previews: PreviewProvider {
struct CircleChooseView: View {
#State var checked = false
var body: some View {
HStack {
SelectableCircle(isSelected: $checked)
Text("Item that needs to be selected")
}
}
}
static var previews: some View {
CircleChooseView()
}
}
You should take a look to this post, it's awesome!
https://medium.com/better-programming/how-to-create-and-animate-checkboxes-in-swiftui-e428fe7cc9c1

Align two SwiftUI text views in HStack with correct alignment

I have a simple list view that contains two rows.
Each row contains two text views. View one and View two.
I would like to align the last label (View two) in each row so that the name labels are leading aligned and keep being aligned regardless of font size.
The first label (View one) also needs to be leading aligned.
I've tried setting a min frame width on the first label (View One) but it doesn't work. It also seems impossible to set the min width and also to get a text view to be leading aligned in View One.
Any ideas? This is fairly straight forward in UIKit.
I've found a way to fix this that supports dynamic type and isn't hacky. The answer is using PreferenceKeys and GeometryReader!
The essence of this solution is that each number Text will have a width that it will be drawn with depending on its text size. GeometryReader can detect this width and then we can use PreferenceKey to bubble it up to the List itself, where the max width can be kept track of and then assigned to each number Text's frame width.
A PreferenceKey is a type you create with an associated type (can be any struct conforming to Equatable, this is where you store the data about the preference) that is attached to any View and when it is attached, it bubbles up through the view tree and can be listened to in an ancestor view by using .onPreferenceChange(PreferenceKeyType.self).
To start, we'll create our PreferenceKey type and the data it contains:
struct WidthPreferenceKey: PreferenceKey {
typealias Value = [WidthPreference]
static var defaultValue: [WidthPreference] = []
static func reduce(value: inout [WidthPreference], nextValue: () -> [WidthPreference]) {
value.append(contentsOf: nextValue())
}
}
struct WidthPreference: Equatable {
let width: CGFloat
}
Next, we'll create a View called WidthPreferenceSettingView that will be attached to the background of whatever we want to size (in this case, the number labels). This will take care of setting the preference which will pass up this number label's preferred width with PreferenceKeys.
struct WidthPreferenceSettingView: View {
var body: some View {
GeometryReader { geometry in
Rectangle()
.fill(Color.clear)
.preference(
key: WidthPreferenceKey.self,
value: [WidthPreference(width: geometry.frame(in: CoordinateSpace.global).width)]
)
}
}
}
Lastly, the list itself! We have an #State variable which is the width of the numbers "column" (not really a column in the sense that the numbers don't directly affect other numbers in code). Through .onPreferenceChange(WidthPreference.self) we listen to changes in the preference we created and store the max width in our width state. After all of the number labels have been drawn and their width read by the GeometryReader, the widths propagate back up and the max width is assigned by .frame(width: width)
struct ContentView: View {
#State private var width: CGFloat? = nil
var body: some View {
List {
HStack {
Text("1. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(WidthPreferenceSettingView())
Text("John Smith")
}
HStack {
Text("20. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(WidthPreferenceSettingView())
Text("Jane Done")
}
HStack {
Text("2000. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(WidthPreferenceSettingView())
Text("Jax Dax")
}
}.onPreferenceChange(WidthPreferenceKey.self) { preferences in
for p in preferences {
let oldWidth = self.width ?? CGFloat.zero
if p.width > oldWidth {
self.width = p.width
}
}
}
}
}
If you have multiple columns of data, one way to scale this is to make an enum of your columns or to index them, and the #State for width would become a dictionary where each key is a column and .onPreferenceChange compares against the key-value for the max width of a column.
To show results, this is what it looks like with larger text turned on, works like a charm :).
This article on PreferenceKey and inspecting the view tree helped tremendously: https://swiftui-lab.com/communicating-with-the-view-tree-part-1/
From iOS16 you can do:
struct ContentView: View {
var rowData: [RowData] = RowData.sample
var body: some View {
Grid(alignment: .leading) {
Text("Some sort of title")
ForEach(RowData.sample) { row in
GridRow {
Text(row.id)
Text(row.name)
}
}
}
.padding()
}
}
struct RowData: Identifiable {
var id: String
var name: String
static var sample: [Self] = [.init(id: "1", name: "Joe"), .init(id: "1000", name: "Diana")]
}
Previous answer
Here are three options to do it statically.
struct ContentView: View {
#State private var width: CGFloat? = 100
var body: some View {
List {
HStack {
Text("1. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
// Option 1
Text("John Smith")
.multilineTextAlignment(.leading)
//.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.background(Color.green)
}
HStack {
Text("20. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
// Option 2 (works mostly like option 1)
Text("Jane Done")
.background(Color.green)
Spacer()
}
HStack {
Text("2000. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
// Option 3 - takes all the rest space to the right
Text("Jax Dax")
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.background(Color.green)
}
}
}
}
Here is how it looks:
We may calculate the width based on the longenst entry as suggested in this answer.
There is couple of options to dynamically calculate width.
Option 1
import SwiftUI
import Combine
struct WidthGetter: View {
let widthChanged: PassthroughSubject<CGFloat, Never>
var body: some View {
GeometryReader { (g) -> Path in
print("width: \(g.size.width), height: \(g.size.height)")
self.widthChanged.send(g.frame(in: .global).width)
return Path() // could be some other dummy view
}
}
}
struct ContentView: View {
let event = PassthroughSubject<CGFloat, Never>()
#State private var width: CGFloat?
var body: some View {
List {
HStack {
Text("1. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
.background(WidthGetter(widthChanged: event))
// Option 1
Text("John Smith")
.multilineTextAlignment(.leading)
//.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.background(Color.green)
}
HStack {
Text("20. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
.background(WidthGetter(widthChanged: event))
// Option 2 (works mostly like option 1)
Text("Jane Done")
.background(Color.green)
Spacer()
}
HStack {
Text("2000. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
.background(WidthGetter(widthChanged: event))
// Option 3 - takes all the rest space to the right
Text("Jax Dax")
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.background(Color.green)
}
}.onReceive(event) { (w) in
print("event ", w)
if w > (self.width ?? .zero) {
self.width = w
}
}
}
}
Option 2
import SwiftUI
struct ContentView: View {
#State private var width: CGFloat?
var body: some View {
List {
HStack {
Text("1. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
.alignmentGuide(.leading, computeValue: { dimension in
self.width = max(self.width ?? 0, dimension.width)
return dimension[.leading]
})
// Option 1
Text("John Smith")
.multilineTextAlignment(.leading)
//.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.background(Color.green)
}
HStack {
Text("20. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
.alignmentGuide(.leading, computeValue: { dimension in
self.width = max(self.width ?? 0, dimension.width)
return dimension[.leading]
})
// Option 2 (works mostly like option 1)
Text("Jane Done")
.background(Color.green)
Spacer()
}
HStack {
Text("2000. ")
.frame(width: width, alignment: .leading)
.lineLimit(1)
.background(Color.blue)
.alignmentGuide(.leading, computeValue: { dimension in
self.width = max(self.width ?? 0, dimension.width)
return dimension[.leading]
})
// Option 3 - takes all the rest space to the right
Text("Jax Dax")
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.background(Color.green)
}
}
}
}
The result looks like this:
With Swift 5.2 and iOS 13, you can use PreferenceKey protocol, preference(key:value:) method and onPreferenceChange(_:perform:) method to solve this problem.
You can implement the code for the View proposed by OP in 3 major steps, as shown below.
#1. Initial implementation
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationView {
List {
HStack {
Text("5.")
Text("John Smith")
}
HStack {
Text("20.")
Text("Jane Doe")
}
}
.listStyle(GroupedListStyle())
.navigationBarTitle("Challenge")
}
}
}
#2. Intermediate implementation (set equal width)
The idea here is to collect all the widths for the Texts that represent a rank and assign the widest among them to the width property of ContentView.
import SwiftUI
struct WidthPreferenceKey: PreferenceKey {
static var defaultValue: [CGFloat] = []
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}
struct ContentView: View {
#State private var width: CGFloat? = nil
var body: some View {
NavigationView {
List {
HStack {
Text("5.")
.overlay(
GeometryReader { proxy in
Color.clear
.preference(
key: WidthPreferenceKey.self,
value: [proxy.size.width]
)
}
)
.frame(width: width, alignment: .leading)
Text("John Smith")
}
HStack {
Text("20.")
.overlay(
GeometryReader { proxy in
Color.clear
.preference(
key: WidthPreferenceKey.self,
value: [proxy.size.width]
)
}
)
.frame(width: width, alignment: .leading)
Text("Jane Doe")
}
}
.onPreferenceChange(WidthPreferenceKey.self) { widths in
if let width = widths.max() {
self.width = width
}
}
.listStyle(GroupedListStyle())
.navigationBarTitle("Challenge")
}
}
}
#3. Final implementation (with some refactoring)
To make our code reusable, we can refactor our preference logic into a ViewModifier.
import SwiftUI
struct WidthPreferenceKey: PreferenceKey {
static var defaultValue: [CGFloat] = []
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}
struct EqualWidth: ViewModifier {
func body(content: Content) -> some View {
content
.overlay(
GeometryReader { proxy in
Color.clear
.preference(
key: WidthPreferenceKey.self,
value: [proxy.size.width]
)
}
)
}
}
extension View {
func equalWidth() -> some View {
modifier(EqualWidth())
}
}
struct ContentView: View {
#State private var width: CGFloat? = nil
var body: some View {
NavigationView {
List {
HStack {
Text("5.")
.equalWidth()
.frame(width: width, alignment: .leading)
Text("John Smith")
}
HStack {
Text("20.")
.equalWidth()
.frame(width: width, alignment: .leading)
Text("Jane Doe")
}
}
.onPreferenceChange(WidthPreferenceKey.self) { widths in
if let width = widths.max() {
self.width = width
}
}
.listStyle(GroupedListStyle())
.navigationBarTitle("Challenge")
}
}
}
The result looks like this:
I just had to deal with this. The solutions that rely on a fixed width frame won't work for dynamic type, so I couldn't use them. The way I got around it was by putting the flexible item (the left number in this case) in a ZStack with a placeholder containing the widest allowable content, and then setting the placeholder's opacity to 0:
ZStack {
Text("9999")
.opacity(0)
.accessibility(visibility: .hidden)
Text(id)
}
It's pretty hacky, but at least it supports dynamic type 🤷‍♂️
Full example below! 📜
import SwiftUI
struct Person: Identifiable {
var name: String
var id: Int
}
struct IDBadge : View {
var id: Int
var body: some View {
ZStack(alignment: .trailing) {
Text("9999.") // The maximum width dummy value
.font(.headline)
.opacity(0)
.accessibility(visibility: .hidden)
Text(String(id) + ".")
.font(.headline)
}
}
}
struct ContentView : View {
var people: [Person]
var body: some View {
List(people) { person in
HStack(alignment: .top) {
IDBadge(id: person.id)
Text(person.name)
.lineLimit(nil)
}
}
}
}
#if DEBUG
struct ContentView_Previews : PreviewProvider {
static let people = [Person(name: "John Doe", id: 1), Person(name: "Alexander Jones", id: 2000), Person(name: "Tom Lee", id: 45)]
static var previews: some View {
Group {
ContentView(people: people)
.previewLayout(.fixed(width: 320.0, height: 150.0))
ContentView(people: people)
.environment(\.sizeCategory, .accessibilityMedium)
.previewLayout(.fixed(width: 320.0, height: 200.0))
}
}
}
#endif
You can just have your two Texts and then a Spacer in an HStack. The Spacer will push your Texts to the left, and everything will self-adjust if either Texts change size due to the length of their content:
HStack {
Text("1.")
Text("John Doe")
Spacer()
}
.padding()
The Texts are technically center-aligned, but since the views automatically resize and only take up as much space as the text inside of it (since we did not explicitly set a frame size), and are pushed to the left by the Spacer, they appear left-aligned. The benefit of this over setting a fixed width is that you don't have to worry about text being truncated.
Also, I added padding to the HStack to make it look nicer, but if you want to adjust how close the Texts are to each other, you can manually set the padding on any of its sides. (You can even set negative padding to push items closer to each other than their natural spacing).
Edit
Didn't realize OP needed the second Text to be vertically aligned as well. I have a way to do it, but its "hacky" and wouldn't work for larger font sizes without more work:
These are the data objects:
class Person {
var name: String
var id: Int
init(name: String, id: Int) {
self.name = name
self.id = id
}
}
class People {
var people: [Person]
init(people: [Person]) {
self.people = people
}
func maxIDDigits() -> Int {
let maxPerson = people.max { (p1, p2) -> Bool in
p1.id < p2.id
}
print(maxPerson!.id)
let digits = log10(Float(maxPerson!.id)) + 1
return Int(digits)
}
func minTextWidth(fontSize: Int) -> Length {
print(maxIDDigits())
print(maxIDDigits() * 30)
return Length(maxIDDigits() * fontSize)
}
}
This is the View:
var people = People(people: [Person(name: "John Doe", id: 1), Person(name: "Alexander Jones", id: 2000), Person(name: "Tom Lee", id: 45)])
var body: some View {
List {
ForEach(people.people.identified(by: \.id)) { person in
HStack {
Text("\(person.id).")
.frame(minWidth: self.people.minTextWidth(fontSize: 12), alignment: .leading)
Text("\(person.name)")
}
}
}
}
To make it work for multiple font sizes, you would have to get the font size and pass it into the minTextWidth(fontSize:).
Again, I'd like to emphasize that this is "hacky" and probably goes against SwiftUI principles, but I could not find a built in way to do the layout you asked for (probably because the Texts in different rows do not interact with each other, so they have no way of knowing how to stay vertically aligned with each other).
Edit 2
The above code generates this:
You can set a fixed width to a number Text view. It makes this Text component with a fixed size.
HStack {
Text(item.number)
.multilineTextAlignment(.leading)
.frame(width: 30)
Text(item.name)
}
The drawback of this solution is that, if you will have a longer text there, it will be wrapped and ended with "...", but in that case I think you can roughly estimate which width will be enough.
If 1 line limit is ok with you:
Group {
HStack {
VStack(alignment: .trailing) {
Text("Vehicle:")
Text("Lot:")
Text("Zone:")
Text("Location:")
Text("Price:")
}
VStack(alignment: .leading) {
Text("vehicle")
Text("lot")
Text("zone")
Text("location")
Text("price")
}
}
.lineLimit(1)
.font(.footnote)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
HStack {
HStack {
Spacer()
Text("5.")
}
.frame(width: 40)
Text("Jon Smith")
}
But this will only work with fix width.
.frame(minWidth: 40) will fill the entire View because of Space()
.multilineTextAlignment(.leading) don't have any effect in my tests.
After trying to get this to work for a full day I came up with this solution:
EDIT: Link to Swift Package
import SwiftUI
fileprivate extension Color {
func exec(block: #escaping ()->Void) -> Self {
block()
return self
}
}
fileprivate class Deiniter {
let block: ()->Void
init(block: #escaping ()->Void) {
self.block = block
}
deinit {
block()
}
}
struct SameWidthContainer<Content: View>: View {
private var id: UUID
private let deiniter: Deiniter
#ObservedObject private var group: WidthGroup
private var content: () -> Content
init(group: WidthGroup, content: #escaping ()-> Content) {
self.group = group
self.content = content
let id = UUID()
self.id = id
WidthGroup.widths[group.id]?[id] = 100.0
self.deiniter = Deiniter() {
WidthGroup.widths[group.id]?.removeValue(forKey: id)
}
}
var body: some View {
ZStack(alignment: .leading) {
Rectangle()
.frame(width: self.group.width, height: 1)
.foregroundColor(.clear)
content()
.overlay(
GeometryReader { proxy in
Color.clear
.exec {
WidthGroup.widths[self.group.id]?[self.id] = proxy.size.width
let newWidth = WidthGroup.widths[group.id]?.values.max() ?? 0
if newWidth != self.group.width {
self.group.width = newWidth
}
}
}
)
}
}
}
class WidthGroup: ObservableObject {
static var widths: [UUID: [UUID: CGFloat]] = [:]
#Published var width: CGFloat = 0.0
let id: UUID
init() {
let id = UUID()
self.id = id
WidthGroup.widths[id] = [:]
}
deinit {
WidthGroup.widths.removeValue(forKey: id)
}
}
struct SameWidthText_Previews: PreviewProvider {
private static let GROUP = WidthGroup()
static var previews: some View {
Group {
SameWidthContainer(group: Self.GROUP) {
Text("One")
}
SameWidthContainer(group: Self.GROUP) {
Text("Two")
}
SameWidthContainer(group: Self.GROUP) {
Text("Three")
}
}
}
}
It is then used like this:
struct SomeView: View {
#State private var group1 = WidthGroup()
#State private var group2 = WidthGroup()
var body: some View {
VStack() {
ForEach(9..<12) { index in
HStack {
SameWidthContainer(group: group1) {
Text("All these will have same width in group 1 \(index)")
}
Text("Some other text")
SameWidthContainer(group: group2) {
Text("All these will have same width in group 2 \(index)")
}
}
}
}
}
}
If one of the views grows or shrinks all the views in the same group will grow/shrink with it. I just got it to work so I haven't tried it that much.
It's a bit of a hack but, hey, it doesn't seem to be another way than hacking.
Xcode 12.5
If you know the amount you want to offset the second view by, then you can place both views in a leading aligned ZStack and use the .padding(.horizontal, amount) modifier on the second view to offset it.
var body: some View {
NavigationView {
List {
ForEach(persons) { person in
ZStack(alignment: .leading) {
Text(person.number)
Text(person.name)
.padding(.horizontal, 30)
}
}
}
.navigationTitle("Challenge")
}
}
I think the correct way to do this would be using HorizontalAlignment. Something like:
extension HorizontalAlignment {
private enum LeadingName : AlignmentID {
static func defaultValue(in d: ViewDimensions) -> Length { d[.leading] }
}
static let leadingName = HorizontalAlignment(LeadingName.self)
}
List (people.identified(by: \.id)) {person in
HStack {
Text("\(person.id)")
Text("\(person.name)").alignmentGuide(.leadingName) {d in d[.leading]}
}
}
But I can't get it to work.
I can't find any examples of this with a List. It seems that List doesn't support alignment (yet?)
I can sort of get it to work with a VStack, and hard coded values like:
VStack (alignment: .leadingName ) {
HStack {
Text("1.")
Text("John Doe").alignmentGuide(.leadingName) {d in d[.leading]}
Spacer()
}
HStack {
Text("2000.")
Text("Alexander Jones").alignmentGuide(.leadingName) {d in d[.leading]}
Spacer()
}
HStack {
Text("45.")
Text("Tom Lee").alignmentGuide(.leadingName) {d in d[.leading]}
Spacer()
}
}
I'm hoping this will be fixed in a later beta...

Resources