Last active
February 15, 2021 23:05
-
-
Save littlebobert/1c5d8abb9c6af3c47fb9137a3ccec0d5 to your computer and use it in GitHub Desktop.
the beginnings of a SwiftUI based token input view
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
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