react-native-platform-components 0.6.0 → 0.7.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 +211 -44
- package/android/src/main/java/com/platformcomponents/PCContextMenuView.kt +2 -2
- package/android/src/main/java/com/platformcomponents/PCSegmentedControlView.kt +241 -0
- package/android/src/main/java/com/platformcomponents/PCSegmentedControlViewManager.kt +105 -0
- package/android/src/main/java/com/platformcomponents/PCSelectionMenuView.kt +4 -0
- package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +1 -0
- package/app.plugin.cjs +4 -0
- package/expo-module.config.json +4 -0
- package/ios/PCContextMenu.swift +65 -22
- package/ios/PCDatePickerView.swift +28 -11
- package/ios/PCSegmentedControl.h +10 -0
- package/ios/PCSegmentedControl.mm +194 -0
- package/ios/PCSegmentedControl.swift +200 -0
- package/lib/commonjs/ContextMenu.js +118 -0
- package/lib/commonjs/ContextMenu.js.map +1 -0
- package/lib/commonjs/ContextMenuNativeComponent.ts +141 -0
- package/lib/commonjs/DatePicker.js +86 -0
- package/lib/commonjs/DatePicker.js.map +1 -0
- package/lib/commonjs/DatePickerNativeComponent.ts +69 -0
- package/lib/commonjs/SegmentedControl.js +93 -0
- package/lib/commonjs/SegmentedControl.js.map +1 -0
- package/lib/commonjs/SegmentedControlNativeComponent.ts +79 -0
- package/lib/commonjs/SelectionMenu.js +73 -0
- package/lib/commonjs/SelectionMenu.js.map +1 -0
- package/lib/commonjs/SelectionMenuNativeComponent.ts +97 -0
- package/lib/commonjs/index.js +61 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/sharedTypes.js +6 -0
- package/lib/commonjs/sharedTypes.js.map +1 -0
- package/lib/module/SegmentedControl.js +87 -0
- package/lib/module/SegmentedControl.js.map +1 -0
- package/lib/module/SegmentedControlNativeComponent.ts +79 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/src/ContextMenu.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/ContextMenuNativeComponent.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/DatePicker.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/DatePickerNativeComponent.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/SegmentedControl.d.ts +62 -0
- package/lib/typescript/commonjs/src/SegmentedControl.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts +63 -0
- package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/SelectionMenu.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/SelectionMenuNativeComponent.d.ts.map +1 -0
- package/lib/typescript/{src → commonjs/src}/index.d.ts +1 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/sharedTypes.d.ts.map +1 -0
- package/lib/typescript/module/src/ContextMenu.d.ts +79 -0
- package/lib/typescript/module/src/ContextMenu.d.ts.map +1 -0
- package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts +122 -0
- package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts.map +1 -0
- package/lib/typescript/module/src/DatePicker.d.ts +40 -0
- package/lib/typescript/module/src/DatePicker.d.ts.map +1 -0
- package/lib/typescript/module/src/DatePickerNativeComponent.d.ts +54 -0
- package/lib/typescript/module/src/DatePickerNativeComponent.d.ts.map +1 -0
- package/lib/typescript/module/src/SegmentedControl.d.ts +62 -0
- package/lib/typescript/module/src/SegmentedControl.d.ts.map +1 -0
- package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts +63 -0
- package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts.map +1 -0
- package/lib/typescript/module/src/SelectionMenu.d.ts +47 -0
- package/lib/typescript/module/src/SelectionMenu.d.ts.map +1 -0
- package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts +78 -0
- package/lib/typescript/module/src/SelectionMenuNativeComponent.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 -0
- package/lib/typescript/module/src/sharedTypes.d.ts +12 -0
- package/lib/typescript/module/src/sharedTypes.d.ts.map +1 -0
- package/package.json +32 -12
- package/plugin/build/index.cjs +26 -0
- package/plugin/build/index.d.ts +22 -0
- package/plugin/build/index.d.ts.map +1 -0
- package/plugin/tsconfig.json +16 -0
- package/react-native.config.js +1 -0
- package/shared/PCSegmentedControlComponentDescriptors-custom.h +22 -0
- package/shared/PCSegmentedControlShadowNode-custom.cpp +54 -0
- package/shared/PCSegmentedControlShadowNode-custom.h +56 -0
- package/shared/PCSegmentedControlState-custom.h +62 -0
- package/shared/react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h +1 -0
- package/src/SegmentedControl.tsx +178 -0
- package/src/SegmentedControlNativeComponent.ts +79 -0
- package/src/index.tsx +1 -0
- package/lib/typescript/src/ContextMenu.d.ts.map +0 -1
- package/lib/typescript/src/ContextMenuNativeComponent.d.ts.map +0 -1
- package/lib/typescript/src/DatePicker.d.ts.map +0 -1
- package/lib/typescript/src/DatePickerNativeComponent.d.ts.map +0 -1
- package/lib/typescript/src/SelectionMenu.d.ts.map +0 -1
- package/lib/typescript/src/SelectionMenuNativeComponent.d.ts.map +0 -1
- package/lib/typescript/src/index.d.ts.map +0 -1
- package/lib/typescript/src/sharedTypes.d.ts.map +0 -1
- /package/lib/typescript/{src → commonjs/src}/ContextMenu.d.ts +0 -0
- /package/lib/typescript/{src → commonjs/src}/ContextMenuNativeComponent.d.ts +0 -0
- /package/lib/typescript/{src → commonjs/src}/DatePicker.d.ts +0 -0
- /package/lib/typescript/{src → commonjs/src}/DatePickerNativeComponent.d.ts +0 -0
- /package/lib/typescript/{src → commonjs/src}/SelectionMenu.d.ts +0 -0
- /package/lib/typescript/{src → commonjs/src}/SelectionMenuNativeComponent.d.ts +0 -0
- /package/lib/typescript/{src → commonjs/src}/sharedTypes.d.ts +0 -0
- /package/lib/typescript/{package.json → module/package.json} +0 -0
package/app.plugin.cjs
ADDED
package/ios/PCContextMenu.swift
CHANGED
|
@@ -114,6 +114,8 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
|
|
118
|
+
|
|
117
119
|
private func updateEnabled() {
|
|
118
120
|
let disabled = (interactivity == "disabled")
|
|
119
121
|
alpha = disabled ? 0.5 : 1.0
|
|
@@ -146,10 +148,14 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
|
|
|
146
148
|
|
|
147
149
|
private func installContextMenuInteraction() {
|
|
148
150
|
guard contextMenuInteraction == nil else { return }
|
|
151
|
+
|
|
152
|
+
// Install interaction on self so that long-press on our view triggers the menu.
|
|
153
|
+
// We use UITargetedPreview in the delegate methods to show the parent view
|
|
154
|
+
// (which contains the React content) as the preview.
|
|
149
155
|
let interaction = UIContextMenuInteraction(delegate: self)
|
|
150
156
|
addInteraction(interaction)
|
|
151
157
|
contextMenuInteraction = interaction
|
|
152
|
-
logger.debug("Installed UIContextMenuInteraction")
|
|
158
|
+
logger.debug("Installed UIContextMenuInteraction on PCContextMenuView")
|
|
153
159
|
}
|
|
154
160
|
|
|
155
161
|
private func removeContextMenuInteraction() {
|
|
@@ -167,14 +173,12 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
|
|
|
167
173
|
return
|
|
168
174
|
}
|
|
169
175
|
|
|
170
|
-
let button = UIButton(type: .
|
|
176
|
+
let button = UIButton(type: .system)
|
|
171
177
|
button.backgroundColor = .clear
|
|
172
178
|
button.showsMenuAsPrimaryAction = true
|
|
173
179
|
button.translatesAutoresizingMaskIntoConstraints = false
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
let interaction = UIContextMenuInteraction(delegate: self)
|
|
177
|
-
button.addInteraction(interaction)
|
|
180
|
+
// Make button invisible but still tappable
|
|
181
|
+
button.tintColor = .clear
|
|
178
182
|
|
|
179
183
|
addSubview(button)
|
|
180
184
|
NSLayoutConstraint.activate([
|
|
@@ -218,25 +222,13 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
|
|
|
218
222
|
let actions = parsedActions.filter { !$0.hidden }
|
|
219
223
|
guard !actions.isEmpty else { return nil }
|
|
220
224
|
|
|
221
|
-
|
|
225
|
+
let actionCountStr = String(actions.count)
|
|
226
|
+
logger.debug("contextMenuInteraction: creating configuration with \(actionCountStr) actions")
|
|
222
227
|
|
|
228
|
+
// Use nil previewProvider - we'll use UITargetedPreview via delegate instead
|
|
223
229
|
return UIContextMenuConfiguration(
|
|
224
230
|
identifier: nil,
|
|
225
|
-
previewProvider:
|
|
226
|
-
// Return a preview controller showing the content
|
|
227
|
-
guard let self else { return nil }
|
|
228
|
-
let preview = UIViewController()
|
|
229
|
-
preview.view.backgroundColor = .clear
|
|
230
|
-
|
|
231
|
-
// Snapshot the current view for preview
|
|
232
|
-
let snapshot = self.snapshotView(afterScreenUpdates: false)
|
|
233
|
-
if let snap = snapshot {
|
|
234
|
-
snap.frame = CGRect(origin: .zero, size: self.bounds.size)
|
|
235
|
-
preview.view.addSubview(snap)
|
|
236
|
-
preview.preferredContentSize = self.bounds.size
|
|
237
|
-
}
|
|
238
|
-
return preview
|
|
239
|
-
} : nil,
|
|
231
|
+
previewProvider: nil,
|
|
240
232
|
actionProvider: { [weak self] suggestedActions in
|
|
241
233
|
guard let self else { return nil }
|
|
242
234
|
return self.buildMenu(from: actions, title: self.menuTitle)
|
|
@@ -244,6 +236,57 @@ public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
|
|
|
244
236
|
)
|
|
245
237
|
}
|
|
246
238
|
|
|
239
|
+
/// Get the parent component view (PCContextMenu) which contains all React content
|
|
240
|
+
private func getParentComponentView() -> UIView? {
|
|
241
|
+
return superview
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
public func contextMenuInteraction(
|
|
245
|
+
_ interaction: UIContextMenuInteraction,
|
|
246
|
+
previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration
|
|
247
|
+
) -> UITargetedPreview? {
|
|
248
|
+
let parameters = UIPreviewParameters()
|
|
249
|
+
parameters.backgroundColor = .clear
|
|
250
|
+
|
|
251
|
+
// Check if preview is enabled via the enablePreview prop
|
|
252
|
+
if enablePreview == "true" {
|
|
253
|
+
// Target the parent view (PCContextMenu) which contains the React content
|
|
254
|
+
guard let parentView = getParentComponentView() else {
|
|
255
|
+
logger.debug("previewForHighlighting: no parent found")
|
|
256
|
+
return nil
|
|
257
|
+
}
|
|
258
|
+
let parentType = String(describing: type(of: parentView))
|
|
259
|
+
logger.debug("previewForHighlighting: targeting parent \(parentType)")
|
|
260
|
+
parameters.visiblePath = UIBezierPath(roundedRect: parentView.bounds, cornerRadius: 8)
|
|
261
|
+
return UITargetedPreview(view: parentView, parameters: parameters)
|
|
262
|
+
} else {
|
|
263
|
+
// When preview is disabled, target self with zero-size path
|
|
264
|
+
// This prevents iOS from manipulating the parent view and causing white flashes
|
|
265
|
+
logger.debug("previewForHighlighting: preview disabled, targeting self with zero path")
|
|
266
|
+
parameters.visiblePath = UIBezierPath(rect: .zero)
|
|
267
|
+
return UITargetedPreview(view: self, parameters: parameters)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
public func contextMenuInteraction(
|
|
272
|
+
_ interaction: UIContextMenuInteraction,
|
|
273
|
+
previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration
|
|
274
|
+
) -> UITargetedPreview? {
|
|
275
|
+
let parameters = UIPreviewParameters()
|
|
276
|
+
parameters.backgroundColor = .clear
|
|
277
|
+
|
|
278
|
+
if enablePreview == "true" {
|
|
279
|
+
guard let parentView = getParentComponentView() else {
|
|
280
|
+
return nil
|
|
281
|
+
}
|
|
282
|
+
parameters.visiblePath = UIBezierPath(roundedRect: parentView.bounds, cornerRadius: 8)
|
|
283
|
+
return UITargetedPreview(view: parentView, parameters: parameters)
|
|
284
|
+
} else {
|
|
285
|
+
parameters.visiblePath = UIBezierPath(rect: .zero)
|
|
286
|
+
return UITargetedPreview(view: self, parameters: parameters)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
247
290
|
public func contextMenuInteraction(
|
|
248
291
|
_ interaction: UIContextMenuInteraction,
|
|
249
292
|
willDisplayMenuFor configuration: UIContextMenuConfiguration,
|
|
@@ -284,30 +284,47 @@ public final class PCDatePickerView: UIControl,
|
|
|
284
284
|
suppressNextChangesBriefly()
|
|
285
285
|
|
|
286
286
|
let vc = UIViewController()
|
|
287
|
-
vc.view.backgroundColor = .clear
|
|
288
|
-
vc.view.isOpaque = false
|
|
289
|
-
|
|
290
287
|
picker.translatesAutoresizingMaskIntoConstraints = false
|
|
291
288
|
vc.view.addSubview(picker)
|
|
292
289
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
290
|
+
let useInlineFallback: Bool
|
|
291
|
+
if #available(iOS 26.0, *) {
|
|
292
|
+
useInlineFallback = false
|
|
293
|
+
} else {
|
|
294
|
+
useInlineFallback = true
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if useInlineFallback {
|
|
298
|
+
// Pre–Liquid Glass fallback
|
|
299
|
+
vc.view.backgroundColor = .systemBackground
|
|
300
|
+
vc.view.isOpaque = true
|
|
301
|
+
|
|
302
|
+
NSLayoutConstraint.activate([
|
|
303
|
+
picker.topAnchor.constraint(equalTo: vc.view.topAnchor),
|
|
304
|
+
picker.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor),
|
|
305
|
+
picker.trailingAnchor.constraint(equalTo: vc.view.trailingAnchor),
|
|
306
|
+
picker.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor),
|
|
307
|
+
])
|
|
308
|
+
} else {
|
|
309
|
+
// Liquid Glass path
|
|
310
|
+
vc.view.backgroundColor = .clear
|
|
311
|
+
vc.view.isOpaque = false
|
|
312
|
+
|
|
313
|
+
NSLayoutConstraint.activate([
|
|
314
|
+
picker.topAnchor.constraint(equalTo: vc.view.topAnchor),
|
|
315
|
+
picker.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor),
|
|
316
|
+
])
|
|
317
|
+
}
|
|
298
318
|
|
|
299
319
|
let size = popoverContentSize()
|
|
300
320
|
vc.preferredContentSize = size
|
|
301
321
|
|
|
302
|
-
// ✅ Anchored popover-style (not a full sheet)
|
|
303
322
|
vc.modalPresentationStyle = .popover
|
|
304
323
|
vc.presentationController?.delegate = self
|
|
305
324
|
|
|
306
325
|
if let pop = vc.popoverPresentationController {
|
|
307
326
|
pop.delegate = self
|
|
308
327
|
pop.sourceView = self
|
|
309
|
-
// Use a minimum height for sourceRect to help popover positioning
|
|
310
|
-
// (modal presentation views have zero intrinsic height)
|
|
311
328
|
let sourceRect = CGRect(
|
|
312
329
|
x: bounds.minX,
|
|
313
330
|
y: bounds.minY,
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// PCSegmentedControl.mm
|
|
2
|
+
|
|
3
|
+
#import "PCSegmentedControl.h"
|
|
4
|
+
|
|
5
|
+
#import <React/RCTComponentViewFactory.h>
|
|
6
|
+
#import <React/RCTConversions.h>
|
|
7
|
+
#import <React/RCTFabricComponentsPlugins.h>
|
|
8
|
+
|
|
9
|
+
#import <react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h>
|
|
10
|
+
#import <react/renderer/components/PlatformComponentsViewSpec/EventEmitters.h>
|
|
11
|
+
#import <react/renderer/components/PlatformComponentsViewSpec/Props.h>
|
|
12
|
+
#import <react/renderer/core/LayoutPrimitives.h>
|
|
13
|
+
|
|
14
|
+
#if __has_include(<PlatformComponents/PlatformComponents-Swift.h>)
|
|
15
|
+
#import <PlatformComponents/PlatformComponents-Swift.h>
|
|
16
|
+
#else
|
|
17
|
+
#import "PlatformComponents-Swift.h"
|
|
18
|
+
#endif
|
|
19
|
+
|
|
20
|
+
#import "PCSegmentedControlComponentDescriptors-custom.h"
|
|
21
|
+
#import "PCSegmentedControlShadowNode-custom.h"
|
|
22
|
+
#import "PCSegmentedControlState-custom.h"
|
|
23
|
+
|
|
24
|
+
using namespace facebook::react;
|
|
25
|
+
|
|
26
|
+
namespace {
|
|
27
|
+
static inline bool SegmentsEqual(
|
|
28
|
+
const std::vector<facebook::react::PCSegmentedControlSegmentsStruct> &a,
|
|
29
|
+
const std::vector<facebook::react::PCSegmentedControlSegmentsStruct> &b) {
|
|
30
|
+
if (a.size() != b.size()) return false;
|
|
31
|
+
for (size_t i = 0; i < a.size(); i++) {
|
|
32
|
+
if (a[i].label != b[i].label) return false;
|
|
33
|
+
if (a[i].value != b[i].value) return false;
|
|
34
|
+
if (a[i].disabled != b[i].disabled) return false;
|
|
35
|
+
if (a[i].icon != b[i].icon) return false;
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
} // namespace
|
|
40
|
+
|
|
41
|
+
@interface PCSegmentedControl ()
|
|
42
|
+
|
|
43
|
+
- (void)updateMeasurements;
|
|
44
|
+
|
|
45
|
+
@end
|
|
46
|
+
|
|
47
|
+
@implementation PCSegmentedControl {
|
|
48
|
+
PCSegmentedControlView *_view;
|
|
49
|
+
MeasuringPCSegmentedControlShadowNode::ConcreteState::Shared _state;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
+ (ComponentDescriptorProvider)componentDescriptorProvider {
|
|
53
|
+
return concreteComponentDescriptorProvider<
|
|
54
|
+
MeasuringPCSegmentedControlComponentDescriptor>();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
- (instancetype)initWithFrame:(CGRect)frame {
|
|
58
|
+
if (self = [super initWithFrame:frame]) {
|
|
59
|
+
_view = [PCSegmentedControlView new];
|
|
60
|
+
self.contentView = _view;
|
|
61
|
+
|
|
62
|
+
__weak __typeof(self) weakSelf = self;
|
|
63
|
+
|
|
64
|
+
_view.onSelect = ^(NSInteger index, NSString *value) {
|
|
65
|
+
__typeof(self) strongSelf = weakSelf;
|
|
66
|
+
if (!strongSelf) return;
|
|
67
|
+
|
|
68
|
+
auto eventEmitter =
|
|
69
|
+
std::static_pointer_cast<const PCSegmentedControlEventEmitter>(
|
|
70
|
+
strongSelf->_eventEmitter);
|
|
71
|
+
if (!eventEmitter) return;
|
|
72
|
+
|
|
73
|
+
PCSegmentedControlEventEmitter::OnSelect payload = {
|
|
74
|
+
.index = (int)index,
|
|
75
|
+
.value = value.UTF8String,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
eventEmitter->onSelect(payload);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return self;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
- (void)updateProps:(Props::Shared const &)props
|
|
85
|
+
oldProps:(Props::Shared const &)oldProps {
|
|
86
|
+
const auto &newProps =
|
|
87
|
+
*std::static_pointer_cast<const PCSegmentedControlProps>(props);
|
|
88
|
+
const auto prevProps =
|
|
89
|
+
std::static_pointer_cast<const PCSegmentedControlProps>(oldProps);
|
|
90
|
+
|
|
91
|
+
// segments: [{label, value, disabled, icon}]
|
|
92
|
+
if (!prevProps || !SegmentsEqual(newProps.segments, prevProps->segments)) {
|
|
93
|
+
NSMutableArray *arr = [NSMutableArray new];
|
|
94
|
+
for (const auto &seg : newProps.segments) {
|
|
95
|
+
NSString *label = seg.label.empty()
|
|
96
|
+
? @""
|
|
97
|
+
: [NSString stringWithUTF8String:seg.label.c_str()];
|
|
98
|
+
NSString *value = seg.value.empty()
|
|
99
|
+
? @""
|
|
100
|
+
: [NSString stringWithUTF8String:seg.value.c_str()];
|
|
101
|
+
NSString *disabled = seg.disabled.empty()
|
|
102
|
+
? @"enabled"
|
|
103
|
+
: [NSString stringWithUTF8String:seg.disabled.c_str()];
|
|
104
|
+
NSString *icon = seg.icon.empty()
|
|
105
|
+
? @""
|
|
106
|
+
: [NSString stringWithUTF8String:seg.icon.c_str()];
|
|
107
|
+
[arr addObject:@{
|
|
108
|
+
@"label": label,
|
|
109
|
+
@"value": value,
|
|
110
|
+
@"disabled": disabled,
|
|
111
|
+
@"icon": icon
|
|
112
|
+
}];
|
|
113
|
+
}
|
|
114
|
+
_view.segments = arr;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// selectedValue (default "")
|
|
118
|
+
if (!prevProps || newProps.selectedValue != prevProps->selectedValue) {
|
|
119
|
+
if (!newProps.selectedValue.empty()) {
|
|
120
|
+
_view.selectedValue =
|
|
121
|
+
[NSString stringWithUTF8String:newProps.selectedValue.c_str()];
|
|
122
|
+
} else {
|
|
123
|
+
_view.selectedValue = @""; // sentinel for no selection
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// interactivity: "enabled" | "disabled"
|
|
128
|
+
if (!prevProps || newProps.interactivity != prevProps->interactivity) {
|
|
129
|
+
if (!newProps.interactivity.empty()) {
|
|
130
|
+
_view.interactivity =
|
|
131
|
+
[NSString stringWithUTF8String:newProps.interactivity.c_str()];
|
|
132
|
+
} else {
|
|
133
|
+
_view.interactivity = @"enabled";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// iOS-specific props
|
|
138
|
+
const auto &newIos = newProps.ios;
|
|
139
|
+
const auto &oldIos =
|
|
140
|
+
prevProps ? prevProps->ios : PCSegmentedControlIosStruct{};
|
|
141
|
+
|
|
142
|
+
if (!prevProps || newIos.momentary != oldIos.momentary) {
|
|
143
|
+
_view.momentary = (newIos.momentary == "true");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!prevProps || newIos.apportionsSegmentWidthsByContent != oldIos.apportionsSegmentWidthsByContent) {
|
|
147
|
+
_view.apportionsSegmentWidthsByContent = (newIos.apportionsSegmentWidthsByContent == "true");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!prevProps || newIos.selectedSegmentTintColor != oldIos.selectedSegmentTintColor) {
|
|
151
|
+
if (!newIos.selectedSegmentTintColor.empty()) {
|
|
152
|
+
_view.selectedSegmentTintColor =
|
|
153
|
+
[NSString stringWithUTF8String:newIos.selectedSegmentTintColor.c_str()];
|
|
154
|
+
} else {
|
|
155
|
+
_view.selectedSegmentTintColor = nil;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
[super updateProps:props oldProps:oldProps];
|
|
160
|
+
|
|
161
|
+
// Update measurements when props change that affect layout
|
|
162
|
+
[self updateMeasurements];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#pragma mark - State (Measuring)
|
|
166
|
+
|
|
167
|
+
- (void)updateState:(const State::Shared &)state
|
|
168
|
+
oldState:(const State::Shared &)oldState {
|
|
169
|
+
_state = std::static_pointer_cast<
|
|
170
|
+
const MeasuringPCSegmentedControlShadowNode::ConcreteState>(state);
|
|
171
|
+
|
|
172
|
+
if (oldState == nullptr) {
|
|
173
|
+
// First time: compute initial size.
|
|
174
|
+
[self updateMeasurements];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
[super updateState:state oldState:oldState];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
- (void)updateMeasurements {
|
|
181
|
+
if (_state == nullptr)
|
|
182
|
+
return;
|
|
183
|
+
|
|
184
|
+
// Use the real width Yoga gave us
|
|
185
|
+
const CGFloat w = self.bounds.size.width > 1 ? self.bounds.size.width : 320;
|
|
186
|
+
|
|
187
|
+
CGSize size = [_view sizeForLayoutWithConstrainedTo:CGSizeMake(w, 0)];
|
|
188
|
+
|
|
189
|
+
PCSegmentedControlStateFrameSize next;
|
|
190
|
+
next.frameSize = {(Float)size.width, (Float)size.height};
|
|
191
|
+
_state->updateState(std::move(next));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
@end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
// MARK: - Segment model (bridged from ObjC++ as dictionaries)
|
|
4
|
+
|
|
5
|
+
struct PCSegmentedControlSegment {
|
|
6
|
+
let label: String
|
|
7
|
+
let value: String
|
|
8
|
+
let disabled: Bool
|
|
9
|
+
let icon: String
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@objcMembers
|
|
13
|
+
public final class PCSegmentedControlView: UIControl {
|
|
14
|
+
// MARK: - Props (set from ObjC++)
|
|
15
|
+
|
|
16
|
+
/// ObjC++ sets this as an array of dictionaries: [{label, value, disabled, icon}]
|
|
17
|
+
public var segments: [Any] = [] { didSet { rebuildControl() } }
|
|
18
|
+
|
|
19
|
+
/// Controlled selection by value. "" = no selection.
|
|
20
|
+
public var selectedValue: String = "" { didSet { updateSelection() } }
|
|
21
|
+
|
|
22
|
+
/// "enabled" | "disabled"
|
|
23
|
+
public var interactivity: String = "enabled" { didSet { updateEnabled() } }
|
|
24
|
+
|
|
25
|
+
/// iOS-specific: momentary mode (segment springs back after touch)
|
|
26
|
+
public var momentary: Bool = false { didSet { control.isMomentary = momentary } }
|
|
27
|
+
|
|
28
|
+
/// iOS-specific: segment widths proportional to content
|
|
29
|
+
public var apportionsSegmentWidthsByContent: Bool = false {
|
|
30
|
+
didSet { control.apportionsSegmentWidthsByContent = apportionsSegmentWidthsByContent }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// iOS-specific: selected segment tint color (hex string)
|
|
34
|
+
public var selectedSegmentTintColor: String? { didSet { updateTintColor() } }
|
|
35
|
+
|
|
36
|
+
// MARK: - Events back to ObjC++
|
|
37
|
+
|
|
38
|
+
public var onSelect: ((Int, String) -> Void)? // (index, value)
|
|
39
|
+
|
|
40
|
+
// MARK: - Internal
|
|
41
|
+
|
|
42
|
+
private let control = UISegmentedControl()
|
|
43
|
+
private var parsedSegments: [PCSegmentedControlSegment] = []
|
|
44
|
+
|
|
45
|
+
// MARK: - Init
|
|
46
|
+
|
|
47
|
+
public override init(frame: CGRect) {
|
|
48
|
+
super.init(frame: frame)
|
|
49
|
+
setup()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public required init?(coder: NSCoder) {
|
|
53
|
+
super.init(coder: coder)
|
|
54
|
+
setup()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private func setup() {
|
|
58
|
+
control.translatesAutoresizingMaskIntoConstraints = false
|
|
59
|
+
addSubview(control)
|
|
60
|
+
|
|
61
|
+
NSLayoutConstraint.activate([
|
|
62
|
+
control.topAnchor.constraint(equalTo: topAnchor),
|
|
63
|
+
control.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
64
|
+
control.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
65
|
+
control.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
control.addTarget(self, action: #selector(valueChanged), for: .valueChanged)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@objc private func valueChanged() {
|
|
72
|
+
let index = control.selectedSegmentIndex
|
|
73
|
+
guard index != UISegmentedControl.noSegment,
|
|
74
|
+
index >= 0, index < parsedSegments.count else { return }
|
|
75
|
+
|
|
76
|
+
let segment = parsedSegments[index]
|
|
77
|
+
onSelect?(index, segment.value)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// MARK: - Props handling
|
|
81
|
+
|
|
82
|
+
private func rebuildControl() {
|
|
83
|
+
parsedSegments = segments.compactMap { any in
|
|
84
|
+
guard let dict = any as? [String: Any] else { return nil }
|
|
85
|
+
let label = (dict["label"] as? String) ?? ""
|
|
86
|
+
let value = (dict["value"] as? String) ?? ""
|
|
87
|
+
let disabled = (dict["disabled"] as? String) == "disabled"
|
|
88
|
+
let icon = (dict["icon"] as? String) ?? ""
|
|
89
|
+
return PCSegmentedControlSegment(
|
|
90
|
+
label: label,
|
|
91
|
+
value: value,
|
|
92
|
+
disabled: disabled,
|
|
93
|
+
icon: icon
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
control.removeAllSegments()
|
|
98
|
+
|
|
99
|
+
for (index, segment) in parsedSegments.enumerated() {
|
|
100
|
+
// Try to use SF Symbol icon if available
|
|
101
|
+
if !segment.icon.isEmpty,
|
|
102
|
+
let sfImage = UIImage(systemName: segment.icon) {
|
|
103
|
+
control.insertSegment(with: sfImage, at: index, animated: false)
|
|
104
|
+
} else {
|
|
105
|
+
control.insertSegment(withTitle: segment.label, at: index, animated: false)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Set enabled state for this segment
|
|
109
|
+
control.setEnabled(!segment.disabled, forSegmentAt: index)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
updateSelection()
|
|
113
|
+
invalidateIntrinsicContentSize()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private func updateSelection() {
|
|
117
|
+
if selectedValue.isEmpty {
|
|
118
|
+
control.selectedSegmentIndex = UISegmentedControl.noSegment
|
|
119
|
+
} else if let index = parsedSegments.firstIndex(where: { $0.value == selectedValue }) {
|
|
120
|
+
control.selectedSegmentIndex = index
|
|
121
|
+
} else {
|
|
122
|
+
control.selectedSegmentIndex = UISegmentedControl.noSegment
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private func updateEnabled() {
|
|
127
|
+
let enabled = interactivity != "disabled"
|
|
128
|
+
control.isEnabled = enabled
|
|
129
|
+
alpha = enabled ? 1.0 : 0.5
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private func updateTintColor() {
|
|
133
|
+
if let colorString = selectedSegmentTintColor, !colorString.isEmpty {
|
|
134
|
+
control.selectedSegmentTintColor = UIColor(hex: colorString)
|
|
135
|
+
} else {
|
|
136
|
+
control.selectedSegmentTintColor = nil
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// MARK: - Sizing
|
|
141
|
+
|
|
142
|
+
public override func sizeThatFits(_ size: CGSize) -> CGSize {
|
|
143
|
+
let fitted = control.sizeThatFits(CGSize(width: size.width, height: .greatestFiniteMagnitude))
|
|
144
|
+
return CGSize(
|
|
145
|
+
width: size.width > 0 ? size.width : fitted.width,
|
|
146
|
+
height: max(PCConstants.minTouchTargetHeight, fitted.height)
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public override var intrinsicContentSize: CGSize {
|
|
151
|
+
let fitted = control.intrinsicContentSize
|
|
152
|
+
return CGSize(
|
|
153
|
+
width: fitted.width,
|
|
154
|
+
height: max(PCConstants.minTouchTargetHeight, fitted.height)
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// Called by the measuring pipeline to get the size for Yoga layout.
|
|
159
|
+
@objc public func sizeForLayout(withConstrainedTo constrainedSize: CGSize) -> CGSize {
|
|
160
|
+
let fitted = control.sizeThatFits(
|
|
161
|
+
CGSize(width: constrainedSize.width > 0 ? constrainedSize.width : .greatestFiniteMagnitude,
|
|
162
|
+
height: .greatestFiniteMagnitude)
|
|
163
|
+
)
|
|
164
|
+
return CGSize(
|
|
165
|
+
width: constrainedSize.width > 0 ? constrainedSize.width : fitted.width,
|
|
166
|
+
height: max(PCConstants.minTouchTargetHeight, fitted.height)
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// MARK: - UIColor hex extension
|
|
172
|
+
|
|
173
|
+
private extension UIColor {
|
|
174
|
+
convenience init?(hex: String) {
|
|
175
|
+
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
176
|
+
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
|
|
177
|
+
|
|
178
|
+
var rgb: UInt64 = 0
|
|
179
|
+
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil }
|
|
180
|
+
|
|
181
|
+
let length = hexSanitized.count
|
|
182
|
+
if length == 6 {
|
|
183
|
+
self.init(
|
|
184
|
+
red: CGFloat((rgb & 0xFF0000) >> 16) / 255.0,
|
|
185
|
+
green: CGFloat((rgb & 0x00FF00) >> 8) / 255.0,
|
|
186
|
+
blue: CGFloat(rgb & 0x0000FF) / 255.0,
|
|
187
|
+
alpha: 1.0
|
|
188
|
+
)
|
|
189
|
+
} else if length == 8 {
|
|
190
|
+
self.init(
|
|
191
|
+
red: CGFloat((rgb & 0xFF000000) >> 24) / 255.0,
|
|
192
|
+
green: CGFloat((rgb & 0x00FF0000) >> 16) / 255.0,
|
|
193
|
+
blue: CGFloat((rgb & 0x0000FF00) >> 8) / 255.0,
|
|
194
|
+
alpha: CGFloat(rgb & 0x000000FF) / 255.0
|
|
195
|
+
)
|
|
196
|
+
} else {
|
|
197
|
+
return nil
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|