Wheel Pickers in form disappear after some show/hide cycles - swiftui

I'm experiencing a strange behavior with wheel pickers embedded in a conditional subview. When the subview is shown, sometimes the values are not shown. Switching around between two conditional views, the values sometimes reappear. I've attached an animation showing the behavior and the full code. I can't find the reason for this.
Update: I've tried a lot of things to find the reason for this. Even after simplifying the revealing subform to just one single picker, replacing the foreach loop with hardcoded Text() entries, removing the framing and clipping on the picker, it still doesn't work.
import SwiftUI
fileprivate enum OpenSetting {
case none, start, end
}
struct ContentView: View {
#State private var openSetting = OpenSetting.none
#State private var startMinutes = 0
#State private var startSeconds = 10
#State private var endMinutes = 3
#State private var endSeconds = 0
var body: some View {
NavigationView {
Form {
// Start
TimeSetting(
title: "Start",
color: Color.yellow,
minutes: startMinutes,
seconds: startSeconds,
setting: .start,
openSetting: $openSetting
)
if openSetting == .start {
TimePicker(minutes: $startMinutes, seconds: $startSeconds)
}
// End
TimeSetting(
title: "End",
color: Color.green,
minutes: endMinutes,
seconds: endSeconds,
setting: .end,
openSetting: $openSetting
)
if openSetting == .end {
TimePicker(minutes: $endMinutes, seconds: $endSeconds)
}
}
.navigationBarTitle("Test")
.navigationBarItems(
trailing: Text("Start")
)
}
}
}
struct TimeSetting: View {
var title: String
var color: Color
var minutes: Int
var seconds: Int
fileprivate var setting: OpenSetting
fileprivate var openSetting: Binding<OpenSetting>
var body: some View {
HStack {
Text(title)
Spacer()
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(color)
Text(toTime(minutes: minutes, seconds: seconds))
}
.frame(width: 64, height: 32)
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation() {
self.openSetting.wrappedValue = (self.openSetting.wrappedValue == self.setting) ? OpenSetting.none : self.setting
}
}
}
func toTime(minutes: Int, seconds: Int) -> String {
let timeString = String(format: "%02d", minutes) + ":" + String(format: "%02d", seconds)
return timeString
}
}
struct TimePicker: View {
var minutes: Binding<Int>
var seconds: Binding<Int>
var body: some View {
HStack() {
Spacer()
Picker(selection: minutes, label: EmptyView()) {
ForEach((0...9), id: \.self) { ix in
Text("\(ix)").tag(ix)
}
}.pickerStyle(WheelPickerStyle()).frame(width: 50).clipped()
Text("Min.")
Picker(selection: seconds, label: EmptyView()) {
ForEach((0...59), id: \.self) { ix in
Text("\(ix)").tag(ix)
}
}.pickerStyle(WheelPickerStyle()).frame(width: 50).clipped()
Text("Sec.")
Spacer()
}
}
}

Using .id for Pickers seems helped. Tested with Xcode 11.2 / iOS 13.2.
var body: some View {
HStack() {
Spacer()
Picker(selection: minutes, label: EmptyView()) {
ForEach((0...9), id: \.self) { ix in
Text("\(ix)").tag(ix)
}
}.pickerStyle(WheelPickerStyle()).frame(width: 50).clipped()
.id(UUID().uuidString)
Text("Min.")
Picker(selection: seconds, label: EmptyView()) {
ForEach((0...59), id: \.self) { ix in
Text("\(ix)").tag(ix)
}
}.pickerStyle(WheelPickerStyle()).frame(width: 50).clipped()
.id(UUID().uuidString)
Text("Sec.")
Spacer()
}
}

You can solve it with this way:
if openSetting == .start {
TimePicker1(minutes: $startMinutes, seconds: $startSeconds)
}
.......
if openSetting == .end {
TimePicker1(minutes: $endMinutes, seconds: $endSeconds).id(1)
}
One with id, the other without id.

Related

Why Timer Change Will Trigger LazyVGrid View Update?

