Last active
March 11, 2024 06:35
-
-
Save mralexhay/d16aab434b9d765c13b9180fb42aada9 to your computer and use it in GitHub Desktop.
A SwiftUI interface for adding tags
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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() | |
} | |
} |
@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?
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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 becauseGeometryReader
is "greedy" about consuming height.I worked around this by passing in the width from an external
GeometryReader
wrapping the wholeVStack
.(also resolved with
.layoutPriority
)Thanks Alex!