Skip to content

Instantly share code, notes, and snippets.

@littlebobert
Last active February 15, 2021 23:05
Show Gist options
  • Save littlebobert/1c5d8abb9c6af3c47fb9137a3ccec0d5 to your computer and use it in GitHub Desktop.
Save littlebobert/1c5d8abb9c6af3c47fb9137a3ccec0d5 to your computer and use it in GitHub Desktop.
the beginnings of a SwiftUI based token input view
import SwiftUI
extension String {
func width(using font: UIFont) -> CGFloat {
let fontAttributes = [NSAttributedString.Key.font: font]
let size = self.size(withAttributes: fontAttributes)
return size.width
}
func lineHeight(using font: UIFont) -> CGFloat {
let fontAttributes = [NSAttributedString.Key.font: font]
let size = self.size(withAttributes: fontAttributes)
return size.height
}
}
extension Optional where Wrapped == String {
var isNilOrEmpty: Bool {
return self == nil || (self?.isEmpty ?? false)
}
}
extension View {
func hideKeyboard() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
fileprivate protocol DeleteCapturingTextFieldDelegate: class {
func deleteButtonPressed()
}
fileprivate class DeleteCapturingTextField: UITextField {
weak var deletionDelegate: DeleteCapturingTextFieldDelegate?
override func deleteBackward() {
deletionDelegate?.deleteButtonPressed()
}
}
struct InvisibleTextField: UIViewRepresentable {
var isFirstResponder: Bool
var onBecomeFirstResponder: ()->()
var onDeleteButtonPressed: ()->()
class Coordinator: NSObject, UITextFieldDelegate, DeleteCapturingTextFieldDelegate {
var onBecomeFirstResponder: ()->()
var onDeleteButtonPressed: ()->()
init(onBecomeFirstResponder: @escaping ()->(), onDeleteButtonPressed: @escaping ()->()) {
self.onBecomeFirstResponder = onBecomeFirstResponder
self.onDeleteButtonPressed = onDeleteButtonPressed
}
func deleteButtonPressed() {
onDeleteButtonPressed()
}
func textFieldDidBeginEditing(_ textField: UITextField) {
/// Use an async call to avoid updating state during a UI refresh
DispatchQueue.main.async {
self.onBecomeFirstResponder()
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(onBecomeFirstResponder: onBecomeFirstResponder, onDeleteButtonPressed: onDeleteButtonPressed)
}
func makeUIView(context: Context) -> UITextField {
let textField = DeleteCapturingTextField()
textField.deletionDelegate = context.coordinator
textField.delegate = context.coordinator
/// This makes the text field invisible:
textField.tintColor = .clear
textField.textColor = .clear
return textField
}
func updateUIView(_ textField: UITextField, context: Context) {
if isFirstResponder && !textField.isFirstResponder {
textField.becomeFirstResponder()
} else if !isFirstResponder && textField.isFirstResponder {
textField.resignFirstResponder()
}
textField.setContentHuggingPriority(.defaultHigh, for: .vertical)
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}
}
/// This custom text field is used because at the moment there is no way to programatically make a `TextField` the first responder in SwiftUI.
struct CustomTextField: UIViewRepresentable {
class Coordinator: NSObject, UITextFieldDelegate {
@Binding var text: String
var didBecomeFirstResponder = false
var didChangeFocus: (Bool)->()
var didPressReturnKey: ()->()
var insideViewUpdate = false
init(text: Binding<String>, didChangeFocus: @escaping (Bool)->(), didPressReturnKey: @escaping ()->()) {
_text = text
self.didChangeFocus = didChangeFocus
self.didPressReturnKey = didPressReturnKey
}
/// This is where we grab the `UITextField`’s current text and set the binding so that it changes the `TokenView`’s state.
/// We could have also done this in `textField(_:, shouldChangeCharactersInRange:, with:)` I guess.
func textFieldDidChangeSelection(_ textField: UITextField) {
if !insideViewUpdate { /// Updating `text` while inside a view update will trigger undefined behavior and a console warning, so don’t do it.
text = textField.text ?? ""
}
}
func textFieldDidBeginEditing(_ textField: UITextField) {
didChangeFocus(true)
}
func textFieldDidEndEditing(_ textField: UITextField) {
didChangeFocus(false)
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
if textField.text.isNilOrEmpty {
textField.resignFirstResponder()
}
didPressReturnKey()
return true
}
}
@Binding var text: String
var font: UIFont
var isFirstResponder: Bool = false
var didChangeFocus: (Bool)->()
var didPressReturnKey: ()->()
func makeUIView(context: UIViewRepresentableContext<CustomTextField>) -> UITextField {
let textField = UITextField(frame: .zero)
textField.font = font
textField.clipsToBounds = true
/// The following line keeps the text from overflowing the field once you reach the edge of the screen.
textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textField.delegate = context.coordinator
return textField
}
func makeCoordinator() -> CustomTextField.Coordinator {
return Coordinator(text: $text, didChangeFocus: didChangeFocus, didPressReturnKey: didPressReturnKey)
}
func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext<CustomTextField>) {
context.coordinator.insideViewUpdate = true
uiView.text = text
context.coordinator.insideViewUpdate = false
if isFirstResponder && !context.coordinator.didBecomeFirstResponder {
uiView.becomeFirstResponder()
context.coordinator.didBecomeFirstResponder = true
}
}
}
struct TokenView: View {
private struct Token: Identifiable {
let id: Int
let text: String
}
private struct Line: Identifiable {
let id: Int
let tokens: [Token]
let width: CGFloat
}
@State var items = ["John Appleseed", "Kate Bell", "Anna Haro", "Daniel Higgens", "David Taylor", "Hank Zakroff"]
fileprivate let font = UIFont.systemFont(ofSize: 17.0, weight: .medium)
private static let space = " "
private var widthOfSpacer: CGFloat {
return Self.space.width(using: font)
}
private var lineHeight: CGFloat {
return "foo".lineHeight(using: font)
}
private static let tokenPadding = EdgeInsets(top: 0, leading: 4, bottom: 0, trailing: 4)
@State private var selectedTokenID: Int? = nil
/// With exact text measurements, SwiftUI doesn’t have enough room to accomodate some very full lines of text.
private static let errorCorrectingFudgeFactor: CGFloat = 1.0
@State private var newTokenText: String = ""
@State private var isEditingInsideTextField = true
private static let lightBlue = Color(red: 208/255.0, green: 222/255.0, blue: 1)
private static let deleteKey = KeyEquivalent(Character(UnicodeScalar(8)))
/// This measures out the text to determine which tokens go on which lines.
private func makeLines(forAvailableWidth availableWidth: CGFloat) -> [Line] {
guard items.count > 0 else { return [] }
var lines = [Line]()
var widthUsedForCurrentLine: CGFloat = 0
var tokensForCurrentLine = [Token]()
var tokenID = 0
for pair in items.enumerated() {
let item = pair.element
let index = pair.offset
let textWidth = item.width(using: font) + Self.errorCorrectingFudgeFactor
let tokenWidth = Self.tokenPadding.leading + textWidth + Self.tokenPadding.trailing
if widthUsedForCurrentLine + widthOfSpacer + tokenWidth < availableWidth {
widthUsedForCurrentLine += widthOfSpacer + tokenWidth
tokensForCurrentLine.append(Token(id: tokenID, text: item))
tokenID += 1
} else {
lines.append(Line(id: lines.count, tokens: tokensForCurrentLine, width: widthUsedForCurrentLine))
tokensForCurrentLine = [Token(id: tokenID, text: item)]
tokenID += 1
widthUsedForCurrentLine = tokenWidth + widthOfSpacer
if index == items.count - 1 {
lines.append(Line(id: lines.count, tokens: tokensForCurrentLine, width: widthUsedForCurrentLine))
return lines
}
}
}
lines.append(Line(id: lines.count, tokens: tokensForCurrentLine, width: widthUsedForCurrentLine))
return lines
}
@State private var someText = ""
private func text(for token: Token, onLine line: Line, isSelected: Bool) -> some View {
ZStack {
Text(token.text)
.font(Font(font as CTFont))
.foregroundColor(isSelected ? .white : .blue)
.padding(Self.tokenPadding)
.background(RoundedRectangle(cornerRadius: 3).foregroundColor(isSelected ? Color.blue : Self.lightBlue))
.fixedSize()
InvisibleTextField(isFirstResponder: isSelected) {
selectedTokenID = token.id
} onDeleteButtonPressed: {
if let selectedTokenID = selectedTokenID {
if items.count > 0 {
items.remove(at: selectedTokenID)
}
self.selectedTokenID = nil
isEditingInsideTextField = true
}
}
}
.fixedSize()
}
private var spaceView: Text {
Text(Self.space)
.font(Font(font as CTFont))
}
private func makeTextField(availableWidth: CGFloat) -> some View {
CustomTextField(text: $newTokenText, font: font, isFirstResponder: isEditingInsideTextField) { isEditing in
isEditingInsideTextField = isEditing
if isEditing {
selectedTokenID = nil
}
} didPressReturnKey: {
if newTokenText.isEmpty {
isEditingInsideTextField = false
} else {
items.append(newTokenText)
newTokenText = ""
}
}
.frame(width: availableWidth, height: lineHeight)
}
private func makeHStack(line: Line, lines: [Line], availableWidth: CGFloat) -> some View {
let lastLine = lines[lines.count - 1]
return HStack(spacing: 0) {
ForEach(line.tokens) { token in
let isSelected = selectedTokenID == token.id
text(for: token, onLine: line, isSelected: isSelected)
spaceView
let isLastToken = token.id == lastLine.tokens[lastLine.tokens.count - 1].id
if isLastToken {
makeTextField(availableWidth: availableWidth - line.width)
}
}
}
}
private func lastTokenID(given lines: [Line]) -> Int {
let lastLine = lines[lines.count - 1]
return lastLine.tokens[lastLine.tokens.count - 1].id
}
var body: some View {
GeometryReader { geometryProxy in
let lines = makeLines(forAvailableWidth: geometryProxy.size.width)
VStack(alignment: .leading, spacing: 4) {
if lines.isEmpty {
makeTextField(availableWidth: geometryProxy.size.width)
} else {
ForEach(lines) { line in
makeHStack(line: line, lines: lines, availableWidth: geometryProxy.size.width)
}
}
if !isEditingInsideTextField || newTokenText.isEmpty {
/// Add a button for catching delete events:
Button("") {
if let selectedTokenID = selectedTokenID {
if items.count > 0 {
items.remove(at: selectedTokenID)
}
self.selectedTokenID = nil
isEditingInsideTextField = true
} else {
selectedTokenID = items.count - 1
hideKeyboard()
}
}
.keyboardShortcut(Self.deleteKey, modifiers: [])
}
}
}
.padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8))
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment