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:

preview

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 UIViews 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()
  }
}

Next Post Previous Post