Hero animation not working in List when setting row's id dynamically - swiftui

Recently ran into an issue trying to perform Hero animation using matchedGeometryEffect in SwiftUI. My issue is that setting id for matchedGeometryEffect effect dynamically isn't working as expected.
This is what I have so far:
import SwiftUI
struct HeroAnimationTest: View {
let items: [Item] = [.init(id: 1), .init(id: 2), .init(id: 3), .init(id: 4)]
#State var selectedItemInRowIndex: Int? = nil
#Namespace var namespace
var body: some View {
List {
ForEach(items, id: \.id) { item in
ItemListRow(namespace: namespace, item: item) { tappedItem in
withAnimation {
selectedItemInRowIndex = tappedItem.id
}
}
}
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(h: 16, v: 8))
}
.animation(.spring(), value: selectedItemInRowIndex)
.scrollIndicators(.never)
.listStyle(.plain)
.overlay {
if selectedItemInRowIndex != nil {
largeGreen
}
}
}
var largeGreen: some View {
ZStack {
Color.black
.onTapGesture {
withAnimation {
selectedItemInRowIndex = nil
}
}
Color.green
.frame(width: 200, height: 400)
.matchedGeometryEffect(id: selectedItemInRowIndex, in: namespace)
Text("ID -> \(selectedItemInRowIndex ?? 0)")
}
}
}
struct HeroAnimationTest_Previews: PreviewProvider {
static var previews: some View {
HeroAnimationTest()
}
}
struct Item {
let id: Int
}
struct ItemListRow: View {
#State var enlargeElement = false
let namespace: Namespace.ID
let item: Item
let onGreenTap: (Item) -> Void
var body: some View {
HStack {
Text("ID -> \(item.id)")
VStack {
Color.green
}
.frame(width: 100, height: 40)
.matchedGeometryEffect(id: item.id, in: namespace)
.onTapGesture {
onGreenTap(item)
}
VStack {
Color.yellow
}
.frame(width: 100, height: 40)
}
}
}
Current result:
I tried to hard-code id for largeGreen inside .matchedGeometryEffect(id: 3, in: namespace) to check if animation would work, and it does:
Animation with hard-coded id is the expected result, but obviously it's only working for the 3rd row. Is it even possible to achieve this effect for a green container in every row?
I'd really appreciate if anyone could take a look and give me some hint of what I'm missing here. I've been looking at this for a few hours now, but still can't figure out what went wrong.

Okay, I finally figured it out. It turns out that in order for matchedGeometryEffect to work, its id needs to be unwrapped, assigning optional just won't work.
So, what I did was:
Extracted largeGreen to a ExpandedLargeGreen with selectedItemIdx &
show parameters with #Binding property wrapper, and namespace.
And then inside .overlay closure within HeroAnimationTest unwrapped
optional Binding selectedItemInRowIndex. In case it's not nil and
show is set to true ExpandedLargeGreen will be displayed with
proper animation.
Also set animation value to show instead of selectedItemInRowIndex.
Full code:
struct HeroAnimationTest: View {
let items: [Item] = [.init(id: 1), .init(id: 2), .init(id: 3), .init(id: 4)]
#State var selectedItemInRowIndex: Int? = nil
#State var show = false
#Namespace var namespace
var body: some View {
ZStack {
List {
ForEach(items, id: \.id) { item in
ItemListRow(namespace: namespace, item: item) { tappedItem in
withAnimation {
selectedItemInRowIndex = tappedItem.id
show.toggle()
}
}
}
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(h: 16, v: 8))
}
.animation(.spring(), value: show)
.scrollIndicators(.never)
.listStyle(.plain)
}
.overlay {
if let selectedIdx = Binding($selectedItemInRowIndex), show {
ExpandedLargeGreen(selectedItemIdx: selectedIdx, show: $show, namespace: namespace)
}
}
}
}
struct ExpandedLargeGreen: View {
#Binding var selectedItemIdx: Int
#Binding var show: Bool
var namespace: Namespace.ID
var body: some View {
ZStack {
Color.black
.onTapGesture {
withAnimation {
show.toggle()
}
}
Color.green
.frame(width: 200, height: 400)
.matchedGeometryEffect(id: selectedItemIdx, in: namespace)
Text("ID -> \(selectedItemIdx)")
}
}
}
struct HeroAnimationTest_Previews: PreviewProvider {
static var previews: some View {
HeroAnimationTest()
}
}
struct Item {
let id: Int
}
struct ItemListRow: View {
#State var enlargeElement = false
let namespace: Namespace.ID
let item: Item
let onGreenTap: (Item) -> Void
var body: some View {
HStack {
Text("ID -> \(item.id)")
VStack {
Color.green
}
.frame(width: 100, height: 40)
.matchedGeometryEffect(id: item.id, in: namespace)
.onTapGesture {
onGreenTap(item)
}
VStack {
Color.yellow
}
.frame(width: 100, height: 40)
}
}
}
Final result:

