Skip to content

Commit f49246d

Browse files
authored
feat: add UITextView validation support (#92)
* feat: add UITextView validation support * docs: update examples
1 parent 90b075a commit f49246d

7 files changed

Lines changed: 648 additions & 214 deletions

File tree

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,51 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
2+
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24127" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="In3-Gf-dmP">
3+
<device id="retina6_12" orientation="portrait" appearance="light"/>
34
<dependencies>
4-
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
5+
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24063"/>
56
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
7+
<capability name="System colors in document resources" minToolsVersion="11.0"/>
68
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
79
</dependencies>
810
<scenes>
911
<!--View Controller-->
1012
<scene sceneID="tne-QT-ifu">
1113
<objects>
12-
<viewController id="BYZ-38-t0r" customClass="ViewController" customModuleProvider="target" sceneMemberID="viewController">
14+
<viewController id="BYZ-38-t0r" customClass="ViewController" customModule="UIKitExample" customModuleProvider="target" sceneMemberID="viewController">
1315
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
14-
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
16+
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
1517
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
16-
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
1718
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
19+
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
1820
</view>
21+
<navigationItem key="navigationItem" id="xZX-kb-XF8"/>
1922
</viewController>
2023
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
2124
</objects>
25+
<point key="canvasLocation" x="1064.885496183206" y="130.98591549295776"/>
26+
</scene>
27+
<!--Navigation Controller-->
28+
<scene sceneID="jvk-0h-3ho">
29+
<objects>
30+
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="In3-Gf-dmP" sceneMemberID="viewController">
31+
<toolbarItems/>
32+
<navigationBar key="navigationBar" contentMode="scaleToFill" id="xXu-9J-MCw">
33+
<rect key="frame" x="0.0" y="118" width="393" height="54"/>
34+
<autoresizingMask key="autoresizingMask"/>
35+
</navigationBar>
36+
<nil name="viewControllers"/>
37+
<connections>
38+
<segue destination="BYZ-38-t0r" kind="relationship" relationship="rootViewController" id="Fe5-Td-pvt"/>
39+
</connections>
40+
</navigationController>
41+
<placeholder placeholderIdentifier="IBFirstResponder" id="kBa-xd-Ry9" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
42+
</objects>
43+
<point key="canvasLocation" x="138.1679389312977" y="130.98591549295776"/>
2244
</scene>
2345
</scenes>
46+
<resources>
47+
<systemColor name="systemBackgroundColor">
48+
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
49+
</systemColor>
50+
</resources>
2451
</document>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//
2+
// Validator
3+
// Copyright © 2025 Space Code. All rights reserved.
4+
//
5+
6+
import UIKit
7+
8+
extension UIView {
9+
func findFirstResponder() -> UIView? {
10+
if isFirstResponder { return self }
11+
for sub in subviews {
12+
if let found = sub.findFirstResponder() { return found }
13+
}
14+
return nil
15+
}
16+
}

Examples/UIKit/UIKitExample/ViewController.swift

Lines changed: 37 additions & 209 deletions
Original file line numberDiff line numberDiff line change
@@ -10,235 +10,63 @@ import ValidatorUI
1010
// MARK: - ViewController
1111

1212
final class ViewController: UIViewController {
13-
// MARK: - Properties
13+
private let examples: [ExampleItem] = [
14+
ExampleItem(title: "UITextField Example", controller: LoginTextFieldExampleViewController()),
15+
ExampleItem(title: "UITextView Example", controller: FeedbackTextViewExampleViewController()),
16+
]
1417

15-
// UI
16-
private let scrollView: UIScrollView = {
17-
let scrollView = UIScrollView()
18-
scrollView.translatesAutoresizingMaskIntoConstraints = false
19-
return scrollView
20-
}()
21-
22-
private let contentView: UIView = {
23-
let view = UIView()
24-
view.translatesAutoresizingMaskIntoConstraints = false
25-
return view
26-
}()
27-
28-
private lazy var firstNameTextField: UITextField = {
29-
let textField = makeField("First Name")
30-
textField.validationRules = [
31-
LengthValidationRule(min: 2, max: 50, error: "First name should be 2–50 characters long"),
32-
]
33-
return textField
34-
}()
35-
36-
private lazy var lastNameTextField: UITextField = {
37-
let textField = makeField("Last Name")
38-
textField.validationRules = [
39-
LengthValidationRule(min: 2, max: 50, error: "Last name should be 2–50 characters long"),
40-
]
41-
return textField
42-
}()
43-
44-
private lazy var emailTextField: UITextField = {
45-
let textField = makeField("Email")
46-
textField.keyboardType = .emailAddress
47-
textField.validationRules = [
48-
EmailValidationRule(error: "Please enter a valid email address"),
49-
]
50-
return textField
51-
}()
52-
53-
private lazy var submitButton: UIButton = {
54-
let button = UIButton(type: .system)
55-
button.setTitle("Sign Up", for: .normal)
56-
button.layer.cornerRadius = 10
57-
button.backgroundColor = .systemGray4
58-
button.setTitleColor(.white, for: .normal)
59-
button.translatesAutoresizingMaskIntoConstraints = false
60-
button.addTarget(self, action: #selector(submit), for: .touchUpInside)
61-
return button
62-
}()
63-
64-
private let stackView: UIStackView = {
65-
let stackView = UIStackView()
66-
stackView.axis = .vertical
67-
stackView.spacing = 16
68-
stackView.translatesAutoresizingMaskIntoConstraints = false
69-
return stackView
70-
}()
71-
72-
// Private properties
73-
private var isValid: Bool {
74-
[firstNameTextField, lastNameTextField, emailTextField]
75-
.allSatisfy { $0.validationResult == .valid }
76-
}
77-
78-
// MARK: - Lifecycle
18+
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
7919

8020
override func viewDidLoad() {
8121
super.viewDidLoad()
22+
title = "Examples"
8223
view.backgroundColor = .systemBackground
83-
configure()
84-
}
8524

86-
override func viewWillAppear(_ animated: Bool) {
87-
super.viewWillAppear(animated)
88-
registerKeyboardNotifications()
25+
setupTableView()
8926
}
9027

91-
override func viewWillDisappear(_ animated: Bool) {
92-
super.viewWillDisappear(animated)
93-
unregisterKeyboardNotifications()
94-
}
28+
private func setupTableView() {
29+
tableView.dataSource = self
30+
tableView.delegate = self
31+
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
32+
tableView.translatesAutoresizingMaskIntoConstraints = false
9533

96-
// MARK: - UI Setup
97-
98-
private func configure() {
99-
for item in [firstNameTextField, lastNameTextField, emailTextField] {
100-
item.validationHandler = { [weak self] _ in
101-
guard let self else { return }
102-
updateSubmitButtonState()
103-
}
104-
}
105-
106-
[firstNameTextField, lastNameTextField, emailTextField, submitButton]
107-
.forEach(stackView.addArrangedSubview)
108-
109-
view.addSubview(scrollView)
110-
scrollView.addSubview(contentView)
111-
contentView.addSubview(stackView)
34+
view.addSubview(tableView)
11235

11336
NSLayoutConstraint.activate([
114-
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
115-
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
116-
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
117-
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
118-
119-
contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
120-
contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
121-
contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
122-
contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
123-
contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
124-
contentView.heightAnchor.constraint(equalTo: scrollView.heightAnchor),
125-
126-
stackView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
127-
stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
128-
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24),
129-
130-
submitButton.heightAnchor.constraint(equalToConstant: 48),
37+
tableView.topAnchor.constraint(equalTo: view.topAnchor),
38+
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
39+
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
40+
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
13141
])
13242
}
43+
}
13344

134-
private func updateSubmitButtonState() {
135-
submitButton.isEnabled = isValid
136-
UIView.animate(withDuration: 0.25) {
137-
self.submitButton.backgroundColor = self.isValid ? .systemBlue : .systemGray4
138-
}
139-
}
140-
141-
// MARK: Private
142-
143-
private func makeField(_ placeholder: String) -> UITextField {
144-
let textField = UITextField()
145-
textField.placeholder = placeholder
146-
147-
textField.layer.cornerRadius = 10
148-
textField.layer.borderWidth = 1
149-
textField.layer.borderColor = UIColor.systemGray4.cgColor
150-
textField.backgroundColor = UIColor.secondarySystemBackground
151-
textField.textColor = .label
152-
153-
textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 0))
154-
textField.leftViewMode = .always
155-
156-
textField.heightAnchor.constraint(equalToConstant: 56.0).isActive = true
157-
textField.translatesAutoresizingMaskIntoConstraints = false
158-
textField.validateOnInputChange(isEnabled: true)
159-
160-
return textField
161-
}
162-
163-
// MARK: Notifications
164-
165-
private func registerKeyboardNotifications() {
166-
NotificationCenter.default.addObserver(
167-
self,
168-
selector: #selector(keyboardWillShow(_:)),
169-
name: UIResponder.keyboardWillShowNotification,
170-
object: nil
171-
)
172-
173-
NotificationCenter.default.addObserver(
174-
self,
175-
selector: #selector(keyboardWillHide(_:)),
176-
name: UIResponder.keyboardWillHideNotification,
177-
object: nil
178-
)
179-
}
180-
181-
private func unregisterKeyboardNotifications() {
182-
NotificationCenter.default.removeObserver(self)
183-
}
184-
185-
// MARK: - Actions
186-
187-
@objc
188-
private func submit() {
189-
guard isValid else {
190-
let alert = UIAlertController(
191-
title: "The form is invalid",
192-
message: "Please check the highlighted fields and correct the entered information.",
193-
preferredStyle: .alert
194-
)
195-
alert.addAction(UIAlertAction(title: "Got it", style: .default))
196-
197-
present(alert, animated: true)
198-
return
199-
}
200-
201-
let alert = UIAlertController(
202-
title: "Success 🎉",
203-
message: "Your account has been successfully created.",
204-
preferredStyle: .alert
205-
)
206-
alert.addAction(UIAlertAction(title: "OK", style: .default))
45+
// MARK: UITableViewDataSource, UITableViewDelegate
20746

208-
present(alert, animated: true)
47+
extension ViewController: UITableViewDataSource, UITableViewDelegate {
48+
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
49+
examples.count
20950
}
21051

211-
@objc
212-
private func keyboardWillShow(_ notification: Notification) {
213-
guard
214-
let userInfo = notification.userInfo,
215-
let frameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue
216-
else { return }
217-
218-
let keyboardFrame = frameValue.cgRectValue
219-
let bottomInset = keyboardFrame.height - view.safeAreaInsets.bottom
220-
221-
scrollView.contentInset.bottom = bottomInset
222-
223-
if let firstResponder = view.findFirstResponder(),
224-
!scrollView.frame.contains(firstResponder.frame)
225-
{
226-
scrollView.scrollRectToVisible(firstResponder.frame, animated: true)
227-
}
52+
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
53+
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
54+
let example = examples[indexPath.row]
55+
cell.textLabel?.text = example.title
56+
cell.accessoryType = .disclosureIndicator
57+
return cell
22858
}
22959

230-
@objc
231-
private func keyboardWillHide(_: Notification) {
232-
scrollView.contentInset.bottom = .zero
60+
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
61+
tableView.deselectRow(at: indexPath, animated: true)
62+
let example = examples[indexPath.row]
63+
navigationController?.pushViewController(example.controller, animated: true)
23364
}
23465
}
23566

236-
extension UIView {
237-
func findFirstResponder() -> UIView? {
238-
if isFirstResponder { return self }
239-
for sub in subviews {
240-
if let found = sub.findFirstResponder() { return found }
241-
}
242-
return nil
243-
}
67+
// MARK: - ExampleItem
68+
69+
struct ExampleItem {
70+
let title: String
71+
let controller: UIViewController
24472
}

0 commit comments

Comments
 (0)