Last active
July 9, 2021 18:16
-
-
Save exorcyze/7f3ad753ba9da0f63b7646d21663aac1 to your computer and use it in GitHub Desktop.
This file contains hidden or 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
// | |
// EXTextField | |
// Created / Copyright © : Mike Johnson, 2021 | |
// | |
import Foundation | |
import UIKit | |
// MARK: - Lifecycle | |
public class EXTextField: UITextField { | |
/// The states the control can render | |
enum State: Int { | |
case normal | |
case error | |
} | |
/// Configuration struct | |
struct Config { | |
var unfocusedColor = UIColor.lightGray | |
var focusedColor = UIColor.systemBlue | |
var errorColor = UIColor.systemRed | |
var hintLabelHeight: CGFloat = 14 | |
var font: UIFont = UIFont.systemFont( ofSize: 18, weight: UIFont.Weight.regular ) | |
} | |
private let hintLabel: UILabel = UILabel( frame: CGRect.zero ) | |
private let button = UIButton( type: .custom ) | |
private var isCurrentlyFocused: Bool = false | |
private var lastTextValue = "" | |
private var placeholderText = "" | |
private let normalInset = UIEdgeInsets( top: 0, left: 10, bottom: 0, right: 10 ) | |
private let editingInset = UIEdgeInsets( top: 10, left: 10, bottom: 0, right: 10 ) | |
public override var placeholder: String? { didSet(newValue) { | |
placeholderText = placeholder ?? "" | |
stateChanged() | |
} } | |
typealias VoidCallback = ()->Void | |
typealias InputCallback = (EXTextField)->Void | |
/// Will set return key type to Next and set focus to given field when Next is pressed | |
var nextFocusField: UITextField? { didSet { self.returnKeyType = .next } } | |
/// Will set return key type to Done and fire callback when Done is pressed | |
var onDone: VoidCallback? { didSet { self.returnKeyType = .done } } | |
/// Called when text changes | |
var onTextChanged: InputCallback? | |
/// The current config for rendering the control | |
var currentConfig = Config() { didSet { configChanged() } } | |
/// The current state for rendering the control | |
var currentState = State.normal { didSet { stateChanged() } } | |
required init?( coder: NSCoder ) { | |
super.init( coder: coder ) | |
setup() | |
} | |
override init(frame: CGRect) { | |
super.init( frame: frame ) | |
setup() | |
} | |
public override func layoutSubviews() { | |
super.layoutSubviews() | |
hintLabel.width = width - 40 | |
} | |
} | |
// MARK: - Public | |
public extension EXTextField { | |
func addViewPassword() { | |
isSecureTextEntry = true | |
setRevealButtonImage() | |
button.imageEdgeInsets = UIEdgeInsets( top: 0, left: 0, bottom: 0, right: 0 ) | |
button.contentEdgeInsets = UIEdgeInsets( top: 0, left: 0, bottom: 0, right: normalInset.right ) | |
button.frame = CGRect( x: 0, y: 16, width: 22, height: 16 ) | |
button.clipsToBounds = true | |
rightView = button | |
rightViewMode = .always | |
button.addTarget( self, action: #selector(self.toggleSecureEntry), for: .touchUpInside ) | |
} | |
/// Shows the error message in the hint label and | |
/// sets the currentState of the control to .error | |
func setError( message: String ) { | |
hintLabel.text = message | |
currentState = .error | |
} | |
} | |
// MARK: - Private | |
private extension EXTextField { | |
func setup() { | |
delegate = self | |
clipsToBounds = true | |
backgroundColor = .white | |
hintLabel.alpha = 0 | |
setupFloatingLabel() | |
addSubview( hintLabel ) | |
addTarget( self, action: #selector(self.onEdit), for: .editingDidBegin ) | |
addTarget( self, action: #selector(self.onEdit), for: .editingChanged ) | |
addTarget( self, action: #selector(self.onEdit), for: .touchUpInside ) | |
addTarget( self, action: #selector(self.onEditEnd), for: .editingDidEnd ) | |
addTarget( self, action: #selector(self.onEditEnd), for: .touchUpOutside ) | |
addTarget( self, action: #selector(self.onChangeTextValue), for: .editingChanged ) | |
} | |
func setupFloatingLabel() { | |
hintLabel.font = font?.withSize( 10 ) | |
hintLabel.text = ( text == "" ) ? "" : placeholderText | |
hintLabel.layer.backgroundColor = backgroundColor?.cgColor | |
configChanged() | |
} | |
func configChanged() { | |
font = currentConfig.font | |
hintLabel.textColor = currentConfig.focusedColor | |
hintLabel.font = currentConfig.font.withSize( currentConfig.hintLabelHeight - 2 ) | |
hintLabel.frame = CGRect( x: normalInset.left, y: 0, width: frame.size.width - normalInset.left, height: currentConfig.hintLabelHeight + 4 ) | |
setColors() | |
setNeedsDisplay() | |
} | |
func animateFloatingLabel() { | |
// leave if it doesn't need to show / hide | |
if lastTextValue == "" && text == "" { return } | |
if lastTextValue != "" && text != "" { return } | |
let showLabel = lastTextValue == "" | |
let startY: CGFloat = showLabel ? currentConfig.hintLabelHeight - 2 : 0 | |
let endY: CGFloat = showLabel ? 0 : currentConfig.hintLabelHeight - 2 | |
lastTextValue = text ?? "" | |
hintLabel.frame = CGRect( x: self.normalInset.left, y: startY, width: hintLabel.frame.size.width, height: hintLabel.frame.size.height ) | |
hintLabel.alpha = showLabel ? 0 : 1 | |
UIView.animate( withDuration: 0.15 ) { | |
self.hintLabel.frame = CGRect( x: self.normalInset.left, y: endY, width: self.hintLabel.frame.size.width, height: self.hintLabel.frame.size.height ) | |
self.hintLabel.alpha = showLabel ? 1.0 : 0.0 | |
self.setNeedsDisplay() | |
} | |
} | |
func setColors() { | |
if isCurrentlyFocused { | |
let activeColors: [UIColor] = [ currentConfig.focusedColor, currentConfig.errorColor ] | |
layer.borderColor = activeColors[ currentState.rawValue ].cgColor | |
} | |
else { | |
let activeColors: [UIColor] = [ currentConfig.unfocusedColor, currentConfig.errorColor ] | |
layer.borderColor = activeColors[ currentState.rawValue ].cgColor | |
} | |
hintLabel.textColor = ( currentState == .error ) ? currentConfig.errorColor : currentConfig.focusedColor | |
setNeedsDisplay() | |
} | |
/// Sets the border and will reset the hint label to | |
/// the placeholder text if the state is normal | |
func stateChanged() { | |
setColors() | |
guard currentState == .normal else { return } | |
hintLabel.text = placeholderText | |
} | |
@objc func onEdit() { | |
bringSubviewToFront( subviews.last! ) | |
animateFloatingLabel() | |
setNeedsDisplay() | |
isCurrentlyFocused = true | |
setColors() | |
} | |
@objc func onEditEnd() { | |
animateFloatingLabel() | |
setNeedsDisplay() | |
isCurrentlyFocused = false | |
setColors() | |
} | |
@objc func onChangeTextValue() { | |
onTextChanged?( self ) | |
} | |
@objc func toggleSecureEntry() { | |
isSecureTextEntry.toggle() | |
setRevealButtonImage() | |
} | |
func setRevealButtonImage() { | |
let imageName = isSecureTextEntry ? "eye" : "eye.slash" | |
let myimage = UIImage( systemName: imageName )?.withTintColor( .darkGray, renderingMode: .alwaysOriginal ) | |
button.setImage( myimage, for: .normal ) | |
} | |
} | |
// MARK: - Delegate | |
extension EXTextField: UITextFieldDelegate { | |
public func textFieldShouldReturn(_ textField: UITextField) -> Bool { | |
// Allow for multi-stage input if they have a marked range | |
// https://stackoverflow.com/questions/20247796 | |
guard textField.markedTextRange == nil else { return false } | |
if let next = nextFocusField { next.becomeFirstResponder() } | |
if let callback = onDone { | |
self.resignFirstResponder() | |
callback() | |
} | |
return true | |
} | |
} | |
// MARK: Layout Rect | |
extension EXTextField { | |
public override func textRect(forBounds bounds: CGRect) -> CGRect { | |
let myinset = (text ?? "").isEmpty ? normalInset : editingInset | |
return bounds.inset( by: myinset ) | |
} | |
public override func placeholderRect(forBounds bounds: CGRect) -> CGRect { | |
return bounds.inset( by: normalInset ) | |
} | |
public override func editingRect(forBounds bounds: CGRect) -> CGRect { | |
let myinset = (text ?? "").isEmpty ? normalInset : editingInset | |
return bounds.inset( by: myinset ) | |
} | |
} | |
// MARK: - EXTextField Extensions | |
extension EXTextField { | |
enum Style { | |
case standard | |
case secure | |
case secureViewable | |
} | |
convenience init( style: Style ) { | |
self.init() | |
self.style( style ) | |
} | |
@discardableResult func style( _ style: Style ) -> EXTextField { | |
// default properties | |
var config = Config( unfocusedColor: .lightGray, focusedColor: .systemBlue, errorColor: .systemRed, hintLabelHeight: 14 ) | |
//config.font = UIFont.monospacedSystemFont( ofSize: 16, weight: .regular ) | |
currentConfig = config | |
bordered( color: currentConfig.unfocusedColor, width: 1 ) | |
rounded( cornerRadius: 4 ) | |
switch style { | |
case .standard: | |
break | |
case .secure: | |
isSecureTextEntry = true | |
case .secureViewable: | |
addViewPassword() | |
} | |
return self | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment