Skip to content

Instantly share code, notes, and snippets.

@exorcyze
Last active July 9, 2021 18:16
Show Gist options
  • Save exorcyze/7f3ad753ba9da0f63b7646d21663aac1 to your computer and use it in GitHub Desktop.
Save exorcyze/7f3ad753ba9da0f63b7646d21663aac1 to your computer and use it in GitHub Desktop.
//
// 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