diff --git a/OTPSwiftView/Assets/preview.gif b/OTPSwiftView/Assets/preview.gif
new file mode 100644
index 00000000..7738c77b
Binary files /dev/null and b/OTPSwiftView/Assets/preview.gif differ
diff --git a/OTPSwiftView/README.md b/OTPSwiftView/README.md
new file mode 100644
index 00000000..61585f8b
--- /dev/null
+++ b/OTPSwiftView/README.md
@@ -0,0 +1,151 @@
+# OTPSwiftView
+
+
+
+A fully customizable OTP view.
+
+
+
+
+
+# Usage
+```swift
+class ViewController: UIViewController {
+ let otpView = CustomOTPSwiftView() // Custom OTP view
+
+ let config = OTPCodeConfig(codeSymbolsCount: 6, // Base configuration of OTP view
+ spacing: 6,
+ customSpacing: [2: 20])
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ /*
+ Add your codeView and set layout
+ */
+
+ /* Configure OTP view */
+
+ otpView.configure(with: config)
+
+ /* Bind events */
+
+ otpView.onTextEnter = { code in
+ // Get code from codeView
+ }
+
+ /* Update text */
+
+ otpView.code = "234435"
+
+ /* Update focus */
+
+ otpView.beginFirstResponder() // show keyboard
+ otpView.resignFirstResponder() // hide keyboard
+ }
+}
+```
+
+# Customization
+## Single OTP View
+*OTPView* is a base class that describes a single OTP textfield.
+To customize the appearance and layout, you must inherit from the OTPView.
+*Don't forget to add UIGestureRecognizer to call closure `onTap?()`. Use UITapGestureRecognizer to avoid bugs.*
+
+```swift
+import OTPSwiftView
+
+class CustomOTPView: OTPView {
+ override func addViews() {
+ super.addViews()
+
+ // Adding additional views to current view. The OTP textfield has already been added.
+ }
+
+ override func configureLayout() {
+ super.configureLayout()
+
+ // Confgiure layout of subviews
+ }
+
+ override func bindViews() {
+ super.bindViews()
+
+ // Binding to data or user actions
+
+ let gesture = UITapGestureRecognizer(target: self, action: #selector(onTapAction))
+ addGestureRecognizer(gesture)
+ }
+
+ private func onTapAction() {
+ onTap?()
+ }
+
+ override func configureAppearance() {
+ super.configureAppearance()
+
+ // Appearance configuration method
+ }
+}
+```
+
+*If needed to set validation for input use `validationClosure: ValidationClosure?`*. For example, only numbers validation:
+
+```swift
+import OTPSwiftView
+
+class CustomOTPView: OTPView {
+
+ override func bindViews() {
+ super.bindViews()
+
+ codeTextField.validationClosure = { input in
+ input.allSatisfy { $0.isNumber }
+ }
+ }
+}
+```
+
+## OTPSwiftView
+*OTPSwiftView* is a base class that is responsible for the layout of single OTP views.
+As with OTPView, you should create an heir class to configure your full OTP view.
+
+```swift
+import OTPSwiftView
+
+final class CustomOTPSwiftView: OTPSwiftView {
+ override func addViews() {
+ super.addViews()
+
+ // Adding additional views to current code view. The single OTP views has already been added.
+ }
+
+ override func configureLayout() {
+ super.configureLayout()
+
+ // Confgiure layout of subviews
+ }
+
+ override func bindViews() {
+ super.bindViews()
+
+ // Binding to data or user actions
+ }
+
+ override func configureAppearance() {
+ super.configureAppearance()
+
+ // Appearance configuration method
+ }
+
+ override func configure(with config: OTPCodeConfig) {
+ super.configure(with: config)
+
+ // Configure you code view with configuration
+ }
+}
+```
+
+# Installation via SPM
+
+You can install this framework as a target of LeadKit.
diff --git a/OTPSwiftView/Sources/Extensions/Substring/Substring+Extensions.swift b/OTPSwiftView/Sources/Extensions/Substring/Substring+Extensions.swift
new file mode 100644
index 00000000..61d0812f
--- /dev/null
+++ b/OTPSwiftView/Sources/Extensions/Substring/Substring+Extensions.swift
@@ -0,0 +1,30 @@
+//
+// Copyright (c) 2020 Touch Instinct
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the Software), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+//
+
+import Foundation
+
+public extension Substring {
+ var string: String {
+ String(self)
+ }
+}
+
diff --git a/OTPSwiftView/Sources/Extensions/UITextField/UITextField+Extensions.swift b/OTPSwiftView/Sources/Extensions/UITextField/UITextField+Extensions.swift
new file mode 100644
index 00000000..5371dd4e
--- /dev/null
+++ b/OTPSwiftView/Sources/Extensions/UITextField/UITextField+Extensions.swift
@@ -0,0 +1,29 @@
+//
+// Copyright (c) 2020 Touch Instinct
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the Software), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+//
+
+import UIKit
+
+public extension UITextField {
+ var unwrappedText: String {
+ text ?? ""
+ }
+}
diff --git a/OTPSwiftView/Sources/Helpers/Typealias.swift b/OTPSwiftView/Sources/Helpers/Typealias.swift
new file mode 100644
index 00000000..c324660e
--- /dev/null
+++ b/OTPSwiftView/Sources/Helpers/Typealias.swift
@@ -0,0 +1,27 @@
+//
+// Copyright (c) 2020 Touch Instinct
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the Software), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+//
+
+import UIKit
+
+public typealias Spacing = [Int: CGFloat]
+public typealias VoidClosure = (() -> Void)
+public typealias ValidationClosure = ((T) -> Bool)
diff --git a/OTPSwiftView/Sources/Models/OTPCodeConfig.swift b/OTPSwiftView/Sources/Models/OTPCodeConfig.swift
new file mode 100644
index 00000000..f0c85782
--- /dev/null
+++ b/OTPSwiftView/Sources/Models/OTPCodeConfig.swift
@@ -0,0 +1,36 @@
+//
+// Copyright (c) 2020 Touch Instinct
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the Software), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+//
+
+import UIKit
+
+/// Base configuration for OTPSwiftView
+open class OTPCodeConfig {
+ public let codeSymbolsCount: Int
+ public let spacing: CGFloat
+ public let customSpacing: Spacing?
+
+ public init(codeSymbolsCount: Int, spacing: CGFloat, customSpacing: Spacing?) {
+ self.codeSymbolsCount = codeSymbolsCount
+ self.spacing = spacing
+ self.customSpacing = customSpacing
+ }
+}
diff --git a/OTPSwiftView/Sources/Views/OTPSwiftView/OTPSwiftView.swift b/OTPSwiftView/Sources/Views/OTPSwiftView/OTPSwiftView.swift
new file mode 100644
index 00000000..08ceeb5d
--- /dev/null
+++ b/OTPSwiftView/Sources/Views/OTPSwiftView/OTPSwiftView.swift
@@ -0,0 +1,150 @@
+//
+// Copyright (c) 2020 Touch Instinct
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the Software), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+//
+
+import UIKit
+import TIUIKitCore
+
+/// Base full OTP View for entering the verification code
+open class OTPSwiftView: BaseInitializableControl {
+ private var emptyOTPView: View? {
+ textFieldsCollection.first { $0.codeTextField.unwrappedText.isEmpty } ?? textFieldsCollection.last
+ }
+
+ public private(set) var codeStackView = UIStackView()
+ public private(set) var textFieldsCollection: [View] = []
+
+ public var onTextEnter: ((String) -> Void)?
+
+ public var code: String {
+ get {
+ textFieldsCollection.compactMap { $0.codeTextField.text }.joined()
+ }
+ set {
+ textFieldsCollection.first?.codeTextField.set(inputText: newValue)
+ }
+ }
+
+ public override var isFirstResponder: Bool {
+ !textFieldsCollection.allSatisfy { !$0.codeTextField.isFirstResponder }
+ }
+
+ open override func addViews() {
+ super.addViews()
+
+ addSubview(codeStackView)
+ }
+
+ open override func configureAppearance() {
+ super.configureAppearance()
+
+ codeStackView.contentMode = .center
+ codeStackView.distribution = .fillEqually
+ }
+
+ open func configure(with config: OTPCodeConfig) {
+ textFieldsCollection = createTextFields(numberOfFields: config.codeSymbolsCount)
+
+ codeStackView.addArrangedSubviews(textFieldsCollection)
+ codeStackView.spacing = config.spacing
+
+ configure(customSpacing: config.customSpacing, for: codeStackView)
+
+ bindTextFields(with: config)
+ }
+
+ @discardableResult
+ open override func becomeFirstResponder() -> Bool {
+ guard let emptyOTPView = emptyOTPView, !emptyOTPView.isFirstResponder else {
+ return false
+ }
+
+ return emptyOTPView.codeTextField.becomeFirstResponder()
+ }
+
+ @discardableResult
+ open override func resignFirstResponder() -> Bool {
+ guard let emptyOTPView = emptyOTPView, emptyOTPView.isFirstResponder else {
+ return false
+ }
+
+ return emptyOTPView.codeTextField.resignFirstResponder()
+ }
+}
+
+// MARK: - Configure textfields
+
+private extension OTPSwiftView {
+ func configure(customSpacing: Spacing?, for stackView: UIStackView) {
+ guard let customSpacing = customSpacing else {
+ return
+ }
+
+ customSpacing.forEach { [weak self] viewIndex, spacing in
+ guard viewIndex < stackView.arrangedSubviews.count, viewIndex >= 0 else {
+ return
+ }
+
+ self?.set(spacing: spacing,
+ after: stackView.arrangedSubviews[viewIndex],
+ at: viewIndex,
+ for: stackView)
+ }
+ }
+
+ func set(spacing: CGFloat,
+ after view: UIView,
+ at index: Int,
+ for stackView: UIStackView) {
+ stackView.setCustomSpacing(spacing, after: view)
+ }
+
+ func createTextFields(numberOfFields: Int) -> [View] {
+ var textFieldsCollection: [View] = []
+
+ (0..?
+ public var caretHeight: CGFloat?
+
+ public var lastNotEmpty: OTPTextField {
+ let isLastNotEmpty = !unwrappedText.isEmpty && nextTextField?.unwrappedText.isEmpty ?? true
+ return isLastNotEmpty ? self : nextTextField?.lastNotEmpty ?? self
+ }
+
+ open override var font: UIFont? {
+ didSet {
+ if caretHeight == nil, let font = font {
+ caretHeight = font.pointSize - font.descender
+ }
+ }
+ }
+
+ public override init(frame: CGRect) {
+ super.init(frame: frame)
+
+ delegate = self
+ }
+
+ @available(*, unavailable)
+ required public init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ open override func deleteBackward() {
+ guard unwrappedText.isEmpty else {
+ return
+ }
+
+ onTextChangedSignal?()
+ previousTextField?.text = ""
+ previousTextField?.becomeFirstResponder()
+ }
+
+ public func set(inputText: String) {
+ text = inputText.prefix(maxSymbolsCount).string
+
+ let nextInputText = inputText.count >= maxSymbolsCount
+ ? inputText.suffix(inputText.count - maxSymbolsCount).string
+ : ""
+
+ nextTextField?.set(inputText: nextInputText)
+ }
+
+ open override func caretRect(for position: UITextPosition) -> CGRect {
+ guard let caretHeight = caretHeight else {
+ return super.caretRect(for: position)
+ }
+
+ var superRect = super.caretRect(for: position)
+ superRect.size.height = caretHeight
+
+ return superRect
+ }
+
+ open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
+ let view = super.hitTest(point, with: event)
+ return view == self && isFirstResponder ? view : nil
+ }
+}
+
+extension OTPTextField: UITextFieldDelegate {
+ public func textField(_ textField: UITextField,
+ shouldChangeCharactersIn range: NSRange,
+ replacementString string: String) -> Bool {
+ guard let textField = textField as? OTPTextField else {
+ return true
+ }
+
+ let isInputEmpty = textField.unwrappedText.isEmpty && string.isEmpty
+
+ guard isInputEmpty || validationClosure?(string) ?? true else {
+ return false
+ }
+
+ switch range.length {
+ case 0:
+ textField.set(inputText: string)
+
+ let currentTextField = textField.lastNotEmpty.nextTextField ?? textField.lastNotEmpty
+ currentTextField.becomeFirstResponder()
+ textField.onTextChangedSignal?()
+
+ return false
+
+ case 1:
+ textField.text = ""
+ textField.onTextChangedSignal?()
+ return false
+
+ default:
+ return true
+ }
+ }
+}
diff --git a/OTPSwiftView/Sources/Views/OTPView/OTPView.swift b/OTPSwiftView/Sources/Views/OTPView/OTPView.swift
new file mode 100644
index 00000000..a416b1ce
--- /dev/null
+++ b/OTPSwiftView/Sources/Views/OTPView/OTPView.swift
@@ -0,0 +1,36 @@
+//
+// Copyright (c) 2020 Touch Instinct
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the Software), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+//
+
+import TIUIKitCore
+
+/// Base OTP view with textfield for entering a one symbol
+open class OTPView: BaseInitializableView {
+ public let codeTextField = OTPTextField()
+
+ public var onTap: VoidClosure?
+
+ open override func addViews() {
+ super.addViews()
+
+ addSubview(codeTextField)
+ }
+}
diff --git a/Package.swift b/Package.swift
index 6d1622df..22b09a56 100644
--- a/Package.swift
+++ b/Package.swift
@@ -9,11 +9,13 @@ let package = Package(
products: [
.library(name: "TITransitions", targets: ["TITransitions"]),
.library(name: "TIUIKitCore", targets: ["TIUIKitCore"]),
- .library(name: "TIUIElements", targets: ["TIUIElements"])
+ .library(name: "TIUIElements", targets: ["TIUIElements"]),
+ .library(name: "OTPSwiftView", targets: ["OTPSwiftView"])
],
targets: [
.target(name: "TITransitions", path: "TITransitions/Sources"),
.target(name: "TIUIKitCore", path: "TIUIKitCore/Sources"),
- .target(name: "TIUIElements", dependencies: ["TIUIKitCore"], path: "TIUIElements/Sources")
+ .target(name: "TIUIElements", dependencies: ["TIUIKitCore"], path: "TIUIElements/Sources"),
+ .target(name: "OTPSwiftView", dependencies: ["TIUIKitCore"], path: "OTPSwiftView/Sources")
]
)
diff --git a/TIUIKitCore/Sources/Views/BaseInitializableControl.swift b/TIUIKitCore/Sources/Views/BaseInitializableControl.swift
new file mode 100644
index 00000000..c5cc67f0
--- /dev/null
+++ b/TIUIKitCore/Sources/Views/BaseInitializableControl.swift
@@ -0,0 +1,37 @@
+import UIKit
+
+open class BaseInitializableControl: UIControl, InitializableView {
+ override public init(frame: CGRect) {
+ super.init(frame: frame)
+
+ initializeView()
+ }
+
+ required public init?(coder aDecoder: NSCoder) {
+ super.init(coder: aDecoder)
+
+ initializeView()
+ }
+
+ // MARK: - InitializableView
+
+ open func addViews() {
+ // override in subclass
+ }
+
+ open func configureLayout() {
+ // override in subclass
+ }
+
+ open func bindViews() {
+ // override in subclass
+ }
+
+ open func configureAppearance() {
+ // override in subclass
+ }
+
+ open func localize() {
+ // override in subclass
+ }
+}