Related
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.
I am attempting the configure the text field and button in my openweathermap app to be in its own view other than the main content view. In TextFieldView, the action of the button is set up to call an API response. Then, the weather data from the response is populated on a sheet-based DetailView, which is triggered by the button in TextFieldView. I configured the ForEach method in the sheet to return the last city added to the WeatherModel array (which would technically be the most recent city entered into the text field), then populate the sheet-based DetailView with weather data for that city. Previously, When I had the HStack containing the text field, button, and sheet control set up in the ContentView, the Sheet would properly display weather for the city that had just entered into the text field. After moving those items to a separate TextFieldView, the ForEach method appears to have stopped working. Instead, the weather info returned after entering a city name into the text field is displayed on the wrong count. For instance, if I were to enter "London" in the text field, the DetailView in the sheet is completely blank. If I then enter "Rome" as the next entry, the DetailView in the sheet shows weather info for the previous "London" entry. Entering "Paris" in the textfield displays weather info for "Rome", and so on...
To summarize, the ForEach method in the sheet stopped working properly after I moved the whole textfield and button feature to a separate view. Any idea why the issue I described is happening?
Here is my code:
ContentView
struct ContentView: View {
// Whenever something in the viewmodel changes, the content view will know to update the UI related elements
#StateObject var viewModel = WeatherViewModel()
var body: some View {
NavigationView {
VStack(alignment: .leading) {
List {
ForEach(viewModel.cityNameList.reversed()) { city in
NavigationLink(destination: DetailView(detail: city), label: {
Text(city.name).font(.system(size: 18))
Spacer()
Text("\(city.main.temp, specifier: "%.0f")°")
.font(.system(size: 18))
})
}
.onDelete { indexSet in
let reversed = Array(viewModel.cityNameList.reversed())
let items = Set(indexSet.map { reversed[$0].id })
viewModel.cityNameList.removeAll { items.contains($0.id) }
}
}
.refreshable {
viewModel.updatedAll()
}
TextFieldView(viewModel: viewModel)
}.navigationBarTitle("Weather", displayMode: .inline)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
TextFieldView
struct TextFieldView: View {
#State private var cityName = ""
#State private var showingDetail = false
#FocusState var isInputActive: Bool
var viewModel: WeatherViewModel
var body: some View {
HStack {
TextField("Enter City Name", text: $cityName)
.focused($isInputActive)
Spacer()
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Button("Done") {
isInputActive = false
}
}
}
if isInputActive == false {
Button(action: {
viewModel.fetchWeather(for: cityName)
cityName = ""
self.showingDetail.toggle()
}) {
Image(systemName: "plus")
.font(.largeTitle)
.frame(width: 75, height: 75)
.foregroundColor(Color.white)
.background(Color(.systemBlue))
.clipShape(Circle())
}
.sheet(isPresented: $showingDetail) {
ForEach(0..<viewModel.cityNameList.count, id: \.self) { city in
if (city == viewModel.cityNameList.count-1) {
DetailView(detail: viewModel.cityNameList[city])
}
}
}
}
}
.frame(minWidth: 100, idealWidth: 150, maxWidth: 500, minHeight: 30, idealHeight: 40, maxHeight: 50, alignment: .leading)
.padding(.leading, 16)
.padding(.trailing, 16)
}
}
struct TextFieldView_Previews: PreviewProvider {
static var previews: some View {
TextFieldView(viewModel: WeatherViewModel())
}
}
DetailView
struct DetailView: View {
#State private var cityName = ""
#State var selection: Int? = nil
var detail: WeatherModel
var body: some View {
VStack(spacing: 20) {
Text(detail.name)
.font(.system(size: 32))
Text("\(detail.main.temp, specifier: "%.0f")°")
.font(.system(size: 44))
Text(detail.firstWeatherInfo())
.font(.system(size: 24))
}
}
}
struct DetailView_Previews: PreviewProvider {
static var previews: some View {
DetailView(detail: WeatherModel.init())
}
}
ViewModel
class WeatherViewModel: ObservableObject {
#Published var cityNameList = [WeatherModel]()
func fetchWeather(for cityName: String) {
guard let url = URL(string: "https://api.openweathermap.org/data/2.5/weather?q=\(cityName.escaped())&units=imperial&appid=<YourAPIKey>") else { return }
let task = URLSession.shared.dataTask(with: url) { data, _, error in
guard let data = data, error == nil else { return }
do {
let model = try JSONDecoder().decode(WeatherModel.self, from: data)
DispatchQueue.main.async {
self.addToList(model)
}
}
catch {
print(error)
}
}
task.resume()
}
func updatedAll() {
// keep a copy of all the cities names
let listOfNames = cityNameList.map{$0.name}
// fetch the up-to-date weather info
for city in listOfNames {
fetchWeather(for: city)
}
}
func addToList( _ city: WeatherModel) {
// if already have this city, just update
if let ndx = cityNameList.firstIndex(where: {$0.name == city.name}) {
cityNameList[ndx].main = city.main
cityNameList[ndx].weather = city.weather
} else {
// add a new city
cityNameList.append(city)
}
}
}
Model
struct WeatherModel: Identifiable, Codable {
let id = UUID()
var name: String = ""
var main: CurrentWeather = CurrentWeather()
var weather: [WeatherInfo] = []
func firstWeatherInfo() -> String {
return weather.count > 0 ? weather[0].description : ""
}
}
struct CurrentWeather: Codable {
var temp: Double = 0.0
var humidity = 0
}
struct WeatherInfo: Codable {
var description: String = ""
}
You need to use an ObservedObject in your TextFieldView to use your
original (single source of truth) #StateObject var viewModel that you create in ContentView and observe any change to it.
So use this:
struct TextFieldView: View {
#ObservedObject var viewModel: WeatherViewModel
...
}
Problem:
I am unable to force my alpha, beta, or gamma buttons to turn ON when an input parameter is passed from Landing.swift.
I do not understand why when onAppear fires in the stack, the output becomes:
gamma is the title
beta is the title
alpha is the title
gamma is the title
beta is the title
alpha is the title
Confused -> Why is this outputting 2x when the ForEach loop has only 3 elements inside?
Background:
I am trying to pass a parameter from one view (Landing.swift) to another (ContentView.swift) and then based on that parameter force the correct button (in ContentView) to trigger an ON state so it's selected. I have logic shown below in ButtonOnOff.swift that keeps track of what's selected and not.
For instance, there are 3 buttons in ContentView (alpha, beta, and gamma) and based on the selected input button choice from Landing, the respective alpha, beta, or gamma button (in ContentView) should turn ON.
I am dynamically generating these 3 buttons in ContentView and want the flexibility to extend to possibly 10 or more in the future. Hence why I'm using the ForEach in ContentView. I need some help please understanding if I'm incorrectly using EnvironmentObject/ObservedObject or something else.
Maintaining the ON/OFF logic works correctly with the code. That is, if you manually press alpha, it'll turn ON but the other two will turn OFF and so forth.
Thanks for your help in advance! :)
Testing.swift
import SwiftUI
#main
struct Testing: App {
#StateObject var buttonsEnvironmentObject = ButtonOnOff()
var body: some Scene {
WindowGroup {
Landing().environmentObject(buttonsEnvironmentObject)
}
}
}
Landing.swift
import SwiftUI
struct Landing: View {
#State private var tag:String? = nil
var body: some View {
NavigationView {
ZStack{
HStack{
NavigationLink(destination: ContentView(landingChoice:tag ?? ""), tag: tag ?? "", selection: $tag) {
EmptyView()
}
Button(action: {
self.tag = "alpha"
}) {
HStack {
Text("alpha")
}
}
Button(action: {
self.tag = "beta"
}) {
HStack {
Text("beta")
}
}
Button(action: {
self.tag = "gamma"
}) {
HStack {
Text("gamma")
}
}
}
.navigationBarHidden(true)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
ContentView.swift
import SwiftUI
struct ContentView: View {
var btnName:String
#EnvironmentObject var buttonEnvObj:ButtonOnOff
init(landingChoice:String){
self.btnName = landingChoice
print("\(self.btnName) is the input string")
}
var body: some View {
VStack{
Form{
Section{
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing:10) {
ForEach(0..<buttonEnvObj.buttonNames.count) { index in
BubbleButton(label: "\(buttonEnvObj.buttonNames[index])")
.padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 0))
.onAppear {
print("\(buttonEnvObj.buttonNames[index]) is the title")
}
}
}
}.frame(height: 50)
}
}
}
}
}
struct BubbleButton: View{
#EnvironmentObject var buttonBrandButtons:ButtonOnOff
var label: String
var body: some View{
HStack{
Button(action: {
print("Button action")
buttonBrandButtons.changeState(buttonName: self.label)
}) {
ZStack {
VStack{
HStack {
Spacer()
Text(label)
.font(.system(size: 12,weight:.regular, design: .default))
.foregroundColor(buttonBrandButtons.buttonBrand[self.label]! ? Color.white : Color.gray)
Spacer()
}
}
.frame(height:30)
.fixedSize()
}
}
.background(buttonBrandButtons.buttonBrand[self.label]! ? Color.blue : .clear)
.cornerRadius(15)
.overlay(buttonBrandButtons.buttonBrand[self.label]! ?
RoundedRectangle(cornerRadius: 15).stroke(Color.blue,lineWidth:1) : RoundedRectangle(cornerRadius: 15).stroke(Color.gray,lineWidth:1))
.animation(.linear, value: 0.15)
}
}
}
ButtonOnOff.swift
import Foundation
class ButtonOnOff:ObservableObject{
var buttonNames = ["alpha","beta","gamma"]
#Published var buttonBrand:[String:Bool] = [
"alpha":false,
"beta":false,
"gamma":false
]
func changeState(buttonName:String) -> Void {
for (key,_) in buttonBrand{
if key == buttonName && buttonBrand[buttonName] == true{
buttonBrand[buttonName] = false
} else{
buttonBrand[key] = (key == buttonName) ? true : false
}
}
print(buttonBrand)
}
}
For a short answer just add
.onAppear(){
buttonEnvObj.changeState(buttonName: self.btnName)
}
to ContentView that will highlight the button that was selected.
As for a solution that can be expanded at will. I would suggest a single source of truth for everything and a little simplifying.
struct Landing: View {
#EnvironmentObject var buttonEnvObj:ButtonOnOff
#State private var tag:String? = nil
var body: some View {
NavigationView {
ZStack{
HStack{
NavigationLink(destination: ContentView(), tag: tag ?? "", selection: $tag) {
EmptyView()
}
//Put your buttons here
HStack{
//Use the keys of the dictionary to create the buttons
ForEach(buttonEnvObj.buttonBrand.keys.sorted(by: <), id: \.self){ key in
//Have the button set the value when pressed
Button(action: {
self.tag = key
buttonEnvObj.changeState(buttonName: key)
}) {
Text(key)
}
}
}
}
.navigationBarHidden(true)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
struct ContentView: View {
#EnvironmentObject var buttonEnvObj:ButtonOnOff
var body: some View {
VStack{
Form{
Section{
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing:10) {
//Change this to use the dictionary
ForEach(buttonEnvObj.buttonBrand.sorted(by: {$0.key < $1.key }), id:\.key) { key, value in
BubbleButton(key: key, value: value)
.padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 0))
.onAppear {
print("\(value) is the title")
}
}
}
}.frame(height: 50)
}
}
}
}
}
struct BubbleButton: View{
#EnvironmentObject var buttonBrandButtons:ButtonOnOff
var key: String
var value: Bool
var body: some View{
HStack{
Button(action: {
print("Button action")
buttonBrandButtons.changeState(buttonName: key)
}) {
ZStack {
VStack{
HStack {
Spacer()
Text(key)
.font(.system(size: 12,weight:.regular, design: .default))
.foregroundColor(value ? Color.white : Color.gray)
Spacer()
}
}
.frame(height:30)
.fixedSize()
}
}
.background(value ? Color.blue : .clear)
.cornerRadius(15)
.overlay(value ?
RoundedRectangle(cornerRadius: 15).stroke(Color.blue,lineWidth:1) : RoundedRectangle(cornerRadius: 15).stroke(Color.gray,lineWidth:1))
.animation(.linear, value: 0.15)
}
}
}
class ButtonOnOff:ObservableObject{
//Get rid of this so you can keep the single source
//var buttonNames = ["alpha","beta","gamma"]
//When you want to add buttons just add them here it will all adjust
#Published var buttonBrand:[String:Bool] = [
"alpha":false,
"beta":false,
"gamma":false
]
func changeState(buttonName:String) -> Void {
for (key,_) in buttonBrand{
if key == buttonName && buttonBrand[buttonName] == true{
buttonBrand[buttonName] = false
} else{
buttonBrand[key] = (key == buttonName) ? true : false
}
}
print(buttonBrand)
}
}
I'm trying to implement a Multi selection inside a dynamic Grid.
With this code, it selects graphically all the elements together, basically they all change color. It saves the data from the selected one, but graphically they are all selected, and I can't select and save more than one at a time.
I think the problem could be the grid, but I also tried to change the grid with other libraries, but it didnt change anything.
Before I had simply an array and it works, now getting the data from the database it is not.
I'm still using and have to use swiftui 1, so I dont have the grid offered in the new swiftui.
I'm using this library for the grid, https://github.com/spacenation/swiftui-grid, the modular grid one, since I have this events that comes from the database.
Thanks.
struct ItemsEventSelectionView: View {
#ObservedObject var eventItems = getEventItems()
#State var eventSelections = [EventItem]()
#State var style2 = ModularGridStyle(.vertical, columns: .min(70), rows: .fixed(40))
#EnvironmentObject var globalDataObservableObject: GlobalDataObservableObject
var body: some View {
ZStack(alignment: Alignment(horizontal: .center, vertical: .top)) {
Group {
Rectangle()
.fill(UIManager.bgGradient)
.frame(minWidth: 0, maxWidth: .infinity)
.edgesIgnoringSafeArea(.all)
}
VStack(alignment: .leading) {
ScrollView(self.style2.axes) {
Grid(self.eventItems.events) { event in
HStack {
MultipleSelectionEvent(title: event.name!, isSelected: self.eventSelections.contains(event)) {
if self.eventSelections.contains(event) {
self.eventSelections.removeAll(where: { $0 == event })
}
else {
self.eventSelections.append(event)
}
}
}
}
}.gridStyle(self.style2)
}
}
}
}
struct MultipleSelectionEvent: View {
var title: String
var isSelected: Bool
var action: () -> Void
var body: some View {
Button(action: self.action) {
HStack{
if self.isSelected {
Text(self.title)
.font(UIManager.einaBodySemibold)
.foregroundColor(UIManager.hBlue)
.padding(.vertical, 7)
.padding(.horizontal, 10)
.background(UIManager.hBlueLight)
.cornerRadius(6)
} else {
Text(self.title)
.font(UIManager.einaBody)
.foregroundColor(UIManager.hDarkBlue)
.padding(.vertical, 7)
.padding(.horizontal, 10)
.background(UIManager.hLightGrey)
.cornerRadius(6)
}
}
}
}
}
class getEventItems : ObservableObject {
let didChange = PassthroughSubject<getEventItems,Never>()
#Published var events = [EventItem]() {
didSet {
didChange.send(self)
}
}
func getEventItems() {
EventItemViewModel().fetchEvents(complete: { (eventItems) in
self.events = eventItems
})
}
init() {
getEventItems()
}
}
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.