-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathPinCodeControl.swift
More file actions
427 lines (350 loc) · 16.8 KB
/
PinCodeControl.swift
File metadata and controls
427 lines (350 loc) · 16.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
//
// PinCodeControl.swift
// QUIckControl
//
// Created by Denis Koryttsev on 07/11/16.
// Copyright © 2016 Denis Koryttsev. All rights reserved.
//
import UIKit
import Statable
import QUIckControl
extension UIControl.Event {
public static var typeComplete = UIControl.Event(rawValue: 1 << 24)
}
extension UIControl.State {
public static var filled = UIControl.State(rawValue: 1 << 16)
public static var invalid = UIControl.State(rawValue: 1 << 17)
public static let valid = UIControl.State(rawValue: (1 << 18) | filled.rawValue)
}
fileprivate class ValueApplier: NSObject {
private weak var control: PinCodeControl!
init(control: PinCodeControl) {
super.init()
self.control = control
}
override func setValue(_ value: Any?, forKey key: String) {
if control.sublayers.count == 0 { return }
if !key.isEqual(#keyPath(CAShapeLayer.fillColor)) || control.codeLength == control.code.count {
control.sublayers.forEach { $0.setValue(value, forKey: key) }
return
}
for i in 0..<control.codeLength {
let value = i < control.code.count ? (control.filledItemColor?.cgColor as Any?) : value
control.sublayers[i].setValue(value, forKey: key)
}
}
override func value(forKeyPath keyPath: String) -> Any? {
return control.sublayers.last?.value(forKeyPath: keyPath)
}
}
// TODO: Remove limit on input only numbers. Create enum with types of control for enter secure code. Create base class SecureCodeControl with private class PinCodeControl.
@IBDesignable open class PinCodeControl: QUIckControl, UIKeyInput, UITextInputTraits {
/// preset states
public enum States {
public static let plain = QUICStateDescriptor(inverted: .filled)
public static let valid = QUICStateDescriptor(intersected: .valid)
public static let invalid = QUICStateDescriptor(intersected: [.filled, .invalid])
public static let highlighted = QUICStateDescriptor(intersected: .highlighted)
public static let disabled = QUICStateDescriptor(intersected: .disabled, priority: 1000)
}
/// structure for initialize
public struct Parameters {
let length: Int
let spaceSize: CGFloat
let sideSize: CGFloat
public init(length: Int, spaceSize: CGFloat, sideSize: CGFloat) {
self.length = length
self.spaceSize = spaceSize
self.sideSize = sideSize
}
}
/// current code string
open var code: String { return text }
/// full pin code length == count code items
@IBInspectable open private(set) var codeLength: Int = 0 {
didSet { if (oldValue != codeLength) { loadSublayers() } }
}
/// space between code items
@IBInspectable open var spaceSize: CGFloat = 15 {
didSet { if (oldValue != spaceSize && codeLength != 0) { setNeedsLayout() } }
}
/// size of side code item
@IBInspectable open private(set) var sideSize: CGFloat = 0 {
didSet { if (oldValue != sideSize) { loadDefaultPath(); setNeedsLayout() } }
}
/// filled state, yes when code type ended.
@objc open private(set) var filled = false {
didSet {
if oldValue != filled {
applyCurrentState()
if filled { sendActions(for: .typeComplete) }
}
}
}
/// valid state, yes if entered code is valid.
@objc open private(set) var valid = true {
didSet { if oldValue != valid { applyCurrentState() } }
}
/// object for user validation pin code value.
open var validator: BlockPredicate<String>?
/// if true, then code equal strings, such as '1111', '1234', '9876' will be defined as invalid values
open var shouldUseDefaultValidation = true
/// color for filled code item
@objc open dynamic var filledItemColor: UIColor?
/// bezier path for code item
open var itemPath: UIBezierPath?
private lazy var applier: ValueApplier = ValueApplier(control: self)
private var text = String()
fileprivate var sublayers: [CAShapeLayer] { return (layer.sublayers as? [CAShapeLayer]) ?? [] }
private var defaultPath: UIBezierPath!
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
initializeInstance()
}
required public init(parameters: Parameters, frame: CGRect? = nil) {
super.init(frame: frame ?? .zero)
initializeInstance()
self.spaceSize = parameters.spaceSize
self.sideSize = parameters.sideSize
self.codeLength = parameters.length
loadDefaultPath()
loadSublayers()
}
public override init(frame: CGRect) {
#if !TARGET_INTERFACE_BUILDER
fatalError("You should use init(parameters: Parameters, frame: CGRect?).")
#else
super.init(frame: frame)
#endif
}
private func initializeInstance() {
register(.filled, forBoolKeyPath: #keyPath(PinCodeControl.filled), inverted: false)
register(.invalid, forBoolKeyPath: #keyPath(PinCodeControl.valid), inverted: true)
// register(.valid, with: NSPredicate(format: "\(#keyPath(PinCodeControl.valid)) == YES AND \(#keyPath(PinCodeControl.filled)) == YES"))
register(.valid, with: NSPredicate { control, _ in
let control = control as! PinCodeControl
return control.filled && control.valid
})
// example use block factor
// register(.valid) { control in
// let control = control as! PinCodeControl
//
// return control.filled && control.valid
// }
if PinCodeControl.isDisabledAppearance {
loadAppearance()
}
}
private func loadDefaultPath() {
defaultPath = UIBezierPath(ovalIn: CGRect(origin: .zero, size: CGSize(width: sideSize, height: sideSize)))
}
private func loadSublayers() {
// let strokeColor = value(for: applier, forKey: #keyPath(CAShapeLayer.strokeColor), for: lastAppliedState)
// let fillColor = value(for: applier, forKey: #keyPath(CAShapeLayer.fillColor), for: lastAppliedState)
for _ in 0..<codeLength {
let sublayer = CAShapeLayer()
let borderLayer = CAShapeLayer()
sublayer.actions = [#keyPath(CAShapeLayer.fillColor): NSNull(), #keyPath(CAShapeLayer.lineWidth): NSNull(), #keyPath(CAShapeLayer.strokeColor): NSNull()]
// borderLayer.actions = [#keyPath(CAShapeLayer.fillColor): NSNull(), #keyPath(CAShapeLayer.lineWidth): NSNull(), #keyPath(CAShapeLayer.strokeColor): NSNull()]
sublayer.strokeColor = UIColor.lightGray.cgColor
// borderLayer.fillColor = nil
sublayer.fillColor = nil
sublayer.addSublayer(borderLayer)
layer.addSublayer(sublayer)
}
setNeedsLayout()
}
override open func layoutSubviews() {
super.layoutSubviews()
layoutCodeItemLayers()
}
private func layoutCodeItemLayers() {
let fullWidth: CGFloat = CGFloat((codeLength * Int(sideSize)) + (codeLength - 1) * Int(spaceSize))
let originX: CGFloat = bounds.midX - (fullWidth / 2)
for (i, sublayer) in sublayers.enumerated() {
sublayer.frame = CGRect(x: originX + (CGFloat(i) * (spaceSize + sideSize)), y: bounds.midY - sideSize / 2, width: sideSize, height: sideSize)
let currentPath = itemPath ?? defaultPath
sublayer.path = currentPath!.cgPath
// let currentBorderWidth = sublayers.last!.lineWidth
// let borderPath = UIBezierPath(cgPath: currentPath!.cgPath)
// var transform = CGAffineTransform(scaleX: 1 + (currentBorderWidth / currentPath!.bounds.size.width), y: 1 + (currentBorderWidth / currentPath!.bounds.size.height))
// transform = transform.translatedBy(x: -currentBorderWidth/2, y: -currentBorderWidth/2)
// borderPath.apply(transform)
// (sublayer.sublayers?.first as! CAShapeLayer).path = borderPath.cgPath
}
}
/// clear entered code
open func clear() {
deleteCharacters(in: 0..<text.count)
performTransition {
self.filled = false
self.valid = true
}
}
// MARK: - QUIckControl
/// Sets fill color for state. In most cases, you can use preset states in PinCodeControl.State
open func setFillColor(fillColor: UIColor?, for state: QUICStateDescriptor) {
setValue(fillColor?.cgColor, forTarget: applier, forKeyPath: #keyPath(CAShapeLayer.fillColor), for: state)
}
/// Sets border color for state. In most cases, you can use preset states in PinCodeControl.State
open func setBorderColor(borderColor: UIColor?, for state: QUICStateDescriptor) {
setValue(borderColor?.cgColor, forTarget: applier, forKeyPath: #keyPath(CAShapeLayer.strokeColor), for: state)
}
/// Sets border width for state. In most cases, you can use preset states in PinCodeControl.State
open func setBorderWidth(borderWidth: CGFloat, for state: QUICStateDescriptor) {
setValue(borderWidth, forTarget: applier, forKeyPath: #keyPath(CAShapeLayer.lineWidth), for: state)
}
open func setForValidState(fillColor: UIColor?, borderColor: UIColor?, borderWidth: CGFloat = 1) {
setFillColor(fillColor: fillColor, for: States.valid)
setBorderColor(borderColor: borderColor, for: States.valid)
setBorderWidth(borderWidth: borderWidth, for: States.valid)
}
open func setForInvalidState(fillColor: UIColor?, borderColor: UIColor?, borderWidth: CGFloat = 1) {
setFillColor(fillColor: fillColor, for: States.invalid)
setBorderColor(borderColor: borderColor, for: States.invalid)
setBorderWidth(borderWidth: borderWidth, for: States.invalid)
}
open func setForPlainState(fillColor: UIColor?, borderColor: UIColor?, borderWidth: CGFloat = 1) {
setFillColor(fillColor: fillColor, for: States.plain)
setBorderColor(borderColor: borderColor, for: States.plain)
setBorderWidth(borderWidth: borderWidth, for: States.plain)
}
open func setForHighlightedState(fillColor: UIColor?, borderColor: UIColor?, borderWidth: CGFloat = 1) {
setFillColor(fillColor: fillColor, for: States.highlighted)
setBorderColor(borderColor: borderColor, for: States.highlighted)
setBorderWidth(borderWidth: borderWidth, for: States.highlighted)
}
open func setForDisabledState(fillColor: UIColor?, borderColor: UIColor?, borderWidth: CGFloat = 1) {
setFillColor(fillColor: fillColor, for: States.disabled)
setBorderColor(borderColor: borderColor, for: States.disabled)
setBorderWidth(borderWidth: borderWidth, for: States.disabled)
}
// MARK: - UIResponder
override open var canBecomeFirstResponder: Bool { return true }
open override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
performTransition(withCommit: false) {
super.touchesBegan(touches, with: event)
}
}
override open func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
performTransition {
super.touchesEnded(touches, with: event)
_ = self.becomeFirstResponder()
}
}
override open func becomeFirstResponder() -> Bool {
isHighlighted = true
return super.becomeFirstResponder()
}
override open func resignFirstResponder() -> Bool {
isHighlighted = false
return super.resignFirstResponder()
}
// MARK: - UIKeyInput
public var hasText: Bool { return text.count > 0 }
public func deleteBackward() {
if hasText {
if text.count == codeLength {
beginTransition()
filled = false
valid = true
}
deleteCharacters(in: text.count-1..<text.count)
commitTransition()
}
}
private func deleteCharacters(in range: Range<Int>) {
let start = text.index(text.startIndex, offsetBy: range.lowerBound)
let end = text.index(text.startIndex, offsetBy: range.upperBound)
text.removeSubrange(start..<end)
let val = value(for: applier, forKey: #keyPath(CAShapeLayer.fillColor), for: state) as! CGColor?
sublayers[range].forEach { $0.fillColor = val }
}
public func insertText(_ txt: String) {
if text.count < codeLength {
sublayers[text.count].fillColor = filledItemColor?.cgColor
text += txt
if text.count == codeLength {
performTransition {
self.filled = true
self.valid = self.validate()
}
}
}
}
/// read only. Always has UITextAutocorrectionType.no value.
public var autocorrectionType: UITextAutocorrectionType {
set { _ = newValue }
get { return .no }
}
/// read only. Always has UIKeyboardType.numberPad value.
public var keyboardType: UIKeyboardType {
set { _ = newValue }
get { return .numberPad }
}
/// read only. Always has UITextAutocapitalizationType.none value.
public var autocapitalizationType: UITextAutocapitalizationType {
set { _ = newValue }
get { return .none }
}
// MARK: - Validation
/// perform validation current code value
public func validate() -> Bool {
return validate(text)
}
/// method for validation entered pin code. Declared for subclasses override.
open func validate(_ pin: String) -> Bool {
return (shouldUseDefaultValidation ? defaultValidator.evaluate(with: pin) : true) && (validator != nil ? validator!.evaluate(with: pin) : true)
}
private let defaultValidator = BlockPredicate<String> { (pin) -> Bool in
let result = pin.reduce((true, true, true, 0)) { (result, character) -> (Bool, Bool, Bool, Int) in
if result.3 == pin.count - 1 { return result }
let number: Int = Int(String(character))!
let next: Int = Int(String(pin[pin.index(pin.startIndex, offsetBy: result.3 + 1)]))!
return (result.0 && number == next, result.1 && (number + 1) == next, result.2 && (number - 1) == next, result.3 + 1)
}
return !(result.0 || result.1 || result.2)
}
}
/// Methods for configure appearance
// TODO: Create appearance configurator class.
extension PinCodeControl {
static var isDisabledAppearance: Bool = true
fileprivate func loadAppearance() {
filledItemColor = UIColor.gray
let filledColor = UIColor(red: 76.0 / 255.0, green: 145.0 / 255.0, blue: 65.0 / 255.0, alpha: 1).withAlphaComponent(0.7)
let invalidColor = UIColor(red: 250.0 / 255.0, green: 88.0 / 255.0, blue: 87.0 / 255.0, alpha: 1)
setFillColorForDisabledState(fillColor: filledItemColor!.withAlphaComponent(0.5))
setBorderColorForDisabledState(borderColor: UIColor.black.withAlphaComponent(0.3))
setBorderColorForPlainState(borderColor: UIColor.gray)
setBorderColorForHighlightedState(borderColor: UIColor.lightGray.withAlphaComponent(0.5))
setFillColorForValidState(fillColor: filledColor)
setBorderColorForValidState(borderColor: filledColor)
setFillColorForInvalidState(fillColor: invalidColor)
setBorderColorForInvalidState(borderColor: invalidColor)
}
@objc fileprivate dynamic func setFillColorForDisabledState(fillColor: UIColor?) {
setFillColor(fillColor: fillColor, for: States.disabled)
}
@objc fileprivate dynamic func setFillColorForValidState(fillColor: UIColor?) {
setFillColor(fillColor: fillColor, for: States.valid)
}
@objc fileprivate dynamic func setFillColorForInvalidState(fillColor: UIColor?) {
setFillColor(fillColor: fillColor, for: States.invalid)
}
@objc fileprivate dynamic func setBorderColorForDisabledState(borderColor: UIColor?) {
setBorderColor(borderColor: borderColor, for: States.disabled)
}
@objc fileprivate dynamic func setBorderColorForPlainState(borderColor: UIColor?) {
setBorderColor(borderColor: borderColor, for: States.plain)
}
@objc fileprivate dynamic func setBorderColorForHighlightedState(borderColor: UIColor?) {
setBorderColor(borderColor: borderColor, for: States.highlighted)
}
@objc fileprivate dynamic func setBorderColorForValidState(borderColor: UIColor?) {
setBorderColor(borderColor: borderColor, for: States.valid)
}
@objc fileprivate dynamic func setBorderColorForInvalidState(borderColor: UIColor?) {
setBorderColor(borderColor: borderColor, for: States.invalid)
}
}