matchedgeometryeffect doesn't work as expected - swiftui

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:

Related

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

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:

Crash due index out of range -- although I'm sure index is not out of range

I have a parent view whose child view is any given index of an array. The index of the array is scrolled through by tapping buttons that increment or decrement the index which is stored in a State property.
However when the view is first initialized I get a crash, even though the State's initial value is always 0.
What is going on?
Code can be copied and pasted to reproduce error
import SwiftUI
struct ContentView: View {
#State private var shouldShowQuotes = false
var body: some View {
ZStack {
Color.orange
VStack {
Button(action: showQuotes){
Text("Get Quotes").bold()
.frame(maxWidth: 300)
}
// .controlProminence(.increased) //Safe to uncomment if Xcode 13
// .buttonStyle(.bordered)
// .controlSize(.large)
}
.fullScreenCover(isPresented: $shouldShowQuotes) {
QuoteScreen()
}
}.ignoresSafeArea()
}
private func showQuotes() {
self.shouldShowQuotes.toggle()
}
}
struct QuoteScreen: View {
#State private var quoteIndex = 0
var currentQuote: Quote {
return dummyData[quoteIndex]
}
var body: some View {
ZStack {
Color.orange
VStack {
QuoteView(quote: currentQuote)
Spacer()
HStack {
Button(action: degress) {
Image(systemName: "arrow.left.square.fill")
.resizable()
.frame(width: 50, height: 50)
}
Spacer()
Button(action: progress) {
Image(systemName: "arrow.right.square.fill")
.resizable()
.frame(width: 50, height: 50)
}
}
.padding(28)
//.buttonStyle(.plain) Safe to uncomment if Xcode 13
}
}.ignoresSafeArea()
}
private func progress() {
quoteIndex += 1
}
private func degress() {
quoteIndex -= 1
}
}
struct QuoteView: View {
#State private var showQuotes = false
let quote: Quote
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25)
.stroke(lineWidth: 2)
VStack {
Text(quote.quote)
frame(maxWidth: 300)
Text(quote.author)
.frame(maxWidth: 300, alignment: .trailing)
.foregroundColor(.secondary)
}
}.navigationBarHidden(true)
.frame(height: 400)
.padding()
}
}
let dummyData = [Quote(quote: "The apple does not fall far from the tree", author: "Lincoln", index: 1),
Quote(quote: "Not everything that can be faced can be changed, but be sure that nothing can change until it is faced", author: "Unknown", index: 2),
Quote(quote: "Actions are but intentions", author: "Muhammad", index: 3)
]
struct Quote: Codable {
let quote: String
let author: String
let index: Int
}
When using arrays you always have to check that the element at the chosen index exist. This is how
I tested and modify your code to make it work.
(note: although this is just a test with dummyData, you need to decide if you want to scroll through the array index, or the Quote-index value, and adjust accordingly)
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
let dummyData = [
Quote(quote: "the index zero quote", author: "silly-billy", index: 0),
Quote(quote: "The apple does not fall far from the tree", author: "Lincoln", index: 1),
Quote(quote: "Not everything that can be faced can be changed, but be sure that nothing can change until it is faced", author: "Unknown", index: 2),
Quote(quote: "Actions are but intentions", author: "Muhammad", index: 3)
]
struct ContentView: View {
#State private var shouldShowQuotes = false
var body: some View {
ZStack {
Color.orange
VStack {
Button(action: showQuotes){
Text("Get Quotes").bold()
.frame(maxWidth: 300)
}
// .controlProminence(.increased) //Safe to uncomment if Xcode 13
// .buttonStyle(.bordered)
// .controlSize(.large)
}
.fullScreenCover(isPresented: $shouldShowQuotes) {
QuoteScreen()
}
}.ignoresSafeArea()
}
private func showQuotes() {
self.shouldShowQuotes.toggle()
}
}
struct QuoteScreen: View {
#State private var quoteIndex = 0
#State var currentQuote: Quote = dummyData[0] // <--- here, do not use "quoteIndex"
var body: some View {
ZStack {
Color.orange
VStack {
QuoteView(quote: $currentQuote) // <--- here
Spacer()
HStack {
Button(action: degress) {
Image(systemName: "arrow.left.square.fill")
.resizable()
.frame(width: 50, height: 50)
}
Spacer()
Button(action: progress) {
Image(systemName: "arrow.right.square.fill")
.resizable()
.frame(width: 50, height: 50)
}
}
.padding(28)
//.buttonStyle(.plain) Safe to uncomment if Xcode 13
}
}.ignoresSafeArea()
}
// you will have to adjust this to your needs
private func progress() {
let prevValue = quoteIndex
quoteIndex += 1
if let thisQuote = dummyData.first(where: { $0.index == quoteIndex}) { // <--- here
currentQuote = thisQuote
} else {
quoteIndex = prevValue
}
}
// you will have to adjust this to your needs
private func degress() {
let prevValue = quoteIndex
quoteIndex -= 1
if let thisQuote = dummyData.first(where: { $0.index == quoteIndex}) { // <--- here
currentQuote = thisQuote
} else {
quoteIndex = prevValue
}
}
}
struct QuoteView: View {
#State private var showQuotes = false
#Binding var quote: Quote // <--- here
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25)
.stroke(lineWidth: 2)
VStack {
Text(quote.quote)
.frame(maxWidth: 300) // <--- here missing leading "."
Text(quote.author)
.frame(maxWidth: 300, alignment: .trailing)
.foregroundColor(.secondary)
}
}.navigationBarHidden(true)
.frame(height: 400)
.padding()
}
}
struct Quote: Identifiable {
let id = UUID()
var quote: String
var author: String
var index: Int
}
This crash is not caused by the array access but by a typo in your code. You can see that if you run it in the simulator and look at the stack trace. It gets in an endless loop in the internals of SwiftUI. The reason is the missing dot before the frame modifier:
struct QuoteView: View {
#State private var showQuotes = false
let quote: Quote
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25)
.stroke(lineWidth: 2)
VStack {
Text(quote.quote)
frame(maxWidth: 300) << !!!!! missing dot
Text(quote.author)
.frame(maxWidth: 300, alignment: .trailing)
.foregroundColor(.secondary)
}
}.navigationBarHidden(true)
.frame(height: 400)
.padding()
}
}
This calls the frame method on the QuoteView and not on the Text - which is an invalid operation.

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

