// // TextView.swift // MVMCoreUI // // Created by Kevin Christiano on 4/1/20. // Copyright © 2020 Verizon Wireless. All rights reserved. // import UIKit @objc open class TextView: UITextView, UITextViewDelegate, MVMCoreViewProtocol { //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- open var model: MoleculeModelProtocol? private var initialSetupPerformed = false /// If true then text textView is currently displaying the stored placeholder text as there is not content to display. public var isShowingPlaceholder = true /// Set to true to hide the blinking textField cursor. public var hideBlinkingCaret = false public var textViewModel: TextViewModel? { return model as? TextViewModel } //-------------------------------------------------- // MARK: - Drawing Properties //-------------------------------------------------- /// Total control over the drawn top, bottom, left and right borders. public var disableAllBorders = false private(set) var fieldState: FieldState = .original { didSet (oldState) { // Will not update if new state is the same as old. if fieldState != oldState { DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.fieldState.setStateUI(for: self) } } } } /// Determines if the top, left, and right borders should be drawn. private var hideBorders = false public var borderStrokeColor: UIColor = .mvmCoolGray3 public var bottomStrokeColor: UIColor = .mvmBlack private var borderPath: UIBezierPath = UIBezierPath() private var bottomPath: UIBezierPath = UIBezierPath() //-------------------------------------------------- // MARK: - Property Observers //-------------------------------------------------- private var _isEnabled: Bool = true private var _showError: Bool = false private var _isLocked: Bool = false private var _isSelected: Bool = false public var isEnabled: Bool { get { return _isEnabled } set (enabled) { _isEnabled = enabled _isLocked = false _isSelected = false _showError = false fieldState = enabled ? .original : .disabled } } public var showError: Bool { get { return _showError } set (error) { _showError = error _isEnabled = true _isLocked = false _isSelected = false fieldState = error ? .error : .original } } public var isLocked: Bool { get { return _isLocked } set (locked) { _isLocked = locked _isEnabled = true _isSelected = false _showError = false fieldState = locked ? .locked : .original } } public var isSelected: Bool { get { return _isSelected } set (selected) { _isSelected = selected _isLocked = false _isEnabled = true if _showError { fieldState = selected ? .selectedError : .error } else { fieldState = selected ? .selected : .original } } } //-------------------------------------------------- // MARK: - Delegate //-------------------------------------------------- /// Holds a reference to the delegating class so this class can internally influence the TextField behavior as well. public weak var didDeleteDelegate: TextInputDidDeleteProtocol? /// Holds a reference to the delegating class so this class can internally influence the TextField behavior as well. private weak var proprietorTextDelegate: UITextViewDelegate? /// If you're using a ViewController, you must set this to it. public weak var uiTextViewDelegate: UITextViewDelegate? { get { return delegate } set { delegate = self proprietorTextDelegate = newValue } } var delegateObject: MVMCoreUIDelegateObject? //-------------------------------------------------- // MARK: - Constraint //-------------------------------------------------- public var heightConstraint: NSLayoutConstraint? //-------------------------------------------------- // MARK: - Initialization //-------------------------------------------------- public override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: .zero, textContainer: nil) initialSetup() } public convenience init() { self.init(frame: .zero, textContainer: nil) } public required init?(coder: NSCoder) { super.init(coder: coder) initialSetup() } convenience init(delegate: UITextViewDelegate) { self.init(frame: .zero, textContainer: nil) self.delegate = delegate } //-------------------------------------------------- // MARK: - Lifecycle //-------------------------------------------------- public func initialSetup() { if !initialSetupPerformed { tintColor = .mvmBlack initialSetupPerformed = true setupView() } } open func updateView(_ size: CGFloat) { refreshUI() } /// Will be called only once. open func setupView() { translatesAutoresizingMaskIntoConstraints = false insetsLayoutMarginsFromSafeArea = false showsVerticalScrollIndicator = false showsHorizontalScrollIndicator = false contentInset = UIEdgeInsets(top: Padding.Three, left: Padding.Three, bottom: Padding.Three, right: Padding.Three) backgroundColor = .mvmWhite clipsToBounds = true smartQuotesType = .no smartDashesType = .no smartInsertDeleteType = .no font = textViewModel?.fontStyle.getFont() isEditable = true isOpaque = false } open func reset() { backgroundColor = .mvmWhite text = "" inputAccessoryView?.removeFromSuperview() contentInset = UIEdgeInsets(top: Padding.Three, left: Padding.Three, bottom: Padding.Three, right: Padding.Three) } open override func layoutSubviews() { super.layoutSubviews() refreshUI(bottomBarSize: showError ? 4 : 1) } //-------------------------------------------------- // MARK: - Draw //-------------------------------------------------- /// This handles the top, left, and right border lines. open override func draw(_ rect: CGRect) { super.draw(rect) borderPath.removeAllPoints() bottomPath.removeAllPoints() if !disableAllBorders && !hideBorders { // Brings the other half of the line inside the view to prevent cropping. let origin = bounds.origin let size = frame.size let insetLean: CGFloat = 0.5 borderPath.lineWidth = 1 borderPath.move(to: CGPoint(x: origin.x + insetLean, y: origin.y + size.height)) borderPath.addLine(to: CGPoint(x: origin.x + insetLean, y: origin.y + insetLean)) borderPath.addLine(to: CGPoint(x: origin.x + size.width - insetLean, y: origin.y + insetLean)) borderPath.addLine(to: CGPoint(x: origin.x + size.width - insetLean, y: origin.y + size.height)) borderStrokeColor.setStroke() borderPath.stroke() bottomPath.lineWidth = 4 bottomPath.move(to: CGPoint(x: origin.x + size.width, y: origin.y + size.height - 2)) bottomPath.addLine(to: CGPoint(x: origin.x, y: origin.y + size.height - 2)) bottomStrokeColor.setStroke() bottomPath.stroke() } } //-------------------------------------------------- // MARK: - Draw States //-------------------------------------------------- public enum FieldState { case original case error case selectedError case selected case locked case disabled public func setStateUI(for formField: TextView) { switch self { case .original: formField.originalUI() case .error: formField.errorUI() case .selectedError: formField.selectedErrorUI() case .selected: formField.selectedUI() case .locked: formField.lockedUI() case .disabled: formField.disabledUI() } } } open func originalUI() { isEditable = true hideBorders = false borderStrokeColor = .mvmCoolGray3 bottomStrokeColor = .mvmBlack refreshUI(bottomBarSize: 1) } open func errorUI() { isEditable = true hideBorders = false borderStrokeColor = .mvmOrange bottomStrokeColor = .mvmOrange refreshUI(bottomBarSize: 4) } open func selectedErrorUI() { isEditable = true hideBorders = false borderStrokeColor = .mvmBlack bottomStrokeColor = .mvmOrange refreshUI(bottomBarSize: 4) } open func selectedUI() { isEditable = true hideBorders = false borderStrokeColor = .mvmBlack bottomStrokeColor = .mvmBlack refreshUI(bottomBarSize: 1) } open func lockedUI() { isEditable = false hideBorders = true borderStrokeColor = .clear bottomStrokeColor = .clear refreshUI(bottomBarSize: 1) } open func disabledUI() { isEditable = false hideBorders = false borderStrokeColor = .mvmCoolGray3 bottomStrokeColor = .mvmCoolGray3 refreshUI(bottomBarSize: 1) } open func refreshUI(bottomBarSize: CGFloat? = nil, updateMoleculeLayout: Bool = false) { if !disableAllBorders { // let size: CGFloat = bottomBarSize ?? (showError ? 4 : 1) // var heightChanged = false // if let bottomHeight = bottomBar?.bounds.height { // heightChanged = size != bottomHeight // } if updateMoleculeLayout {//|| heightChanged { delegateObject?.moleculeDelegate?.moleculeLayoutUpdated(self) } setNeedsDisplay() layoutIfNeeded() } } //-------------------------------------------------- // MARK: - Methods //-------------------------------------------------- /// Alters the blinking caret line as per design standards. open override func caretRect(for position: UITextPosition) -> CGRect { if hideBlinkingCaret { return .zero } let caretRect = super.caretRect(for: position) return CGRect(origin: caretRect.origin, size: CGSize(width: 1, height: caretRect.height)) } open override func deleteBackward() { super.deleteBackward() didDeleteDelegate?.textFieldDidDelete() } public func setTextAppearance() { if isShowingPlaceholder { setTextContentTraits() } } public func setPlaceholderIfAvailable() { if let placeholder = textViewModel?.placeholder, !placeholder.isEmpty && text.isEmpty { setPlaceholderContentTraits() } } public func setTextContentTraits() { isShowingPlaceholder = false text = "" font = textViewModel?.fontStyle.getFont() textColor = textViewModel?.textColor.uiColor } public func setPlaceholderContentTraits() { isShowingPlaceholder = true textColor = textViewModel?.placeholderTextColor.uiColor font = textViewModel?.placeholderFontStyle.getFont() text = textViewModel?.placeholder } //-------------------------------------------------- // MARK: - UITextViewDelegate //-------------------------------------------------- @objc public func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { return proprietorTextDelegate?.textViewShouldBeginEditing?(textView) ?? true } @objc public func textViewDidBeginEditing(_ textView: UITextView) { setTextAppearance() proprietorTextDelegate?.textViewDidBeginEditing?(textView) } @objc public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { return proprietorTextDelegate?.textView?(textView, shouldChangeTextIn: range, replacementText: text) ?? true } @objc public func textViewDidChange(_ textView: UITextView) { textViewModel?.text = textView.text proprietorTextDelegate?.textViewDidChange?(textView) } @objc public func textViewShouldEndEditing(_ textView: UITextView) -> Bool { return proprietorTextDelegate?.textViewShouldEndEditing?(textView) ?? true } @objc public func textViewDidEndEditing(_ textView: UITextView) { setPlaceholderIfAvailable() proprietorTextDelegate?.textViewDidEndEditing?(textView) } } // MARK:- MoleculeViewProtocol extension TextView: MoleculeViewProtocol { open func set(with model: MoleculeModelProtocol, _ delegateObject: MVMCoreUIDelegateObject?, _ additionalData: [AnyHashable: Any]?) { self.model = model self.delegateObject = delegateObject if let color = model.backgroundColor?.uiColor { backgroundColor = color } guard let model = model as? TextViewModel else { return } heightConstraint?.isActive = false if let height = model.height { heightConstraint = heightAnchor.constraint(equalToConstant: height) heightConstraint?.isActive = true } isEditable = model.isEditable textAlignment = model.textAlignment textColor = model.textColor.uiColor text = model.text uiTextViewDelegate = delegateObject?.uiTextViewDelegate isShowingPlaceholder = model.text!.isEmpty if let accessibilityText = model.accessibilityText { accessibilityLabel = accessibilityText } font = model.fontStyle.getFont() setPlaceholderIfAvailable() if isEditable { FormValidator.setupValidation(for: model, delegate: delegateObject?.formHolderDelegate) MVMCoreUICommonViewsUtility.addDismissToolbar(to: self, delegate: delegateObject?.uiTextViewDelegate) if model.selected ?? false { isSelected = true model.wasInitiallySelected = true becomeFirstResponder() } } } }