react-native-platform-components 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -30
- package/android/src/main/java/com/platformcomponents/PCButtonGroupView.kt +498 -0
- package/android/src/main/java/com/platformcomponents/PCButtonGroupViewManager.kt +191 -0
- package/android/src/main/java/com/platformcomponents/PCButtonSupport.kt +127 -0
- package/android/src/main/java/com/platformcomponents/PCButtonView.kt +345 -0
- package/android/src/main/java/com/platformcomponents/PCButtonViewManager.kt +126 -0
- package/android/src/main/java/com/platformcomponents/PCExpressive.kt +107 -0
- package/android/src/main/java/com/platformcomponents/PCFloatingToolbarView.kt +115 -0
- package/android/src/main/java/com/platformcomponents/PCFloatingToolbarViewManager.kt +65 -0
- package/android/src/main/java/com/platformcomponents/PCSegmentedControlView.kt +11 -3
- package/android/src/main/java/com/platformcomponents/PCSegmentedControlViewManager.kt +6 -1
- package/android/src/main/java/com/platformcomponents/PCTextFieldView.kt +1018 -0
- package/android/src/main/java/com/platformcomponents/PCTextFieldViewManager.kt +248 -0
- package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +4 -0
- package/android/src/main/res/values/attrs.xml +13 -0
- package/android/src/main/res/values/styles.xml +183 -0
- package/ios/PCButton.h +10 -0
- package/ios/PCButton.mm +196 -0
- package/ios/PCButtonGroup.h +10 -0
- package/ios/PCButtonGroup.mm +256 -0
- package/ios/PCButtonGroupView.swift +254 -0
- package/ios/PCButtonSupport.swift +141 -0
- package/ios/PCButtonView.swift +155 -0
- package/ios/PCFloatingToolbar.h +10 -0
- package/ios/PCFloatingToolbar.mm +76 -0
- package/ios/PCFloatingToolbarView.swift +95 -0
- package/ios/PCTextField.h +10 -0
- package/ios/PCTextField.mm +391 -0
- package/ios/PCTextFieldView.swift +1012 -0
- package/lib/commonjs/Button.js +77 -0
- package/lib/commonjs/Button.js.map +1 -0
- package/lib/commonjs/ButtonGroup.js +99 -0
- package/lib/commonjs/ButtonGroup.js.map +1 -0
- package/lib/commonjs/ButtonGroupNativeComponent.ts +121 -0
- package/lib/commonjs/ButtonNativeComponent.ts +76 -0
- package/lib/commonjs/FloatingToolbar.js +69 -0
- package/lib/commonjs/FloatingToolbar.js.map +1 -0
- package/lib/commonjs/FloatingToolbarNativeComponent.ts +31 -0
- package/lib/commonjs/SegmentedControl.js +11 -85
- package/lib/commonjs/SegmentedControl.js.map +1 -1
- package/lib/commonjs/SegmentedControlNativeComponent.ts +3 -0
- package/lib/commonjs/TextField.js +195 -0
- package/lib/commonjs/TextField.js.map +1 -0
- package/lib/commonjs/TextFieldNativeComponent.ts +218 -0
- package/lib/commonjs/focusRegistry.js +78 -0
- package/lib/commonjs/focusRegistry.js.map +1 -0
- package/lib/commonjs/icons.js +92 -0
- package/lib/commonjs/icons.js.map +1 -0
- package/lib/commonjs/index.js +66 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +32 -2
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/labelStyle.js +27 -0
- package/lib/commonjs/labelStyle.js.map +1 -0
- package/lib/commonjs/reactNativeInternals.d.js +2 -0
- package/lib/commonjs/reactNativeInternals.d.js.map +1 -0
- package/lib/module/Button.js +70 -0
- package/lib/module/Button.js.map +1 -0
- package/lib/module/ButtonGroup.js +92 -0
- package/lib/module/ButtonGroup.js.map +1 -0
- package/lib/module/ButtonGroupNativeComponent.ts +121 -0
- package/lib/module/ButtonNativeComponent.ts +76 -0
- package/lib/module/FloatingToolbar.js +62 -0
- package/lib/module/FloatingToolbar.js.map +1 -0
- package/lib/module/FloatingToolbarNativeComponent.ts +31 -0
- package/lib/module/SegmentedControl.js +12 -87
- package/lib/module/SegmentedControl.js.map +1 -1
- package/lib/module/SegmentedControlNativeComponent.ts +3 -0
- package/lib/module/TextField.js +189 -0
- package/lib/module/TextField.js.map +1 -0
- package/lib/module/TextFieldNativeComponent.ts +218 -0
- package/lib/module/focusRegistry.js +74 -0
- package/lib/module/focusRegistry.js.map +1 -0
- package/lib/module/icons.js +87 -0
- package/lib/module/icons.js.map +1 -0
- package/lib/module/index.js +6 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +28 -0
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/labelStyle.js +23 -0
- package/lib/module/labelStyle.js.map +1 -0
- package/lib/module/reactNativeInternals.d.js +2 -0
- package/lib/module/reactNativeInternals.d.js.map +1 -0
- package/lib/typescript/commonjs/docs/sidebars.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/Button.d.ts +76 -0
- package/lib/typescript/commonjs/src/Button.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/ButtonGroup.d.ts +102 -0
- package/lib/typescript/commonjs/src/ButtonGroup.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/ButtonGroupNativeComponent.d.ts +93 -0
- package/lib/typescript/commonjs/src/ButtonGroupNativeComponent.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/ButtonNativeComponent.d.ts +57 -0
- package/lib/typescript/commonjs/src/ButtonNativeComponent.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/FloatingToolbar.d.ts +45 -0
- package/lib/typescript/commonjs/src/FloatingToolbar.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/FloatingToolbarNativeComponent.d.ts +24 -0
- package/lib/typescript/commonjs/src/FloatingToolbarNativeComponent.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/SegmentedControl.d.ts +17 -46
- package/lib/typescript/commonjs/src/SegmentedControl.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts +2 -0
- package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/TextField.d.ts +174 -0
- package/lib/typescript/commonjs/src/TextField.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/TextFieldNativeComponent.d.ts +162 -0
- package/lib/typescript/commonjs/src/TextFieldNativeComponent.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/focusRegistry.d.ts +13 -0
- package/lib/typescript/commonjs/src/focusRegistry.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/icons.d.ts +52 -0
- package/lib/typescript/commonjs/src/icons.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/index.d.ts +6 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/index.web.d.ts +121 -0
- package/lib/typescript/commonjs/src/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/labelStyle.d.ts +20 -0
- package/lib/typescript/commonjs/src/labelStyle.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/sharedTypes.d.ts +6 -1
- package/lib/typescript/commonjs/src/sharedTypes.d.ts.map +1 -1
- package/lib/typescript/module/docs/sidebars.d.ts.map +1 -1
- package/lib/typescript/module/src/Button.d.ts +76 -0
- package/lib/typescript/module/src/Button.d.ts.map +1 -0
- package/lib/typescript/module/src/ButtonGroup.d.ts +102 -0
- package/lib/typescript/module/src/ButtonGroup.d.ts.map +1 -0
- package/lib/typescript/module/src/ButtonGroupNativeComponent.d.ts +93 -0
- package/lib/typescript/module/src/ButtonGroupNativeComponent.d.ts.map +1 -0
- package/lib/typescript/module/src/ButtonNativeComponent.d.ts +57 -0
- package/lib/typescript/module/src/ButtonNativeComponent.d.ts.map +1 -0
- package/lib/typescript/module/src/FloatingToolbar.d.ts +45 -0
- package/lib/typescript/module/src/FloatingToolbar.d.ts.map +1 -0
- package/lib/typescript/module/src/FloatingToolbarNativeComponent.d.ts +24 -0
- package/lib/typescript/module/src/FloatingToolbarNativeComponent.d.ts.map +1 -0
- package/lib/typescript/module/src/SegmentedControl.d.ts +17 -46
- package/lib/typescript/module/src/SegmentedControl.d.ts.map +1 -1
- package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts +2 -0
- package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts.map +1 -1
- package/lib/typescript/module/src/TextField.d.ts +174 -0
- package/lib/typescript/module/src/TextField.d.ts.map +1 -0
- package/lib/typescript/module/src/TextFieldNativeComponent.d.ts +162 -0
- package/lib/typescript/module/src/TextFieldNativeComponent.d.ts.map +1 -0
- package/lib/typescript/module/src/focusRegistry.d.ts +13 -0
- package/lib/typescript/module/src/focusRegistry.d.ts.map +1 -0
- package/lib/typescript/module/src/icons.d.ts +52 -0
- package/lib/typescript/module/src/icons.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +6 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -1
- package/lib/typescript/module/src/index.web.d.ts +121 -0
- package/lib/typescript/module/src/index.web.d.ts.map +1 -1
- package/lib/typescript/module/src/labelStyle.d.ts +20 -0
- package/lib/typescript/module/src/labelStyle.d.ts.map +1 -0
- package/lib/typescript/module/src/sharedTypes.d.ts +6 -1
- package/lib/typescript/module/src/sharedTypes.d.ts.map +1 -1
- package/package.json +25 -4
- package/react-native.config.js +3 -0
- package/shared/PCButtonComponentDescriptors-custom.h +22 -0
- package/shared/PCButtonGroupComponentDescriptors-custom.h +22 -0
- package/shared/PCButtonGroupShadowNode-custom.cpp +48 -0
- package/shared/PCButtonGroupShadowNode-custom.h +55 -0
- package/shared/PCButtonGroupState-custom.h +61 -0
- package/shared/PCButtonShadowNode-custom.cpp +48 -0
- package/shared/PCButtonShadowNode-custom.h +55 -0
- package/shared/PCButtonState-custom.h +61 -0
- package/shared/PCTextFieldComponentDescriptors-custom.h +22 -0
- package/shared/PCTextFieldShadowNode-custom.cpp +47 -0
- package/shared/PCTextFieldShadowNode-custom.h +55 -0
- package/shared/PCTextFieldState-custom.h +61 -0
- package/shared/react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h +3 -0
- package/src/Button.tsx +144 -0
- package/src/ButtonGroup.tsx +233 -0
- package/src/ButtonGroupNativeComponent.ts +121 -0
- package/src/ButtonNativeComponent.ts +76 -0
- package/src/FloatingToolbar.tsx +113 -0
- package/src/FloatingToolbarNativeComponent.ts +31 -0
- package/src/SegmentedControl.tsx +26 -118
- package/src/SegmentedControlNativeComponent.ts +3 -0
- package/src/TextField.tsx +494 -0
- package/src/TextFieldNativeComponent.ts +218 -0
- package/src/focusRegistry.ts +89 -0
- package/src/icons.ts +116 -0
- package/src/index.tsx +6 -0
- package/src/index.web.tsx +194 -0
- package/src/labelStyle.ts +36 -0
- package/src/reactNativeInternals.d.ts +22 -0
- package/src/sharedTypes.ts +7 -1
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// A native text field: a caption label, a `UITextField` (or a growing
|
|
4
|
+
/// `UITextView` for multi-line text) and supporting text below, with the
|
|
5
|
+
/// text traits iOS adds each year exposed as props.
|
|
6
|
+
///
|
|
7
|
+
/// The text and its edit counter live here. User edits report the counter
|
|
8
|
+
/// with the text; JS pushes a controlled value back with the last counter it
|
|
9
|
+
/// saw, and a push older than the latest edit is dropped so a slow JS
|
|
10
|
+
/// round-trip can't erase what was typed meanwhile.
|
|
11
|
+
@objcMembers
|
|
12
|
+
public final class PCTextFieldView: UIView, UITextFieldDelegate, UITextViewDelegate {
|
|
13
|
+
// MARK: - Props (set from ObjC++)
|
|
14
|
+
|
|
15
|
+
public var label: String = "" {
|
|
16
|
+
didSet { if oldValue != label { applyLabel() } }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public var placeholder: String = "" {
|
|
20
|
+
didSet { if oldValue != placeholder { applyPlaceholder() } }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public var supportingText: String = "" {
|
|
24
|
+
didSet { if oldValue != supportingText { applyFooter() } }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// "none" | "error"
|
|
28
|
+
public var errorState: String = "none" {
|
|
29
|
+
didSet { if oldValue != errorState { applyFooter(); applyColors() } }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public var errorText: String = "" {
|
|
33
|
+
didSet { if oldValue != errorText { applyFooter() } }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public var prefix: String = "" {
|
|
37
|
+
didSet { if oldValue != prefix { applyAccessories() } }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public var suffix: String = "" {
|
|
41
|
+
didSet { if oldValue != suffix { applyAccessories() } }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// "never" | "while-editing" | "unless-editing" | "always"
|
|
45
|
+
public var clearButtonMode: String = "never" {
|
|
46
|
+
didSet { if oldValue != clearButtonMode { applyAccessories() } }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public var passwordToggle: Bool = false {
|
|
50
|
+
didSet { if oldValue != passwordToggle { applyAccessories() } }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public var characterCount: Bool = false {
|
|
54
|
+
didSet { if oldValue != characterCount { applyFooter() } }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// 0 = unlimited
|
|
58
|
+
public var maxLength: Int = 0 {
|
|
59
|
+
didSet { if oldValue != maxLength { applyFooter() } }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public var keyboardType: String = "default" {
|
|
63
|
+
didSet { if oldValue != keyboardType { applyTraits() } }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public var returnKeyType: String = "default" {
|
|
67
|
+
didSet { if oldValue != returnKeyType { applyTraits() } }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public var autoCapitalize: String = "sentences" {
|
|
71
|
+
didSet { if oldValue != autoCapitalize { applyTraits() } }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public var autoCorrect: Bool = true {
|
|
75
|
+
didSet { if oldValue != autoCorrect { applyTraits() } }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
public var secureTextEntry: Bool = false {
|
|
79
|
+
didSet { if oldValue != secureTextEntry { revealed = false; applyTraits(); applyAccessories() } }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public var multiline: Bool = false {
|
|
83
|
+
didSet { if oldValue != multiline { rebuildInput() } }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// "enabled" | "disabled"
|
|
87
|
+
public var interactivity: String = "enabled" {
|
|
88
|
+
didSet { if oldValue != interactivity { applyEnabled() } }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public var autoFocus: Bool = false {
|
|
92
|
+
didSet { if autoFocus, window != nil { performAutoFocus() } }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public var selectTextOnFocus: Bool = false
|
|
96
|
+
|
|
97
|
+
/// React Native autoComplete value; "" = default
|
|
98
|
+
public var autoComplete: String = "" {
|
|
99
|
+
didSet { if oldValue != autoComplete { applyTraits() } }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// "default" | "light" | "dark"
|
|
103
|
+
public var keyboardAppearance: String = "default" {
|
|
104
|
+
didSet { if oldValue != keyboardAppearance { applyTraits() } }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// Input font; nil keeps the body text style
|
|
108
|
+
public var textFont: UIFont? {
|
|
109
|
+
didSet { applyFonts() }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Screen-reader label; empty uses the label
|
|
113
|
+
public var spokenLabel: String = "" {
|
|
114
|
+
didSet { applyAccessibility() }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// iOS text traits, "" for the system default
|
|
118
|
+
public var writingTools: String = "" {
|
|
119
|
+
didSet { if oldValue != writingTools { applyTraits() } }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
public var inlinePrediction: String = "" {
|
|
123
|
+
didSet { if oldValue != inlinePrediction { applyTraits() } }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
public var smartQuotes: String = "" {
|
|
127
|
+
didSet { if oldValue != smartQuotes { applyTraits() } }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
public var smartDashes: String = "" {
|
|
131
|
+
didSet { if oldValue != smartDashes { applyTraits() } }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
public var smartInsertDelete: String = "" {
|
|
135
|
+
didSet { if oldValue != smartInsertDelete { applyTraits() } }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
public var mathExpressionCompletion: String = "" {
|
|
139
|
+
didSet { if oldValue != mathExpressionCompletion { applyTraits() } }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// "" | "roundedRect" | "none" | "line" | "bezel"
|
|
143
|
+
public var borderStyle: String = "" {
|
|
144
|
+
didSet { if oldValue != borderStyle { applyBorderStyle() } }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// "" | "above" | "leading": the caption above the field, or in a column
|
|
148
|
+
/// beside it, the layout of the grouped forms in Contacts and Settings
|
|
149
|
+
public var labelPlacement: String = "" {
|
|
150
|
+
didSet { if oldValue != labelPlacement { applyLabel(); applyFonts(); applyColors() } }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// Width of the leading label column; 0 = default
|
|
154
|
+
public var labelWidth: CGFloat = 0 {
|
|
155
|
+
didSet { if oldValue != labelWidth { setNeedsLayout(); onNeedsRemeasure?() } }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private var leadingLabel: Bool { labelPlacement == "leading" && !label.isEmpty }
|
|
159
|
+
private var leadingColumnWidth: CGFloat { labelWidth > 0 ? labelWidth : 100 }
|
|
160
|
+
private let leadingSpacing: CGFloat = 8
|
|
161
|
+
|
|
162
|
+
// MARK: - Events back to ObjC++
|
|
163
|
+
|
|
164
|
+
public var onChange: ((String, Int) -> Void)?
|
|
165
|
+
public var onFocusChange: ((Bool, String) -> Void)?
|
|
166
|
+
public var onSubmit: ((String) -> Void)?
|
|
167
|
+
public var onTrailingIconPress: (() -> Void)?
|
|
168
|
+
|
|
169
|
+
/// Called when content changed in a way that affects the height (a
|
|
170
|
+
/// multi-line edit, an error message appearing), so the Fabric
|
|
171
|
+
/// measurement can be refreshed.
|
|
172
|
+
public var onNeedsRemeasure: (() -> Void)?
|
|
173
|
+
|
|
174
|
+
// MARK: - Text state
|
|
175
|
+
|
|
176
|
+
public private(set) var text: String = ""
|
|
177
|
+
private var nativeEventCount = 0
|
|
178
|
+
private var initialTextApplied = false
|
|
179
|
+
private var autoFocusDone = false
|
|
180
|
+
|
|
181
|
+
/// Whether a secure field is currently showing its text (password toggle)
|
|
182
|
+
private var revealed = false
|
|
183
|
+
|
|
184
|
+
// MARK: - Subviews
|
|
185
|
+
|
|
186
|
+
private let captionLabel = UILabel()
|
|
187
|
+
private let textField = UITextField()
|
|
188
|
+
private let textView = PCPlaceholderTextView()
|
|
189
|
+
private let footerLabel = UILabel()
|
|
190
|
+
private let counterLabel = UILabel()
|
|
191
|
+
|
|
192
|
+
private var leadingIcon = PCButtonSupport.Icon.none
|
|
193
|
+
private var trailingIcon = PCButtonSupport.Icon.none
|
|
194
|
+
private var leadingImage: UIImage?
|
|
195
|
+
private var trailingImage: UIImage?
|
|
196
|
+
private var iconGeneration = 0
|
|
197
|
+
|
|
198
|
+
// MARK: - Layout metrics
|
|
199
|
+
|
|
200
|
+
private let captionSpacing: CGFloat = 6
|
|
201
|
+
private let footerSpacing: CGFloat = 4
|
|
202
|
+
private let singleLineMinHeight: CGFloat = 34
|
|
203
|
+
private let textViewInsets = UIEdgeInsets(top: 7, left: 4, bottom: 7, right: 4)
|
|
204
|
+
|
|
205
|
+
// MARK: - Init
|
|
206
|
+
|
|
207
|
+
public override init(frame: CGRect) {
|
|
208
|
+
super.init(frame: frame)
|
|
209
|
+
setup()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
public required init?(coder: NSCoder) {
|
|
213
|
+
super.init(coder: coder)
|
|
214
|
+
setup()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private func setup() {
|
|
218
|
+
captionLabel.numberOfLines = 1
|
|
219
|
+
captionLabel.adjustsFontForContentSizeCategory = true
|
|
220
|
+
|
|
221
|
+
footerLabel.numberOfLines = 0
|
|
222
|
+
footerLabel.adjustsFontForContentSizeCategory = true
|
|
223
|
+
|
|
224
|
+
counterLabel.numberOfLines = 1
|
|
225
|
+
counterLabel.adjustsFontForContentSizeCategory = true
|
|
226
|
+
counterLabel.textAlignment = .right
|
|
227
|
+
counterLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
228
|
+
|
|
229
|
+
textField.delegate = self
|
|
230
|
+
textField.borderStyle = .roundedRect
|
|
231
|
+
textField.adjustsFontForContentSizeCategory = true
|
|
232
|
+
textField.addTarget(self, action: #selector(fieldEditingChanged), for: .editingChanged)
|
|
233
|
+
|
|
234
|
+
textView.delegate = self
|
|
235
|
+
textView.isScrollEnabled = false
|
|
236
|
+
textView.adjustsFontForContentSizeCategory = true
|
|
237
|
+
textView.textContainerInset = textViewInsets
|
|
238
|
+
textView.textContainer.lineFragmentPadding = 4
|
|
239
|
+
textView.backgroundColor = .clear
|
|
240
|
+
|
|
241
|
+
addSubview(captionLabel)
|
|
242
|
+
addSubview(textField)
|
|
243
|
+
addSubview(footerLabel)
|
|
244
|
+
addSubview(counterLabel)
|
|
245
|
+
|
|
246
|
+
applyFonts()
|
|
247
|
+
applyColors()
|
|
248
|
+
applyBorderStyle()
|
|
249
|
+
applyTraits()
|
|
250
|
+
applyLabel()
|
|
251
|
+
applyPlaceholder()
|
|
252
|
+
applyFooter()
|
|
253
|
+
applyAccessories()
|
|
254
|
+
applyAccessibility()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// MARK: - Text
|
|
258
|
+
|
|
259
|
+
/// The text the field starts with; only the first value is used (see setText).
|
|
260
|
+
public func applyInitialText(_ value: String) {
|
|
261
|
+
guard !initialTextApplied else { return }
|
|
262
|
+
initialTextApplied = true
|
|
263
|
+
setTextInternal(value, moveCursorToEnd: true)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/// A controlled value from JS. Dropped when the user has edited since
|
|
267
|
+
/// `eventCount` was reported: the edit event that follows carries the
|
|
268
|
+
/// text JS should reconcile against.
|
|
269
|
+
public func setText(_ value: String, eventCount: Int) {
|
|
270
|
+
guard eventCount >= nativeEventCount, value != text else { return }
|
|
271
|
+
// Replacing text mid-composition (Japanese, Chinese keyboards) would
|
|
272
|
+
// break the composition; the next edit reconciles instead
|
|
273
|
+
guard activeInput.markedTextRange == nil else { return }
|
|
274
|
+
setTextInternal(value, moveCursorToEnd: false)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/// Clears the text as if the user had, so JS gets a change event.
|
|
278
|
+
public func clear() {
|
|
279
|
+
guard !text.isEmpty else { return }
|
|
280
|
+
setTextInternal("", moveCursorToEnd: true)
|
|
281
|
+
reportEdit()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
public func focus() {
|
|
285
|
+
activeInput.becomeFirstResponder()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
public func blur() {
|
|
289
|
+
activeInput.resignFirstResponder()
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
public func setLeadingIcon(type: String, name: String, uri: String, scale: CGFloat, tinted: Bool) {
|
|
293
|
+
let next = PCButtonSupport.Icon(type: type, name: name, uri: uri, scale: scale, tinted: tinted)
|
|
294
|
+
guard next != leadingIcon else { return }
|
|
295
|
+
leadingIcon = next
|
|
296
|
+
iconGeneration += 1
|
|
297
|
+
let generation = iconGeneration
|
|
298
|
+
leadingImage = PCButtonSupport.image(for: next) { [weak self] image in
|
|
299
|
+
guard let self, self.iconGeneration == generation else { return }
|
|
300
|
+
self.leadingImage = image
|
|
301
|
+
self.applyAccessories()
|
|
302
|
+
}
|
|
303
|
+
applyAccessories()
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
public func setTrailingIcon(type: String, name: String, uri: String, scale: CGFloat, tinted: Bool) {
|
|
307
|
+
let next = PCButtonSupport.Icon(type: type, name: name, uri: uri, scale: scale, tinted: tinted)
|
|
308
|
+
guard next != trailingIcon else { return }
|
|
309
|
+
trailingIcon = next
|
|
310
|
+
iconGeneration += 1
|
|
311
|
+
let generation = iconGeneration
|
|
312
|
+
trailingImage = PCButtonSupport.image(for: next) { [weak self] image in
|
|
313
|
+
guard let self, self.iconGeneration == generation else { return }
|
|
314
|
+
self.trailingImage = image
|
|
315
|
+
self.applyAccessories()
|
|
316
|
+
}
|
|
317
|
+
applyAccessories()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/// Resets the text state for a recycled view.
|
|
321
|
+
public func resetForRecycle() {
|
|
322
|
+
blur()
|
|
323
|
+
initialTextApplied = false
|
|
324
|
+
autoFocusDone = false
|
|
325
|
+
nativeEventCount = 0
|
|
326
|
+
revealed = false
|
|
327
|
+
setTextInternal("", moveCursorToEnd: true)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private var activeInput: UIView & UITextInput {
|
|
331
|
+
multiline ? textView : textField
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private func setTextInternal(_ value: String, moveCursorToEnd: Bool) {
|
|
335
|
+
if multiline {
|
|
336
|
+
let wasAtEnd = textView.selectedRange.location == (textView.text as NSString).length
|
|
337
|
+
let cursor = textView.selectedRange.location
|
|
338
|
+
textView.text = value
|
|
339
|
+
let length = (value as NSString).length
|
|
340
|
+
textView.selectedRange = NSRange(location: moveCursorToEnd || wasAtEnd ? length : min(cursor, length), length: 0)
|
|
341
|
+
textView.updatePlaceholderVisibility()
|
|
342
|
+
} else {
|
|
343
|
+
let wasAtEnd: Bool
|
|
344
|
+
if let selected = textField.selectedTextRange {
|
|
345
|
+
wasAtEnd = textField.compare(selected.end, to: textField.endOfDocument) == .orderedSame
|
|
346
|
+
} else {
|
|
347
|
+
wasAtEnd = true
|
|
348
|
+
}
|
|
349
|
+
let cursorOffset = textField.selectedTextRange.map {
|
|
350
|
+
textField.offset(from: textField.beginningOfDocument, to: $0.start)
|
|
351
|
+
} ?? 0
|
|
352
|
+
textField.text = value
|
|
353
|
+
let length = (value as NSString).length
|
|
354
|
+
let offset = moveCursorToEnd || wasAtEnd ? length : min(cursorOffset, length)
|
|
355
|
+
if let position = textField.position(from: textField.beginningOfDocument, offset: offset) {
|
|
356
|
+
textField.selectedTextRange = textField.textRange(from: position, to: position)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
text = value
|
|
360
|
+
applyFooter()
|
|
361
|
+
onNeedsRemeasure?()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private func reportEdit() {
|
|
365
|
+
nativeEventCount += 1
|
|
366
|
+
onChange?(text, nativeEventCount)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// MARK: - UITextField
|
|
370
|
+
|
|
371
|
+
@objc private func fieldEditingChanged() {
|
|
372
|
+
text = textField.text ?? ""
|
|
373
|
+
applyFooter()
|
|
374
|
+
reportEdit()
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
public func textFieldDidBeginEditing(_ field: UITextField) {
|
|
378
|
+
if selectTextOnFocus {
|
|
379
|
+
field.selectAll(nil)
|
|
380
|
+
}
|
|
381
|
+
applyColors()
|
|
382
|
+
onFocusChange?(true, text)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
public func textFieldDidEndEditing(_ field: UITextField) {
|
|
386
|
+
applyColors()
|
|
387
|
+
onFocusChange?(false, text)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
public func textFieldShouldReturn(_ field: UITextField) -> Bool {
|
|
391
|
+
onSubmit?(text)
|
|
392
|
+
// Single-line fields blur on submit, as the core TextInput does by default
|
|
393
|
+
field.resignFirstResponder()
|
|
394
|
+
return true
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
public func textField(_ field: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
|
398
|
+
allowsChange(current: field.text ?? "", range: range, replacement: string, input: field)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// MARK: - UITextView
|
|
402
|
+
|
|
403
|
+
public func textViewDidChange(_ view: UITextView) {
|
|
404
|
+
text = view.text ?? ""
|
|
405
|
+
textView.updatePlaceholderVisibility()
|
|
406
|
+
applyFooter()
|
|
407
|
+
reportEdit()
|
|
408
|
+
onNeedsRemeasure?()
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
public func textViewDidBeginEditing(_ view: UITextView) {
|
|
412
|
+
if selectTextOnFocus {
|
|
413
|
+
view.selectAll(nil)
|
|
414
|
+
}
|
|
415
|
+
applyColors()
|
|
416
|
+
onFocusChange?(true, text)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
public func textViewDidEndEditing(_ view: UITextView) {
|
|
420
|
+
applyColors()
|
|
421
|
+
onFocusChange?(false, text)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
public func textView(_ view: UITextView, shouldChangeTextIn range: NSRange, replacementText string: String) -> Bool {
|
|
425
|
+
allowsChange(current: view.text ?? "", range: range, replacement: string, input: view)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/// Enforces `maxLength` in UTF-16 units, like the core TextInput and the
|
|
429
|
+
/// Android length filter, leaving IME composition alone.
|
|
430
|
+
private func allowsChange(current: String, range: NSRange, replacement: String, input: UITextInput) -> Bool {
|
|
431
|
+
guard maxLength > 0, input.markedTextRange == nil else { return true }
|
|
432
|
+
let currentLength = (current as NSString).length
|
|
433
|
+
let nextLength = currentLength - range.length + (replacement as NSString).length
|
|
434
|
+
return nextLength <= maxLength
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// MARK: - Props handling
|
|
438
|
+
|
|
439
|
+
private func rebuildInput() {
|
|
440
|
+
let focused = activeInput.isFirstResponder
|
|
441
|
+
textField.removeFromSuperview()
|
|
442
|
+
textView.removeFromSuperview()
|
|
443
|
+
if multiline {
|
|
444
|
+
textView.text = text
|
|
445
|
+
textView.updatePlaceholderVisibility()
|
|
446
|
+
insertSubview(textView, at: 1)
|
|
447
|
+
} else {
|
|
448
|
+
textField.text = text
|
|
449
|
+
insertSubview(textField, at: 1)
|
|
450
|
+
}
|
|
451
|
+
applyFonts()
|
|
452
|
+
applyColors()
|
|
453
|
+
applyBorderStyle()
|
|
454
|
+
applyTraits()
|
|
455
|
+
applyPlaceholder()
|
|
456
|
+
applyAccessories()
|
|
457
|
+
applyEnabled()
|
|
458
|
+
applyAccessibility()
|
|
459
|
+
if focused { activeInput.becomeFirstResponder() }
|
|
460
|
+
setNeedsLayout()
|
|
461
|
+
onNeedsRemeasure?()
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private func applyLabel() {
|
|
465
|
+
captionLabel.text = label
|
|
466
|
+
captionLabel.isHidden = label.isEmpty
|
|
467
|
+
applyAccessibility()
|
|
468
|
+
setNeedsLayout()
|
|
469
|
+
onNeedsRemeasure?()
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private func applyPlaceholder() {
|
|
473
|
+
textField.placeholder = placeholder.isEmpty ? nil : placeholder
|
|
474
|
+
textView.placeholder = placeholder
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/// Supporting text, error message and counter below the field.
|
|
478
|
+
private func applyFooter() {
|
|
479
|
+
let showsError = errorState == "error"
|
|
480
|
+
let message = showsError && !errorText.isEmpty ? errorText : supportingText
|
|
481
|
+
footerLabel.text = message
|
|
482
|
+
footerLabel.isHidden = message.isEmpty
|
|
483
|
+
|
|
484
|
+
if characterCount {
|
|
485
|
+
let count = (text as NSString).length
|
|
486
|
+
counterLabel.text = maxLength > 0 ? "\(count) / \(maxLength)" : "\(count)"
|
|
487
|
+
counterLabel.isHidden = false
|
|
488
|
+
} else {
|
|
489
|
+
counterLabel.text = nil
|
|
490
|
+
counterLabel.isHidden = true
|
|
491
|
+
}
|
|
492
|
+
applyColors()
|
|
493
|
+
setNeedsLayout()
|
|
494
|
+
onNeedsRemeasure?()
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private func applyFonts() {
|
|
498
|
+
captionLabel.font = .preferredFont(forTextStyle: leadingLabel ? .body : .subheadline)
|
|
499
|
+
footerLabel.font = .preferredFont(forTextStyle: .footnote)
|
|
500
|
+
counterLabel.font = .preferredFont(forTextStyle: .footnote)
|
|
501
|
+
let inputFont = textFont ?? .preferredFont(forTextStyle: .body)
|
|
502
|
+
textField.font = inputFont
|
|
503
|
+
textView.font = inputFont
|
|
504
|
+
textView.placeholderLabel.font = inputFont
|
|
505
|
+
setNeedsLayout()
|
|
506
|
+
onNeedsRemeasure?()
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private func applyColors() {
|
|
510
|
+
let showsError = errorState == "error"
|
|
511
|
+
let focused = activeInput.isFirstResponder
|
|
512
|
+
captionLabel.textColor = showsError
|
|
513
|
+
? .systemRed
|
|
514
|
+
: (leadingLabel ? .label : (focused ? tintColor : .secondaryLabel))
|
|
515
|
+
footerLabel.textColor = showsError ? .systemRed : .secondaryLabel
|
|
516
|
+
counterLabel.textColor = showsError && maxLength > 0 && (text as NSString).length > maxLength
|
|
517
|
+
? .systemRed
|
|
518
|
+
: .secondaryLabel
|
|
519
|
+
textView.layer.borderColor = (showsError ? UIColor.systemRed : UIColor.systemGray4).cgColor
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private func applyBorderStyle() {
|
|
523
|
+
let style: UITextField.BorderStyle
|
|
524
|
+
switch borderStyle {
|
|
525
|
+
case "none": style = .none
|
|
526
|
+
case "line": style = .line
|
|
527
|
+
case "bezel": style = .bezel
|
|
528
|
+
default: style = .roundedRect
|
|
529
|
+
}
|
|
530
|
+
textField.borderStyle = style
|
|
531
|
+
// The text view mirrors the rounded-rect field
|
|
532
|
+
let bordered = style != .none
|
|
533
|
+
textView.layer.borderWidth = bordered ? 1 : 0
|
|
534
|
+
textView.layer.cornerRadius = style == .roundedRect ? 5 : 0
|
|
535
|
+
textView.backgroundColor = bordered ? .systemBackground : .clear
|
|
536
|
+
setNeedsLayout()
|
|
537
|
+
onNeedsRemeasure?()
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private func applyEnabled() {
|
|
541
|
+
let enabled = interactivity != "disabled"
|
|
542
|
+
textField.isEnabled = enabled
|
|
543
|
+
textView.isEditable = enabled
|
|
544
|
+
textView.isUserInteractionEnabled = enabled
|
|
545
|
+
textView.alpha = enabled ? 1 : 0.5
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private func applyAccessibility() {
|
|
549
|
+
let spoken = spokenLabel.isEmpty ? (label.isEmpty ? nil : label) : spokenLabel
|
|
550
|
+
textField.accessibilityLabel = spoken
|
|
551
|
+
textView.accessibilityLabel = spoken
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private func applyTraits() {
|
|
555
|
+
applyTraits(to: textField)
|
|
556
|
+
applyTraits(to: textView)
|
|
557
|
+
// The keyboard only picks the traits up when input restarts
|
|
558
|
+
if activeInput.isFirstResponder {
|
|
559
|
+
activeInput.reloadInputViews()
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/// Both inputs get the traits: UITextField and UITextView declare the
|
|
564
|
+
/// same UITextInputTraits properties, but as their own members.
|
|
565
|
+
private func applyTraits(to input: UITextField) {
|
|
566
|
+
input.keyboardType = PCTextFieldView.keyboardType(keyboardType)
|
|
567
|
+
input.returnKeyType = PCTextFieldView.returnKeyType(returnKeyType)
|
|
568
|
+
input.autocapitalizationType = PCTextFieldView.autocapitalization(autoCapitalize)
|
|
569
|
+
input.autocorrectionType = autoCorrect ? .default : .no
|
|
570
|
+
input.spellCheckingType = autoCorrect ? .default : .no
|
|
571
|
+
input.isSecureTextEntry = secureTextEntry && !revealed
|
|
572
|
+
input.textContentType = PCTextFieldView.contentType(autoComplete)
|
|
573
|
+
input.keyboardAppearance = PCTextFieldView.keyboardAppearance(keyboardAppearance)
|
|
574
|
+
input.smartQuotesType = PCTextFieldView.smartQuotesType(smartQuotes)
|
|
575
|
+
input.smartDashesType = PCTextFieldView.smartDashesType(smartDashes)
|
|
576
|
+
input.smartInsertDeleteType = PCTextFieldView.smartInsertDeleteType(smartInsertDelete)
|
|
577
|
+
if #available(iOS 17.0, *) {
|
|
578
|
+
input.inlinePredictionType = PCTextFieldView.inlinePredictionType(inlinePrediction)
|
|
579
|
+
}
|
|
580
|
+
if #available(iOS 18.0, *) {
|
|
581
|
+
input.mathExpressionCompletionType = PCTextFieldView.mathCompletionType(mathExpressionCompletion)
|
|
582
|
+
input.writingToolsBehavior = PCTextFieldView.writingToolsBehavior(writingTools)
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private func applyTraits(to input: UITextView) {
|
|
587
|
+
input.keyboardType = PCTextFieldView.keyboardType(keyboardType)
|
|
588
|
+
input.returnKeyType = PCTextFieldView.returnKeyType(returnKeyType)
|
|
589
|
+
input.autocapitalizationType = PCTextFieldView.autocapitalization(autoCapitalize)
|
|
590
|
+
input.autocorrectionType = autoCorrect ? .default : .no
|
|
591
|
+
input.spellCheckingType = autoCorrect ? .default : .no
|
|
592
|
+
input.isSecureTextEntry = secureTextEntry && !revealed
|
|
593
|
+
input.textContentType = PCTextFieldView.contentType(autoComplete)
|
|
594
|
+
input.keyboardAppearance = PCTextFieldView.keyboardAppearance(keyboardAppearance)
|
|
595
|
+
input.smartQuotesType = PCTextFieldView.smartQuotesType(smartQuotes)
|
|
596
|
+
input.smartDashesType = PCTextFieldView.smartDashesType(smartDashes)
|
|
597
|
+
input.smartInsertDeleteType = PCTextFieldView.smartInsertDeleteType(smartInsertDelete)
|
|
598
|
+
if #available(iOS 17.0, *) {
|
|
599
|
+
input.inlinePredictionType = PCTextFieldView.inlinePredictionType(inlinePrediction)
|
|
600
|
+
}
|
|
601
|
+
if #available(iOS 18.0, *) {
|
|
602
|
+
input.mathExpressionCompletionType = PCTextFieldView.mathCompletionType(mathExpressionCompletion)
|
|
603
|
+
input.writingToolsBehavior = PCTextFieldView.writingToolsBehavior(writingTools)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/// Prefix and suffix labels, icons, the clear button and the password
|
|
608
|
+
/// toggle. Single-line only: a text view has no accessory views.
|
|
609
|
+
private func applyAccessories() {
|
|
610
|
+
textField.leftView = nil
|
|
611
|
+
textField.rightView = nil
|
|
612
|
+
textField.leftViewMode = .never
|
|
613
|
+
textField.rightViewMode = .never
|
|
614
|
+
|
|
615
|
+
if let leading = accessoryView(image: leadingImage, tinted: leadingIcon.tinted, text: prefix, action: nil) {
|
|
616
|
+
textField.leftView = leading
|
|
617
|
+
textField.leftViewMode = .always
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
let trailingAction: (() -> Void)?
|
|
621
|
+
let trailingImageForSlot: UIImage?
|
|
622
|
+
if trailingImage != nil {
|
|
623
|
+
trailingImageForSlot = trailingImage
|
|
624
|
+
trailingAction = { [weak self] in self?.onTrailingIconPress?() }
|
|
625
|
+
} else if passwordToggle, secureTextEntry || revealed {
|
|
626
|
+
trailingImageForSlot = UIImage(systemName: revealed ? "eye.slash" : "eye")
|
|
627
|
+
trailingAction = { [weak self] in self?.toggleReveal() }
|
|
628
|
+
} else {
|
|
629
|
+
trailingImageForSlot = nil
|
|
630
|
+
trailingAction = nil
|
|
631
|
+
}
|
|
632
|
+
let trailingTinted = trailingImage != nil ? trailingIcon.tinted : true
|
|
633
|
+
if let trailing = accessoryView(image: trailingImageForSlot, tinted: trailingTinted, text: suffix, action: trailingAction) {
|
|
634
|
+
textField.rightView = trailing
|
|
635
|
+
textField.rightViewMode = .always
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// A right view takes the clear button's place, as UIKit draws them in the same slot
|
|
639
|
+
switch clearButtonMode {
|
|
640
|
+
case "while-editing": textField.clearButtonMode = .whileEditing
|
|
641
|
+
case "unless-editing": textField.clearButtonMode = .unlessEditing
|
|
642
|
+
case "always": textField.clearButtonMode = .always
|
|
643
|
+
default: textField.clearButtonMode = .never
|
|
644
|
+
}
|
|
645
|
+
setNeedsLayout()
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/// A label and/or an image button for the field's left or right slot.
|
|
649
|
+
private func accessoryView(image: UIImage?, tinted: Bool, text: String, action: (() -> Void)?) -> UIView? {
|
|
650
|
+
guard image != nil || !text.isEmpty else { return nil }
|
|
651
|
+
let stack = UIStackView()
|
|
652
|
+
stack.axis = .horizontal
|
|
653
|
+
stack.alignment = .center
|
|
654
|
+
stack.spacing = 6
|
|
655
|
+
stack.isLayoutMarginsRelativeArrangement = true
|
|
656
|
+
stack.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
|
|
657
|
+
|
|
658
|
+
if let image {
|
|
659
|
+
let config = UIImage.SymbolConfiguration(textStyle: .body)
|
|
660
|
+
if let action {
|
|
661
|
+
let button = PCAccessoryButton(type: .system)
|
|
662
|
+
button.setImage(image.applyingSymbolConfiguration(config) ?? image, for: .normal)
|
|
663
|
+
button.tintColor = tinted ? .secondaryLabel : nil
|
|
664
|
+
button.action = action
|
|
665
|
+
button.accessibilityLabel = passwordToggle && trailingImage == nil
|
|
666
|
+
? (revealed ? "Hide password" : "Show password")
|
|
667
|
+
: nil
|
|
668
|
+
stack.addArrangedSubview(button)
|
|
669
|
+
} else {
|
|
670
|
+
let imageView = UIImageView(image: image.applyingSymbolConfiguration(config) ?? image)
|
|
671
|
+
imageView.tintColor = tinted ? .secondaryLabel : nil
|
|
672
|
+
imageView.contentMode = .scaleAspectFit
|
|
673
|
+
stack.addArrangedSubview(imageView)
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if !text.isEmpty {
|
|
677
|
+
let label = UILabel()
|
|
678
|
+
label.text = text
|
|
679
|
+
label.font = textFont ?? .preferredFont(forTextStyle: .body)
|
|
680
|
+
label.textColor = .secondaryLabel
|
|
681
|
+
label.adjustsFontForContentSizeCategory = true
|
|
682
|
+
stack.addArrangedSubview(label)
|
|
683
|
+
}
|
|
684
|
+
stack.frame.size = stack.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
|
685
|
+
return stack
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/// Shows or hides the text of a secure field. Toggling `isSecureTextEntry`
|
|
689
|
+
/// makes UIKit clear the field on the next keystroke; re-inserting the
|
|
690
|
+
/// text through the text range keeps it and the caret.
|
|
691
|
+
private func toggleReveal() {
|
|
692
|
+
revealed.toggle()
|
|
693
|
+
let current = textField.text ?? ""
|
|
694
|
+
textField.isSecureTextEntry = secureTextEntry && !revealed
|
|
695
|
+
if textField.isFirstResponder, let range = textField.textRange(from: textField.beginningOfDocument, to: textField.endOfDocument) {
|
|
696
|
+
textField.replace(range, withText: current)
|
|
697
|
+
}
|
|
698
|
+
applyAccessories()
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private func performAutoFocus() {
|
|
702
|
+
guard !autoFocusDone else { return }
|
|
703
|
+
autoFocusDone = true
|
|
704
|
+
DispatchQueue.main.async { [weak self] in self?.focus() }
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
public override func didMoveToWindow() {
|
|
708
|
+
super.didMoveToWindow()
|
|
709
|
+
if window != nil, autoFocus { performAutoFocus() }
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
public override func tintColorDidChange() {
|
|
713
|
+
super.tintColorDidChange()
|
|
714
|
+
applyColors()
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
718
|
+
super.traitCollectionDidChange(previousTraitCollection)
|
|
719
|
+
if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory {
|
|
720
|
+
applyFonts()
|
|
721
|
+
}
|
|
722
|
+
if previousTraitCollection?.hasDifferentColorAppearance(comparedTo: traitCollection) == true {
|
|
723
|
+
applyColors()
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// MARK: - Layout
|
|
728
|
+
|
|
729
|
+
private struct Metrics {
|
|
730
|
+
var captionHeight: CGFloat
|
|
731
|
+
var inputHeight: CGFloat
|
|
732
|
+
var footerHeight: CGFloat
|
|
733
|
+
var leading: Bool
|
|
734
|
+
var total: CGFloat {
|
|
735
|
+
var height = leading ? max(inputHeight, captionHeight) : inputHeight
|
|
736
|
+
if captionHeight > 0 && !leading { height += captionHeight + 6 }
|
|
737
|
+
if footerHeight > 0 { height += footerHeight + 4 }
|
|
738
|
+
return height
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/// The x where the field (and the footer) starts: after a leading label column
|
|
743
|
+
private func fieldOffset() -> CGFloat {
|
|
744
|
+
leadingLabel ? leadingColumnWidth + leadingSpacing : 0
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private func metrics(for width: CGFloat) -> Metrics {
|
|
748
|
+
let captionHeight = label.isEmpty ? 0 : ceil(captionLabel.font.lineHeight)
|
|
749
|
+
let fieldWidth = max(1, width - fieldOffset())
|
|
750
|
+
|
|
751
|
+
let inputHeight: CGFloat
|
|
752
|
+
if multiline {
|
|
753
|
+
let fitted = textView.sizeThatFits(CGSize(width: fieldWidth, height: .greatestFiniteMagnitude))
|
|
754
|
+
inputHeight = max(singleLineMinHeight, ceil(fitted.height))
|
|
755
|
+
} else {
|
|
756
|
+
let fitted = textField.sizeThatFits(CGSize(width: fieldWidth, height: .greatestFiniteMagnitude))
|
|
757
|
+
inputHeight = max(singleLineMinHeight, ceil(fitted.height))
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
var footerHeight: CGFloat = 0
|
|
761
|
+
if !footerLabel.isHidden || !counterLabel.isHidden {
|
|
762
|
+
let counterWidth = counterLabel.isHidden
|
|
763
|
+
? 0
|
|
764
|
+
: ceil(counterLabel.sizeThatFits(CGSize(width: fieldWidth, height: .greatestFiniteMagnitude)).width) + 8
|
|
765
|
+
let messageWidth = max(1, fieldWidth - counterWidth)
|
|
766
|
+
let messageHeight = footerLabel.isHidden
|
|
767
|
+
? 0
|
|
768
|
+
: ceil(footerLabel.sizeThatFits(CGSize(width: messageWidth, height: .greatestFiniteMagnitude)).height)
|
|
769
|
+
let counterHeight = counterLabel.isHidden ? 0 : ceil(counterLabel.font.lineHeight)
|
|
770
|
+
footerHeight = max(messageHeight, counterHeight)
|
|
771
|
+
}
|
|
772
|
+
return Metrics(captionHeight: captionHeight, inputHeight: inputHeight, footerHeight: footerHeight, leading: leadingLabel)
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
public override func layoutSubviews() {
|
|
776
|
+
super.layoutSubviews()
|
|
777
|
+
let width = bounds.width
|
|
778
|
+
let m = metrics(for: width)
|
|
779
|
+
let x = fieldOffset()
|
|
780
|
+
let fieldWidth = max(0, width - x)
|
|
781
|
+
var y: CGFloat = 0
|
|
782
|
+
|
|
783
|
+
if m.leading {
|
|
784
|
+
// Label column and field side by side, centred on the taller one
|
|
785
|
+
let rowHeight = max(m.inputHeight, m.captionHeight)
|
|
786
|
+
captionLabel.frame = CGRect(
|
|
787
|
+
x: 0,
|
|
788
|
+
y: (rowHeight - m.captionHeight) / 2,
|
|
789
|
+
width: leadingColumnWidth,
|
|
790
|
+
height: m.captionHeight
|
|
791
|
+
)
|
|
792
|
+
activeInput.frame = CGRect(x: x, y: (rowHeight - m.inputHeight) / 2, width: fieldWidth, height: m.inputHeight)
|
|
793
|
+
y += rowHeight
|
|
794
|
+
} else {
|
|
795
|
+
if m.captionHeight > 0 {
|
|
796
|
+
captionLabel.frame = CGRect(x: 0, y: y, width: width, height: m.captionHeight)
|
|
797
|
+
y += m.captionHeight + captionSpacing
|
|
798
|
+
}
|
|
799
|
+
activeInput.frame = CGRect(x: 0, y: y, width: width, height: m.inputHeight)
|
|
800
|
+
y += m.inputHeight
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if m.footerHeight > 0 {
|
|
804
|
+
y += footerSpacing
|
|
805
|
+
let counterWidth = counterLabel.isHidden
|
|
806
|
+
? 0
|
|
807
|
+
: ceil(counterLabel.sizeThatFits(CGSize(width: fieldWidth, height: .greatestFiniteMagnitude)).width)
|
|
808
|
+
let messageWidth = max(0, fieldWidth - (counterWidth > 0 ? counterWidth + 8 : 0))
|
|
809
|
+
footerLabel.frame = CGRect(x: x, y: y, width: messageWidth, height: m.footerHeight)
|
|
810
|
+
counterLabel.frame = CGRect(x: width - counterWidth, y: y, width: counterWidth, height: m.footerHeight)
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/// Called by the measuring pipeline to get the size for Yoga layout: the
|
|
815
|
+
/// height the field needs at the given width. The width is whatever Yoga
|
|
816
|
+
/// offers; a text field fills its row.
|
|
817
|
+
@objc public func sizeForLayout(withConstrainedTo constrainedSize: CGSize) -> CGSize {
|
|
818
|
+
let width: CGFloat
|
|
819
|
+
if constrainedSize.width > 0 && constrainedSize.width < CGFloat.greatestFiniteMagnitude {
|
|
820
|
+
width = constrainedSize.width
|
|
821
|
+
} else if bounds.width > 0 {
|
|
822
|
+
width = bounds.width
|
|
823
|
+
} else {
|
|
824
|
+
width = PCConstants.fallbackWidth
|
|
825
|
+
}
|
|
826
|
+
return CGSize(width: 0, height: ceil(metrics(for: width).total))
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// MARK: - Trait mapping
|
|
830
|
+
|
|
831
|
+
private static func keyboardType(_ value: String) -> UIKeyboardType {
|
|
832
|
+
switch value {
|
|
833
|
+
case "number-pad": return .numberPad
|
|
834
|
+
case "decimal-pad": return .decimalPad
|
|
835
|
+
case "numeric": return .decimalPad
|
|
836
|
+
case "email-address": return .emailAddress
|
|
837
|
+
case "phone-pad": return .phonePad
|
|
838
|
+
case "url": return .URL
|
|
839
|
+
case "ascii-capable": return .asciiCapable
|
|
840
|
+
case "numbers-and-punctuation": return .numbersAndPunctuation
|
|
841
|
+
case "name-phone-pad": return .namePhonePad
|
|
842
|
+
case "twitter": return .twitter
|
|
843
|
+
case "web-search": return .webSearch
|
|
844
|
+
case "visible-password": return .asciiCapable
|
|
845
|
+
default: return .default
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
private static func returnKeyType(_ value: String) -> UIReturnKeyType {
|
|
850
|
+
switch value {
|
|
851
|
+
case "done": return .done
|
|
852
|
+
case "go": return .go
|
|
853
|
+
case "next": return .next
|
|
854
|
+
case "search": return .search
|
|
855
|
+
case "send": return .send
|
|
856
|
+
default: return .default
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
private static func autocapitalization(_ value: String) -> UITextAutocapitalizationType {
|
|
861
|
+
switch value {
|
|
862
|
+
case "none": return .none
|
|
863
|
+
case "words": return .words
|
|
864
|
+
case "characters": return .allCharacters
|
|
865
|
+
default: return .sentences
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
private static func keyboardAppearance(_ value: String) -> UIKeyboardAppearance {
|
|
870
|
+
switch value {
|
|
871
|
+
case "light": return .light
|
|
872
|
+
case "dark": return .dark
|
|
873
|
+
default: return .default
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
private static func contentType(_ value: String) -> UITextContentType? {
|
|
878
|
+
switch value {
|
|
879
|
+
case "username": return .username
|
|
880
|
+
case "password": return .password
|
|
881
|
+
case "new-password": return .newPassword
|
|
882
|
+
case "one-time-code": return .oneTimeCode
|
|
883
|
+
case "email": return .emailAddress
|
|
884
|
+
case "name": return .name
|
|
885
|
+
case "given-name": return .givenName
|
|
886
|
+
case "family-name": return .familyName
|
|
887
|
+
case "tel": return .telephoneNumber
|
|
888
|
+
case "street-address": return .fullStreetAddress
|
|
889
|
+
case "postal-code": return .postalCode
|
|
890
|
+
case "country": return .countryName
|
|
891
|
+
case "cc-number": return .creditCardNumber
|
|
892
|
+
case "cc-exp":
|
|
893
|
+
if #available(iOS 17.0, *) { return .creditCardExpiration }
|
|
894
|
+
return nil
|
|
895
|
+
case "cc-csc":
|
|
896
|
+
if #available(iOS 17.0, *) { return .creditCardSecurityCode }
|
|
897
|
+
return nil
|
|
898
|
+
case "url": return .URL
|
|
899
|
+
default: return nil
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
private static func smartQuotesType(_ value: String) -> UITextSmartQuotesType {
|
|
904
|
+
switch value {
|
|
905
|
+
case "yes": return .yes
|
|
906
|
+
case "no": return .no
|
|
907
|
+
default: return .default
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
private static func smartDashesType(_ value: String) -> UITextSmartDashesType {
|
|
912
|
+
switch value {
|
|
913
|
+
case "yes": return .yes
|
|
914
|
+
case "no": return .no
|
|
915
|
+
default: return .default
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
private static func smartInsertDeleteType(_ value: String) -> UITextSmartInsertDeleteType {
|
|
920
|
+
switch value {
|
|
921
|
+
case "yes": return .yes
|
|
922
|
+
case "no": return .no
|
|
923
|
+
default: return .default
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
@available(iOS 17.0, *)
|
|
928
|
+
private static func inlinePredictionType(_ value: String) -> UITextInlinePredictionType {
|
|
929
|
+
switch value {
|
|
930
|
+
case "yes": return .yes
|
|
931
|
+
case "no": return .no
|
|
932
|
+
default: return .default
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
@available(iOS 18.0, *)
|
|
937
|
+
private static func mathCompletionType(_ value: String) -> UITextMathExpressionCompletionType {
|
|
938
|
+
switch value {
|
|
939
|
+
case "yes": return .yes
|
|
940
|
+
case "no": return .no
|
|
941
|
+
default: return .default
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
@available(iOS 18.0, *)
|
|
946
|
+
private static func writingToolsBehavior(_ value: String) -> UIWritingToolsBehavior {
|
|
947
|
+
switch value {
|
|
948
|
+
case "complete": return .complete
|
|
949
|
+
case "limited": return .limited
|
|
950
|
+
case "none": return .none
|
|
951
|
+
default: return .default
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// MARK: - Helpers
|
|
957
|
+
|
|
958
|
+
/// A `UITextView` with a placeholder, drawn where the text starts.
|
|
959
|
+
final class PCPlaceholderTextView: UITextView {
|
|
960
|
+
let placeholderLabel = UILabel()
|
|
961
|
+
|
|
962
|
+
var placeholder: String = "" {
|
|
963
|
+
didSet {
|
|
964
|
+
placeholderLabel.text = placeholder
|
|
965
|
+
updatePlaceholderVisibility()
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
|
970
|
+
super.init(frame: frame, textContainer: textContainer)
|
|
971
|
+
placeholderLabel.textColor = .placeholderText
|
|
972
|
+
placeholderLabel.numberOfLines = 0
|
|
973
|
+
placeholderLabel.adjustsFontForContentSizeCategory = true
|
|
974
|
+
placeholderLabel.isUserInteractionEnabled = false
|
|
975
|
+
addSubview(placeholderLabel)
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
required init?(coder: NSCoder) {
|
|
979
|
+
super.init(coder: coder)
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
func updatePlaceholderVisibility() {
|
|
983
|
+
placeholderLabel.isHidden = !(text ?? "").isEmpty || placeholder.isEmpty
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
override func layoutSubviews() {
|
|
987
|
+
super.layoutSubviews()
|
|
988
|
+
let x = textContainerInset.left + textContainer.lineFragmentPadding
|
|
989
|
+
let width = max(0, bounds.width - x - textContainerInset.right - textContainer.lineFragmentPadding)
|
|
990
|
+
let height = placeholderLabel.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)).height
|
|
991
|
+
placeholderLabel.frame = CGRect(x: x, y: textContainerInset.top, width: width, height: height)
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/// A button with a closure action, for the accessory slots.
|
|
996
|
+
final class PCAccessoryButton: UIButton {
|
|
997
|
+
var action: (() -> Void)?
|
|
998
|
+
|
|
999
|
+
override init(frame: CGRect) {
|
|
1000
|
+
super.init(frame: frame)
|
|
1001
|
+
addTarget(self, action: #selector(pressed), for: .touchUpInside)
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
required init?(coder: NSCoder) {
|
|
1005
|
+
super.init(coder: coder)
|
|
1006
|
+
addTarget(self, action: #selector(pressed), for: .touchUpInside)
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
@objc private func pressed() {
|
|
1010
|
+
action?()
|
|
1011
|
+
}
|
|
1012
|
+
}
|