Hi I have a Stack of Hstacks that consist of a Text and a slider. The slider width extends to the edge of the text in front of it but I want them all to have the same width and appear in a straight column.
Like this below.
This is how I am forming the stacks.
VStack {
ForEach(defensiveLayers.indices, id: \.self) { i in
HStack {
Text(defensiveLayers[i].name).font(.custom("Gill Sans", size: 12)).padding(.trailing).foregroundColor(.gray)
MyNodeView(myNode: $defensiveLayers[i])
}
}
}
this is the view where I am forming the sliders. Can someone pls help
struct MyNodeView : View {
#Binding var myNode : Sliders
var body: some View {
HStack {
Text("\(String(format: "%.f", myNode.percent))%").font(.footnote)
Slider(value: $myNode.percent, in: 0 ... 100)
}
}
}
To make sure all the text is the same width, the simplest way is .frame(width:).
struct Sliders {
var name = "Name"
var percent = CGFloat(100)
}
struct ContentView: View {
#State var defensiveLayers = [
Sliders(name: "Long name", percent: 80),
Sliders(name: "Name", percent: 70),
Sliders(name: "Ok name", percent: 65),
Sliders(name: "Hi", percent: 15),
Sliders(name: "Hello", percent: 45),
]
var body: some View {
VStack {
ForEach(defensiveLayers.indices, id: \.self) { i in
HStack {
Text(defensiveLayers[i].name).font(.custom("Gill Sans", size: 12)).padding(.trailing).foregroundColor(.gray)
.frame(width: 100) /// add frame here!
MyNodeView(myNode: $defensiveLayers[i])
}
}
}
}
}
struct MyNodeView : View {
#Binding var myNode: Sliders
var body: some View {
HStack {
Text("\(String(format: "%.f", myNode.percent))%").font(.footnote)
Slider(value: $myNode.percent, in: 0 ... 100)
}
}
}
Result:
You could calculate the required width for the longest Text and apply that particular width to all the leading Text views in all the HStacks unifying them in the width. I've created a helper method for finding the required width for any SwiftUI View.
Helper:
extension View {
func viewSize(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) { }
}
Usage:
#State private var textFrameWidth: CGFloat = 0
var body: some View {
VStack {
ForEach(defensiveLayers.indices, id: \.self) { i in
HStack {
Text(defensiveLayers[i].name)
.font(.custom("Gill Sans", size: 12))
.padding(.trailing)
.foregroundColor(.gray)
.viewSize { size in
textFrameWidth = textFrameWidth < size.width ? size.width : textFrameWidth
}
.frame(width: textFrameWidth)
MyNodeView(myNode: $defensiveLayers[i])
}
}
}
}
Related
So I am trying to make my TabView height dynamic. I have been looking for a way to do this but I can't seem to find a solution anywhere. This is how my code looks like.
struct ContentView: View {
#State var contentHeight: CGFloat = 0
var body: some View {
NavigationView {
ScrollView {
VStack {
TabView {
TestView1(contentHeight: $contentHeight)
TestView2(contentHeight: $contentHeight)
}
.tabViewStyle(.page)
.frame(height: contentHeight)
.indexViewStyle(.page(backgroundDisplayMode: .always))
.background(.yellow)
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Test Project")
}
}
}
}
This is how my test1view and test2view look like.
struct TestView1: View {
#State var height: CGFloat = 0
#Binding var contentHeight: CGFloat
var body: some View {
Color.red
.frame(maxWidth:.infinity, minHeight: 200, maxHeight: 200)
.background(
GeometryReader { geo in
Color.clear
.preference(
key: HeightPreferenceKey.self,
value: geo.size.height
)
.onAppear {
contentHeight = height
}
}
.onPreferenceChange(HeightPreferenceKey.self) { height in
self.height = height
}
)
}
}
struct TestView2: View {
#Binding var contentHeight: CGFloat
#State var height: CGFloat = 0
var body: some View {
Color.black
.frame(maxWidth:.infinity, minHeight: 350, maxHeight: 350)
.background(
GeometryReader { geo in
Color.clear
.preference(
key: HeightPreferenceKey.self,
value: geo.size.height
)
.onAppear {
contentHeight = height
}
}
.onPreferenceChange(HeightPreferenceKey.self) { height in
self.height = height
}
)
}
}
struct HeightPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
Now the problem is that when I drag it just a little the height changes. So when I drag it a little to the left the height changes to the height of TestView2 and it is still on TestView1.
I tried to add a drag gesture but it didn't let me swipe to the next page. So I don't know how I will be able to achieve this. Ive been looking for a solution but still no luck.
You can use the TabView($selection) initializer to do this. https://developer.apple.com/documentation/swiftui/tabview/init(selection:content:)
It tells you which tab you're currently viewing. Based off the middle point of the screen. And you don't have to deal with nasty GeometryReader and HeightPreferenceKey.
Here's your updated code. I even added a nice animation to fade between the two heights!
struct ContentView: View {
#State var selectedTab: Tab = .first
#State var animatedContentHeight: CGFloat = 300
enum Tab {
case first
case second
var contentHeight: CGFloat {
switch self {
case .first:
return 200
case .second:
return 350
}
}
}
var body: some View {
TabView(selection: $selectedTab) {
TestView1()
.tag(Tab.first)
TestView2()
.tag(Tab.second)
}
.tabViewStyle(.page)
// .frame(height: selectedTab.contentHeight) // Uncomment to see without animation
.frame(height: animatedContentHeight)
.indexViewStyle(.page(backgroundDisplayMode: .always))
.onChange(of: selectedTab) { newValue in
print("now selected:", newValue)
withAnimation { animatedContentHeight = selectedTab.contentHeight }
}
}
}
struct TestView1: View {
var body: some View {
Color.red
}
}
struct TestView2: View {
var body: some View {
Color.black
}
}
I am building a custom SegmentedPicker in SwiftUI where the selector adjusts its size to fit the frame of each picker item. I did it already using PreferenceKeys as inspired by this post (Inspecting the View Tree) for uniformly sized items like shown below:
I think I can simplify my implementation considerably and avoid using PreferencyKeys altogether by using a .matchedGeometryEffect(). My idea was to present a selector behind each item only when that item has been selected and sync the transition using the .matchedGeometryEffect(). Almost everything is working except for an issue where the selector will be in front of the previously selected item. I tried explicitly setting the zIndex, but it does not seem to affect the result:
The code:
struct MatchedGeometryPicker: View {
#Namespace private var animation
#Binding var selection: Int
let items: [String]
var body: some View {
HStack {
ForEach(items.indices) { index in
ZStack {
if isSelected(index) {
Color.gray.clipShape(Capsule())
.matchedGeometryEffect(id: "selector", in: animation)
.animation(.easeInOut)
.zIndex(0)
}
itemView(for: index)
.padding(7)
.zIndex(1)
}
.fixedSize()
}
}
.padding(7)
}
func itemView(for index: Int) -> some View {
Text(items[index])
.frame(minWidth: 0, maxWidth: .infinity)
.foregroundColor(isSelected(index) ? .black : .gray)
.font(.caption)
.onTapGesture { selection = index }
}
func isSelected(_ index: Int) -> Bool { selection == index }
}
And in ContentView:
struct ContentView: View {
#State private var selection = 0
let pickerItems = [ "Item 1", "Long item 2", "Item 3", "Item 4", "Long item 5"]
var body: some View {
MatchedGeometryPicker(selection: $selection, items: pickerItems)
.background(Color.gray.opacity(0.10).clipShape(Capsule()))
.padding(.horizontal, 5)
}
}
Any ideas how to fix this?
I managed to solve all the animation issues I had with the picker implementation that uses PreferenceKeys when the items have different frame sizes. This does not solve the issue I have with the zIndex and the .matchedGeometryEffect(), so I will not accept my own answer, but I'll post it as a reference in case anyone needs it in the future.
The code:
public struct PKPicker: View {
#Binding var selection: Int
#State private var frames: [CGRect] = []
let items: [String]
public init(
selection: Binding<Int>,
items: [String])
{
self._selection = selection
self._frames = State(wrappedValue: Array<CGRect>(repeating: CGRect(),
count: items.count))
self.items = items
}
public var body: some View {
ZStack(alignment: .topLeading) {
selector
HStack {
ForEach(items.indices) { index in
itemView(for: index)
}
}
}
.onPreferenceChange(PKPickerItemPreferenceKey.self) { preferences in
preferences.forEach { frames[$0.id] = $0.frame }
}
.coordinateSpace(name: "picker2")
}
var selector: some View {
Color.gray.opacity(0.25).clipShape(Capsule())
.frame(width: frames[selection].size.width,
height: frames[selection].size.height)
.offset(x: frames[selection].minX, y: frames[selection].minY)
}
func itemView(for index: Int) -> some View {
Text(items[index])
.fixedSize()
.padding(7)
.foregroundColor(isSelected(index) ? .black : .gray)
.font( .caption)
.onTapGesture { selection = index }
.background(PKPickerItemPreferenceSetter(id: index))
}
func isSelected(_ index: Int) -> Bool {
index == selection
}
}
struct PKPickerItemPreferenceData: Equatable {
let id: Int
let frame: CGRect
}
struct PKPickerItemPreferenceKey: PreferenceKey {
typealias Value = [PKPickerItemPreferenceData]
static var defaultValue: [PKPickerItemPreferenceData] = []
static func reduce(
value: inout [PKPickerItemPreferenceData],
nextValue: () -> [PKPickerItemPreferenceData])
{
value.append(contentsOf: nextValue())
}
}
struct PKPickerItemPreferenceSetter: View {
let id: Int
let coordinateSpace = CoordinateSpace.named("picker2")
var body: some View {
GeometryReader { geometry in
Color.clear
.preference(key: PKPickerItemPreferenceKey.self,
value: [PKPickerItemPreferenceData(
id: id, frame: geometry.frame(in: coordinateSpace))])
}
}
}
And in ContentView
struct ContentView: View {
#State private var selection = 0
let pickerItems = [ "Item 1", "Long item 2", "Item 3", "Item 4", "Long Item 5"]
var body: some View {
PKPicker(selection: $selection.animation(.easeInOut), items: pickerItems)
.padding(7)
.background(Color.gray.opacity(0.10).clipShape(Capsule()))
.padding(5)
}
Result:
I'm trying to do something that's pretty straight forward in my mind.
I want a subview of a VStack to dynamically change its height based on its content (ProblematicView in the sample below).
It usually works pretty well, but in this case ProblematicView contains a GeometryReader (to simulate a HStack over several lines).
However, the GeometryReader greedily takes all the space it can (the expected behavior happens if you remove the GeometryReader and it's content). Unfortunately on the Parent view (UmbrellaView in the sample below), the UmbrellaView VStack assigns 50% of itself to the ProblematicView instead of the minimal size to display the content of the view.
I've spend a few hours playing with min/ideal/maxHeight frame arguments, to no avail.
Is what I'm trying to achieve doable?
I added pictures at the bottom to clarify visually.
struct UmbrellaView: View {
var body: some View {
VStack(spacing: 0) {
ProblematicView()
.background(Color.blue)
ScrollView(.vertical) {
Group {
Text("A little bit about this").font(.system(size: 20))
Divider()
}
Group {
Text("some").font(.system(size: 20))
Divider()
}
Group {
Text("group").font(.system(size: 20)).padding(.bottom)
Divider()
}
Group {
Text("content").font(.system(size: 20))
}
}
}
}
}
struct ProblematicView: View {
var body: some View {
let tags: [String] = ["content", "content 2 ", "content 3"]
var width = CGFloat.zero
var height = CGFloat.zero
return VStack(alignment: .center) {
Text("Some reasonnably long text that changes dynamically do can be any size").background(Color.red)
GeometryReader { g in
ZStack(alignment: .topLeading) {
ForEach(tags, id: \.self) { tag in
TagView(content: tag, color: .red, action: {})
.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 == tags.last! {
width = 0 //last item
} else {
width -= d.width
}
return result
})
.alignmentGuide(.top, computeValue: {d in
let result = height
if tag == tags.last! {
height = 0 // last item
}
return result
})
}
}.background(Color.green)
}.background(Color.blue)
}.background(Color.gray)
}
}
struct TagView: View {
let content: String
let color: Color
let action: () -> Void?
var body: some View {
HStack {
Text(content).padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5))
Button(action: {}) {
Image(systemName: "xmark.circle").foregroundColor(Color.gray)
}.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 7))
}
.background(color)
.cornerRadius(8.0)
}
}
struct ProblematicView_Previews: PreviewProvider {
static var previews: some View {
return ProblematicView()
}
}
struct UmbrellaView_Previews: PreviewProvider {
static var previews: some View {
return UmbrellaView()
}
}
Due to "hen-egg" problem in nature of GeometryReader the solution for topic question is possible only in run-time, because 1) initial height is unknown 2) it needs to calculate internal size based on all available external size 3) it needs to tight external size to calculated internal size.
So here is possible approach (with some additional fixes in your code)
Preview 2-3) Run-time
Code:
struct ProblematicView: View {
#State private var totalHeight = CGFloat(100) // no matter - just for static Preview !!
#State private var tags: [String] = ["content", "content 2 ", "content 3", "content 4", "content 5"]
var body: some View {
var width = CGFloat.zero
var height = CGFloat.zero
return VStack {
Text("Some reasonnably long text that changes dynamically do can be any size").background(Color.red)
VStack { // << external container
GeometryReader { g in
ZStack(alignment: .topLeading) { // internal container
ForEach(self.tags, id: \.self) { tag in
TagView(content: tag, color: .red, action: {
// self.tags.removeLast() // << just for testing
})
.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(Color.green)
.background(GeometryReader {gp -> Color in
DispatchQueue.main.async {
// update on next cycle with calculated height of ZStack !!!
self.totalHeight = gp.size.height
}
return Color.clear
})
}.background(Color.blue)
}.frame(height: totalHeight)
}.background(Color.gray)
}
}
struct TagView: View {
let content: String
let color: Color
let action: (() -> Void)?
var body: some View {
HStack {
Text(content).padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5))
Button(action: action ?? {}) {
Image(systemName: "xmark.circle").foregroundColor(Color.gray)
}.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 7))
}
.background(color)
.cornerRadius(8.0)
}
}
Based on #Asperi's code I've implemented a universal solution. It works in Previews and is compatible with iOS 13+.
My solution does not use DispatchQueue.main.async and has a convenient #ViewBuilder for you to toss in any View you like. Put the VerticalFlow in VStack or ScrollView. Set hSpacing and vSpacing to items. Add padding to the whole View.
Simple example:
struct ContentView: View {
#State var items: [String] = ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight"]
var body: some View {
VerticalFlow(items: $items) { item in
Text(item)
}
}
}
VerticalFlow.swift:
import SwiftUI
struct VerticalFlow<Item, ItemView: View>: View {
#Binding var items: [Item]
var hSpacing: CGFloat = 20
var vSpacing: CGFloat = 10
#ViewBuilder var itemViewBuilder: (Item) -> ItemView
#SwiftUI.State private var size: CGSize = .zero
var body: some View {
var width: CGFloat = .zero
var height: CGFloat = .zero
VStack {
GeometryReader { geometryProxy in
ZStack(alignment: .topLeading) {
ForEach(items.indices, id: \.self) { i in
itemViewBuilder(items[i])
.alignmentGuide(.leading) { dimensions in
if abs(width - dimensions.width) > geometryProxy.size.width {
width = 0
height -= dimensions.height + vSpacing
}
let leadingOffset = width
if i == items.count - 1 {
width = 0
} else {
width -= dimensions.width + hSpacing
}
return leadingOffset
}
.alignmentGuide(.top) { dimensions in
let topOffset = height
if i == items.count - 1 {
height = 0
}
return topOffset
}
}
}
.readVerticalFlowSize(to: $size)
}
}
.frame(height: size.height > 0 ? size.height : nil)
}
}
struct VerticalFlow_Previews: PreviewProvider {
#SwiftUI.State static var items: [String] = [
"One 1", "Two 2", "Three 3", "Four 4", "Eleven 5", "Six 6",
"Seven 7", "Eight 8", "Nine 9", "Ten 10", "Eleven 11",
"ASDFGHJKLqwertyyuio d fadsf",
"Poiuytrewq lkjhgfdsa mnbvcxzI 0987654321"
]
static var previews: some View {
VStack {
Text("Text at the top")
VerticalFlow(items: $items) { item in
VerticalFlowItem(systemImage: "pencil.circle", title: item, isSelected: true)
}
Text("Text at the bottom")
}
ScrollView {
VStack {
Text("Text at the top")
VerticalFlow(items: $items) { item in
VerticalFlowItem(systemImage: "pencil.circle", title: item, isSelected: true)
}
Text("Text at the bottom")
}
}
}
}
private struct VerticalFlowItem: View {
let systemImage: String
let title: String
#SwiftUI.State var isSelected: Bool
var body: some View {
HStack {
Image(systemName: systemImage).font(.title3)
Text(title).font(.title3).lineLimit(1)
}
.padding(10)
.foregroundColor(isSelected ? .white : .blue)
.background(isSelected ? Color.blue : Color.white)
.cornerRadius(40)
.overlay(RoundedRectangle(cornerRadius: 40).stroke(Color.blue, lineWidth: 1.5))
.onTapGesture {
isSelected.toggle()
}
}
}
private extension View {
func readVerticalFlowSize(to size: Binding<CGSize>) -> some View {
background(GeometryReader { proxy in
Color.clear.preference(
key: VerticalFlowSizePreferenceKey.self,
value: proxy.size
)
})
.onPreferenceChange(VerticalFlowSizePreferenceKey.self) {
size.wrappedValue = $0
}
}
}
private struct VerticalFlowSizePreferenceKey: PreferenceKey {
static let defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
let next = nextValue()
if next != .zero {
value = next
}
}
}
I am experiencing the following animation of the text in the selected segment of Segmented Controls when the View is refreshed after changing some other data in the View:
Is this a bug/feature or is there a way to eliminate this behaviour?
This is the code to reproduce the effect:
import SwiftUI
struct ContentView: View {
let colorNames1 = ["Red", "Green", "Blue"]
#State private var color1 = 0
let colorNames2 = ["Yellow", "Purple", "Orange"]
#State private var color2 = 0
var body: some View {
VStack {
VStack {
Picker(selection: $color1, label: Text("Color")) {
ForEach(0..<3, id: \.self) { index in
Text(self.colorNames1[index]).tag(index)
}
}.pickerStyle(SegmentedPickerStyle())
Text("Color 1: \(color1)")
}
.padding()
VStack {
Picker(selection: $color2, label: Text("Color")) {
ForEach(0..<3, id: \.self) { index in
Text(self.colorNames2[index]).tag(index)
}
}.pickerStyle(SegmentedPickerStyle())
Text("Color 2: \(color2)")
}
.padding()
}
}
}
This was run under iOS 13.4 / Xcode 11.4
rearrange you code base ... (this helps SwiftUI to "refresh" only necessary Views)
import SwiftUI
struct ContentView: View {
let colorNames1 = ["Red", "Green", "Blue"]
#State private var color1 = 0
let colorNames2 = ["Yellow", "Purple", "Orange"]
#State private var color2 = 0
var body: some View {
VStack {
MyPicker(colorNames: colorNames1, color: $color1)
.padding()
MyPicker(colorNames: colorNames2, color: $color2)
.padding()
}
}
}
struct MyPicker: View {
let colorNames: [String]
#Binding var color: Int
var body: some View {
VStack {
Picker(selection: $color, label: Text("Color")) {
ForEach(0..<colorNames.count) { index in
Text(self.colorNames[index]).tag(index)
}
}.pickerStyle(SegmentedPickerStyle())
Text("Color 1: \(color)")
}
}
}
struct ContetView_Preview: PreviewProvider {
static var previews: some View {
ContentView()
}
}
result
I created a custom SegmentControl to solve this problem:
import SwiftUI
struct MyTextPreferenceKey: PreferenceKey {
typealias Value = [MyTextPreferenceData]
static var defaultValue: [MyTextPreferenceData] = []
static func reduce(value: inout [MyTextPreferenceData], nextValue: () -> [MyTextPreferenceData]) {
value.append(contentsOf: nextValue())
}
}
struct MyTextPreferenceData: Equatable {
let viewIndex: Int
let rect: CGRect
}
struct SegmentedControl : View {
#Binding var selectedIndex: Int
#Binding var rects: [CGRect]
#Binding var titles: [String]
var body: some View {
ZStack(alignment: .topLeading) {
SelectedView()
.frame(width: rects[selectedIndex].size.width - 4, height: rects[selectedIndex].size.height - 4)
.offset(x: rects[selectedIndex].minX + 2, y: rects[selectedIndex].minY + 2)
.animation(.easeInOut(duration: 0.5))
VStack {
self.addTitles()
}.onPreferenceChange(MyTextPreferenceKey.self) { preferences in
for p in preferences {
self.rects[p.viewIndex] = p.rect
}
}
}.background(Color(.red)).clipShape(Capsule()).coordinateSpace(name: "CustomSegmentedControl")
}
func totalSize() -> CGSize {
var totalSize: CGSize = .zero
for rect in rects {
totalSize.width += rect.width
totalSize.height = rect.height
}
return totalSize
}
func addTitles() -> some View {
HStack(alignment: .center, spacing: 8, content: {
ForEach(0..<titles.count) { index in
return SegmentView(selectedIndex: self.$selectedIndex, label: self.titles[index], index: index, isSelected: self.segmentIsSelected(selectedIndex: self.selectedIndex, segmentIndex: index))
}
})
}
func segmentIsSelected(selectedIndex: Int, segmentIndex: Int) -> Binding<Bool> {
return Binding(get: {
return selectedIndex == segmentIndex
}) { (value) in }
}
}
struct SegmentView: View {
#Binding var selectedIndex: Int
let label: String
let index: Int
#Binding var isSelected: Bool
var body: some View {
Text(label)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.foregroundColor(Color(.label))
.background(MyPreferenceViewSetter(index: index)).onTapGesture {
self.selectedIndex = self.index
}
}
}
struct MyPreferenceViewSetter: View {
let index: Int
var body: some View {
GeometryReader { geometry in
Rectangle()
.fill(Color.clear)
.preference(key: MyTextPreferenceKey.self,
value: [MyTextPreferenceData(viewIndex: self.index, rect: geometry.frame(in: .named("CustomSegmentedControl")))])
}
}
}
struct SelectedView: View {
var body: some View {
Capsule()
.fill(Color(.systemBackground))
.edgesIgnoringSafeArea(.horizontal)
}
}
result
I am trying to implement a tag list in SwiftUI but I'm unsure how to get it to wrap the tags to additional lines if the list overflows horizontally. I started with a string array called tags and within SwiftUI I loop through the array and create buttons as follows:
HStack{
ForEach(tags, id: \.self){tag in
Button(action: {}) {
HStack {
Text(tag)
Image(systemName: "xmark.circle")
}
}
.padding()
.foregroundColor(.white)
.background(Color.orange)
.cornerRadius(.infinity)
.lineLimit(1)
}
}
If the tags array is small it renders as follows:
However, if the array has more values it does this:
The behavior I am looking for is for the last tag (yellow) to wrap to the second line. I realize it is in an HStack, I was hoping I could add a call to lineLimit with a value of greater than one but it doesn't seem to change the behavior. If I change the outer HStack to a VStack, it puts each Button on a separate line, so still not quite the behavior I am trying create. Any guidance would be greatly appreciated.
Federico Zanetello shared a nice solution in his blog: Flexible layouts in SwiftUI.
The solution is a custom view called FlexibleView which computes the necessary Row's and HStack's to lay down the given elements and wrap them into multiple rows if needed.
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
}
}
Usage:
FlexibleView(
data: [
"Here’s", "to", "the", "crazy", "ones", "the", "misfits", "the", "rebels", "the", "troublemakers", "the", "round", "pegs", "in", "the", "square", "holes", "the", "ones", "who", "see", "things", "differently", "they’re", "not", "fond", "of", "rules"
],
spacing: 15,
alignment: .leading
) { item in
Text(verbatim: item)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.2))
)
}
.padding(.horizontal, model.padding)
}
Full code available at https://github.com/zntfdr/FiveStarsCodeSamples.
Ok, this is my first answer on this site, so bear with me if I commit some kind of stack overflow faux pas.
I'll post my solution, which works for a model where the tags are either present in a selectedTags set or not, and all available tags are present in an allTags set. In my solution, these are set as bindings, so they can be injected from elsewhere in the app. Also, my solution has the tags ordered alphabetically because that was easiest. If you want them ordered a different way, you'll probably need to use a different model than two independent sets.
This definitely won't work for everyone's use case, but since I couldn't find my own answer for this out there, and your question was the only place I could find mentioning the idea, I decided I would try to build something that would work for me and share it with you. Hope it helps:
struct TagList: View {
#Binding var allTags: Set<String>
#Binding var selectedTags: Set<String>
private var orderedTags: [String] { allTags.sorted() }
private func rowCounts(_ geometry: GeometryProxy) -> [Int] { TagList.rowCounts(tags: orderedTags, padding: 26, parentWidth: geometry.size.width) }
private func tag(rowCounts: [Int], rowIndex: Int, itemIndex: Int) -> String {
let sumOfPreviousRows = rowCounts.enumerated().reduce(0) { total, next in
if next.offset < rowIndex {
return total + next.element
} else {
return total
}
}
let orderedTagsIndex = sumOfPreviousRows + itemIndex
guard orderedTags.count > orderedTagsIndex else { return "[Unknown]" }
return orderedTags[orderedTagsIndex]
}
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading) {
ForEach(0 ..< self.rowCounts(geometry).count, id: \.self) { rowIndex in
HStack {
ForEach(0 ..< self.rowCounts(geometry)[rowIndex], id: \.self) { itemIndex in
TagButton(title: self.tag(rowCounts: self.rowCounts(geometry), rowIndex: rowIndex, itemIndex: itemIndex), selectedTags: self.$selectedTags)
}
Spacer()
}.padding(.vertical, 4)
}
Spacer()
}
}
}
}
struct TagList_Previews: PreviewProvider {
static var previews: some View {
TagList(allTags: .constant(["one", "two", "three"]), selectedTags: .constant(["two"]))
}
}
extension String {
func widthOfString(usingFont font: UIFont) -> CGFloat {
let fontAttributes = [NSAttributedString.Key.font: font]
let size = self.size(withAttributes: fontAttributes)
return size.width
}
}
extension TagList {
static func rowCounts(tags: [String], padding: CGFloat, parentWidth: CGFloat) -> [Int] {
let tagWidths = tags.map{$0.widthOfString(usingFont: UIFont.preferredFont(forTextStyle: .headline))}
var currentLineTotal: CGFloat = 0
var currentRowCount: Int = 0
var result: [Int] = []
for tagWidth in tagWidths {
let effectiveWidth = tagWidth + (2 * padding)
if currentLineTotal + effectiveWidth <= parentWidth {
currentLineTotal += effectiveWidth
currentRowCount += 1
guard result.count != 0 else { result.append(1); continue }
result[result.count - 1] = currentRowCount
} else {
currentLineTotal = effectiveWidth
currentRowCount = 1
result.append(1)
}
}
return result
}
}
struct TagButton: View {
let title: String
#Binding var selectedTags: Set<String>
private let vPad: CGFloat = 13
private let hPad: CGFloat = 22
private let radius: CGFloat = 24
var body: some View {
Button(action: {
if self.selectedTags.contains(self.title) {
self.selectedTags.remove(self.title)
} else {
self.selectedTags.insert(self.title)
}
}) {
if self.selectedTags.contains(self.title) {
HStack {
Text(title)
.font(.headline)
}
.padding(.vertical, vPad)
.padding(.horizontal, hPad)
.foregroundColor(.white)
.background(Color.blue)
.cornerRadius(radius)
.overlay(
RoundedRectangle(cornerRadius: radius)
.stroke(Color(UIColor.systemBackground), lineWidth: 1)
)
} else {
HStack {
Text(title)
.font(.headline)
.fontWeight(.light)
}
.padding(.vertical, vPad)
.padding(.horizontal, hPad)
.foregroundColor(.gray)
.overlay(
RoundedRectangle(cornerRadius: radius)
.stroke(Color.gray, lineWidth: 1)
)
}
}
}
}
I found this gist which once built, looks amazing! It did exactly what I needed for making and deleting tags. Here is a sample I built for a multi platform swift app from the code.
Tagger View
struct TaggerView: View {
#State var newTag = ""
#State var tags = ["example","hello world"]
#State var showingError = false
#State var errorString = "x" // Can't start empty or view will pop as size changes
var body: some View {
VStack(alignment: .leading) {
ErrorMessage(showingError: $showingError, errorString: $errorString)
TagEntry(newTag: $newTag, tags: $tags, showingError: $showingError, errorString: $errorString)
TagList(tags: $tags)
}
.padding()
.onChange(of: showingError, perform: { value in
if value {
// Hide the error message after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
showingError = false
}
}
})
}
}
ErrorMessage View
struct ErrorMessage: View {
#Binding var showingError: Bool
#Binding var errorString: String
var body: some View {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text(errorString)
.foregroundColor(.secondary)
.padding(.leading, -6)
}
.font(.caption)
.opacity(showingError ? 1 : 0)
.animation(.easeIn(duration: 0.3), value: showingError)
}
}
TagEntry View
struct TagEntry: View {
#Binding var newTag: String
#Binding var tags: [String]
#Binding var showingError: Bool
#Binding var errorString: String
var body: some View {
HStack {
TextField("Add Tags", text: $newTag, onCommit: {
addTag(newTag)
})
.textFieldStyle(RoundedBorderTextFieldStyle())
Spacer()
Image(systemName: "plus.circle")
.foregroundColor(.blue)
.onTapGesture {
addTag(newTag)
}
}
.onChange(of: newTag, perform: { value in
if value.contains(",") {
// Try to add the tag if user types a comma
newTag = value.replacingOccurrences(of: ",", with: "")
addTag(newTag)
}
})
}
/// Checks if the entered text is valid as a tag. Sets the error message if it isn't
private func tagIsValid(_ tag: String) -> Bool {
// Invalid tags:
// - empty strings
// - tags already in the tag array
let lowerTag = tag.lowercased()
if lowerTag == "" {
showError(.Empty)
return false
} else if tags.contains(lowerTag) {
showError(.Duplicate)
return false
} else {
return true
}
}
/// If the tag is valid, it is added to an array, otherwise the error message is shown
private func addTag(_ tag: String) {
if tagIsValid(tag) {
tags.append(newTag.lowercased())
newTag = ""
}
}
private func showError(_ code: ErrorCode) {
errorString = code.rawValue
showingError = true
}
enum ErrorCode: String {
case Empty = "Tag can't be empty"
case Duplicate = "Tag can't be a duplicate"
}
}
TagList View
struct TagList: View {
#Binding var tags: [String]
var body: some View {
GeometryReader { geo in
generateTags(in: geo)
.padding(.top)
}
}
/// Adds a tag view for each tag in the array. Populates from left to right and then on to new rows when too wide for the screen
private func generateTags(in geo: GeometryProxy) -> some View {
var width: CGFloat = 0
var height: CGFloat = 0
return ZStack(alignment: .topLeading) {
ForEach(tags, id: \.self) { tag in
Tag(tag: tag, tags: $tags)
.alignmentGuide(.leading, computeValue: { tagSize in
if (abs(width - tagSize.width) > geo.size.width) {
width = 0
height -= tagSize.height
}
let offset = width
if tag == tags.last ?? "" {
width = 0
} else {
width -= tagSize.width
}
return offset
})
.alignmentGuide(.top, computeValue: { tagSize in
let offset = height
if tag == tags.last ?? "" {
height = 0
}
return offset
})
}
}
}
}
Tag View
struct Tag: View {
var tag: String
#Binding var tags: [String]
#State var fontSize: CGFloat = 20.0
#State var iconSize: CGFloat = 20.0
var body: some View {
HStack {
Text(tag.lowercased())
.font(.system(size: fontSize, weight: .regular, design: .rounded))
.padding(.leading, 2)
Image(systemName: "xmark.circle.fill")
.symbolRenderingMode(.palette)
.foregroundStyle(.red, .blue, .white)
.font(.system(size: iconSize, weight: .black, design: .rounded))
.opacity(0.7)
.padding(.leading, -5)
}
.foregroundColor(.white)
.font(.caption2)
.padding(4)
.background(Color.blue.cornerRadius(5))
.padding(4)
.onTapGesture {
tags = tags.filter({ $0 != tag })
}
}
}
And finally…
Context View
import SwiftUI
struct ContentView: View {
var body: some View {
TaggerView()
}
}
I can’t take any credit for the code but let me send a huge thanks to Alex Hay for creating and posting this.
Link to the gist code on GitHub
I hope this helps someone.