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" ]))
Related
I'm trying to use a SwiftUI Lazy Grid to lay out views with strings of varying lengths. How can I construct my code so that, e.g. if 3 view's do not fit, it will only make 2 columns and push the 3rd view to the next row so that they won't overlap?
struct ContentView: View {
var data = [
"Beatles",
"Pearl Jam",
"REM",
"Guns n Roses",
"Red Hot Chili Peppers",
"No Doubt",
"Nirvana",
"Tom Petty and the Heart Breakers",
"The Eagles"
]
var columns: [GridItem] = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
LazyVGrid(columns: columns, alignment: .center) {
ForEach(data, id: \.self) { bandName in
Text(bandName)
.fixedSize(horizontal: true, vertical: false)
}
}
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You can use this method to achieve what you're looking for, solution source: https://www.fivestars.blog/articles/flexible-swiftui/
ContentView
struct ContentView: View {
// MARK: - PROPERTIES
var data = [
"Beatles",
"Pearl Jam",
"REM",
"Guns n Roses",
"Red Hot Chili Peppers",
"No Doubt",
"Nirvana",
"Tom Petty and the Heart Breakers",
"The Eagles"
]
// MARK: - BODY
var body: some View {
FlexibleView(
availableWidth: UIScreen.main.bounds.width, data: data,
spacing: 15,
alignment: .leading
) { item in
Text(verbatim: item)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.2))
)
}
.padding(.horizontal, 10)
}
}
// MARK: - PREVIEW
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
FlexibleView
// MARK: - FLEXIBLE VIEW
struct FlexibleView<Data: Collection, Content: View>: View where Data.Element: Hashable {
let availableWidth: CGFloat
let data: Data
let spacing: CGFloat
let alignment: HorizontalAlignment
let content: (Data.Element) -> Content
#State var elementsSize: [Data.Element: CGSize] = [:]
var body : some View {
VStack(alignment: alignment, spacing: spacing) {
ForEach(computeRows(), id: \.self) { rowElements in
HStack(spacing: spacing) {
ForEach(rowElements, id: \.self) { element in
content(element)
.fixedSize()
.readSize { size in
elementsSize[element] = size
}
}
}
}
}
}
func computeRows() -> [[Data.Element]] {
var rows: [[Data.Element]] = [[]]
var currentRow = 0
var remainingWidth = availableWidth
for element in data {
let elementSize = elementsSize[element, default: CGSize(width: availableWidth, height: 1)]
if remainingWidth - (elementSize.width + spacing) >= 0 {
rows[currentRow].append(element)
} else {
currentRow = currentRow + 1
rows.append([element])
remainingWidth = availableWidth
}
remainingWidth = remainingWidth - (elementSize.width + spacing)
}
return rows
}
}
View Extension
// MARK: - EXTENSION
extension View {
func readSize(onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
}
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
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:
Apple introduced the way to make CollectionViews in SwiftUI by using the new LazyVGrid and LazyHGrid embedded inside an ScrollView.
But if the last row have less elements than number of columns, the items appear aligned to the leading. It is possible to align the last row items to the .center?
Swift 5.3 - SwiftUI 2.0 - Xcode 12.0b - macOS 11 Big Sur
I don't know if that's possible within a LazyGrid, but here is a possible workaround:
You could simply put the last item inside a VStack and align it centered, whenever the number of items in your data array is uneven.
I have implemented a demo for you:
Simple:
import SwiftUI
//MARK: - Content
struct ContentView: View {
//Your data
let data = Array(0...4)
let columns = [
GridItem(.fixed(160)),
GridItem(.fixed(160))
]
//Same spacing both for items inside grid and between grid and stack
let rowSpacing: CGFloat = 32
//If number of items is odd, remove the last one from grid and add to stack
var gridData: [Int] { data.count%2 == 1 ? data.dropLast() : data }
var stackData: Int? { data.count%2 == 1 ? data.last : nil }
var body: some View {
ScrollView {
VStack(spacing: rowSpacing) {
LazyVGrid(columns: columns, spacing: rowSpacing) {
ForEach(gridData, id: \.self) { i in
ItemView(i: i)
}
}
if let data = stackData {
VStack {
ItemView(i: data)
}
}
}
}
}
}
//MARK: - Item
struct ItemView: View {
let i: Int
var body: some View {
Rectangle()
.frame(width: 160, height: 240)
.foregroundColor(Color.green)
.overlay(Text(String(i)).foregroundColor(.white))
}
}
Alternative, reusable:
//MARK: - Data
struct SampleData: Identifiable {
let id: Int
var text: String
}
//MARK: - View
struct ContentView: View {
//Your data
let data = [SampleData(id: 0, text: "A"), SampleData(id: 1, text: "B"), SampleData(id: 2, text: "C")]
let columns = [
GridItem(.fixed(160)),
GridItem(.fixed(160))
]
var body: some View {
ScrollView {
CenteredLazyVGrid(data, columns: columns, spacing: 32) { i in
ItemView(i: i.id)
}
}
}
}
//MARK: - Item
struct ItemView: View {
let i: Int
var body: some View {
Rectangle()
.frame(width: 160, height: 240)
.foregroundColor(Color.green)
.overlay(Text(String(i)).foregroundColor(.white))
}
}
//MARK: - Centered Grid View
struct CenteredLazyVGrid<Data, Content>: View where Data: RandomAccessCollection, Content: View, /*Data: Hashable, */Data.Element: Identifiable {
private var data: Data
//private var id: KeyPath<Data.Element, ID>
private var columns: [GridItem]
private var alignment: HorizontalAlignment = .center
private var spacing: CGFloat? = nil
private var pinnedViews: PinnedScrollableViews = []
private var content: (Data.Element) -> Content
init(_ data: Data, /*id: KeyPath<Data.Element, ID>, */columns: [GridItem], alignment: HorizontalAlignment = .center, spacing: CGFloat?
= nil, pinnedViews: PinnedScrollableViews = .init(), content: #escaping (Data.Element) -> Content) {
self.data = data
//self.id = id
self.columns = columns
self.alignment = alignment
self.spacing = spacing
self.pinnedViews = pinnedViews
self.content = content
}
private var gridData: [Data.Element] { data.count%2 == 1 ? data.dropLast() : data as! [Data.Element] }
private var stackData: Data.Element? { data.count%2 == 1 ? data.last : nil }
var body: some View {
VStack(spacing: spacing) {
LazyVGrid(columns: columns, alignment: alignment, spacing: spacing, pinnedViews: pinnedViews) {
ForEach(gridData/*, id: id*/) { i in
content(i)
}
}
if let data = stackData {
VStack {
content(data)
}
}
}
}
}
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)
}
}
}
}
I'm trying to recreate a portion of the Twitter iOS app to learn SwiftUI and am wondering how to dynamically change the width of one view to be the width of another view. In my case, to have the underline be the same width as the Text view.
I have attached a screenshot to try and better explain what I'm referring to. Any help would be greatly appreciated, thanks!
Also here is the code I have so far:
import SwiftUI
struct GridViewHeader : View {
#State var leftPadding: Length = 0.0
#State var underLineWidth: Length = 100
var body: some View {
return VStack {
HStack {
Text("Tweets")
.tapAction {
self.leftPadding = 0
}
Spacer()
Text("Tweets & Replies")
.tapAction {
self.leftPadding = 100
}
Spacer()
Text("Media")
.tapAction {
self.leftPadding = 200
}
Spacer()
Text("Likes")
}
.frame(height: 50)
.padding(.horizontal, 10)
HStack {
Rectangle()
.frame(width: self.underLineWidth, height: 2, alignment: .bottom)
.padding(.leading, leftPadding)
.animation(.basic())
Spacer()
}
}
}
}
I have written a detailed explanation about using GeometryReader, view preferences and anchor preferences. The code below uses those concepts. For further information on how they work, check this article I posted: https://swiftui-lab.com/communicating-with-the-view-tree-part-1/
The solution below, will properly animate the underline:
I struggled to make this work and I agree with you. Sometimes, you just need to be able to pass up or down the hierarchy, some framing information. In fact, the WWDC2019 session 237 (Building Custom Views with SwiftUI), explains that views communicate their sizing continuously. It basically says Parent proposes size to child, childen decide how they want to layout theirselves and communicate back to the parent. How they do that? I suspect the anchorPreference has something to do with it. However it is very obscure and not at all documented yet. The API is exposed, but grasping how those long function prototypes work... that's a hell I do not have time for right now.
I think Apple has left this undocumented to force us rethink the whole framework and forget about "old" UIKit habits and start thinking declaratively. However, there are still times when this is needed. Have you ever wonder how the background modifier works? I would love to see that implementation. It would explain a lot! I'm hoping Apple will document preferences in the near future. I have been experimenting with custom PreferenceKey and it looks interesting.
Now back to your specific need, I managed to work it out. There are two dimensions you need (the x position and width of the text). One I get it fair and square, the other seems a bit of a hack. Nevertheless, it works perfectly.
The x position of the text I solved it by creating a custom horizontal alignment. More information on that check session 237 (at minute 19:00). Although I recommend you watch the whole thing, it sheds a lot of light on how the layout process works.
The width, however, I'm not so proud of... ;-) It requires DispatchQueue to avoid updating the view while being displayed. UPDATE: I fixed it in the second implementation down below
First implementation
extension HorizontalAlignment {
private enum UnderlineLeading: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
return d[.leading]
}
}
static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}
struct GridViewHeader : View {
#State private var activeIdx: Int = 0
#State private var w: [CGFloat] = [0, 0, 0, 0]
var body: some View {
return VStack(alignment: .underlineLeading) {
HStack {
Text("Tweets").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 0))
Spacer()
Text("Tweets & Replies").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 1))
Spacer()
Text("Media").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 2))
Spacer()
Text("Likes").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 3))
}
.frame(height: 50)
.padding(.horizontal, 10)
Rectangle()
.alignmentGuide(.underlineLeading) { d in d[.leading] }
.frame(width: w[activeIdx], height: 2)
.animation(.linear)
}
}
}
struct MagicStuff: ViewModifier {
#Binding var activeIdx: Int
#Binding var widths: [CGFloat]
let idx: Int
func body(content: Content) -> some View {
Group {
if activeIdx == idx {
content.alignmentGuide(.underlineLeading) { d in
DispatchQueue.main.async { self.widths[self.idx] = d.width }
return d[.leading]
}.onTapGesture { self.activeIdx = self.idx }
} else {
content.onTapGesture { self.activeIdx = self.idx }
}
}
}
}
Update: Better implementation without using DispatchQueue
My first solution works, but I was not too proud of the way the width is passed to the underline view.
I found a better way of achieving the same thing. It turns out, the background modifier is very powerful. It is much more than a modifier that can let you decorate the background of a view.
The basic steps are:
Use Text("text").background(TextGeometry()). TextGeometry is a custom view that has a parent with the same size as the text view. That is what .background() does. Very powerful.
In my implementation of TextGeometry I use GeometryReader, to get the geometry of the parent, which means, I get the geometry of the Text view, which means I now have the width.
Now to pass the width back, I am using Preferences. There's zero documentation about them, but after a little experimentation, I think preferences are something like "view attributes" if you like. I created my custom PreferenceKey, called WidthPreferenceKey and I use it in TextGeometry to "attach" the width to the view, so it can be read higher in the hierarchy.
Back in the ancestor, I use onPreferenceChange to detect changes in the width, and set the widths array accordingly.
It may all sound too complex, but the code illustrates it best. Here's the new implementation:
import SwiftUI
extension HorizontalAlignment {
private enum UnderlineLeading: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
return d[.leading]
}
}
static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}
struct WidthPreferenceKey: PreferenceKey {
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
typealias Value = CGFloat
}
struct GridViewHeader : View {
#State private var activeIdx: Int = 0
#State private var w: [CGFloat] = [0, 0, 0, 0]
var body: some View {
return VStack(alignment: .underlineLeading) {
HStack {
Text("Tweets")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 0))
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[0] = $0 })
Spacer()
Text("Tweets & Replies")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 1))
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[1] = $0 })
Spacer()
Text("Media")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 2))
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[2] = $0 })
Spacer()
Text("Likes")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 3))
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[3] = $0 })
}
.frame(height: 50)
.padding(.horizontal, 10)
Rectangle()
.alignmentGuide(.underlineLeading) { d in d[.leading] }
.frame(width: w[activeIdx], height: 2)
.animation(.linear)
}
}
}
struct TextGeometry: View {
var body: some View {
GeometryReader { geometry in
return Rectangle().fill(Color.clear).preference(key: WidthPreferenceKey.self, value: geometry.size.width)
}
}
}
struct MagicStuff: ViewModifier {
#Binding var activeIdx: Int
let idx: Int
func body(content: Content) -> some View {
Group {
if activeIdx == idx {
content.alignmentGuide(.underlineLeading) { d in
return d[.leading]
}.onTapGesture { self.activeIdx = self.idx }
} else {
content.onTapGesture { self.activeIdx = self.idx }
}
}
}
}
First, to answer the question in the title, if you want to make a shape (view) fit to the size of another view, you can use an .overlay(). The .overlay() gets offered its size from the view it is modifying.
In order to set offsets and widths in your Twitter recreation, you can use a GeometryReader. The GeometryReader has the ability to find its .frame(in:) another coordinate space.
You can use .coordinateSpace(name:) to identify the reference coordinate space.
struct ContentView: View {
#State private var offset: CGFloat = 0
#State private var width: CGFloat = 0
var body: some View {
HStack {
Text("Tweets")
.overlay(MoveUnderlineButton(offset: $offset, width: $width))
Text("Tweets & Replies")
.overlay(MoveUnderlineButton(offset: $offset, width: $width))
Text("Media")
.overlay(MoveUnderlineButton(offset: $offset, width: $width))
Text("Likes")
.overlay(MoveUnderlineButton(offset: $offset, width: $width))
}
.coordinateSpace(name: "container")
.overlay(underline, alignment: .bottomLeading)
.animation(.spring())
}
var underline: some View {
Rectangle()
.frame(height: 2)
.frame(width: width)
.padding(.leading, offset)
}
struct MoveUnderlineButton: View {
#Binding var offset: CGFloat
#Binding var width: CGFloat
var body: some View {
GeometryReader { geometry in
Button(action: {
self.offset = geometry.frame(in: .named("container")).minX
self.width = geometry.size.width
}) {
Rectangle().foregroundColor(.clear)
}
}
}
}
}
The underline view is is a 2 point high Rectangle, put in an .overlay() on top of the HStack.
The underline view is aligned to .bottomLeading, so that we can programmatically set its .padding(.leading, _) using a #State value.
The underline view's .frame(width:) is also set using a #State value.
The HStack is set as the .coordinateSpace(name: "container") so we can find the frame of our buttons relative to this.
The MoveUnderlineButton uses a GeometryReader to find its own width and minX in order to set the respective values for the underline view
The MoveUnderlineButton is set as the .overlay() for the Text view containing the text of that button so that its GeometryReader inherits its size from that Text view.
Give this a try:
import SwiftUI
var titles = ["Tweets", "Tweets & Replies", "Media", "Likes"]
struct GridViewHeader : View {
#State var selectedItem: String = "Tweets"
var body: some View {
HStack(spacing: 20) {
ForEach(titles.identified(by: \.self)) { title in
HeaderTabButton(title: title, selectedItem: self.$selectedItem)
}
.frame(height: 50)
}.padding(.horizontal, 10)
}
}
struct HeaderTabButton : View {
var title: String
#Binding var selectedItem: String
var isSelected: Bool {
selectedItem == title
}
var body: some View {
VStack {
Button(action: { self.selectedItem = self.title }) {
Text(title).fixedSize(horizontal: true, vertical: false)
Rectangle()
.frame(height: 2, alignment: .bottom)
.relativeWidth(1)
.foregroundColor(isSelected ? Color.accentColor : Color.clear)
}
}
}
}
And here's what it looks like in preview:
Let me modestly suggest a slight modification of this bright answer:
Version without using preferences:
import SwiftUI
extension HorizontalAlignment {
private enum UnderlineLeading: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
return d[.leading]
}
}
static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}
struct GridViewHeader : View {
#State private var activeIdx: Int = 0
#State private var w: [CGFloat] = [0, 0, 0, 0]
var body: some View {
return VStack(alignment: .underlineLeading) {
HStack {
Text("Tweets").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 0))
Spacer()
Text("Tweets & Replies").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 1))
Spacer()
Text("Media").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 2))
Spacer()
Text("Likes").modifier(MagicStuff(activeIdx: $activeIdx, widths: $w, idx: 3))
}
.frame(height: 50)
.padding(.horizontal, 10)
Rectangle()
.alignmentGuide(.underlineLeading) { d in d[.leading] }
.frame(width: w[activeIdx], height: 2)
.animation(.linear)
}
}
}
struct MagicStuff: ViewModifier {
#Binding var activeIdx: Int
#Binding var widths: [CGFloat]
let idx: Int
func body(content: Content) -> some View {
var w: CGFloat = 0
return Group {
if activeIdx == idx {
content.alignmentGuide(.underlineLeading) { d in
w = d.width
return d[.leading]
}.onTapGesture { self.activeIdx = self.idx }.onAppear(perform: {self.widths[self.idx] = w})
} else {
content.onTapGesture { self.activeIdx = self.idx }
}
}
}
}
Version using preferences and GeometryReader:
import SwiftUI
extension HorizontalAlignment {
private enum UnderlineLeading: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
return d[.leading]
}
}
static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}
struct WidthPreferenceKey: PreferenceKey {
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
typealias Value = CGFloat
}
struct GridViewHeader : View {
#State private var activeIdx: Int = 0
#State private var w: [CGFloat] = [0, 0, 0, 0]
var body: some View {
return VStack(alignment: .underlineLeading) {
HStack {
Text("Tweets")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 0, widthStorage: $w))
Spacer()
Text("Tweets & Replies")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 1, widthStorage: $w))
Spacer()
Text("Media")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 2, widthStorage: $w))
Spacer()
Text("Likes")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 3, widthStorage: $w))
}
.frame(height: 50)
.padding(.horizontal, 10)
Rectangle()
.frame(width: w[activeIdx], height: 2)
.animation(.linear)
}
}
}
struct MagicStuff: ViewModifier {
#Binding var activeIdx: Int
let idx: Int
#Binding var widthStorage: [CGFloat]
func body(content: Content) -> some View {
Group {
if activeIdx == idx {
content.background(GeometryReader { geometry in
return Color.clear.preference(key: WidthPreferenceKey.self, value: geometry.size.width)
})
.alignmentGuide(.underlineLeading) { d in
return d[.leading]
}.onTapGesture { self.activeIdx = self.idx }
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.widthStorage[self.idx] = $0 })
} else {
content.onTapGesture { self.activeIdx = self.idx }.onPreferenceChange(WidthPreferenceKey.self, perform: { self.widthStorage[self.idx] = $0 })
}
}
}
}
Here's a super simple solution, although it doesn't account for the tabs being stretched full width - but that should just be minor additional math for calculating the padding.
import SwiftUI
struct HorizontalTabs: View {
private let tabsSpacing = CGFloat(16)
private func tabWidth(at index: Int) -> CGFloat {
let label = UILabel()
label.text = tabs[index]
let labelWidth = label.intrinsicContentSize.width
return labelWidth
}
private var leadingPadding: CGFloat {
var padding: CGFloat = 0
for i in 0..<tabs.count {
if i < selectedIndex {
padding += tabWidth(at: i) + tabsSpacing
}
}
return padding
}
let tabs: [String]
#State var selectedIndex: Int = 0
var body: some View {
VStack(alignment: .leading) {
HStack(spacing: tabsSpacing) {
ForEach(0..<tabs.count, id: \.self) { index in
Button(action: { self.selectedIndex = index }) {
Text(self.tabs[index])
}
}
}
Rectangle()
.frame(width: tabWidth(at: selectedIndex), height: 3, alignment: .bottomLeading)
.foregroundColor(.blue)
.padding(.leading, leadingPadding)
.animation(Animation.spring())
}
}
}
HorizontalTabs(tabs: ["one", "two", "three"]) renders this:
You just need to specify a frame with a height within it. Here's an example :
VStack {
Text("First Text Label")
Spacer().frame(height: 50) // This line
Text("Second Text Label")
}
This solution is very wonderful.
But it became a compilation error now, it corrected. (Xcode11.1)
This is a whole code.
extension HorizontalAlignment {
private enum UnderlineLeading: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
return d[.leading]
}
}
static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}
struct WidthPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct HorizontalTabsView : View {
#State private var activeIdx: Int = 0
#State private var w: [CGFloat] = [0, 0, 0, 0]
var body: some View {
return VStack(alignment: .underlineLeading) {
HStack {
Text("Tweets")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 0))
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[0] = $0 })
Spacer()
Text("Tweets & Replies")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 1))
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[1] = $0 })
Spacer()
Text("Media")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 2))
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[2] = $0 })
Spacer()
Text("Likes")
.modifier(MagicStuff(activeIdx: $activeIdx, idx: 3))
.background(TextGeometry())
.onPreferenceChange(WidthPreferenceKey.self, perform: { self.w[3] = $0 })
}
.frame(height: 50)
.padding(.horizontal, 10)
Rectangle()
.alignmentGuide(.underlineLeading) { d in d[.leading] }
.frame(width: w[activeIdx], height: 2)
.animation(.default)
}
}
}
struct TextGeometry: View {
var body: some View {
GeometryReader { geometry in
return Rectangle()
.foregroundColor(.clear)
.preference(key: WidthPreferenceKey.self, value: geometry.size.width)
}
}
}
struct MagicStuff: ViewModifier {
#Binding var activeIdx: Int
let idx: Int
func body(content: Content) -> some View {
Group {
if activeIdx == idx {
content.alignmentGuide(.underlineLeading) { d in
return d[.leading]
}.onTapGesture { self.activeIdx = self.idx }
} else {
content.onTapGesture { self.activeIdx = self.idx }
}
}
}
}
struct HorizontalTabsView_Previews: PreviewProvider {
static var previews: some View {
HorizontalTabsView()
}
}