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 + +![Platform](https://img.shields.io/badge/platform-iOS-green) + +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 + } +}