Skip to content

Instantly share code, notes, and snippets.

@JasonCanCode
Last active August 15, 2022 17:01
Show Gist options
  • Save JasonCanCode/8e5dbb80ea6bbedcb084d0ec14d609b4 to your computer and use it in GitHub Desktop.
Save JasonCanCode/8e5dbb80ea6bbedcb084d0ec14d609b4 to your computer and use it in GitHub Desktop.
A helper class for handling the keyboard for views with one or more text fields
import UIKit
class TextFieldsKeyboardHandler: NSObject, UITextFieldDelegate {
private weak var delegate: TextFieldsKeyboardHandlerDelegate!
/// Used to resolve issues with old devices having offset y origins where nav bars are present
private var restingYOrigin: CGFloat = 0
/// Replace for a different handling of the keyboard offset
lazy var updateKeyboardFrame: (CGRect) -> Void = updateViewOffset
init(delegate: TextFieldsKeyboardHandlerDelegate, shouldAddDismissGesture: Bool = true) {
self.delegate = delegate
super.init()
for textField in delegate.textFields {
textField.addTarget(self, action: #selector(editingChanged), for: .editingChanged)
textField.delegate = self
}
if shouldAddDismissGesture {
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.doDismissKeyboard (_:)))
tapGesture.cancelsTouchesInView = false
delegate.presentingView.addGestureRecognizer(tapGesture)
}
}
private func updateObservationOfKeyboard(shouldObserve: Bool) {
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
if shouldObserve {
NotificationCenter.default.addObserver(self, selector: #selector(gotKeyboardNotification), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
}
}
@objc private func editingChanged() {
delegate.checkValidation()
}
@objc func doDismissKeyboard (_ sender: UITapGestureRecognizer) {
dismissKeyboard()
}
@objc func gotKeyboardNotification(notification: NSNotification) {
guard let currentKeyboardFrame: CGRect = (notification.userInfo?[UIResponder.keyboardFrameBeginUserInfoKey] as? NSValue)?.cgRectValue,
let newKeyboardFrame: CGRect = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else {
return
}
updateOriginIfNeeded(currentKeyboardFrame)
updateDatePickerIfNeeded(newKeyboardFrame)
updateKeyboardFrame(newKeyboardFrame)
}
private func updateOriginIfNeeded(_ currentKeyboardFrame: CGRect) {
let isKeyboardPresenting = currentKeyboardFrame.origin.y >= UIScreen.main.bounds.height
if isKeyboardPresenting {
restingYOrigin = delegate.presentingView.frame.origin.y
}
}
func updateViewOffset(forKeyboardFrame keyboardFrame: CGRect) {
let isKeyboardDismissing = keyboardFrame.origin.y >= UIScreen.main.bounds.height
let view = delegate.presentingView
guard let currentTextField = delegate.currentResponder, !isKeyboardDismissing else {
UIView.animate(withDuration: 0.2, animations: {
view.frame.origin.y = self.restingYOrigin
}, completion: { _ in
self.updateObservationOfKeyboard(shouldObserve: false)
})
return
}
let containingView: UIView = currentTextField.superview ?? view
let textFieldFrame = containingView.convert(currentTextField.frame, to: view)
let textFieldOffset: CGFloat = textFieldFrame.maxY + 3
let accessoryHeight: CGFloat = currentTextField.inputAccessoryView?.frame.size.height ?? 0
let heightOfKeyboard = keyboardFrame.size.height
let offsetTextFieldFromBottom: CGFloat = view.frame.size.height - (textFieldOffset + accessoryHeight)
UIView.animate(withDuration: 0.2) {
var frame = view.frame
frame.origin.y = min(self.restingYOrigin, offsetTextFieldFromBottom - heightOfKeyboard)
view.frame = frame
}
}
func lastTextFieldDidReturn() {
dismissKeyboard()
delegate.lastTextFieldDidReturn()
}
func dismissKeyboard() {
for textField in delegate.textFields where textField.isFirstResponder {
textField.resignFirstResponder()
return
}
}
func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
updateObservationOfKeyboard(shouldObserve: true)
delegate.checkValidation()
previousFieldButton.isEnabled = textField != delegate.textFields.first
let isLastField = delegate.isLastTextField(textField)
let needsADone = !textField.hasKeyboardReturnKey && isLastField
if textField.inputView is UIPickerView || needsADone {
nextFieldButton.isEnabled = true
nextFieldButton.title = Constants.Keyboard.accessoryDoneButtonText
} else {
nextFieldButton.isEnabled = !isLastField
nextFieldButton.title = Constants.Keyboard.accessoryNextButtonText
}
return true
}
func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
if textField.keyboardType == .phonePad {
formatPhoneNumber(in: textField)
}
delegate.checkValidation()
return true
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
let didReturn = string == "\n"
if didReturn && delegate.isLastTextField(textField) {
textField.resignFirstResponder()
return false
}
return true
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
if delegate.isLastTextField(textField) {
textField.resignFirstResponder()
lastTextFieldDidReturn()
} else if let currentTextFieldIndex = delegate.textFields.firstIndex(of: textField) {
let nextTextField = delegate.textFields[currentTextFieldIndex + 1]
nextTextField.becomeFirstResponder()
} else {
textField.resignFirstResponder()
}
return true
}
func textFieldDidEndEditing(_ textField: UITextField) {
delegate.didEditTextField(textField)
}
// MARK: - Phone Number
private func formatPhoneNumber(in textField: UITextField) {
let rawPhoneNumber = textField.text ?? ""
var numberText = extractNumbers(fromString: rawPhoneNumber)
if numberText.count >= 7 {
let strIndex = numberText.index(numberText.startIndex, offsetBy: 3)
numberText.insert("-", at: strIndex)
}
if numberText.count > 10 {
let strIndex = numberText.index(numberText.startIndex, offsetBy: 7)
numberText.insert("-", at: strIndex)
}
let maxCharCount = min(numberText.count, 12)
let strIndex = numberText.index(numberText.startIndex, offsetBy: maxCharCount)
textField.text = String(numberText[..<strIndex])
}
private func extractNumbers(fromString str: String) -> String {
var numbers: String = ""
var remainingString = str
while let range = remainingString.range(of: "[0-9]{1,10}", options: .regularExpression) {
let numberSubstring = remainingString[range]
numbers += numberSubstring
remainingString.removeSubrange(range)
}
return numbers
}
// MARK: - Date Picker
lazy var datePicker: UIDatePicker = {
let datePicker = UIDatePicker(frame: .zero)
datePicker.datePickerMode = .date
datePicker.addTarget(self, action: #selector(datePickerChanged), for: .valueChanged)
return datePicker
}()
lazy var datePickerFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .none
return dateFormatter
}()
func configureDatePicker(forTextField textField: UITextField, minDate: Date? = nil, maxDate: Date? = nil) {
datePicker.minimumDate = minDate
datePicker.maximumDate = maxDate
textField.inputView = datePicker
}
@objc func datePickerChanged(datePicker: UIDatePicker) {
guard let textField = delegate.currentResponder else {
return
}
let strDate = datePickerFormatter.string(from: datePicker.date)
textField.text = strDate
delegate.checkValidation()
}
private func updateDatePickerIfNeeded(_ newKeyboardFrame: CGRect) {
guard delegate.currentResponder?.inputView == datePicker else {
return
}
let size = newKeyboardFrame.size
let accessoryHeight = delegate.currentResponder?.inputAccessoryView?.frame.size.height ?? 0
datePicker.frame = CGRect(x: 0, y: accessoryHeight, width: size.width, height: size.height - accessoryHeight)
}
// MARK: - Input Accessory
func useTextFieldInputAccessory() {
for textField in delegate.textFields {
textField.inputAccessoryView = customInputAccessory
}
}
func useDoneTextFieldInputAccessory() {
for textField in delegate.textFields {
textField.inputAccessoryView = doneInputAccessory
}
}
lazy var customInputAccessory: UIView = {
let toolBar = UIToolbar()
toolBar.barStyle = .default
toolBar.isTranslucent = true
toolBar.tintColor = Theme.toolbarButtonBlue
toolBar.backgroundColor = Theme.toolbarBackgroundGray
toolBar.sizeToFit()
let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
toolBar.setItems([previousFieldButton, flexibleSpace, nextFieldButton], animated: false)
toolBar.isUserInteractionEnabled = true
return toolBar
}()
lazy var doneInputAccessory: UIView = {
let toolBar = UIToolbar()
toolBar.barStyle = .default
toolBar.isTranslucent = true
toolBar.tintColor = Theme.toolbarButtonBlue
toolBar.backgroundColor = Theme.toolbarBackgroundGray
toolBar.sizeToFit()
let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
toolBar.setItems([flexibleSpace, doneKeyboardButton], animated: false)
toolBar.isUserInteractionEnabled = true
return toolBar
}()
lazy var doneKeyboardButton: UIBarButtonItem = {
return UIBarButtonItem(
title: Constants.Keyboard.accessoryDoneButtonText,
style: .plain,
target: self,
action: #selector(donePressed)
)
}()
lazy var previousFieldButton: UIBarButtonItem = {
return UIBarButtonItem(
title: Constants.Keyboard.accessoryPreviousButtonText,
style: .plain,
target: self,
action: #selector(previousPressed)
)
}()
lazy var nextFieldButton: UIBarButtonItem = {
return UIBarButtonItem(
title: Constants.Keyboard.accessoryNextButtonText,
style: .plain,
target: self,
action: #selector(nextPressed)
)
}()
@objc func donePressed() {
guard let textField = delegate.currentResponder else {
return
}
textField.resignFirstResponder()
}
@objc func nextPressed() {
guard let textField = delegate.currentResponder,
textField != delegate.lastTextField,
let currentTextFieldIndex = delegate.textFields.firstIndex(of: textField) else {
delegate.currentResponder?.resignFirstResponder()
return
}
let nextTextField = delegate.textFields[currentTextFieldIndex + 1]
nextTextField.becomeFirstResponder()
}
@objc func previousPressed() {
guard let textField = delegate.currentResponder, textField != delegate.textFields.first else {
return
}
if let currentTextFieldIndex = delegate.textFields.firstIndex(of: textField) {
let nextTextField = delegate.textFields[currentTextFieldIndex - 1]
nextTextField.becomeFirstResponder()
} else {
textField.resignFirstResponder()
}
}
}
private extension UITextField {
var hasKeyboardReturnKey: Bool {
switch self.keyboardType {
case .default, .asciiCapable, .URL, .namePhonePad, .emailAddress, .twitter, .webSearch:
return true
case .numbersAndPunctuation, .numberPad, .phonePad, .decimalPad, .asciiCapableNumberPad:
return false
@unknown default:
return false
}
}
}
import UIKit
protocol TextFieldsKeyboardHandlerDelegate: AnyObject {
var presentingView: UIView { get }
/// Collection of all fields in a form in the order in which they should be navigated
var textFields: [UITextField] { get }
/// Can be used to update a model when a field has been editted
func didEditTextField(_ textField: UITextField)
/// Can be used to take action once a form is completely filled out
func lastTextFieldDidReturn()
/// Can be used to update validate form and update UI elements
func checkValidation()
/// Override to return `true` if you always want to resignFirstResponder and dismiss the keyboard.
func isLastTextField(_ textField: UITextField) -> Bool
}
extension TextFieldsKeyboardHandlerDelegate {
var currentResponder: UITextField? {
return textFields.lazy.filter({ $0.isFirstResponder }).first
}
/// For detemining whether to dismiss the keyboard
var lastTextField: UITextField {
guard let lastTextField = textFields.last else {
fatalError("Must provide at least one textField")
}
return lastTextField
}
/// Redefine to return `true` if you always want to resignFirstResponder and dismiss the keyboard.
func isLastTextField(_ textField: UITextField) -> Bool {
return textField == lastTextField
}
// MARK: - Optional Functions
func didEditTextField(_ textField: UITextField) {}
func lastTextFieldDidReturn() {}
func checkValidation() {}
// MARK: - Validation
/// Redefine if you desire extra form validation
var isFormValid: Bool {
return textPresent(in: textFields)
}
func textPresent(in requiredFields: [UITextField]) -> Bool {
for textField in requiredFields where isValidTextPresent(in: textField.text) == false {
return false
}
return true
}
private func isValidTextPresent(in text: String?) -> Bool {
guard let text = text else {
return false
}
let trimmedText = text.trimmingCharacters(in: .whitespaces)
return !trimmedText.isEmpty
}
}
extension TextFieldsKeyboardHandlerDelegate where Self: UIViewController {
var presentingView: UIView {
return self.view
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment