Chips/Tags Input View in SwiftUI
Reading time: 3 min
This recipe shows how to implement a chips/tags input view in SwiftUI. It is a TextField
that parses its input and stores it as deletable chips. It responds to space, submit and backspace events to allow for seamless editing of chips/tags from within the text field.
The end result looks like this:
This solution works for SwiftUI 2+ (iOS 14+, macOS 11+).
UI components
Let's start off by creating a simple view that represents a chip. It'll just be some text alongside an X button that allows for deletion, enclosed in a nice round background:
struct Chip: View {
let text: String
let onDelete: (String) -> Void
var body: some View {
HStack {
Text(text)
.font(.system(size: 12))
.fontWeight(.bold)
Button {
onDelete(text)
} label: {
Image(systemName: "x.circle")
.foregroundColor(.black)
.frame(width: 16, height: 16)
}
}
.padding(4)
.background(RoundedRectangle(cornerRadius: 10)
.foregroundColor(.gray.opacity(0.5)))
}
}
Then, from the UI side of things at least, our chip input view is nothing more than an HStack
of Chip
views, followed by a single TextField
, all underlined as shown in this recipe:
struct ChipView: View {
@Binding var chips: [String]
let placeholder: String
@State private var inputText = ""
var body: some View {
HStack {
ForEach(chips, id: \.self) { chip in
Chip(text: chip) { text in
chips.removeAll(where: { $0 == text })
}
}
TextField(placeholder, text: $inputText)
}
.underlined()
}
}
Adding new chips
We can add text from the TextField
to the chips array either when the user presses space while typing or submit in the soft keyboard. Or, ideally, we can add it as an option to allow the user to choose between the two, since the "spaces" variant means that chips are limited to a single word, while the "submit" options allows for multi-word chips:
struct ChipView: View {
// ... same as before ...
// if true, typing a space in the TextField adds a new chip
// otherwise, the user must submit in the soft keyboard for a new chip
let useSpaces: Bool
var body: some View {
// ... same as before ...
TextField(placeholder, text: $inputText)
.onSubmit(of: .text) { // submit is pressed in soft keyboard
chips.append(inputText)
inputText = ""
}
.onChange(of: inputText) { _ in
if useSpaces && inputText.hasSuffix(" ") {
// add everything except the space
chips.append(String(inputText[..<inputText.index(before: inputText.endIndex)]))
inputText = ""
}
}
// ... same as before ...
}
}
Editing and removing chips on backspace
The final piece of functionality we need is the ability to edit the last chip when you hit a backspace. This makes the chip input field fully natural, especially if you have useSpaces: true
. To do this, we could resort to the solution in this recipe, but wrapped UIView
s have certain layout issues, so we'll resort to a different trick:
We'll use a special Unicode character, the so-called zero-width space (ZWSP), instead of the empty string for the TextField
input text. That way, the only way for its content to be actually empty is if the user has deleted ZWSP by hitting a backspace. Of course, this requires some more string manipulation when adding the actual chips. Here's the full code for ChipView
, which supports all the required functionality:
struct ChipView: View {
private static let zwsp = "\u{200B}"
@Binding var chips: [String]
let placeholder: String
let useSpaces: Bool
@State private var inputText = zwsp
var body: some View {
HStack {
ForEach(chips, id: \.self) { chip in
Chip(text: chip) { text in
chips.removeAll(where: { $0 == text })
}
}
TextField(placeholder, text: $inputText)
.onSubmit(of: .text) {
chips.append(inputText.hasPrefix(ChipView.zwsp)
? String(inputText[inputText.index(after: inputText.startIndex)...])
: inputText)
inputText = ChipView.zwsp
}
.onChange(of: inputText) { _ in
if useSpaces && inputText.hasSuffix(" ") {
chips.append(String(inputText[..<inputText.index(before: inputText.endIndex)]))
inputText = ChipView.zwsp
} else if !chips.isEmpty && inputText.isEmpty {
let last = chips.removeLast()
inputText = last
}
}
}
.underlined()
}
}
And here's how to put it to use:
struct ChipViewTest: View {
@State private var chips1: [String] = ["some"]
@State private var chips2: [String] = []
var body: some View {
VStack(spacing: 20) {
ChipView(chips: $chips1,
placeholder: "Spaces break",
useSpaces: true)
ChipView(chips: $chips2,
placeholder: "Submit break",
useSpaces: false)
}
.padding()
}
}