Related

matchedgeometryeffect doesn't work as expected

What do I want : Have a smooth animation between two views ( stack of cards and list of cards) so I used matchedgeometryeffect in SwiftUI but there is something wrong with my code that I couldn't fix.
I used same ID for two views also determine the isSource property.
struct ContentView: View {
#State var showCardByList = false
#Namespace var namespace
#State var cards : [Card] = [
Card(color: .green),
Card(color: .blue),
Card(color: .pink),
]
var body: some View {
VStack {
Text("Change")
.onTapGesture {
withAnimation(.spring()) {
showCardByList.toggle()
}
}
ZStack(alignment: .top) {
if showCardByList {
List(cards: $cards, namespace: namespace)
} else {
Stack(cards: $cards, namespace: namespace)
.padding(.top,150)
}
}
}
.frame(maxWidth: .infinity,maxHeight: .infinity,alignment: .top)
}
}
struct Card : Identifiable {
var id = UUID()
var color: Color
}
Stack view:
struct Stack: View {
#Binding var cards : [Card]
let namespace: Namespace.ID
func offset(index : Int) -> CGFloat {
switch index {
case 0:
return -50
case 1:
return -80
case 2:
return -110
default:
return 0
}
}
func getIndex(card : Card) -> Int {
if let index = cards.firstIndex(where: { $0.id == card.id }) {
return index
}
return 0
}
var body: some View {
VStack {
ZStack {
ForEach(cards,id: \.id) { card in
ZStack {
card.color.cornerRadius(20)
.matchedGeometryEffect(id: card.id, in: namespace,isSource: true)
}
.offset(y: offset(index: getIndex(card: card)))
.frame(width: 350, height: 210, alignment: .center)
}
}
}
}
}
List View:
struct List: View {
#Binding var cards : [Card]
let namespace: Namespace.ID
var body: some View {
VStack {
ScrollView {
VStack(spacing: 50) {
ForEach(cards,id: \.id) { card in
ZStack {
card.color.cornerRadius(20)
.matchedGeometryEffect(id: card.id, in: namespace,isSource: false)
}
.frame(width: 350, height: 100, alignment: .center)
}
}
}
}
}
}
Mark both of your .matchedGeometryEffects with isSource: true. The two views are never on the screen at the same time, and each serves as the source when switching to the other.
Change:
.matchedGeometryEffect(id: card.id, in: namespace,isSource: false)
to:
.matchedGeometryEffect(id: card.id, in: namespace,isSource: true)
To address the fading in/out of the views, add .transition(.slide) to the two views:
if showCardByList {
List(cards: $cards, namespace: namespace)
.transition(.slide)
} else {
Stack(cards: $cards, namespace: namespace)
.transition(.slide)
.padding(.top,150)
}
Note: The documentation doesn't say that you can't have two views marked with isSource: true, just that you shouldn't have two such views on screen at the same time.
Here it is running in the simulator:

