Skip to content

Instantly share code, notes, and snippets.

@mralexhay
Last active March 11, 2024 06:35
Show Gist options
  • Save mralexhay/d16aab434b9d765c13b9180fb42aada9 to your computer and use it in GitHub Desktop.
Save mralexhay/d16aab434b9d765c13b9180fb42aada9 to your computer and use it in GitHub Desktop.
A SwiftUI interface for adding tags
//
// TaggerView.swift
//
// Created by Alex Hay on 21/11/2020.
//
// Simple interface for adding tags to an array in SwiftUI
// Example video: https://imgur.com/gallery/CcA1IXp
// alignmentGuide code from Asperi @ https://stackoverflow.com/a/58876712/11685049
import SwiftUI
/// The main view to add tags to an array
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
}
}
})
}
}
/// A subview that displays a message when an error occurs
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))
}
}
/// A subview that contains the text-entry field for entering new tags
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())
.autocapitalization(.none)
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"
}
}
/// A subview containing a list of all tags that are in the array. Tags flow onto the next line when wider than the view's width
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
})
}
}
}
}
/// A subview of a tag shown in a list. When tapped the tag will be removed from the array
struct Tag: View {
var tag: String
@Binding var tags: [String]
var body: some View {
HStack {
Text(tag.lowercased())
.padding(.leading, 2)
Image(systemName: "xmark.circle.fill")
.opacity(0.4)
.padding(.leading, -6)
}
.foregroundColor(.white)
.font(.caption2)
.padding(4)
.background(Color.blue.cornerRadius(5))
.padding(4)
.onTapGesture {
tags = tags.filter({ $0 != tag })
}
}
}
struct TaggerView_Previews: PreviewProvider {
static var previews: some View {
TaggerView()
}
}
@mralexhay
Copy link
Author

Example of the code in action

@eliyap
Copy link

eliyap commented Dec 9, 2020

Great work! A small implementation detail I ran across:
if you want to place a view below the TagList in a VStack, it will end up pushed down because GeometryReader is "greedy" about consuming height.
I worked around this by passing in the width from an external GeometryReader wrapping the whole VStack.

(also resolved with .layoutPriority)

Thanks Alex!

@ericagredo
Copy link

@eliyap any chance you can put your solution? I have run into the same issue and not getting fixed with layoutPriority

@eliyap
Copy link

eliyap commented Jan 26, 2022

@ericagredo apologies, the code never shipped, so I don't have a copy of my solution. SwiftUI's internals may also have changed since 2020, so what worked then may no longer apply :/

@karelszo
Copy link

How could I change the code to store my Tags using @AppStorage for example... ?
Or could UserDefaults be used? Does anyone have a solution to get my embedded Tags loaded when my app starts?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment