Merge pull request #237 from TouchInstinct/feature/otpview

OTPview and BaseInitializableControl
This commit is contained in:
Loupehope 2020-08-25 21:35:40 +03:00 committed by GitHub
commit ebd8ab23a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 674 additions and 3 deletions

View File

@ -1,5 +1,10 @@
# Changelog
### 0.9.41
- **Add**: `OTPSwiftView` - a fully customizable OTP view.
- **Add**: `BaseInitializableControl` UIControl conformance to InitializableView.
- **Add**: `TISwiftUtils` a bunch of useful helpers for development.
### 0.9.40
- **Fix**: Load more request repetion in `PaginationWrapper`.

View File

@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = "LeadKit"
s.version = "0.9.40"
s.version = "0.9.41"
s.summary = "iOS framework with a bunch of tools for rapid development"
s.homepage = "https://github.com/TouchInstinct/LeadKit"
s.license = "Apache License, Version 2.0"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

151
OTPSwiftView/README.md Normal file
View File

@ -0,0 +1,151 @@
# OTPSwiftView
![Platform](https://img.shields.io/badge/platform-iOS-green)
A fully customizable OTP view.
<p align="left">
<img src="Assets/preview.gif" width=300 height=533>
</p>
# 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<String>?`*. 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<CustomOTPView> {
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.

View File

@ -0,0 +1,38 @@
//
// 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 typealias Spacing = [Int: CGFloat]
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
}
}

View File

@ -0,0 +1,149 @@
//
// 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
import TISwiftUtils
/// Base full OTP View for entering the verification code
open class OTPSwiftView<View: OTPView>: BaseInitializableControl {
private var emptyOTPView: View? {
textFieldsCollection.first { $0.codeTextField.text.orEmpty.isEmpty } ?? textFieldsCollection.last
}
public private(set) var codeStackView = UIStackView()
public private(set) var textFieldsCollection: [View] = []
public var onTextEnter: ParameterClosure<String>?
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: OTPCodeConfig.Spacing?, for stackView: UIStackView) {
guard let customSpacing = customSpacing else {
return
}
customSpacing.forEach { viewIndex, spacing in
guard viewIndex < stackView.arrangedSubviews.count, viewIndex >= .zero else {
return
}
self.set(spacing: spacing,
after: stackView.arrangedSubviews[viewIndex],
for: stackView)
}
}
func set(spacing: CGFloat, after view: UIView, for stackView: UIStackView) {
stackView.setCustomSpacing(spacing, after: view)
}
func createTextFields(numberOfFields: Int) -> [View] {
var textFieldsCollection: [View] = []
(.zero..<numberOfFields).forEach { _ in
let textField = View()
textField.codeTextField.previousTextField = textFieldsCollection.last?.codeTextField
textFieldsCollection.last?.codeTextField.nextTextField = textField.codeTextField
textFieldsCollection.append(textField)
}
return textFieldsCollection
}
func bindTextFields(with config: OTPCodeConfig) {
let onTextChangedSignal: VoidClosure = { [weak self] in
guard let code = self?.code else {
return
}
let correctedCode = code.prefix(config.codeSymbolsCount).string
self?.onTextEnter?(correctedCode)
}
let onTap: VoidClosure = { [weak self] in
self?.becomeFirstResponder()
}
textFieldsCollection.forEach {
$0.codeTextField.onTextChangedSignal = onTextChangedSignal
$0.onTap = onTap
}
}
}

View File

@ -0,0 +1,131 @@
//
// 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 TISwiftUtils
/// Base one symbol textfield
open class OTPTextField: UITextField {
private let maxSymbolsCount = 1
public weak var previousTextField: OTPTextField?
public weak var nextTextField: OTPTextField?
public var onTextChangedSignal: VoidClosure?
public var validationClosure: Closure<String, Bool>?
public var caretHeight: CGFloat?
public var lastNotEmpty: OTPTextField {
let isLastNotEmpty = !text.orEmpty.isEmpty && nextTextField?.text.orEmpty.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 text.orEmpty.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.text.orEmpty.isEmpty && string.isEmpty
guard isInputEmpty || validationClosure?(string) ?? true else {
return false
}
switch range.length {
case 0: // set text to textfield
textField.set(inputText: string)
let currentTextField = textField.lastNotEmpty.nextTextField ?? textField.lastNotEmpty
currentTextField.becomeFirstResponder()
textField.onTextChangedSignal?()
return false
case 1: // remove character from textfield
textField.text = ""
textField.onTextChangedSignal?()
return false
default:
return true
}
}
}

View File

@ -0,0 +1,37 @@
//
// 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
import TISwiftUtils
/// 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)
}
}

View File

@ -9,11 +9,15 @@ let package = Package(
products: [
.library(name: "TITransitions", targets: ["TITransitions"]),
.library(name: "TIUIKitCore", targets: ["TIUIKitCore"]),
.library(name: "TIUIElements", targets: ["TIUIElements"])
.library(name: "TISwiftUtils", targets: ["TISwiftUtils"]),
.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: "TISwiftUtils", path: "TISwiftUtils/Sources"),
.target(name: "TIUIElements", dependencies: ["TIUIKitCore"], path: "TIUIElements/Sources"),
.target(name: "OTPSwiftView", dependencies: ["TIUIKitCore"], path: "OTPSwiftView/Sources")
]
)

View File

@ -6,4 +6,6 @@ This repository contains the following additional frameworks:
- [TIUIKitCore](TIUIKitCore) - core ui elements and protocols from LeadKit.
- [TITransitions](TITransitions) - set of custom transitions to present controller.
- [TIUIElements](TIUIElements) - bunch of of useful protocols and views.
- [OTPSwiftView](OTPSwiftView) - a fully customizable OTP view.
- [TISwiftUtils](TISwiftUtils) - a bunch of useful helpers for development.

7
TISwiftUtils/README.md Normal file
View File

@ -0,0 +1,7 @@
# TISwiftUtils
Bunch of useful helpers for development.
# Installation via SPM
You can install this framework as a target of LeadKit.

View File

@ -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 Optional where Wrapped == String {
var orEmpty: String {
self ?? ""
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,51 @@
//
// 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
/// Closure with custom arguments and return value.
public typealias Closure<Input, Output> = (Input) -> Output
/// Closure with no arguments and custom return value.
public typealias ResultClosure<Output> = () -> Output
/// Closure that takes custom arguments and returns Void.
public typealias ParameterClosure<Input> = Closure<Input, Void>
// MARK: Throwable versions
/// Closure with custom arguments and return value, may throw an error.
public typealias ThrowableClosure<Input, Output> = (Input) throws -> Output
/// Closure with no arguments and custom return value, may throw an error.
public typealias ThrowableResultClosure<Output> = () throws -> Output
/// Closure that takes custom arguments and returns Void, may throw an error.
public typealias ThrowableParameterClosure<Input> = ThrowableClosure<Input, Void>
// MARK: Concrete closures
/// Closure that takes no arguments and returns Void.
public typealias VoidClosure = ResultClosure<Void>
/// Closure that takes no arguments, may throw an error and returns Void.
public typealias ThrowableVoidClosure = () throws -> Void

View File

@ -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
}
}