import SwiftUI
struct ContentView: View {
#State private var set = Set<Int>()
#State private var count = "10"
private let columns:[GridItem] = Array(repeating: .init(.flexible()), count: 3)
#State private var timer:Timer? = nil
#State private var time = 0
var body: some View {
VStack {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(Array(set)) { num in
Text(String(num))
}
}
}
.frame(width: 400, height: 400, alignment: .center)
HStack{
TextField("Create \(count) items", text: $count)
Button {
createSet(count: Int(count)!)
} label: {
Text("Create")
}
}
if let _ = timer {
Text(String(time))
.font(.title2)
.foregroundColor(.green)
}
HStack {
Button {
time = 100
let timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { _ in
time -= 10
if time == 0 {
self.timer?.invalidate()
self.timer = nil
}
}
self.timer = timer
} label: {
Text("Start Timer")
}
Button {
self.timer?.invalidate()
self.timer = nil
} label: {
Text("Stop Timer")
}
}
}
.padding()
}
private func createSet(count:Int) {
set.removeAll(keepingCapacity: true)
repeat {
let num = Int.random(in: 1...10000)
set.insert(num)
} while set.count < count
}
}
extension Int:Identifiable {
public var id:Self { self }
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I made a break point on Text(String(num)). Every time the timer was trigger, the GridView updated. Why this happened? As the model of grid didn't change.
Updated
If I put the timer in another view, the grid view wouldn't be trigger.
import SwiftUI
struct ContentView: View {
#State private var set = Set<Int>()
#State private var count = "10"
private let columns:[GridItem] = Array(repeating: .init(.flexible()), count: 3)
var body: some View {
VStack {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(Array(set)) { num in
Text(String(num))
}
}
}
.frame(width: 400, height: 400, alignment: .center)
HStack{
TextField("Create \(count) items", text: $count)
Button {
createSet(count: Int(count)!)
} label: {
Text("Create")
}
}
TimerView()
}
.padding()
}
private func createSet(count:Int) {
set.removeAll(keepingCapacity: true)
repeat {
let num = Int.random(in: 1...10000)
set.insert(num)
} while set.count < count
}
}
extension Int:Identifiable {
public var id:Self { self }
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
import SwiftUI
struct TimerView: View {
#State private var timer:Timer? = nil
#State private var time = 0
var body: some View {
if let _ = timer {
Text(String(time))
.font(.title2)
.foregroundColor(.green)
}
HStack {
Button {
time = 100
let timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { _ in
time -= 10
if time == 0 {
self.timer?.invalidate()
self.timer = nil
}
}
self.timer = timer
} label: {
Text("Start Timer")
}
Button {
self.timer?.invalidate()
self.timer = nil
} label: {
Text("Stop Timer")
}
}
}
}
struct TimerView_Previews: PreviewProvider {
static var previews: some View {
TimerView()
}
}
That´s pretty much how SwiftUI works. Every change to a #State var triggers the View to reevaluate. If you put your ForEach in another view it will only reevaluate if you change a var that changes that view. E.g. set or columns.
struct ExtractedView: View {
var columns: [GridItem]
var set: Set<Int>
var body: some View {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(Array(set)) { num in
Text(String(num))
}
}
}
.frame(width: 400, height: 400, alignment: .center)
}
}
It is encouraged in SwiftUI to make many small Views. The system driving this is pretty good in identifying what needs to be changed and what not. There is a very good WWDC video describing this.
WWDC

SwiftUI Binding Test for Button And Picker

I would like to make a test with SwiftUI binding.
Aim was changing Picker selection with a button and with an other Picker.
In following code ContentView2's Picker selection will be changed with button in ContentView and Picker in ContentView1.
Button can change ContentView2 picker selection but ContentView1 Picker does not.
I can not find the reason.
You can copy paste code to test.
import SwiftUI
struct ContentView: View {
#State private var index1 = 0
#State private var index2 = 1
var body: some View {
GeometryReader { mainView in
HStack {
Button(action: {
if index2 == 0 {
index2 = 1
} else {
index2 = 0
}
}) {
Text("Button")
}
ContentView1(pickerIndex: $index1)
ContentView2(pickerIndex: $index2)
}
}
}
}
struct ContentView1: View {
#State var pickerData = ["Data1", "Data2"]
#Binding var pickerIndex: Int
#State var pickerIndex2 = 0
var customLabel0: some View{
HStack {
VStack(spacing: 10) {
Text("Picker One")
.foregroundColor(.black)
Text(pickerData[pickerIndex])
.multilineTextAlignment(.center)
}
.foregroundColor(.white)
}
.frame(width: (UIScreen.main.bounds.width - (2.5 * 8 )) * 0.6 * 0.5, height: 100)
.background(Color.gray)
.cornerRadius(5)
}
#State private var testIndex = 1
var body: some View {
Menu {
Picker("", selection: self.$pickerIndex) {
ForEach(0..<pickerData.count, id: \.self) {index in
Text(pickerData[index])
}
}
} label: {
customLabel0
}
.onChange(of: pickerIndex, perform: {_ in
ContentView2(pickerIndex: $pickerIndex)
})
}
}
struct ContentView2: View {
#State var pickerData = ["Data1", "Data2"]
#Binding var pickerIndex: Int
var customLabel0: some View{
HStack {
VStack(spacing: 10) {
Text("Picker Two")
.foregroundColor(.black)
Text(pickerData[pickerIndex])
.multilineTextAlignment(.center)
}
.foregroundColor(.white)
}
.frame(width: (UIScreen.main.bounds.width - (2.5 * 8 )) * 0.6 * 0.5, height: 100)
.background(Color.gray)
.cornerRadius(5)
}
var body: some View {
Menu {
Picker("", selection: $pickerIndex) {
ForEach(0..<pickerData.count, id: \.self) {index in
Text(pickerData[index])
}
}
} label: {
customLabel0
}
.onChange(of: pickerIndex, perform: {_ in
})
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Custom SwiftUI view does not update when state changes

I have modified a custom 5 star rating view (https://swiftuirecipes.com/blog/star-rating-view-in-swiftui) to suite my needs. I use that view in several places in my app to display the current rating for a struct and to change the rating through a selectable list. The problem I have is that when I select a new value for the star rating through the NavigationLink, the underlying rating value changes, but the display does not. I have created a sample app that shows the problem and included it below.
//
// StarTestApp.swift
// StarTest
//
// Created by Ferdinand Rios on 12/20/21.
//
import SwiftUI
#main
struct StarTestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct StarHolder {
var rating: Double = 3.5
}
struct ContentView: View {
#State private var starHolder = StarHolder()
var body: some View {
NavigationView {
NavigationLink {
RatingView(starHolder: $starHolder)
} label: {
HStack {
Text("Rating: \(starHolder.rating, specifier: "%.1f")")
Spacer()
StarRatingView(rating: starHolder.rating, fontSize: 15)
}
.padding()
}
.navigationTitle("Test")
}
}
}
struct RatingView: View {
#Binding var starHolder: StarHolder
var body: some View {
List {
ForEach(0..<11, id: \.self) { index in
HStack {
StarRatingView(rating: Double(index) * 0.5, fontSize: 15)
Spacer()
Image(systemName: starHolder.rating == Double(index) * 0.5 ? "checkmark" : "")
}
.contentShape(Rectangle()) //allows to tap whole area
.onTapGesture {
starHolder.rating = Double(index) * 0.5
}
}
}
.navigationBarTitle(Text("Rating"))
}
}
struct StarRatingView: View {
private static let MAX_RATING: Double = 5 // Defines upper limit of the rating
private let RATING_COLOR = Color(UIColor(red: 1.0, green: 0.714, blue: 0.0, alpha: 1.0))
private let EMPTY_COLOR = Color(UIColor.lightGray)
private let fullCount: Int
private let emptyCount: Int
private let halfFullCount: Int
let rating: Double
let fontSize: Double
init(rating: Double, fontSize: Double) {
self.rating = rating
self.fontSize = fontSize
fullCount = Int(rating)
emptyCount = Int(StarRatingView.MAX_RATING - rating)
halfFullCount = (Double(fullCount + emptyCount) < StarRatingView.MAX_RATING) ? 1 : 0
}
var body: some View {
HStack (spacing: 0.5) {
ForEach(0..<fullCount) { _ in
self.fullStar
}
ForEach(0..<halfFullCount) { _ in
self.halfFullStar
}
ForEach(0..<emptyCount) { _ in
self.emptyStar
}
}
}
private var fullStar: some View {
Image(systemName: "star.fill").foregroundColor(RATING_COLOR)
.font(.system(size: fontSize))
}
private var halfFullStar: some View {
Image(systemName: "star.lefthalf.fill").foregroundColor(RATING_COLOR)
.font(.system(size: fontSize))
}
private var emptyStar: some View {
Image(systemName: "star").foregroundColor(EMPTY_COLOR)
.font(.system(size: fontSize))
}
}
If you run the app, the initial rating will be 3.5 and the stars will show the correct rating. When you select the stars, the RatingView will display with the correct rating checked. Select another rating and return to the ContentView. The text for the rating will update, but the star rating will still be the same as before.
Can anyone point me to what I am doing wrong here? I assume that the StarRatingView would refresh when the starHolder rating changes.
There are a couple of problems here. First, in your RatingView, you are passing a Binding<StarHolder>, but when you update the rating, the struct doesn't show as changed. To fix this, pass in a Binding<Double>, and the change will get noted in ContentView.
The second problem is that StarRatingView can't pick up on the change, so it needs some help. I just stuck an .id(starHolder.rating) onto StarRatingView in ContentView, and that signals to SwiftUI when the StarRatingView has changed so it is updated.
struct ContentView: View {
#State private var starHolder = StarHolder()
var body: some View {
NavigationView {
VStack {
NavigationLink {
RatingView(rating: $starHolder.rating)
} label: {
HStack {
Text("Rating: \(starHolder.rating, specifier: "%.1f")")
Spacer()
StarRatingView(rating: starHolder.rating, fontSize: 15)
.id(starHolder.rating)
}
.padding()
}
.navigationTitle("Test")
}
}
}
}
struct RatingView: View {
#Binding var rating: Double
var body: some View {
List {
ForEach(0..<11, id: \.self) { index in
HStack {
StarRatingView(rating: Double(index) * 0.5, fontSize: 15)
Spacer()
Image(systemName: rating == Double(index) * 0.5 ? "circle.fill" : "circle")
}
.contentShape(Rectangle()) //allows to tap whole area
.onTapGesture {
rating = Double(index) * 0.5
}
}
}
.navigationBarTitle(Text("Rating"))
}
}
One last thing. SwiftUI does not like the "" as an SF Symbol, so I changed the checks to "circle" and "circle.fill". Regardless, you should provide an actual image for both parts of the ternary. Or you could use a "check" and make .opacity() the ternary.
In your StarRatingView change the ForEach(0..<fullCount) {...} etc...
to ForEach(0..<fullCount, id: \.self) {...}.
Same for halfFullCount and emptyCount.
Works well for me.

Swiftui: How to reset a Bool State?

When I tap edit, it will show a delete button (minus icon). When the delete button tapped it will show the orange delete option (as the gif shows). Now I'm trying to reset the state back to the origin (from orange button back to video name and length ) when the Done button is tapped.
I'm trying few options like closures but nothing much.
Any help would be much appreciated!
My child view Video
struct Video: View {
var videoImage : String
var title : String
var duaration : Int
#Binding var deleteActivated : Bool
var body: some View {
HStack {
Image(videoImage)
...
if deleteActivated {
Button(action: {
}) {
ZStack {
Rectangle()
.foregroundColor(.orange)
.cornerRadius(radius: 10, corners: [.topRight, .bottomRight])
Text("Delete")
.foregroundColor(.white)
}
}
} else {
VStack(alignment: .leading){
....
My parent view VideosDirectory
struct VideosDirectory: View {
#State var videos:[DraftVideos] = [
DraftVideos(isSelected: true,title: "Superman workout", duration: 5, imageURL: "test"),
DraftVideos(isSelected: true,title: "Ironman workout", duration: 15, imageURL: "test1"),
DraftVideos(isSelected: true,title: "Ohman workout and long name", duration: 522, imageURL: "test2")
]
init() {
self._deleteActivated = State(initialValue: Array(repeating: false, count: videos.count))
}
#State private var deleteActivated: [Bool] = []
#State private var show = false
// #State private var editing = false
var body: some View {
// VStack {
NavigationView {
ScrollView(.vertical) {
VStack {
ForEach(videos.indices, id: \.self) { i in
HStack {
if self.show {
Button(action: {
withAnimation {
self.deleteActivated[i].toggle()
}
}) {
Image(systemName: "minus.circle.fill")
...
}
}
Video(videoImage: videos[i].imageURL, title: videos[i].title, duaration: videos[i].duration, deleteActivated: $deleteActivated[i])
}
}
}
.animation(.spring())
}
.navigationBarItems(trailing:
HStack {
Button(action: {
self.show.toggle()
}) {
if self.show {
Text("Done")
} else {
Text("Edit")
}
}
})
}
}
}
Provided code is not testable so just an idea:
Button(action: {
self.deleteActivated = Array(repeating: false, count: videos.count)
self.show.toggle()
}) {
or almost the same but as "post-action" in
}
.animation(.spring())
.onChange(of: self.show) { _ in
// most probably condition is not needed here, but is up to you
self.deleteActivated = Array(repeating: false, count: videos.count)
}

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.