SwiftUI Nested ForEach should only be used for *constant* data - swiftui

I have created a view with a nested ForEach loop and I am getting the following error in the logs:
> count (2) != its initial count (1). `ForEach(_:content:)` should only
> be used for *constant* data. Instead conform data to `Identifiable` or
> use `ForEach(_:id:content:)` and provide an explicit `id`!
I have made the structs conform to identifiable which doesn't seem to reslove the error.
How can I resolve the error?
import SwiftUI
struct LessonStruct: Identifiable {
var id: Int
var name: String
var questions: [QuestionStruct]
}
struct QuestionStruct: Identifiable {
var id: Int
var type: QuestionType
var description: String
var questionId: Int
}
class LessonsObject: ObservableObject {
#Published var Array = [LessonStruct]()
#Published var selectedLesson: Int?
}
struct CreateLessonList: View {
#EnvironmentObject var lesson: LessonsObject
#Binding var showingActionSheet: Bool
var rowNumber:Int = 0
var body: some View {
VStack{
ForEach(self.lesson.Array) { each in
VStack(spacing: 0){
HStack(){
TextField("Lesson \(each.id) - click to change name", text: self.$lesson.Array[each.id-1].name).font(.custom("Proxima Nova Alt Bold", size: 20)).foregroundColor(Color.white).padding(.leading, 10)
Spacer()
Image("PlusIcon").resizable().frame(width: 20, height: 20).padding(.trailing, 10).onTapGesture {
self.lesson.selectedLesson = each.id - 1
self.showingActionSheet = true
}
}.frame( height: 40).background(Color.init("Orange"))
if(self.lesson.Array[each.id - 1].questions.count == 0){
VStack{
Text("Click the '+' icon to add a question.").font(.custom("Proxima Nova Alt Thin", size: 15))
}.frame(height: 70)
}else{
ForEach(self.lesson.Array[each.id - 1].questions.indices){ i in
if(self.lesson.Array[each.id - 1].questions[i].type == .Translation){
Group{
HStack{
Image("TranslationIcon").resizable().frame(width: 50, height:65)
Text(self.lesson.Array[each.id - 1].questions[i].description)
Spacer()
}.padding(20)
}
}
}
}
}
}.background(Color.init("White"))
}
}
}

Use internal ForEach as follows
ForEach(self.lesson.Array[each.id - 1].questions){ question in
if(question.type == .Translation){
Group{
HStack{
Image("TranslationIcon").resizable().frame(width: 50, height:65)
Text(question.description)
Spacer()
}.padding(20)
}
}
}
and review your LessonStruct.id construction logic, cause it looks error prone to refer to [.id - 1].

Related

SwiftUI - Subtotal TextField entries across multiple views

I have multiple views created by a ForEACH. Each View has a textfield where a user can enter a number. I would like to subtotal each entry in each view. In other words subtotal the binding in each view.
Is my approach wrong?
ForEach(someArray.allCases, id: \.id) { item in
CustomeRowView(name: item.rawValue)
}
struct CustomeRowView: View {
var name: String
#State private var amount: String = ""
var body: some View {
VStack {
HStack {
Label(name, systemImage: image)
VStack {
TextField("Amount", text: $amount)
.frame(width: UIScreen.main.bounds.width / 7)
}
}
}
}
}
Any help would be greatly appreciated.
there are many ways to achieve what you ask. I present here a very
simple approach, using an ObservableObject to keep the info in one place.
It has a function to add to the info dictionary fruits.
A #StateObject is created in ContentView to keep one single source of truth.
It is passed to the CustomeRowView view using #ObservedObject, and used to tally
the input of the TextField when the return key is pressed (.onSubmit).
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class FruitCake: ObservableObject {
#Published var fruits: [String : Int] = ["apples":0,"oranges":0,"bananas":0]
// adjust for you purpose
func add(to name: String, amount: Int) {
if let k = fruits.keys.first(where: {$0 == name}),
let sum = fruits[k] {
fruits[k] = sum + amount
}
}
}
struct ContentView: View {
#StateObject var fruitCake = FruitCake()
var body: some View {
VStack {
ForEach(Array(fruitCake.fruits.keys), id: \.self) { item in
CustomeRowView(name: item, fruitCake: fruitCake)
}
}
}
}
struct CustomeRowView: View {
let name: String
#ObservedObject var fruitCake: FruitCake
#State private var amount = 0
var body: some View {
HStack {
Label(name, systemImage: "info")
TextField("Amount", value: $amount, format: .number)
.frame(width: UIScreen.main.bounds.width / 7)
.border(.red)
.onSubmit {
fruitCake.add(to: name, amount: amount)
}
// subtotal
Text("\(fruitCake.fruits[name] ?? 0)")
}
}
}

Same ForEach loop twice in one SwiftUI View

When I use a ForEach loop over an array twice within a view, I get the following warning at runtime:
LazyVGridLayout: the ID 84308994-9D16-48D2-975E-DC40C5F9EFFF is used by multiple child views, this will give undefined results!
The reason for this is clear so far, but what is the smartest way to work around this problem?
The following sample code illustrates the problem:
import SwiftUI
// MARK: - Model
class Data: ObservableObject
{
#Published var items: [Item] = [Item(), Item(), Item()]
}
struct Item: Identifiable
{
let id = UUID()
var name: String = ""
var description: String = ""
}
// MARK: - View
struct MainView: View {
#StateObject var data: Data
private var gridItems: [GridItem] { Array(repeating: GridItem(), count: data.items.count) }
var body: some View {
LazyVGrid(columns: gridItems, alignment: .leading, spacing: 2) {
ForEach(data.items) { item in
Text(item.name)
}
ForEach(data.items) { item in
Text(item.description)
}
}
}
}
// MARK: - App
#main
struct SwiftUI_TestApp: App {
var body: some Scene {
WindowGroup {
MainView(data: Data())
}
}
}
I could possibly divide the view into several SubViews.
Are there any other options?
Edit:
This is the body of the real app:
var body: some View {
VStack {
LazyVGrid(columns: columns, alignment: .leading, spacing: 2) {
Text("")
ForEach($runde.players) { $player in
PlayerHeader(player: $player)
}
ForEach(Score.Index.allCases) { index in
Text(index.localizedName)
ForEach(runde.players) { player in
Cell(player: player, active: player == runde.activePlayer, index: index)
}
}
Text ("")
ForEach(runde.players) { player in
PlaceView(player: player)
}
}
.padding()
}
}
If you really need that kind of grid filling, then it is possible just to use different identifiers for those ForEach containers, like
LazyVGrid(columns: gridItems, alignment: .leading, spacing: 2) {
ForEach(data.items) { item in
Text(item.name).id("\(item.id)-1") // << here !!
}
ForEach(data.items) { item in
Text(item.description).id("\(item.id)-2") // << here !!
}
}
Tested with Xcode 13beta / iOS 15
While adding identifiers within the ForEach loop sometimes works, I found that accessing the indexes from the loop with indices worked in other cases:
ForEach(items.indices, id: \.self) { i in
Text(items[i])
}

SwiftUI List single selectable item

