-
-
Save mralexhay/d16aab434b9d765c13b9180fb42aada9 to your computer and use it in GitHub Desktop.
// | |
// 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() | |
} | |
} |
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!
@eliyap any chance you can put your solution? I have run into the same issue and not getting fixed with layoutPriority
@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 :/
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?
Example of the code in action