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.
Files changed (99) hide show
  1. package/README.md +211 -44
  2. package/android/src/main/java/com/platformcomponents/PCContextMenuView.kt +2 -2
  3. package/android/src/main/java/com/platformcomponents/PCSegmentedControlView.kt +241 -0
  4. package/android/src/main/java/com/platformcomponents/PCSegmentedControlViewManager.kt +105 -0
  5. package/android/src/main/java/com/platformcomponents/PCSelectionMenuView.kt +4 -0
  6. package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +1 -0
  7. package/app.plugin.cjs +4 -0
  8. package/expo-module.config.json +4 -0
  9. package/ios/PCContextMenu.swift +65 -22
  10. package/ios/PCDatePickerView.swift +28 -11
  11. package/ios/PCSegmentedControl.h +10 -0
  12. package/ios/PCSegmentedControl.mm +194 -0
  13. package/ios/PCSegmentedControl.swift +200 -0
  14. package/lib/commonjs/ContextMenu.js +118 -0
  15. package/lib/commonjs/ContextMenu.js.map +1 -0
  16. package/lib/commonjs/ContextMenuNativeComponent.ts +141 -0
  17. package/lib/commonjs/DatePicker.js +86 -0
  18. package/lib/commonjs/DatePicker.js.map +1 -0
  19. package/lib/commonjs/DatePickerNativeComponent.ts +69 -0
  20. package/lib/commonjs/SegmentedControl.js +93 -0
  21. package/lib/commonjs/SegmentedControl.js.map +1 -0
  22. package/lib/commonjs/SegmentedControlNativeComponent.ts +79 -0
  23. package/lib/commonjs/SelectionMenu.js +73 -0
  24. package/lib/commonjs/SelectionMenu.js.map +1 -0
  25. package/lib/commonjs/SelectionMenuNativeComponent.ts +97 -0
  26. package/lib/commonjs/index.js +61 -0
  27. package/lib/commonjs/index.js.map +1 -0
  28. package/lib/commonjs/package.json +1 -0
  29. package/lib/commonjs/sharedTypes.js +6 -0
  30. package/lib/commonjs/sharedTypes.js.map +1 -0
  31. package/lib/module/SegmentedControl.js +87 -0
  32. package/lib/module/SegmentedControl.js.map +1 -0
  33. package/lib/module/SegmentedControlNativeComponent.ts +79 -0
  34. package/lib/module/index.js +1 -0
  35. package/lib/module/index.js.map +1 -1
  36. package/lib/typescript/commonjs/package.json +1 -0
  37. package/lib/typescript/commonjs/src/ContextMenu.d.ts.map +1 -0
  38. package/lib/typescript/commonjs/src/ContextMenuNativeComponent.d.ts.map +1 -0
  39. package/lib/typescript/commonjs/src/DatePicker.d.ts.map +1 -0
  40. package/lib/typescript/commonjs/src/DatePickerNativeComponent.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/src/SegmentedControl.d.ts +62 -0
  42. package/lib/typescript/commonjs/src/SegmentedControl.d.ts.map +1 -0
  43. package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts +63 -0
  44. package/lib/typescript/commonjs/src/SegmentedControlNativeComponent.d.ts.map +1 -0
  45. package/lib/typescript/commonjs/src/SelectionMenu.d.ts.map +1 -0
  46. package/lib/typescript/commonjs/src/SelectionMenuNativeComponent.d.ts.map +1 -0
  47. package/lib/typescript/{src → commonjs/src}/index.d.ts +1 -0
  48. package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
  49. package/lib/typescript/commonjs/src/sharedTypes.d.ts.map +1 -0
  50. package/lib/typescript/module/src/ContextMenu.d.ts +79 -0
  51. package/lib/typescript/module/src/ContextMenu.d.ts.map +1 -0
  52. package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts +122 -0
  53. package/lib/typescript/module/src/ContextMenuNativeComponent.d.ts.map +1 -0
  54. package/lib/typescript/module/src/DatePicker.d.ts +40 -0
  55. package/lib/typescript/module/src/DatePicker.d.ts.map +1 -0
  56. package/lib/typescript/module/src/DatePickerNativeComponent.d.ts +54 -0
  57. package/lib/typescript/module/src/DatePickerNativeComponent.d.ts.map +1 -0
  58. package/lib/typescript/module/src/SegmentedControl.d.ts +62 -0
  59. package/lib/typescript/module/src/SegmentedControl.d.ts.map +1 -0
  60. package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts +63 -0
  61. package/lib/typescript/module/src/SegmentedControlNativeComponent.d.ts.map +1 -0
  62. package/lib/typescript/module/src/SelectionMenu.d.ts +47 -0
  63. package/lib/typescript/module/src/SelectionMenu.d.ts.map +1 -0
  64. package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts +78 -0
  65. package/lib/typescript/module/src/SelectionMenuNativeComponent.d.ts.map +1 -0
  66. package/lib/typescript/module/src/index.d.ts +6 -0
  67. package/lib/typescript/module/src/index.d.ts.map +1 -0
  68. package/lib/typescript/module/src/sharedTypes.d.ts +12 -0
  69. package/lib/typescript/module/src/sharedTypes.d.ts.map +1 -0
  70. package/package.json +32 -12
  71. package/plugin/build/index.cjs +26 -0
  72. package/plugin/build/index.d.ts +22 -0
  73. package/plugin/build/index.d.ts.map +1 -0
  74. package/plugin/tsconfig.json +16 -0
  75. package/react-native.config.js +1 -0
  76. package/shared/PCSegmentedControlComponentDescriptors-custom.h +22 -0
  77. package/shared/PCSegmentedControlShadowNode-custom.cpp +54 -0
  78. package/shared/PCSegmentedControlShadowNode-custom.h +56 -0
  79. package/shared/PCSegmentedControlState-custom.h +62 -0
  80. package/shared/react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h +1 -0
  81. package/src/SegmentedControl.tsx +178 -0
  82. package/src/SegmentedControlNativeComponent.ts +79 -0
  83. package/src/index.tsx +1 -0
  84. package/lib/typescript/src/ContextMenu.d.ts.map +0 -1
  85. package/lib/typescript/src/ContextMenuNativeComponent.d.ts.map +0 -1
  86. package/lib/typescript/src/DatePicker.d.ts.map +0 -1
  87. package/lib/typescript/src/DatePickerNativeComponent.d.ts.map +0 -1
  88. package/lib/typescript/src/SelectionMenu.d.ts.map +0 -1
  89. package/lib/typescript/src/SelectionMenuNativeComponent.d.ts.map +0 -1
  90. package/lib/typescript/src/index.d.ts.map +0 -1
  91. package/lib/typescript/src/sharedTypes.d.ts.map +0 -1
  92. /package/lib/typescript/{src → commonjs/src}/ContextMenu.d.ts +0 -0
  93. /package/lib/typescript/{src → commonjs/src}/ContextMenuNativeComponent.d.ts +0 -0
  94. /package/lib/typescript/{src → commonjs/src}/DatePicker.d.ts +0 -0
  95. /package/lib/typescript/{src → commonjs/src}/DatePickerNativeComponent.d.ts +0 -0
  96. /package/lib/typescript/{src → commonjs/src}/SelectionMenu.d.ts +0 -0
  97. /package/lib/typescript/{src → commonjs/src}/SelectionMenuNativeComponent.d.ts +0 -0
  98. /package/lib/typescript/{src → commonjs/src}/sharedTypes.d.ts +0 -0
  99. /package/lib/typescript/{package.json → module/package.json} +0 -0