I'm trying to create a List and allow only one item to be selected at a time. How would I do so in a ForEach loop? I can select multiple items just fine, but the end goal is to have only one checkmark in the selected item in the List. It may not even be the proper way to handle what I'm attempting.
struct ContentView: View {
var body: some View {
NavigationView {
List((1 ..< 4).indices, id: \.self) { index in
CheckmarkView(index: index)
.padding(.all, 3)
}
.listStyle(PlainListStyle())
.navigationBarTitleDisplayMode(.inline)
//.environment(\.editMode, .constant(.active))
}
}
}
struct CheckmarkView: View {
let index: Int
#State var check: Bool = false
var body: some View {
Button(action: {
check.toggle()
}) {
HStack {
Image("Image-\(index)")
.resizable()
.frame(width: 70, height: 70)
.cornerRadius(13.5)
Text("Example-\(index)")
Spacer()
if check {
Image(systemName: "checkmark")
.resizable()
.frame(width: 12, height: 12)
}
}
}
}
}
You'll need something to store all of the states instead of storing it per-checkmark view, because of the requirement to just have one thing checked at a time. I made a little example where the logic is handled in an ObservableObject and passed to the checkmark views through a custom Binding that handles checking/unchecking states:
struct CheckmarkModel {
var id = UUID()
var state = false
}
class StateManager : ObservableObject {
#Published var checkmarks = [CheckmarkModel(), CheckmarkModel(), CheckmarkModel(), CheckmarkModel()]
func singularBinding(forIndex index: Int) -> Binding<Bool> {
Binding<Bool> { () -> Bool in
self.checkmarks[index].state
} set: { (newValue) in
self.checkmarks = self.checkmarks.enumerated().map { itemIndex, item in
var itemCopy = item
if index == itemIndex {
itemCopy.state = newValue
} else {
//not the same index
if newValue {
itemCopy.state = false
}
}
return itemCopy
}
}
}
}
struct ContentView: View {
#ObservedObject var state = StateManager()
var body: some View {
NavigationView {
List(Array(state.checkmarks.enumerated()), id: \.1.id) { (index, item) in //<-- here
CheckmarkView(index: index + 1, check: state.singularBinding(forIndex: index))
.padding(.all, 3)
}
.listStyle(PlainListStyle())
.navigationBarTitleDisplayMode(.inline)
}
}
}
struct CheckmarkView: View {
let index: Int
#Binding var check: Bool //<-- Here
var body: some View {
Button(action: {
check.toggle()
}) {
HStack {
Image("Image-\(index)")
.resizable()
.frame(width: 70, height: 70)
.cornerRadius(13.5)
Text("Example-\(index)")
Spacer()
if check {
Image(systemName: "checkmark")
.resizable()
.frame(width: 12, height: 12)
}
}
}
}
}
What's happening:
There's a CheckmarkModel that has an ID for each checkbox, and the state of that box
StateManager keeps an array of those models. It also has a custom binding for each index of the array. For the getter, it just returns the state of the model at that index. For the setter, it makes a new copy of the checkbox array. Any time a checkbox is set, it unchecks all of the other boxes. I also kept your original behavior of allowing nothing to be checked
The List now gets an enumeration of the state.checkmarks -- using enumerated lets me keep your previous behavior of being able to pass an index number to the checkbox view
Inside the ForEach, the custom binding from before is created and passed to the subview
In the subview, instead of using #State, #Binding is used (this is what the custom Binding is passed to)
List {
ForEach(0 ..< RemindTimeType.allCases.count) {
index in CheckmarkView(title:getListTitle(index), index: index, markIndex: $markIndex)
.padding(.all, 3)
}.listRowBackground(Color.clear)
}
struct CheckmarkView: View {
let title: String
let index: Int
#Binding var markIndex: Int
var body: some View {
Button(action: {
markIndex = index
}) {
HStack {
Text(title)
.foregroundColor(Color.white)
.font(.custom(FontEnum.Regular.fontName, size: 14))
Spacer()
if index == markIndex {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color(hex: 0xe6c27c))
}
}
}
}
}
We can benefit from binding collections of Swift 5.5.
import SwiftUI
struct CheckmarkModel: Identifiable, Hashable {
var id = UUID()
var state = false
}
class StateManager : ObservableObject {
#Published var checkmarks = [CheckmarkModel(), CheckmarkModel(), CheckmarkModel(), CheckmarkModel()]
}
struct SingleSelectionList<Content: View>: View {
#Binding var items: [CheckmarkModel]
#Binding var selectedItem: CheckmarkModel?
var rowContent: (CheckmarkModel) -> Content
#State var previouslySelectedItemNdx: Int?
var body: some View {
List(Array($items.enumerated()), id: \.1.id) { (ndx, $item) in
rowContent(item)
.modifier(CheckmarkModifier(checked: item.id == self.selectedItem?.id))
.contentShape(Rectangle())
.onTapGesture {
if let prevIndex = previouslySelectedItemNdx {
items[prevIndex].state = false
}
self.selectedItem = item
item.state = true
previouslySelectedItemNdx = ndx
}
}
}
}
struct CheckmarkModifier: ViewModifier {
var checked: Bool = false
func body(content: Content) -> some View {
Group {
if checked {
ZStack(alignment: .trailing) {
content
Image(systemName: "checkmark")
.resizable()
.frame(width: 20, height: 20)
.foregroundColor(.green)
.shadow(radius: 1)
}
} else {
content
}
}
}
}
struct ContentView: View {
#ObservedObject var state = StateManager()
#State private var selectedItem: CheckmarkModel?
var body: some View {
VStack {
Text("Selected Item: \(selectedItem?.id.description ?? "Select one")")
Divider()
SingleSelectionList(items: $state.checkmarks, selectedItem: $selectedItem) { item in
HStack {
Text(item.id.description + " " + item.state.description)
Spacer()
}
}
}
}
}
A bit simplified version
struct ContentView: View {
#ObservedObject var state = StateManager()
#State private var selection: CheckmarkModel.ID?
var body: some View {
List {
ForEach($state.checkmarks) { $item in
SelectionCell(item: $item, selectedItem: $selection)
.onTapGesture {
if let ndx = state.checkmarks.firstIndex(where: { $0.id == selection}) {
state.checkmarks[ndx].state = false
}
selection = item.id
item.state = true
}
}
}
.listStyle(.plain)
}
}
struct SelectionCell: View {
#Binding var item: CheckmarkModel
#Binding var selectedItem: CheckmarkModel.ID?
var body: some View {
HStack {
Text(item.id.description + " " + item.state.description)
Spacer()
if item.id == selectedItem {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
}
}
A version that uses internal List's selected mark and selection:
import SwiftUI
struct CheckmarkModel: Identifiable, Hashable {
var name: String
var state: Bool = false
var id = UUID()
}
class StateManager : ObservableObject {
#Published var checkmarks = [CheckmarkModel(name: "Name1"), CheckmarkModel(name: "Name2"), CheckmarkModel(name: "Name3"), CheckmarkModel(name: "Name4")]
}
struct ContentView: View {
#ObservedObject var state = StateManager()
#State private var selection: CheckmarkModel.ID?
#State private var selectedItems = [CheckmarkModel]()
var body: some View {
VStack {
Text("Items")
List($state.checkmarks, selection: $selection) { $item in
Text(item.name + " " + item.state.description)
}
.onChange(of: selection) { s in
for index in state.checkmarks.indices {
if state.checkmarks[index].state == true {
state.checkmarks[index].state = false
}
}
selectedItems = []
if let ndx = state.checkmarks.firstIndex(where: { $0.id == selection}) {
state.checkmarks[ndx].state = true
selectedItems = [state.checkmarks[ndx]]
print(selectedItems)
}
}
.environment(\.editMode, .constant(.active))
Divider()
List(selectedItems) {
Text($0.name + " " + $0.state.description)
}
}
Text("\(selectedItems.count) selections")
}
}

How to Transmit a View Entry Count to a Class Method

I'm having trouble with usage of a count of the number of entries in a view. I especially need to know when there are no entries in the view. I have placed debug code in the view below and the view count currants.curItem.countis updating as expected. The count status in checkForUpdates() doesn't follow the view above.
If I recall correctly I should be using #EnvironmentObject or #ObservedObject only in a view. I really need some kind of global variable that I can pass to the method checkForUpdates. It is crashing when count in checkForUpdates() is nonzero when in the view it is actually zero. It also crashes in checkForUpdates() with the error Fatal error: No ObservableObject of type Currencies found. A View.environmentObject(_:) for Currencies may be missing as an ancestor of this view.
struct manCurView: View {
#EnvironmentObject var currants: Currants
var body: some View {
List {
ForEach(currants.curItem, id: \.id) { item in
HStack {
Text(item.curCode)
.frame(width: 100, alignment: .center)
Text(item.cunName)
}
.font(.subheadline)
}
.onDelete(perform: removeItems)
}
.navigationBarTitle(Text("Manage Working Blocks"), displayMode: .inline)
HStack {
NavigationLink(destination: addCurView()) {Text("Add Working Blocks").fontWeight(.bold)}
.font(.title2)
.disabled(currants.curItem.count > 7)
Here is how the data is stored for the view above
struct CurItem: Codable, Identifiable {
var id = UUID()
var cunName: String
var curName: String
var curCode: String
var curSymbol: String
var curRate: Double
}
class Currants: ObservableObject {
#Published var curItem: [CurItem]
}
And here is the class and method where I would like to use count from the view manCurView
class BlockStatus: ObservableObject {
#EnvironmentObject var globalCur : Currants
#ObservedObject var netStatus : TestNetStatus = TestNetStatus()
func checkForUpdates() -> (Bool) {
if netStatus.connected == true {
if globalCur.curItem.count > 0 {
Without a minimal reproducible example it is very difficult to give you exact code but you can try something like the code below in your manCurView
#StateObject var blockStatus: BlockStatus = BlockStatus()
.onChange(of: currants.curItem.count, perform: { value in
print("send value from here")
blockStatus.arrayCount = value
})
And adding the code below to BlockStatus
#Published var arrayCount: Int = 0{
didSet{
//Call your method here
}
}
Look at the code below.
import SwiftUI
import Combine
struct CurItem: Codable, Identifiable {
var id = UUID()
}
class Currants: ObservableObject {
#Published var curItem: [CurItem] = [CurItem(), CurItem(), CurItem(), CurItem()]
}
class TestNetStatus: ObservableObject {
static let sharedInstance = TestNetStatus()
#Published var connected: Bool = false
init() {
//Simulate changes in connection
Timer.scheduledTimer(withTimeInterval: 10, repeats: true){ timer in
self.connected.toggle()
}
}
}
class BlockStatus: ObservableObject {
#Published var arrayCount: Int = 0{
didSet{
checkForUpdates()
}
}
#Published var checkedForUpdates: Bool = false
var netStatus : TestNetStatus = TestNetStatus.sharedInstance
//private var cancellable: AnyCancellable?
init() {
//Maybe? if you want to check upon init.
//checkForUpdates()
//Something like the code below is also possible but with 2 observed objects the other variable could be outdated
// cancellable = netStatus.objectWillChange.sink { [weak self] in
// self?.checkForUpdates()
// }
}
func checkForUpdates() {
if netStatus.connected == true {
if arrayCount > 0 {
checkedForUpdates = true
}else{
checkedForUpdates = false
}
}else{
checkedForUpdates = false
}
}
}
struct ManCurView: View {
#StateObject var currants: Currants = Currants()
#StateObject var blockStatus: BlockStatus = BlockStatus()
#StateObject var testNetStatus: TestNetStatus = TestNetStatus.sharedInstance
var body: some View {
List {
Text("checkedForUpdates = " + blockStatus.checkedForUpdates.description).foregroundColor(blockStatus.checkedForUpdates ? Color.green : Color.red)
Text("connected = " + blockStatus.netStatus.connected.description).foregroundColor(blockStatus.netStatus.connected ? Color.green : Color.red)
ForEach(currants.curItem, id: \.id) { item in
HStack {
Text(item.id.uuidString)
.frame(width: 100, alignment: .center)
Text(item.id.uuidString)
}
.font(.subheadline)
}
//Replaced with toolbar button for sample
//.onDelete(perform: removeItems)
//When the array count changes
.onChange(of: currants.curItem.count, perform: { value in
blockStatus.arrayCount = value
})
//Check when the networkStatus changes
.onChange(of: testNetStatus.connected, perform: { value in
//Check arrayCount
if blockStatus.arrayCount != currants.curItem.count{
blockStatus.arrayCount = currants.curItem.count
}else{
blockStatus.checkForUpdates()
}
})
}
.navigationBarTitle(Text("Manage Working Blocks"), displayMode: .inline)
//Replaced addCurView call with toolbar button for sample
.toolbar(content: {
ToolbarItem(placement: .navigationBarTrailing, content: {
Button("add-currant", action: {
currants.curItem.append(CurItem())
})
})
ToolbarItem(placement: .navigationBarLeading, content: {
Button("delete-currant", action: {
if currants.curItem.count > 0{
currants.curItem.removeFirst()
}
})
})
})
}
}
Here is ContentView: Notice in the menu that because this is a view I can use count directly to disable entry input. Down in getData() notice that I'm calling blockStatus.checkForUpdates() to determine if is OK to call the API. A fault will occur if currants.curItem.count = 0
I just realized that technically getData() is part of the ContentView so I can change the call below to if blockStatus.checkForUpdates() == true && currants.curItem.count != 0 {
I'm going to spend some time studying your suggestions above to see if I could use this in the future.
So thanks for all the help by looking into this. I wasn't aware of the suggestions on code displayed on Stackoverflow. I'll be sure to follow those guidelines in the future. Galen
import SwiftUI
import CoreData
import Combine
struct ContentView: View {
#EnvironmentObject var userData: UserData
#EnvironmentObject var currants: Currants
#EnvironmentObject var blockStatus: BlockStatus
var body: some View {
NavigationView {
VStack (alignment: .center) {
Text("Title")
.font(.title)
.fontWeight(.bold)
Spacer()
Group {
NavigationLink(destination: entryView()) {Text("Entry")}
.disabled(currants.curItem.count == 0)
Spacer()
NavigationLink(destination: totalView()) {Text("View Totals")}
Spacer()
NavigationLink(destination: listView()) {Text("View Entries")}
Spacer()
NavigationLink(destination: xchView()) {Text("View Dates")}
}
Rectangle()
.frame(height: 130)
.foregroundColor(Color.white)
}
.font(.title2)
.navigationBarItems(leading: NavigationLink (destination: settingsView()) {
Image(systemName: "gear")
.foregroundColor(.gray)
.font(.system(.title3))
}, trailing: NavigationLink( destination: aboutView()) {
Text("About")
})
.onAppear(perform: getData)
}
}
func getData() {
// check criteria for updating data once daily
if blockStatus.checkForUpdates() == true {
print(" doing update")
---- API HERE -----
}.resume()
}
}
}

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.