rootstrap/RSFormView

View on GitHub
RSFormView/Classes/Views/TextField/TextFieldView.swift

Summary

Maintainability
A
0 mins
Test Coverage
//
//  TextFieldView.swift
//  RSFormView
//
//  Created by Germán Stábile on 1/29/19.
//  Copyright © 2019 Rootstrap. All rights reserved.
//

import Foundation
import UIKit
import CoreGraphics

public protocol TextFieldDelegate: class {
  func didUpdate(textFieldView: TextFieldView,
                 with fieldData: FormField)
}

@IBDesignable
public class TextFieldView: UIView {
  @IBOutlet weak var textField: UITextField!
  @IBOutlet weak var titleLabel: UILabel!
  @IBOutlet weak var errorLabel: UILabel!
  @IBOutlet weak var bottomLineView: UIView!
  @IBOutlet weak var textFieldContainerView: UIView!
  @IBOutlet weak var titleLabelContainerView: UIView!
  @IBOutlet weak var labelToTextFieldConstraint: NSLayoutConstraint!
  @IBOutlet weak var textFieldToBottomLineConstraint: NSLayoutConstraint!
  @IBOutlet weak var bottomLineToErrorLabelConstraint: NSLayoutConstraint!
  @IBOutlet weak var textFieldContainerToErrorLabelConstraint: NSLayoutConstraint!
  
  var actualView: UIView?
  
  static let dateFormat = "MM/dd/yyyy"
  
  var lastCursorPosition: Int?
  
  private var lastEntry: String?
  
  public weak var delegate: TextFieldDelegate?
  var fieldData: FormField? {
    didSet {
      configureFormPicker()
      setKeyboardType()
    }
  }

  var isValid: Bool {
    return fieldData?.isValid ?? false
  }

  var borderColor: UIColor? {
    didSet {
      setNeedsDisplay()
    }
  }
  
  var formConfigurator = FormConfigurator()
  
  override init(frame: CGRect) {
    super.init(frame: frame)
    configureViews()
  }
  
  required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
    configureViews()
  }

  override public func draw(_ rect: CGRect) {
    super.draw(rect)
    guard let context = UIGraphicsGetCurrentContext() else { return }
    context.resetClip()

    let shouldClip = !(fieldData?.value.isEmpty ?? true) || textField.placeholder?.isEmpty ?? true
    let currentBorderColor = borderColor ?? formConfigurator.validBorderColor
    let rectPath = UIBezierPath(roundedRect: textFieldContainerView.frame,
                                cornerRadius: formConfigurator.borderCornerRadius)
    rectPath.lineWidth = formConfigurator.borderWidth

    if shouldClip {
      let clipPath = UIBezierPath(roundedRect: .infinite,
                                  cornerRadius: formConfigurator.borderCornerRadius)
      let titleLabelPath = UIBezierPath(rect: titleLabelContainerView.frame)
      clipPath.append(titleLabelPath)
      clipPath.usesEvenOddFillRule = true
      /* Clipping paths are used to define in which areas
        the drawing context will actually draw, in this case
        If the titleView is present, we need to create a shape
        that excludes that area, so when we store the border
        that part is not drawn.
        https://developer.apple.com/documentation/uikit/uibezierpath/1624341-addclip
      */
      clipPath.addClip()
    }

    currentBorderColor.setStroke()
    rectPath.stroke()
    // Removed the clipping path, so when we paint the background, it fills the whole shape
    context.resetClip()
    formConfigurator.fieldsBackgroundColor.setFill()
    rectPath.fill()
  }
  
  /// Updates TextFieldView layout according of the validation state of the related FormField
  public func updateErrorState() {
    guard let fieldData = fieldData else { return }
    if fieldData.validationMatch == nil && fieldData.oneTimeErrorMessage == nil {
      //do not override validation match validation
      validate(with: fieldData.value)
    }
    textField.isEnabled = fieldData.isEnabled
    errorLabel.isHidden = !fieldData.shouldDisplayError || fieldData.isValid
    
    let isValid = !fieldData.shouldDisplayError || fieldData.isValid
    
    configureColors(isValid)
    
    let errorText = fieldData.oneTimeErrorMessage ?? fieldData.errorMessage
    errorLabel.text = errorText
    
    errorLabel.accessibilityIdentifier = "Error\(fieldData.name)"
    errorLabel.accessibilityLabel = errorText
    errorLabel.isAccessibilityElement = !errorLabel.isHidden
    errorLabel.accessibilityTraits = .staticText
  }
  
  func configureColors(_ isValid: Bool) {
    backgroundColor = formConfigurator.cellsBackgroundColor
    titleLabel.textColor = isValid ?
      titleValidColor() : formConfigurator.invalidTitleColor
    errorLabel.textColor = formConfigurator.errorTextColor
    textField.textColor = formConfigurator.textColor
    var activeColor = isValid ?
      borderLineValidColor() : formConfigurator.invalidBorderColor
    borderColor = activeColor
    if borderColor == .clear {
      activeColor = isValid ?
        bottomLineValidColor() : formConfigurator.invalidLineColor
      bottomLineView.backgroundColor = activeColor
    }
    textField.tintColor = activeColor
  }
  
  /**
   Updates TextFieldView according to the FormField specifications
   
   - Parameters:
   - data: Model that describes the behaviour of the TextFieldView instance
   - formConfigurator: Model that describes the layout of the TextFieldView instance
   */
  public func update(withData data: FormField, formConfigurator: FormConfigurator) {
    fieldData = data
    self.formConfigurator = formConfigurator
    setAccessibility(withData: data)
    populateTextView(withData: data)
    setContraints()

    updateErrorState()
  }
  
  func setContraints() {
    labelToTextFieldConstraint.constant = formConfigurator.labelToTextFieldDistance
    textFieldToBottomLineConstraint.constant = formConfigurator.textFieldToBottomLineDistance
    bottomLineToErrorLabelConstraint.constant = formConfigurator.bottomLineToErrorLabelDistance
    textFieldContainerToErrorLabelConstraint.constant = formConfigurator.textFieldContainerToErrorLabelDistance
  }
  
  func populateTextView(withData data: FormField) {
    updatePlaceHolder(withText: data.placeholder)
    textField.clearButtonMode = (data.fieldType == .date || data.fieldType == .picker) ? .never : .whileEditing
    textField.keyboardAppearance = formConfigurator.keyboardAppearance
    textField.isSecureTextEntry = data.fieldType == .password
    textField.textContentType = data.textContentType ?? data.defaultTextContentType
    textField.text = data.value
    titleLabel.text = data.name
    errorLabel.text = data.errorMessage
    titleLabel.isHidden = data.value.isEmpty && !data.hasFixedTitle
    titleLabelContainerView.isHidden = data.value.isEmpty && !data.hasFixedTitle
    if !data.value.isEmpty && data.oneTimeErrorMessage == nil {
      data.shouldDisplayError = true
      validate(with: data.value)
    }
  }
  
  func setAccessibility(withData data: FormField) {
    isAccessibilityElement = false
    titleLabel.isAccessibilityElement = false
    titleLabelContainerView.isAccessibilityElement = false
    textFieldContainerView.isAccessibilityElement = false
    textField.accessibilityIdentifier = data.name
    textField.accessibilityLabel = data.name
  }
  
  func updatePlaceHolder(withText text: String) {
    let font = formConfigurator.placeholderFont
    textField.attributedPlaceholder =
      NSAttributedString(string: text,
                         attributes: [
                          .foregroundColor: formConfigurator.placeholderTextColor,
                          .font: font
        ])
  }
  
  @objc func tappedView() {
    textField.becomeFirstResponder()
  }
  
  func validate(with text: String) {
    guard let data = fieldData else { return }
    data.isValid = data.value.isValid(type: data.validationType ?? data.defaultValidationType)
      && data.value.isValidLength(maxLength: data.maximumTextLength, minLength: data.minimumTextLength)
  }
}

