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)
}
}
}
}
}
Related
I would like to make the second row appear when my list is too long.
Do you have any idea how to do that?
Thank you in advance!
import SwiftUI
struct ContentView: View {
#StateObject var vm = SpeakingVM()
var speakingModel: SpeakingModel
var body: some View {
HStack(spacing:20){
ForEach(speakingModel.sentence.indices) { index in
Button(action: {
}, label: {
Text(speakingModel.sentence[index].definition)
.padding(.vertical,10)
.padding(.horizontal)
.background(Capsule().stroke(Color.blue))
.lineLimit(1)
})
}
}.frame(width: UIScreen.main.bounds.width - 30, height: UIScreen.main.bounds.height / 3)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(speakingModel: SpeakingModel(sentence: [SpeakingModel.Word(definition: "Météo"),SpeakingModel.Word(definition: "Cheval"),SpeakingModel.Word(definition: "Ascenceur")], sentenceInFrench: "Quel temps fait-il ?"))
}
}
What i would like :
Put your data in tags and customize the item view and change it appropriately.
import SwiftUI
struct HashTagView: View {
#State var tags: [String] = ["#Lorem", "#Ipsum", "#dolor", "#consectetur", "#adipiscing", "#elit", "#Nam", "#semper", "#sit", "#amet", "#ut", "#eleifend", "#Cras"]
#State private var totalHeight = CGFloat.zero
var body: some View {
VStack {
GeometryReader { geometry in
self.generateContent(in: geometry)
}
}
.frame(height: totalHeight)
}
private func generateContent(in g: GeometryProxy) -> some View {
var width = CGFloat.zero
var height = CGFloat.zero
return ZStack(alignment: .topLeading) {
ForEach(self.tags, id: \.self) { tag in
self.item(for: tag)
.padding([.horizontal, .vertical], 4)
.alignmentGuide(.leading, computeValue: { d in
if (abs(width - d.width) > g.size.width)
{
width = 0
height -= d.height
}
let result = width
if tag == self.tags.last! {
width = 0 //last item
} else {
width -= d.width
}
return result
})
.alignmentGuide(.top, computeValue: {d in
let result = height
if tag == self.tags.last! {
height = 0 // last item
}
return result
})
}
}.background(viewHeightReader($totalHeight))
}
private func item(for text: String) -> some View {
Text(text)
.padding(.all, 5)
.font(.body)
.background(Color.blue)
.foregroundColor(Color.white)
.cornerRadius(5)
}
private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
return GeometryReader { geometry -> Color in
let rect = geometry.frame(in: .local)
DispatchQueue.main.async {
binding.wrappedValue = rect.size.height
}
return .clear
}
}
}
struct HashTagView_Previews: PreviewProvider {
static var previews: some View {
HashTagView()
}
}
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) {}
}
I want to have a TabView, where each tab is a LazyVGrid. I want to fill each grid in a particular tab, before another tab gets created, and I'd like this to be adaptive to whatever screen is being seen (in particular iPad vs. iPhone).
I have something like the below:
struct ContentView: View {
var items: Items
var columns = [GridItem(.adaptive(minimum: 100))]
var body: some View {
VStack {
TabView {
ForEach((1...items.getNumPages(???)), id: \.self) {page in
VStack {
LazyVGrid(columns: columns, alignment: .leading, spacing: 10) {
ForEach(items.getItems(page: page, ???) { item in
ItemView(item: item)
}.tabItem{}.tag(page)
}.padding(.leading).padding(.trailing)
}
}
}.tabViewStyle(.page)
.indexViewStyle(.page(backgroundDisplayMode: .always))
}
}
}
Note that the ItemView will always be the same size for any Item. But depending on the screen size, you might have a view with 2 columns and 40 elements, or 3 columns with 50 elements, etc. In the above I put in ??? to pass in something that will tell me: 1) how many tabs (pages), 2) how many elements per tab (page). I don't want any scrolling at all, just the swipe behavior to move from tab to tab. I suppose I could throw in a GeometryReader and do a bunch of size computation to figure out how many items will fit per page, but is there something simpler?
Thx.
I came up with something like this, the only change I would do is to calculate all the pagination related things on the Items struct init () if you already know how many items there will be.
Since I don't know the sizes of the items inside the grids I just put an arbitrary number per page
import SwiftUI
struct Items{
let quantity: Int
var pager: Int = 0
var column = GridItem(.flexible(minimum: 100))
func getNumPages() -> Int{
switch UIDevice.current.userInterfaceIdiom {
case .phone:
return Int(ceil(Double(self.quantity / 50)))
case .pad:
return Int(ceil(Double(self.quantity / 100)))
#unknown default:
return Int(ceil(Double(self.quantity / 100)))
}
}
func getColums() -> [GridItem] {
switch UIDevice.current.userInterfaceIdiom {
case .phone:
return [column, column]
case .pad:
return [column, column, column]
#unknown default:
return [column, column, column]
}
}
mutating func getItems(page: Int) -> Int {
pager = page + (UIDevice.current.userInterfaceIdiom == .pad ? 100 : 50)
return pager
}
}
struct gridProblem: View {
#State var items = Items(quantity: 500)
var body: some View {
VStack {
TabView {
ForEach((0...items.getNumPages()), id: \.self) {page in
VStack {
LazyVGrid(columns: items.getColums(), alignment: .leading, spacing: 10) {
ForEach(items.pager..<items.getItems(page: Int(page))) { item in
HStack{
Spacer()
Text("\(item + self.items.pager)")
Spacer()
}.background(Color.gray)
}
}.padding(.leading).padding(.trailing)
}
.id(UUID())
.tabItem{
}.tag(page)
}
}.tabViewStyle(.page)
.indexViewStyle(.page(backgroundDisplayMode: .always))
}
}
}
The solution was to use a GeometryReader, and you do have to compute the sizes. It wasn't that bad, the key concepts are computing the number of items per column, and the number of columns per page based on the GeometryReader size. It's absolutely assumed that the items are fixed size.
In my example, the items are just randomly generated words. The below works on all manner of ipads and phones, and deals well when rotating the device.
import SwiftUI
struct Items {
private let alphabet = "abcdefhijklmnopqrstuvwxyz"
private var alphaArray: [String] { return alphabet.map { String($0) }}
var items: [String] = []
static let column = GridItem(.fixed(itemWidth))
static let verticalSpacing: CGFloat = 10
static let itemHeight: CGFloat = 20
static let itemWidth: CGFloat = 160
static let horizontalSpacing: CGFloat = 20
init(quantity: Int) {
for _ in 1...quantity {
let nchars = Int.random(in: 4..<16)
let tempArray = alphaArray.shuffled()
items.append( tempArray[0...nchars].joined() )
}
items.sort()
}
private func wordsPerColumn(height: CGFloat) -> Int {
var retval = Int( (height - Items.verticalSpacing) / (Items.itemHeight + Items.verticalSpacing))
// Let's remove 1, keep it roomy below.
retval -= 1
return retval
}
private func columnsPerPage(width: CGFloat) -> Int {
return Int( width / (Items.itemWidth + Items.horizontalSpacing))
}
func getPageSize(size: CGSize) -> Int {
return wordsPerColumn(height: size.height) * columnsPerPage(width: size.width)
}
func getNumPages(size: CGSize) -> Int {
var retval = items.count / getPageSize(size: size) + 1
if items.count % getPageSize(size: size) == 0 {
retval = retval - 1
}
return retval
}
func getColumns(size: CGSize) -> [GridItem] {
return [GridItem](repeating: Items.column, count: columnsPerPage(width: size.width))
}
func getItems(size: CGSize, page: Int) -> [String] {
let pageSize = getPageSize(size: size)
let startIdx = (page-1) * pageSize
let endIdx = min(startIdx + pageSize-1, items.count-1)
return Array(items[startIdx...endIdx])
}
}
struct ItemView: View {
var item: String
var body: some View {
Text(item.capitalized).frame(width: Items.itemWidth, height: Items.itemHeight, alignment: .leading).background(.orange)
}
}
struct ContentView: View {
#State var items = Items(quantity: 210)
var body: some View {
VStack {
Text("TOP")
GeometryReader { reader in
TabView {
ForEach((1...items.getNumPages(size: reader.size)), id: \.self) {page in
VStack {
LazyVGrid(columns: items.getColumns(size: reader.size), alignment: .leading, spacing: Items.verticalSpacing) {
ForEach(items.getItems(size: reader.size, page: page), id: \.self) { item in
ItemView(item: item)
}.background(Color.gray)
}.padding().border(.red, width: 3)
Spacer()
}
.id(UUID())
.tabItem{
}.tag(page)
}
}.tabViewStyle(.page)
.indexViewStyle(.page(backgroundDisplayMode: .always))
}
Spacer()
}
}
}
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 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" ]))