├── .gitignore ├── Package.swift ├── README.md ├── Sources └── SWCodeField │ └── SWCodeField.swift ├── Tests └── SWCodeFieldTests │ └── SWCodeFieldTests.swift └── img ├── Demonstrate.gif └── main.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SWCodeField", 8 | platforms: [ 9 | .iOS(.v14) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "SWCodeField", 15 | targets: ["SWCodeField"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "SWCodeField", 26 | dependencies: []), 27 | .testTarget( 28 | name: "SWCodeFieldTests", 29 | dependencies: ["SWCodeField"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SWCodeField 2 | 3 | `SWCodeField` - простое поле для ввода кода подтверждения из смс или email с возможность изменения количества элементов. UI-элемент основан на `UIStackView`, объединяет в себе несколько текстовых полей и реализует логичное переключение между ними при вводе и удалении символов. 4 | 5 | ![Внешний вид элемента](img/Demonstrate.gif) 6 | 7 | ### Основные возможности 8 | 9 | - Создание и настройка элемента с помощью кода. 10 | - Создание и настройка элемента с помощью Storyboard (с отображением прямо в storyboard). 11 | - Возможность настройки количества секций и элементов. 12 | - Поддержка автоматической вставки кода из поступившего смс-сообщения. 13 | 14 | ### Требования 15 | 16 | - iOS 14+ 17 | - UIKit (поддержки SwiftUI нет) 18 | 19 | ## Установка 20 | 21 | ### Swift Package Manager 22 | 23 | #### Вариант 1. 24 | 25 | - В Xcode перейдите к `File | Add Packages ...` и введите адрес `https://github.com/DobbyWanKenoby/SWCodeField` в поисковом поле. 26 | - Укажите необходимую версию и нажмите `Add Package`. 27 | 28 | #### Вариант 2. 29 | 30 | Добавьте в качестве зависимости в файл `Package.swift` следующий код: 31 | 32 | ``` 33 | dependencies: [ 34 | .package(url: "https://github.com/DobbyWanKenoby/SWCodeField", .upToNextMajor(from: "1.0")) 35 | ] 36 | ``` 37 | 38 | ### Вручную 39 | 40 | Добавьте в собственный проект код из файла Sources/SWCodeField/SWCodeField.swift 41 | 42 | ## Использование 43 | 44 | ### Использование с помощью Storyboard 45 | 46 | - Добавьте на сцену элемент `UIStackView`. 47 | - В качестве класса элемента укажите `SWCodeField`. 48 | - В `Attributes Inspector` укажите количество блоков и элементов в блоках (текстовых полей). 49 | - Свяжите графический элемент со свойством во вьюконтроллере. 50 | 51 | ```swift 52 | @IBOutlet var codeField: SWCodeField! 53 | ``` 54 | 55 | - В программном коде задайте обработчик, который вызывается после заполнения всех текстовых полей (свойство `doAfterCodeDidEnter`). 56 | 57 | ```swift 58 | codeField.doAfterCodeDidEnter = { code in 59 | print("Code is \(code)") 60 | } 61 | ``` 62 | 63 | ### Использование с помощью программного кода 64 | 65 | - Создайте новый экземпляр типа `SWCodeField`, указав количество блоков и элементов. 66 | 67 | ```swift 68 | let codeField = SWCodeField(blocks: 2, elementsInBlock: 3) 69 | ``` 70 | 71 | - Укажите размер и расположение элемента любым удобным способом: 72 | 73 | Через frame 74 | ```swift 75 | self.view.addSubview(codeField) 76 | codeField.frame.size = CGSize(width: 200, height: 50) 77 | codeField.center = view.center 78 | ``` 79 | 80 | С помощью SnapKit 81 | ```swift 82 | codeField.snp.makeConstraints { make in 83 | make.centerY.centerX.equalToSuperview() 84 | make.leadingMargin.trailingMargin.equalTo(40) 85 | make.height.equalTo(50) 86 | } 87 | ``` 88 | 89 | - Задайте обработчик, который вызывается после заполнения всех текстовых полей (свойство `doAfterCodeDidEnter`). 90 | 91 | ```swift 92 | codeField.doAfterCodeDidEnter = { code in 93 | print("Code is \(code)") 94 | } 95 | ``` 96 | 97 | ## API 98 | 99 | ```swift 100 | // Замыкание, которое выполняется после того, когда введены значения для всех полей 101 | // В качестве входного параметра получает введнный код в виде строки 102 | var doAfterCodeDidEnter: ((String) -> Void)? { get set } 103 | 104 | // Код в текстовых полях 105 | // Может быть как прочитано, так и установлено 106 | var code: String { get set } 107 | ``` 108 | 109 | ## TODO 110 | 111 | - Тесты. 112 | - Обработка вставки скопированного текста. 113 | - Настройка внешнего вида текстовых полей. 114 | - Настройка внешнего вида линий. 115 | - Настрйока шрифта. 116 | - Различное количество элементво в блоках. 117 | - Настройка разделителя между блоками (например символ `-` для кода типа `12-33-56`). 118 | -------------------------------------------------------------------------------- /Sources/SWCodeField/SWCodeField.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /** 4 | SWCodeField - класс, обеспечивающий работу графического элемента "Поле для ввода кода" 5 | */ 6 | 7 | @IBDesignable 8 | open class SWCodeField: UIStackView { 9 | 10 | // MARK: API 11 | 12 | /// Выполняется после того, как заполняются все текстовые поля 13 | // Принимает code в качестве входного значения 14 | public var doAfterCodeDidEnter: ((String) -> Void)? 15 | /// Код в текстовых полях 16 | public var code: String { 17 | get { 18 | enteredCode.map { String($0) }.joined() 19 | } 20 | set { 21 | for (index, symbol) in newValue.enumerated() { 22 | textFields[index].value?.text = "\(symbol)" 23 | } 24 | } 25 | } 26 | 27 | // MARK: Properties 28 | 29 | // количество блоков с текстовыми полями 30 | @IBInspectable 31 | private var blocks: Int = 0 { 32 | didSet { 33 | createBlocks() 34 | } 35 | } 36 | // количество текстовых полей в каждом блоке 37 | @IBInspectable 38 | private var elementsInBlock: Int = 0 { 39 | didSet { 40 | createBlocks() 41 | } 42 | } 43 | 44 | private var enteredCode: [Int] { 45 | var resultNumbers = [Int]() 46 | textFields.forEach { textField in 47 | if let text = textField.value?.text, let number = Int(text) { 48 | resultNumbers.append(number) 49 | } 50 | } 51 | return resultNumbers 52 | } 53 | 54 | // Wrapper для хранения слабых ссылок на текстовые поля 55 | class TFWrapper { 56 | weak var value: UITextField? 57 | init(_ tf: UITextField) { 58 | value = tf 59 | } 60 | } 61 | 62 | private var textFields: [TFWrapper] = [] 63 | 64 | convenience public init(blocks: Int, elementsInBlock: Int) { 65 | 66 | guard blocks > 0, elementsInBlock > 0 else { 67 | fatalError("SWCodeField: Blocks and elements count must more than 0") 68 | } 69 | 70 | self.init(frame: .zero) 71 | 72 | self.blocks = blocks 73 | self.elementsInBlock = elementsInBlock 74 | 75 | createBlocks() 76 | configureMainStackView() 77 | } 78 | 79 | // Создание блоков, включая вложенные элементы 80 | private func createBlocks() { 81 | guard blocks > 0, elementsInBlock > 0 else { 82 | return 83 | } 84 | 85 | removeArrangedViews() 86 | textFields.removeAll() 87 | 88 | // создание блоков 89 | (1...blocks).forEach { _ in 90 | let block = getBlockStackView() 91 | // создание элементов внутри блока 92 | (1...elementsInBlock).forEach { elementIndex in 93 | // текстовое поле 94 | let textField = getTextField() 95 | textFields.append(TFWrapper(textField)) 96 | 97 | // stack для объединения поля и линии 98 | let stackView = UIStackView(arrangedSubviews: [textField, getBottomLine()]) 99 | stackView.distribution = .fill 100 | stackView.axis = .vertical 101 | stackView.spacing = 2 102 | 103 | block.addArrangedSubview(stackView) 104 | } 105 | self.addArrangedSubview(block) 106 | } 107 | } 108 | 109 | private func removeArrangedViews() { 110 | for view in arrangedSubviews { 111 | view.removeFromSuperview() 112 | } 113 | } 114 | 115 | // Конфигурирование основного StackView 116 | private func configureMainStackView() { 117 | self.axis = .horizontal 118 | self.spacing = 20 119 | self.distribution = .fillEqually 120 | } 121 | 122 | // Внутренний StackView, объединяющий текстовое поле и линию под ним 123 | private func getBlockStackView() -> UIStackView { 124 | let stackView = UIStackView() 125 | stackView.spacing = 5 126 | stackView.axis = self.axis 127 | stackView.distribution = .fillEqually 128 | return stackView 129 | } 130 | 131 | // Текстовое поле, в которое вводится число 132 | private func getTextField() -> UITextField { 133 | let textField = SWCodeTextField() 134 | // обработчик нажатия на кнопку удаления символа 135 | textField.onDeleteBackward = { 136 | self.removeLastNumber() 137 | let lastFieldIndex = self.enteredCode.count 138 | self.textFields[lastFieldIndex].value?.becomeFirstResponder() 139 | } 140 | textField.keyboardType = .numberPad 141 | textField.addAction(getActionFor(textField: textField), for: .editingChanged) 142 | textField.textAlignment = .center 143 | textField.font = .systemFont(ofSize: 30) 144 | textField.delegate = self 145 | return textField 146 | } 147 | 148 | // действие для текстового поля 149 | private func getActionFor(textField: UITextField) -> UIAction { 150 | let action = UIAction { action in 151 | guard let text = textField.text, let _ = Int(text) else { 152 | return 153 | } 154 | let lastFieldIndex = self.enteredCode.count 155 | if lastFieldIndex < self.textFields.count && lastFieldIndex > 0 { 156 | self.textFields[lastFieldIndex].value?.becomeFirstResponder() 157 | } else { 158 | self.textFields.last?.value?.resignFirstResponder() 159 | self.doAfterCodeDidEnter?(self.code) 160 | } 161 | } 162 | return action 163 | } 164 | 165 | // Линия под текстовым полем 166 | private func getBottomLine() -> UIView { 167 | let view = UIView() 168 | view.addConstraint(NSLayoutConstraint(item: view, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: 3)) 169 | view.backgroundColor = UIColor.lightGray 170 | view.layer.cornerRadius = 3 171 | return view 172 | } 173 | 174 | // MARK: Helpers 175 | 176 | // Удаляет символ из последнего заполненного текстового поля 177 | private func removeLastNumber() { 178 | for textField in textFields.reversed() { 179 | if let text = textField.value?.text, text != "" { 180 | textField.value!.text = "" 181 | return 182 | } 183 | } 184 | } 185 | 186 | // Активирует первое незаполненное текстовое поле 187 | // В случае, когда заполнены все поля, то активирует последнее 188 | private func activateCorrectTextField() { 189 | let lastFieldIndex = self.enteredCode.count 190 | if lastFieldIndex == textFields.count { 191 | self.textFields.last?.value?.becomeFirstResponder() 192 | } else if lastFieldIndex == 0 { 193 | self.textFields.first?.value?.becomeFirstResponder() 194 | } else { 195 | self.textFields[lastFieldIndex].value?.becomeFirstResponder() 196 | } 197 | } 198 | 199 | } 200 | 201 | extension SWCodeField: UITextFieldDelegate { 202 | 203 | public func textFieldDidBeginEditing(_ textField: UITextField) { 204 | activateCorrectTextField() 205 | } 206 | 207 | public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { 208 | // Если текст вставляется, например из пришедшей СМС 209 | if string.count > 1 { 210 | // TODO: Добавить обработку вставки текста 211 | return true 212 | // Если текст вводится 213 | } else { 214 | // Ограничение на количество вводимых символов 215 | let maxLength = 1 216 | let currentString: NSString = (textField.text ?? "") as NSString 217 | let newString: NSString = 218 | currentString.replacingCharacters(in: range, with: string) as NSString 219 | return newString.length <= maxLength 220 | } 221 | } 222 | 223 | } 224 | 225 | // Кастомный класс текстового поля с переопределенным поведением по нажатию на бэкспейс 226 | fileprivate class SWCodeTextField: UITextField { 227 | 228 | var onDeleteBackward: (() -> Void)? 229 | 230 | override public func deleteBackward() { 231 | onDeleteBackward?() 232 | super.deleteBackward() 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /Tests/SWCodeFieldTests/SWCodeFieldTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SWCodeField 3 | 4 | final class SWCodeFieldTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual(SWCodeField().text, "Hello, World!") 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /img/Demonstrate.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DobbyWanKenoby/SWCodeField/6677ff1e68b0547dd6983b30188517b5937f5812/img/Demonstrate.gif -------------------------------------------------------------------------------- /img/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DobbyWanKenoby/SWCodeField/6677ff1e68b0547dd6983b30188517b5937f5812/img/main.png --------------------------------------------------------------------------------