//Date picker related methods
extension TextFieldView {
  @objc func datePickerChangedValue(sender: UIDatePicker) {
    guard let _ = fieldData else { return }
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = TextFieldView.dateFormat
    
    let dateString = dateFormatter.string(from: sender.date)
    update(withPickerText: dateString)
  }
}

//General picker related methods
extension TextFieldView: UIPickerViewDelegate, UIPickerViewDataSource {
  public func numberOfComponents(in pickerView: UIPickerView) -> Int {
    return 1
  }
  
  public func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
    guard let fieldOptions = fieldData?.options else { return 0 }
    return fieldOptions.count
  }
  
  public func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
    guard let fieldOptions = fieldData?.options else { return "" }
    return fieldOptions[row]
  }
  
  public func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
    guard let fieldData = fieldData,
      let fieldOptions = fieldData.options else { return }
    let pickerString = fieldOptions[row]
    update(withPickerText: pickerString)
  }
}

extension TextFieldView: UITextFieldDelegate {
  public func textField(
    _ textField: UITextField,
    shouldChangeCharactersIn range: NSRange,
    replacementString string: String
  ) -> Bool {
    guard
      let data = fieldData,
      data.fieldType != .date,
      data.fieldType != .picker
    else {
        return false
    }
    
    if lastEntry == string && (string.count > 1 || string == " ") {
      //when using swipe keyboard on iOS 13 we get updates sending the same entered word or spaces a bunch of times
      return false
    }
    
    lastEntry = string
    
    if let text = textField.text,
      let textRange = Range(range, in: text) {
      let updatedText = text.replacingCharacters(in: textRange,
                                                 with: string)
      
      processTextUpdates(with: text,
                         updatedText: updatedText,
                         data: data)
    }
    
    return false
  }
  
  public func textFieldShouldClear(_ textField: UITextField) -> Bool {
    guard let data = fieldData else { return true }
    data.value = ""
    data.shouldDisplayError = true
    validate(with: "")
    delegate?.didUpdate(textFieldView: self, with: data)
    return true
  }
  
  public func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
    if let data = fieldData,
        let options = data.options,
        data.fieldType == .picker,
        textField.text?.isEmpty ?? true {
        update(withPickerText: options[0])
    }
    return true
  }
  
  public func textFieldDidBeginEditing(_ textField: UITextField) {
    titleLabel.textColor = formConfigurator.editingTitleColor
    textField.placeholder = ""
    titleLabel.isHidden = false
    titleLabelContainerView.isHidden = false
    borderColor = formConfigurator.editingBorderColor
    textField.tintColor = formConfigurator.editingBorderColor
    if borderColor == .clear {
      bottomLineView.backgroundColor = formConfigurator.editingLineColor
      textField.tintColor = formConfigurator.editingLineColor
    }
    setNeedsDisplay()
  }
  
  public func textFieldDidEndEditing(_ textField: UITextField) {
    let shouldHideTitle = fieldData?.value ?? "" == "" && !(fieldData?.hasFixedTitle ?? false)
    titleLabel.isHidden = shouldHideTitle
    titleLabelContainerView.isHidden = shouldHideTitle
    updatePlaceHolder(withText: fieldData?.placeholder ?? "")
    updateErrorState()
    setNeedsDisplay()
  }
}