How to create a custom delete button without using the slide to delete that comes with swiftUI I am not using list, just using a foreach loop

Since, the onDelete and onMove are features of List/form I cannot use them when I have custom interfaces without them. I have used a VStack inside a ForEach. I am quite new to swiftUI and unsure on how I can implement custom code for onDelete and onMove.
Here's my code:
struct Trying: View {
#State private var numbers = [0,1,2,3,4,5,6,7,8,9]
var body: some View {
NavigationView {
VStack (spacing: 10) {
ForEach(numbers, id: \.self) { number in
VStack {
Text("\(number)")
}
.frame(width: 50, height: 50)
.background(Color.red)
}.onDelete(perform: removeRows)
}
.navigationTitle("Trying")
.navigationBarItems(trailing: EditButton())
}
}
func removeRows(at offsets: IndexSet) {
numbers.remove(atOffsets: offsets)
}
}
The way it works right now:
Here is a simple demo of possible approach to implement custom delete (of course with move it would be more complicated due to drag/drop, but idea is the same). Tested with Xcode 12 / iOS 14.
struct DemoCustomDelete: View {
#State private var numbers = [0,1,2,3,4,5,6,7,8,9]
var body: some View {
NavigationView {
VStack (spacing: 10) {
ForEach(numbers, id: \.self) { number in
VStack {
Text("\(number)")
}
.frame(width: 50, height: 50)
.background(Color.red)
.overlay(
DeleteButton(number: number, numbers: $numbers, onDelete: removeRows)
, alignment: .topTrailing)
}.onDelete(perform: removeRows)
}
.navigationTitle("Trying")
.navigationBarItems(trailing: EditButton())
}
}
func removeRows(at offsets: IndexSet) {
withAnimation {
numbers.remove(atOffsets: offsets)
}
}
}
struct DeleteButton: View {
#Environment(\.editMode) var editMode
let number: Int
#Binding var numbers: [Int]
let onDelete: (IndexSet) -> ()
var body: some View {
VStack {
if self.editMode?.wrappedValue == .active {
Button(action: {
if let index = numbers.firstIndex(of: number) {
self.onDelete(IndexSet(integer: index))
}
}) {
Image(systemName: "minus.circle")
}
.offset(x: 10, y: -10)
}
}
}
}
Based on #Asperi's answer, I just generalized it to accept any Equatable sequence.
struct DeleteButton<T>: View where T: Equatable {
#Environment(\.editMode) var editMode
let number: T
#Binding var numbers: [T]
let onDelete: (IndexSet) -> ()
var body: some View {
VStack {
if self.editMode?.wrappedValue == .active {
Button(action: {
if let index = numbers.firstIndex(of: number) {
self.onDelete(IndexSet(integer: index))
}
}) {
Image(systemName: "minus.circle")
}
.offset(x: 10, y: -10)
}
}
}
}
I recently had the need to delete a row and I couldn't use a LIST. Instead I had a scroll view... But I was able to implement the edit to simulate the same onDelete behavior as if it was a list. Initially I couldn't get my code to work. It wasn't until I closely examined my implementation and experimented that I stumbled on why mine worked. I'm coding for an iPad so my NavigationView uses,
.navigationViewStyle(StackNavigationViewStyle())
Once I added this to the struct's NavigationView, when you click on the EditButton it activates editMode?.wrappedValue to .active / .inactive
Below is my implementation for the code sample above...
struct Trying: View {
#State var num: Int = 0
#Environment(\.editMode) var editMode
#State private var numbers = [0,1,2,3,4,5,6,7,8,9]
var body: some View {
NavigationView {
VStack {
ForEach(numbers, id: \.self) { number in
HStack {
if editMode?.wrappedValue == .active {
Button(action: { num = number
removeRows(numbr: num)
}, label: {
Image(systemName: "minus.circle.fill")
.foregroundColor(.red)
})
} // END IF editMode?wrappedValue == .active
Text("\(number)")
.frame(width: 50, height: 50)
.background(Color.red)
}
}
// .onDelete(perform: removeRows)
}
.navigationTitle("Trying")
.navigationBarItems(trailing: EditButton())
}
// FOR SOME REASON THIS ALLOWS THE EditButton() to activate editMode without a LIST being present.
.navigationViewStyle(StackNavigationViewStyle())
}
func removeRows(numbr: Int) {
print("removing \(numbr)")
}
}
It looks like:

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.