Creating a Vertical Stack of HStacks with text and sliders swiftUI

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])
}
}
}
}

SwiftUI: cannot delete Row in List

i have a small swiftUI programm in Xcode which let me create and delete Users in a list with a stepper to count points of the users.
everything works fine (adding users, renaming users, stepper counting) unless the deletion of the user.
it throws an error:
Fatal error: Index out of range: file
/Library/Caches/com.apple.xbs/Sources/swiftlang/swiftlang-1103.2.25.8/swift/stdlib/public/core/ContiguousArrayBuffer.swift,
line 444 2020-05-23 12:06:22.854920+0200 Counter[21328:1125981] Fatal
error: Index out of range: file
/Library/Caches/com.apple.xbs/Sources/swiftlang/swiftlang-1103.2.25.8/swift/stdlib/public/core/ContiguousArrayBuffer.swift,
line 444
Here is the code:
import SwiftUI
struct ContentView : View {
#State var isEditing = false
#State var stepperWerte = [3, 5, 7, 9]
#State var editText = ["Player 1", "Player 2", "Player 3", "Player 4"]
var startName = "new Player"
var startLeben = 5
var body: some View {
NavigationView {
List() {
ForEach(0..<editText.count, id: \.self) {
spieler in HStack {
if self.editText.indices.contains(spieler) {
Stepper(value: self.$stepperWerte[spieler], in: -1...10, step: 1, label: {
TextField("", text: self.$editText[spieler], onEditingChanged: {_ in }, onCommit: {self.saveText(id: spieler, Text: self.editText[spieler])} )
.layoutPriority(1)
.fixedSize(horizontal: true, vertical: false)
Text("\(self.stepperWerte[spieler]) - \(spieler) - \(self.editText.count)")})
}
}
}
.onDelete(perform: spielerLoeschen)
.frame(width: nil, height: nil, alignment: .trailing)
}
.navigationBarTitle(Text("Nav_Title"))
.navigationBarItems(leading: Button(action: { self.isEditing.toggle() }) { Text(isEditing ? "Done" : "Edit").frame(width: 85, height: 40, alignment: .leading) },
trailing: Button(action: spielerHinzufuegen, label: { Image(systemName: "plus") }) )
.environment(\.editMode, .constant(self.isEditing ? EditMode.active : EditMode.inactive)).animation(Animation.spring())
}
}
func spielerLoeschen(at offsets: IndexSet) {
stepperWerte.remove(atOffsets: offsets)
editText.remove(atOffsets: offsets)
}
func spielerHinzufuegen() {
stepperWerte.append(startLeben)
editText.append(startName)
}
func saveText(id: Int, Text: String) {
editText[id] = Text
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
(ignore the "if" after the HStack, it has no real effect and those extra prints in the last Text to show the index and the total count)
if i dump the arrays (stepperWerte and editText) they are removed the right way -> the player selected for deletion will be removed correctly from the two arrays.
if i change
TextField("", text: self.$editText[spieler]
to
TextField("", text: self.$editText[0]
it works (unless naturally it displays the first player in all rows and i got the same error after deleting all the players (=rows))
any help would be great - thank you!
According to #Asperi i have changed my code to the following:
import SwiftUI
struct BetterTextField : View {
var container: Binding<[String]>
var index: Int
#State var text: String
var body: some View {
TextField("", text: self.$text, onCommit: {
self.container.wrappedValue[self.index] = self.text
})
.layoutPriority(1)
.fixedSize(horizontal: true, vertical: false)
}
}
struct ContentView : View {
#State var isEditing = false
#State var stepperWerte = [3, 5, 7, 9]
#State var editText = ["Player 1", "Player 2", "Player 3", "Player 4"]
var startName = "new Player"
var startLeben = 5
var body: some View {
NavigationView {
List() {
ForEach(0..<editText.count, id: \.self) {
spieler in HStack {
if self.editText.indices.contains(spieler) {
Stepper(value: self.$stepperWerte[spieler], in: -1...10, step: 1, label: {
BetterTextField(container: self.$editText, index: self.editText.firstIndex(of: self.editText[spieler])!, text: self.editText[spieler])
Text("\(self.stepperWerte[spieler]) - \(spieler) - \(self.editText.count)")})
}
}
}
.onDelete(perform: spielerLoeschen)
.frame(width: nil, height: nil, alignment: .trailing)
}
.navigationBarTitle(Text("Nav_Title"))
.navigationBarItems(leading: Button(action: { self.isEditing.toggle() }) { Text(isEditing ? "Done" : "Edit").frame(width: 85, height: 40, alignment: .leading) },
trailing: Button(action: spielerHinzufuegen, label: { Image(systemName: "plus") }) )
.environment(\.editMode, .constant(self.isEditing ? EditMode.active : EditMode.inactive)).animation(Animation.spring())
}
}
func spielerLoeschen(at offsets: IndexSet) {
stepperWerte.remove(atOffsets: offsets)
editText.remove(atOffsets: offsets)
}
func spielerHinzufuegen() {
stepperWerte.append(startLeben)
editText.append(startName)
}
func saveText(id: Int, Text: String) {
editText[id] = Text
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
... and it works - thank you!
but:
is this a bug in SwiftUI or intentional?
The problem is that you are not using your items directly in the ForEach loop. Consider using structs for your data as objects and make them identifiable.
struct Player : Identifiable {
let id = UUID()
var stepperWerte: Int
var editText : String
}
struct ContentView : View {
#State var isEditing = false
#State var players = [Player(stepperWerte: 3, editText: "Player 1"), Player(stepperWerte: 5, editText: "Player 2"), Player(stepperWerte: 7, editText: "Player 3"), Player(stepperWerte: 9, editText: "Player 4")]
var startName = "new Player"
var startLeben = 5
var body: some View {
NavigationView {
List() {
ForEach(self.players) { player in
SecondView(player: player)
}
.onDelete(perform: spielerLoeschen)
}
.frame(width: nil, height: nil, alignment: .trailing)
.navigationBarTitle(Text("Nav_Title"))
.navigationBarItems(leading: Button(action: { self.isEditing.toggle() }) { Text(isEditing ? "Done" : "Edit").frame(width: 85, height: 40, alignment: .leading) },
trailing: Button(action: spielerHinzufuegen, label: { Image(systemName: "plus") }) )
.environment(\.editMode, .constant(self.isEditing ? EditMode.active : EditMode.inactive)).animation(Animation.spring())
}
}
func spielerLoeschen(at offsets: IndexSet) {
players.remove(atOffsets: offsets)
}
func spielerHinzufuegen() {
players.insert(Player(stepperWerte: 4, editText: "Neuer Player"), at: 0)
}
}
struct SecondView : View {
var player : Player
#State var stepperWerte : Int
#State var name : String
init(player : Player)
{
self._stepperWerte = State(initialValue: player.stepperWerte)
self._name = State(initialValue: player.editText)
self.player = player
}
var body: some View
{
Stepper(value: self.$stepperWerte, in: -1...10, step: 1, label: {
TextField("", text: self.$name)
.layoutPriority(1)
.fixedSize(horizontal: true, vertical: false)
Text("\(player.stepperWerte)")
})
}
}
I created a struct Player, and then an array of many Players. In the ForEach you can directly use players as Player confirms to Identifiable protocol. This is way easier as you can access a player object in your ForEach loop and you do not have to access everything with indices. In the deleting function you just delete the object out of the array or add something new to it. Deleting now works fine.
I have removed some code from the list row, just to reproduce it easier, just if you are wondering.

Dynamically size a GeometryReader height based on its elements

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
}
}
}

Implementing a tag list in SwiftUI

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.