/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ // This code is loosely based on https://github.com/Antol/APAutocompleteTextField import UIKit import Shared /// Delegate for the text field events. Since AutocompleteTextField owns the UITextFieldDelegate, /// callers must use this instead. protocol AutocompleteTextFieldDelegate: class { func autocompleteTextField(_ autocompleteTextField: AutocompleteTextField, didEnterText text: String) func autocompleteTextFieldShouldReturn(_ autocompleteTextField: AutocompleteTextField) -> Bool func autocompleteTextFieldShouldClear(_ autocompleteTextField: AutocompleteTextField) -> Bool func autocompleteTextFieldDidBeginEditing(_ autocompleteTextField: AutocompleteTextField) } struct AutocompleteTextFieldUX { static let HighlightColor = UIColor(rgb: 0xccdded) } class AutocompleteTextField: UITextField, UITextFieldDelegate { var autocompleteDelegate: AutocompleteTextFieldDelegate? // AutocompleteTextLabel repersents the actual autocomplete text. // The textfields "text" property only contains the entered text, while this label holds the autocomplete text // This makes sure that the autocomplete doesnt mess with keyboard suggestions provided by third party keyboards. private var autocompleteTextLabel: UILabel? private var hideCursor: Bool = false var isSelectionActive: Bool { return autocompleteTextLabel != nil } // This variable is a solution to get the right behavior for refocusing // the AutocompleteTextField. The initial transition into Overlay Mode // doesn't involve the user interacting with AutocompleteTextField. // Thus, we update shouldApplyCompletion in touchesBegin() to reflect whether // the highlight is active and then the text field is updated accordingly // in touchesEnd() (eg. applyCompletion() is called or not) fileprivate var notifyTextChanged: (() -> Void)? private var lastReplacement: String? var highlightColor = AutocompleteTextFieldUX.HighlightColor override var text: String? { didSet { super.text = text self.textDidChange(self) } } override var accessibilityValue: String? { get { return (self.text ?? "") + (self.autocompleteTextLabel?.text ?? "") } set(value) { super.accessibilityValue = value } } override init(frame: CGRect) { super.init(frame: frame) commonInit() } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) commonInit() } fileprivate func commonInit() { super.delegate = self super.addTarget(self, action: #selector(AutocompleteTextField.textDidChange(_:)), for: UIControlEvents.editingChanged) notifyTextChanged = debounce(0.1, action: { if self.isEditing { self.autocompleteDelegate?.autocompleteTextField(self, didEnterText: self.normalizeString(self.text ?? "")) } }) } override var keyCommands: [UIKeyCommand]? { return [ UIKeyCommand(input: UIKeyInputLeftArrow, modifierFlags: .init(rawValue: 0), action: #selector(self.handleKeyCommand(sender:))), UIKeyCommand(input: UIKeyInputRightArrow, modifierFlags: .init(rawValue: 0), action: #selector(self.handleKeyCommand(sender:))) ] } func handleKeyCommand(sender: UIKeyCommand) { switch sender.input { case UIKeyInputLeftArrow: if isSelectionActive { applyCompletion() // Set the current position to the beginning of the text. selectedTextRange = textRange(from: beginningOfDocument, to: beginningOfDocument) } else if let range = selectedTextRange { if range.start == beginningOfDocument { return } guard let cursorPosition = position(from: range.start, offset: -1) else { return } selectedTextRange = textRange(from: cursorPosition, to: cursorPosition) } return case UIKeyInputRightArrow: if isSelectionActive { applyCompletion() // Set the current position to the end of the text. selectedTextRange = textRange(from: endOfDocument, to: endOfDocument) } else if let range = selectedTextRange { if range.end == endOfDocument { return } guard let cursorPosition = position(from: range.end, offset: 1) else { return } selectedTextRange = textRange(from: cursorPosition, to: cursorPosition) } return default: return } } func highlightAll() { let text = self.text self.text = "" setAutocompleteSuggestion(text ?? "") selectedTextRange = textRange(from: endOfDocument, to: endOfDocument) } fileprivate func normalizeString(_ string: String) -> String { return string.lowercased().stringByTrimmingLeadingCharactersInSet(CharacterSet.whitespaces) } /// Commits the completion by setting the text and removing the highlight. fileprivate func applyCompletion() { // Clear the current completion, then set the text without the attributed style. let text = (self.text ?? "") + (self.autocompleteTextLabel?.text ?? "") removeCompletion() self.text = text hideCursor = false // Move the cursor to the end of the completion. selectedTextRange = textRange(from: endOfDocument, to: endOfDocument) } /// Removes the autocomplete-highlighted fileprivate func removeCompletion() { autocompleteTextLabel?.removeFromSuperview() autocompleteTextLabel = nil } // `shouldChangeCharactersInRange` is called before the text changes, and textDidChange is called after. // Since the text has changed, remove the completion here, and textDidChange will fire the callback to // get the new autocompletion. func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { lastReplacement = string return true } func setAutocompleteSuggestion(_ suggestion: String?) { let text = self.text ?? "" guard let suggestion = suggestion, isEditing && markedTextRange == nil else { hideCursor = false return } let normalized = normalizeString(text) guard suggestion.startsWith(normalized) && normalized.characters.count < suggestion.characters.count else { hideCursor = false return } let suggestionText = suggestion.substring(from: suggestion.characters.index(suggestion.startIndex, offsetBy: normalized.characters.count)) let autocompleteText = NSMutableAttributedString(string: suggestionText) autocompleteText.addAttribute(NSBackgroundColorAttributeName, value: highlightColor, range: NSRange(location: 0, length: suggestionText.characters.count)) autocompleteTextLabel?.removeFromSuperview() // should be nil. But just in case autocompleteTextLabel = createAutocompleteLabelWith(autocompleteText) if let l = autocompleteTextLabel { addSubview(l) hideCursor = true forceResetCursor() } } override func caretRect(for position: UITextPosition) -> CGRect { return hideCursor ? CGRect.zero : super.caretRect(for: position) } private func createAutocompleteLabelWith(_ autocompleteText: NSAttributedString) -> UILabel { let label = UILabel() var frame = self.bounds label.attributedText = autocompleteText label.font = self.font label.accessibilityIdentifier = "autocomplete" label.backgroundColor = self.backgroundColor label.textColor = self.textColor let enteredTextSize = self.attributedText?.boundingRect(with: self.frame.size, options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil) frame.origin.x = (enteredTextSize?.width.rounded() ?? 0) frame.size.width = self.frame.size.width - frame.origin.x frame.size.height = self.frame.size.height - 1 label.frame = frame return label } func textFieldDidBeginEditing(_ textField: UITextField) { autocompleteDelegate?.autocompleteTextFieldDidBeginEditing(self) } func textFieldShouldEndEditing(_ textField: UITextField) -> Bool { applyCompletion() return true } func textFieldShouldReturn(_ textField: UITextField) -> Bool { applyCompletion() return autocompleteDelegate?.autocompleteTextFieldShouldReturn(self) ?? true } func textFieldShouldClear(_ textField: UITextField) -> Bool { removeCompletion() return autocompleteDelegate?.autocompleteTextFieldShouldClear(self) ?? true } override func setMarkedText(_ markedText: String?, selectedRange: NSRange) { // Clear the autocompletion if any provisionally inserted text has been // entered (e.g., a partial composition from a Japanese keyboard). removeCompletion() super.setMarkedText(markedText, selectedRange: selectedRange) } func textDidChange(_ textField: UITextField) { hideCursor = autocompleteTextLabel != nil removeCompletion() let isAtEnd = selectedTextRange?.start == endOfDocument let isEmpty = lastReplacement?.isEmpty ?? true if !isEmpty, isAtEnd, markedTextRange == nil { notifyTextChanged?() } else { hideCursor = false } } // Reset the cursor to the end of the text field. // This forces `caretRect(for position: UITextPosition)` to be called which will decide if we should show the cursor // This exists because ` caretRect(for position: UITextPosition)` is not called after we apply an autocompletion. private func forceResetCursor() { selectedTextRange = nil selectedTextRange = textRange(from: endOfDocument, to: endOfDocument) } override func deleteBackward() { lastReplacement = nil hideCursor = false if isSelectionActive { removeCompletion() forceResetCursor() } else { super.deleteBackward() } } override func touchesBegan(_ touches: Set, with event: UIEvent?) { applyCompletion() super.touchesBegan(touches, with: event) } }