package/app.plugin.cjs ADDED
@@ -0,0 +1,4 @@
1
+ // Expo config plugin entry point
2
+ // This file is used by Expo to locate the config plugin
3
+
4
+ module.exports = require('./plugin/build/index.cjs').default;
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "react-native-platform-components",
3
+ "platforms": ["ios", "android"]
4
+ }
@@ -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: .custom)
176
+ let button = UIButton(type: .system)
171
177
  button.backgroundColor = .clear
172
178
  button.showsMenuAsPrimaryAction = true
173
179
  button.translatesAutoresizingMaskIntoConstraints = false
174
-
175
- // Add context menu interaction to track menu lifecycle
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
- logger.debug("contextMenuInteraction: creating configuration with \(actions.count) actions")
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: enablePreview == "true" ? { [weak self] in
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
- // Position picker at top-leading (constraints required to avoid freeze with inline style)
294
- NSLayoutConstraint.activate([
295
- picker.topAnchor.constraint(equalTo: vc.view.topAnchor),
296
- picker.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor),
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,10 @@
1
+ // PCSegmentedControl.h
2
+
3
+ #import <React/RCTViewComponentView.h>
4
+
5
+ NS_ASSUME_NONNULL_BEGIN
6
+
7
+ @interface PCSegmentedControl : RCTViewComponentView
8
+ @end
9
+
10
+ NS_ASSUME_NONNULL_END
@@ -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
+ }