@likith99/react-native-adaptive-liquid-glass 0.1.0 → 0.1.2

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 CHANGED
@@ -1,6 +1,20 @@
1
1
  # @likith99/react-native-adaptive-liquid-glass
2
2
 
3
- Native iOS Liquid Glass surfaces and UIKit action controls, with ordinary Android counterparts. No Expo dependency. React Native 0.81–0.86 / React 19 / Fabric; requires Xcode 26+, and iOS 15.1+ (glass on iOS 26+).
3
+ Native iOS Liquid Glass surfaces and UIKit action controls, with ordinary Android counterparts. No Expo or third-party glass dependency. No direct npm runtime dependencies; React/React Native peers and native platform dependencies are required. React Native 0.81+ / React 19 / Fabric; requires Xcode 26+, and iOS 15.1+ (glass on iOS 26+).
4
+
5
+ <p align="center">
6
+ <img src="https://raw.githubusercontent.com/likith099/liquedGlassRecreate/v0.1.1/docs/images/demo.gif" width="280" alt="Screen recording of the demo: tapping a glass button increments a counter, then the action cluster expands into four icons that sit inside the stretching glass, and the live property toggles change the material">
7
+ </p>
8
+
9
+ Every surface here is a real native view — `UIGlassEffect`, `UIMenu`, `UIToolbar` — not a JavaScript approximation. Glass merging is opt-in, and native menus render checked, disabled and destructive items exactly as the system draws them:
10
+
11
+ <table>
12
+ <tr>
13
+ <td align="center"><img src="https://raw.githubusercontent.com/likith099/liquedGlassRecreate/v0.1.1/docs/images/glass-merging-off.png" width="230" alt="Two separate circular glass surfaces with a Bring together button"><br><sub>Merging off</sub></td>
14
+ <td align="center"><img src="https://raw.githubusercontent.com/likith099/liquedGlassRecreate/v0.1.1/docs/images/glass-merging-on.png" width="230" alt="The same two circular glass surfaces joined by a liquid bridge of material"><br><sub>Merging on</sub></td>
15
+ <td align="center"><img src="https://raw.githubusercontent.com/likith099/liquedGlassRecreate/v0.1.1/docs/images/grouped-menu.png" width="230" alt="A native iOS menu with an Order section containing a checked Most recent item and a Maintenance section with a red Clear history item"><br><sub>Grouped native menu</sub></td>
16
+ </tr>
17
+ </table>
4
18
 
5
19
  ## Install
6
20
 
@@ -15,9 +29,11 @@ Then rebuild both native apps; a JS-only reload is not enough the first time. Au
15
29
 
16
30
  ### Requirements
17
31
 
18
- The host app must run **React Native 0.81 to 0.86 with the New Architecture (Fabric)** and React 19; these are Fabric codegen components and will not build on the old architecture. Builds are verified on 0.81.5 and 0.86.3. iOS needs Xcode 26+ and a **deployment target of iOS 15.1 or higher** — below that CocoaPods refuses to resolve the pod — with native glass on iOS 26+ and standard controls below it.
32
+ The host app must run **React Native 0.81 or newer with the New Architecture (Fabric)** and React 19; these are Fabric codegen components and will not build on the old architecture. Builds are verified on 0.81.5, 0.86.3 and 0.87.1. The peer range has no upper bound: it controls what npm will install, while the tested versions are listed here. iOS needs Xcode 26+ and a **deployment target of iOS 15.1 or higher** — below that CocoaPods refuses to resolve the pod — with native glass on iOS 26+ and standard controls below it.
33
+
34
+ On React Native 0.81 specifically, the app must consume React Native's **prebuilt** iOS dependencies, which is the default (`RCT_USE_RN_DEP=1`) and what Expo SDK 54 does. Building React Native from source on 0.81 fails under Xcode 26 inside `Pods/fmt`, independently of this package. On **React Native 0.87** two defects in React Native itself affect any Fabric library, including this one. React's own `ReactCodegen` target fails to compile in 0.87's default prebuilt mode because its header search path misses the `React_RCTFabric` subdirectory; run `RCT_USE_PREBUILT_RNCORE=0 pod install` to build React from source instead. In an npm workspace, `@react-native/metro-config` nests inside the app rather than hoisting, and `@react-native/community-cli-plugin` cannot `require()` it, so Metro will not start until you declare it in the workspace root.
19
35
 
20
- On React Native 0.81 specifically, the app must consume React Native's **prebuilt** iOS dependencies, which is the default (`RCT_USE_RN_DEP=1`) and what Expo SDK 54 does. Building React Native from source on 0.81 fails under Xcode 26 inside `Pods/fmt`, independently of this package. On **iOS 27 the host app must adopt the UIScene lifecycle**, otherwise iOS traps during scene creation before any UI appears; add a `UIApplicationSceneManifest` and a scene delegate that owns the window. Android needs no extra setup. Expo Modules are not used and no router is required.
36
+ On **iOS 27 the host app must adopt the UIScene lifecycle**, otherwise iOS traps during scene creation before any UI appears; add a `UIApplicationSceneManifest` and a scene delegate that owns the window. Android needs no extra setup. Expo Modules are not used and no router is required.
21
37
 
22
38
  ```tsx
23
39
  import {GlassView, GlassButton, GlassContainer, GlassActionCluster} from '@likith99/react-native-adaptive-liquid-glass';
@@ -36,12 +52,36 @@ import {GlassView, GlassButton, GlassContainer, GlassActionCluster} from '@likit
36
52
 
37
53
  `GlassView` accepts material (`regular`, `clear`, `none`), interactive, tintColor, cornerRadius, colorScheme, animationDuration (seconds), fallbackStyle, and forceFallback. Use cornerRadius instead of style.borderRadius. `GlassPressable` adds custom React children, press semantics and Android ripple. `GlassButton` with a `title` uses a fully native SwiftUI button on iOS 26+. Existing children-based GlassButton calls remain compatible. `GlassContainer.mergingEnabled` defaults to false. Set it to true to allow nearby surfaces to join; `spacing` then controls the merging threshold, independently of layout gap. The action cluster also defaults to `mergingEnabled={false}`; enable it to opt into native glass merging during expansion and collapse. For a GlassContainer, place participating GlassViews inside it.
38
54
 
39
- The UIKit action cluster retains controls by stable IDs and uses the same UIGlassEffect material host as GlassView. The toggle keeps its material, bounds, and opacity unchanged during expansion. UIKit owns the native finger-localized highlight and deformation whenever `interactive` is enabled. There is no custom press overlay, scale, or finger-follow animation. SF Symbols remain inside the effect content view, so UIKit deforms the icon and glass together. A tap recognizer observes activation without cancelling or delaying native glass touches. There is no separate UIControl or forced hit-test target. Default dark-mode actions apply a neutral black native material tint at 0.65 alpha to reduce lightness. This also darkens the resting material; it is not a highlight-strength percentage. Light mode uses transparent tint. An explicit tintColor overrides the default. UIKit still owns the highlight and provides no separate intensity setting. The old `pressFeedback` prop is deprecated and ignored. `interactive={false}` disables native press visuals without disabling callbacks. Android keeps its normal ripple. Opt-in merging uses UIGlassContainerEffect and animated UIKit geometry; SwiftUI matched-geometry transitions are no longer used. Supply unique nonempty action IDs; `__toggle` is reserved. Keep expanded controlled. iOS renders SF Symbols with accessible titles, while Android displays titles on ordinary buttons. Allow approximately `24 + 52 * (actions.length + 1) + 12 * actions.length` points of width and at least 80 of height. Arbitrary React children are supported by the surface, but not the action cluster.
55
+ The UIKit action cluster retains controls by stable IDs and uses the same UIGlassEffect material host as GlassView. The toggle keeps its material, bounds, and opacity unchanged during expansion. UIKit owns the native finger-localized highlight and deformation whenever `interactive` is enabled. There is no custom press overlay, scale, or finger-follow animation. SF Symbols remain inside the effect content view, so UIKit deforms the icon and glass together. A tap recognizer observes activation without cancelling or delaying native glass touches. There is no separate UIControl or forced hit-test target. Default dark-mode actions apply a neutral black native material tint at 0.65 alpha to reduce lightness. This also darkens the resting material; it is not a highlight-strength percentage. Light mode uses transparent tint. An explicit tintColor overrides the default. UIKit still owns the highlight and provides no separate intensity setting. The old `pressFeedback` prop is deprecated and ignored. `interactive={false}` disables native press visuals without disabling callbacks. Android keeps its normal ripple. Opt-in merging uses UIGlassContainerEffect and animated UIKit geometry; The default UIKit branch does not use SwiftUI matched-geometry transitions. Supply unique nonempty action IDs; `__toggle` is reserved. Keep expanded controlled. iOS renders SF Symbols with accessible titles, while Android displays titles on ordinary buttons. Allow approximately `24 + 52 * (actions.length + 1) + 12 * actions.length` points of width and at least 80 of height. On iOS, a narrower measured host automatically uses wrapping standard controls, then returns to native glass when it fits. Controlled state and callbacks are retained. Allow height to grow in narrow layouts rather than forcing a fixed height. Arbitrary React children are supported by the surface, but not the action cluster.
40
56
 
41
57
  Reduce Transparency replaces our glass surface/cluster materials with opaque backgrounds. Reduce Motion suppresses our native property/morph transitions. Use semantic text colors for your children; the package does not recolor arbitrary content. `forceFallback` previews the standard implementation on surfaces, buttons, selectors, and action clusters. The native slider has no `forceFallback` prop.
42
58
 
43
59
  See `src/types.ts` for the complete typed API. The repository root README contains the demo commands, architecture, reference study, and verification results. Custom drag/stretch physics, a full native control suite, arbitrary React-tree morphing, physical-device profiling, and broader React Native compatibility remain future work. MIT licensed.
44
60
 
61
+ ## Choose the action-cluster appearance
62
+
63
+ ```tsx
64
+ <GlassActionCluster
65
+ iosImplementation="swiftui" // "uikit" is the existing appearance and default
66
+ actions={actions}
67
+ expanded={expanded}
68
+ onExpandedChange={setExpanded}
69
+ onAction={handleAction}
70
+ />
71
+ ```
72
+
73
+ On iOS 26+, `swiftui` uses Apple's stock `.glass` button style with standard
74
+ material and touch response, without our default dark-mode shading. Change this
75
+ prop at runtime to switch; the controlled actions/expanded state are retained.
76
+ UIKit-specific `material` and `interactive` props do not alter the SwiftUI mode.
77
+ `tintColor` uses SwiftUI's button tint there. Merging remains opt-in, using
78
+ `GlassEffectContainer`; its transitions may differ from UIKit. Older iOS,
79
+ Android, narrow hosts and `forceFallback` keep standard controls. Standalone
80
+ `GlassButton` is unaffected. Rebuild iOS after installing this update.
81
+
82
+ The SwiftUI branch compiled and passed simulator switching, merging and RTL
83
+ interaction checks on iOS 26.5. The older-iOS fallback passed on iOS 18.6.
84
+
45
85
  ## Native controls
46
86
 
47
87
  `GlassButton` accepts `title`, optional `systemImage` (iOS), `variant="regular" | "prominent"`, `disabled`, `loading`, `onPress: () => void`, tintColor, colorScheme, forceFallback, and view layout props. Loading prevents activation. Default native host height: 64 points. For custom children/material/shape, use GlassPressable.
@@ -116,7 +156,7 @@ const [tab, setTab] = useState('home');
116
156
 
117
157
  Supply 1–5 items with unique nonempty IDs/titles and a `value` present in the list. Empty items require `value={null}`. Items accept icon presets (`home`, `search`, `library`, `favorites`, `inbox`, `settings`), iOS `systemImage`/`selectedSystemImage`, Android app drawable name `androidIcon`, numeric or `'dot'` badge, `disabled`, and `accessibilityLabel`. Numeric badges are nonnegative 32-bit integers; native display caps at 999+. Omitting a badge removes it.
118
158
 
119
- Selection is controlled: update `value` in `onValueChange` to accept a tap. Rejected changes restore the supplied value after the React transaction; tapping the current tab calls only `onTabReselect`. Programmatic values emit no callbacks and may select disabled tabs. Global `disabled` blocks user taps. On the iOS 26.5 simulator, XCTest still reports disabled tabs as enabled despite native dimming and blocked activation; VoiceOver announcements need the planned accessibility audit. `tintColor` changes the selected foreground accent. Stable IDs preserve tab identity across reorder/replacement; native controls own press and selection animations.
159
+ Selection is controlled: update `value` in `onValueChange` to accept a tap. Rejected changes restore the supplied value after the React transaction; tapping the current tab calls only `onTabReselect`. Programmatic values emit no callbacks and may select disabled tabs. Global `disabled` blocks user taps. On the iOS 26.5 simulator, XCTest still reports disabled tabs as enabled despite native dimming and blocked activation; spoken VoiceOver acceptance is deferred by the owner. `tintColor` changes the selected foreground accent. Stable IDs preserve tab identity across reorder/replacement; native controls own press and selection animations.
120
160
 
121
161
  Default host height is 80 points, minimum width 180. Supply sufficient width/height for your labels. The screen owns safe-area spacing outside the host and the content above it. This primitive does not own a navigation stack, React screens, automatic scroll minimization, or an iPad sidebar. It does not accept React children or `forceFallback`; the native system determines its appearance. The repository demo shows a React Navigation 7 custom tab-bar adapter using route keys, preventable `tabPress`, reselection, retained screen state, and back history.
122
162
 
@@ -125,3 +165,14 @@ Default host height is 80 points, minimum width 180. Supply sufficient width/hei
125
165
  The deployment floor is iOS 15.1. Below iOS 26, GlassView and GlassPressable use UIKit system blur (`regular`: system material; `clear`: ultra-thin material). `material="none"` is transparent. Reduce Transparency uses an opaque semantic background; `forceFallback` explicitly selects the opaque React surface. `fallbackStyle` still applies below iOS 26 and can cover the blur if it supplies an opaque background. A translucent tint overlays the older-iOS blur. GlassContainer remains ordinary layout with no merging below iOS 26. Other controls retain standard UIKit or React implementations. iOS 18.6 is the installed fallback test runtime; iOS 15–17 and physical older devices remain unverified.
126
166
 
127
167
  Keep blurred areas bounded, avoid nested material surfaces, and use the opaque opt-out for dense lists where appropriate. The native host retains its effect across unchanged props and layout. These safeguards are not measured frame-rate guarantees; profile Release builds on representative physical devices before making performance claims.
168
+
169
+ ## 0.1.2 iPhone-first scope
170
+
171
+ Selectable UIKit/stock SwiftUI action clusters, native action RTL, automatic
172
+ narrow-cluster fallback, corrected native title-button containment and fewer
173
+ redundant SwiftUI updates. SwiftUI switching, merging and RTL interactions passed
174
+ on the iOS 26.5 simulator. The broader current-iOS regression and eight iOS 18.6
175
+ fallback/control cases passed. These results do not establish iOS 15–17 execution,
176
+ spoken VoiceOver/TalkBack compatibility, narrow iPad multitasking or a frame-rate
177
+ guarantee. The package needs no new runtime dependency. Full release evidence is
178
+ maintained in the repository's `release/verification-0.1.2.md`.
@@ -9,6 +9,7 @@ using namespace facebook::react;
9
9
 
10
10
  @implementation ALGActionClusterComponentView {
11
11
  ALGActionClusterView *_cluster;
12
+ ALGSwiftUIActionClusterView *_swiftUICluster;
12
13
  }
13
14
  + (ComponentDescriptorProvider)componentDescriptorProvider {
14
15
  return concreteComponentDescriptorProvider<ALGActionClusterComponentDescriptor>();
@@ -21,13 +22,13 @@ using namespace facebook::react;
21
22
  __weak ALGActionClusterComponentView *weakSelf = self;
22
23
  _cluster.onAction = ^(NSString *identifier) {
23
24
  ALGActionClusterComponentView *strongSelf = weakSelf;
24
- if (!strongSelf || !strongSelf->_eventEmitter) return;
25
+ if (!strongSelf || !strongSelf->_eventEmitter || strongSelf.contentView != strongSelf->_cluster) return;
25
26
  auto emitter = std::static_pointer_cast<const ALGActionClusterEventEmitter>(strongSelf->_eventEmitter);
26
27
  emitter->onAction({std::string(identifier.UTF8String)});
27
28
  };
28
29
  _cluster.onExpandedChange = ^(BOOL expanded) {
29
30
  ALGActionClusterComponentView *strongSelf = weakSelf;
30
- if (!strongSelf || !strongSelf->_eventEmitter) return;
31
+ if (!strongSelf || !strongSelf->_eventEmitter || strongSelf.contentView != strongSelf->_cluster) return;
31
32
  auto emitter = std::static_pointer_cast<const ALGActionClusterEventEmitter>(strongSelf->_eventEmitter);
32
33
  emitter->onExpandedChange({static_cast<bool>(expanded)});
33
34
  };
@@ -36,11 +37,40 @@ using namespace facebook::react;
36
37
  }
37
38
  - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps {
38
39
  const auto &p = *std::static_pointer_cast<const ALGActionClusterProps>(props);
39
- [_cluster configure:[NSString stringWithUTF8String:p.actionsJSON.c_str()]
40
- expanded:p.expanded mergingEnabled:p.mergingEnabled spacing:p.spacing tint:RCTUIColorFromSharedColor(p.glassTint)
41
- material:p.material == ALGActionClusterMaterial::Clear ? @"clear" : @"regular"
42
- interactive:p.interactive
43
- duration:p.duration toggleLabel:[NSString stringWithUTF8String:p.toggleLabel.c_str()]];
40
+ BOOL useSwiftUI = NO;
41
+ if (@available(iOS 26.0, *)) {
42
+ useSwiftUI = p.iosImplementation == ALGActionClusterIosImplementation::Swiftui;
43
+ }
44
+ if (useSwiftUI) {
45
+ if (!_swiftUICluster) {
46
+ _swiftUICluster = [[ALGSwiftUIActionClusterView alloc] initWithFrame:CGRectZero];
47
+ __weak ALGActionClusterComponentView *weakSelf = self;
48
+ _swiftUICluster.onAction = ^(NSString *identifier) {
49
+ ALGActionClusterComponentView *strongSelf = weakSelf;
50
+ if (!strongSelf || !strongSelf->_eventEmitter || strongSelf.contentView != strongSelf->_swiftUICluster) return;
51
+ auto emitter = std::static_pointer_cast<const ALGActionClusterEventEmitter>(strongSelf->_eventEmitter);
52
+ emitter->onAction({std::string(identifier.UTF8String)});
53
+ };
54
+ _swiftUICluster.onExpandedChange = ^(BOOL expanded) {
55
+ ALGActionClusterComponentView *strongSelf = weakSelf;
56
+ if (!strongSelf || !strongSelf->_eventEmitter || strongSelf.contentView != strongSelf->_swiftUICluster) return;
57
+ auto emitter = std::static_pointer_cast<const ALGActionClusterEventEmitter>(strongSelf->_eventEmitter);
58
+ emitter->onExpandedChange({static_cast<bool>(expanded)});
59
+ };
60
+ }
61
+ [_swiftUICluster configure:[NSString stringWithUTF8String:p.actionsJSON.c_str()]
62
+ expanded:p.expanded mergingEnabled:p.mergingEnabled spacing:p.spacing
63
+ tint:RCTUIColorFromSharedColor(p.glassTint) duration:p.duration
64
+ toggleLabel:[NSString stringWithUTF8String:p.toggleLabel.c_str()]];
65
+ if (self.contentView != _swiftUICluster) self.contentView = _swiftUICluster;
66
+ } else {
67
+ [_cluster configure:[NSString stringWithUTF8String:p.actionsJSON.c_str()]
68
+ expanded:p.expanded mergingEnabled:p.mergingEnabled spacing:p.spacing tint:RCTUIColorFromSharedColor(p.glassTint)
69
+ material:p.material == ALGActionClusterMaterial::Clear ? @"clear" : @"regular"
70
+ interactive:p.interactive
71
+ duration:p.duration toggleLabel:[NSString stringWithUTF8String:p.toggleLabel.c_str()]];
72
+ if (self.contentView != _cluster) self.contentView = _cluster;
73
+ }
44
74
  [super updateProps:props oldProps:oldProps];
45
75
  }
46
76
  + (BOOL)shouldBeRecycled { return NO; }
@@ -105,6 +105,7 @@ public final class ALGActionClusterView: UIView {
105
105
  private var toggleLabel = "Actions"
106
106
  private var duration = 0.45
107
107
  private var lastSize = CGSize.zero
108
+ private var lastLayoutDirection: UIUserInterfaceLayoutDirection?
108
109
  private var animationGeneration = 0
109
110
  private var configured = false
110
111
  @objc public var onAction: ((String) -> Void)?
@@ -187,10 +188,12 @@ public final class ALGActionClusterView: UIView {
187
188
  }
188
189
  }
189
190
  private var toggleFrame: CGRect {
190
- CGRect(x: bounds.width - 12 - 52, y: (bounds.height - 52) / 2, width: 52, height: 52)
191
+ let x: CGFloat = effectiveUserInterfaceLayoutDirection == .rightToLeft ? 12 : bounds.width - 12 - 52
192
+ return CGRect(x: x, y: (bounds.height - 52) / 2, width: 52, height: 52)
191
193
  }
192
194
  private func actionFrame(at index: Int) -> CGRect {
193
- toggleFrame.offsetBy(dx: -CGFloat(actions.count - index) * 64, dy: 0)
195
+ let direction: CGFloat = effectiveUserInterfaceLayoutDirection == .rightToLeft ? 1 : -1
196
+ return toggleFrame.offsetBy(dx: direction * CGFloat(actions.count - index) * 64, dy: 0)
194
197
  }
195
198
  private func updatePositions(animated: Bool) {
196
199
  guard bounds.width > 0, bounds.height > 0 else { setNeedsLayout(); return }
@@ -243,7 +246,12 @@ public final class ALGActionClusterView: UIView {
243
246
  public override func layoutSubviews() {
244
247
  super.layoutSubviews()
245
248
  container.frame = bounds
246
- if lastSize != bounds.size { lastSize = bounds.size; updatePositions(animated: false) }
249
+ let direction = effectiveUserInterfaceLayoutDirection
250
+ if lastSize != bounds.size || lastLayoutDirection != direction {
251
+ lastSize = bounds.size
252
+ lastLayoutDirection = direction
253
+ updatePositions(animated: false)
254
+ }
247
255
  }
248
256
  public override func didMoveToWindow() {
249
257
  super.didMoveToWindow()
@@ -68,46 +68,58 @@ public final class ALGButtonView: UIView {
68
68
  let controller = UIHostingController(rootView: NativeButtonContent(model: model))
69
69
  controller.view.backgroundColor = .clear
70
70
  host = controller
71
- addSubview(controller.view)
72
71
  }
73
72
  }
74
73
  required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
75
74
 
76
75
  @objc public func configure(_ title: String, symbol: String, prominent: Bool, disabled: Bool,
77
76
  loading: Bool, tint: UIColor?, scheme: String, label: String, hint: String, identifier: String) {
78
- model.title = title
79
- model.symbol = symbol
80
- model.prominent = prominent
81
- model.disabled = disabled
82
- model.loading = loading
83
- model.tint = tint
84
- model.label = label
85
- model.hint = hint
86
- model.identifier = identifier
77
+ // @Published emits even for equal assignments. React commits frequently
78
+ // carry unchanged fields; keep those from invalidating SwiftUI content.
79
+ if model.title != title { model.title = title }
80
+ if model.symbol != symbol { model.symbol = symbol }
81
+ if model.prominent != prominent { model.prominent = prominent }
82
+ if model.disabled != disabled { model.disabled = disabled }
83
+ if model.loading != loading { model.loading = loading }
84
+ if model.tint != tint { model.tint = tint }
85
+ if model.label != label { model.label = label }
86
+ if model.hint != hint { model.hint = hint }
87
+ if model.identifier != identifier { model.identifier = identifier }
87
88
  overrideUserInterfaceStyle = scheme == "dark" ? .dark : scheme == "light" ? .light : .unspecified
88
89
  }
89
90
 
90
- public override func layoutSubviews() { super.layoutSubviews(); host?.view.frame = bounds }
91
+ private func detachHost() {
92
+ guard let host, host.parent != nil else { return }
93
+ host.willMove(toParent: nil)
94
+ host.view.removeFromSuperview()
95
+ host.removeFromParent()
96
+ }
97
+ private func attachHost() {
98
+ guard window != nil, let host else { return }
99
+ var responder: UIResponder? = superview
100
+ while let current = responder, !(current is UIViewController) { responder = current.next }
101
+ guard let parent = responder as? UIViewController, parent !== host else { return }
102
+ if host.parent !== parent {
103
+ detachHost()
104
+ parent.addChild(host)
105
+ host.view.frame = bounds
106
+ addSubview(host.view)
107
+ host.didMove(toParent: parent)
108
+ }
109
+ }
110
+ public override func layoutSubviews() {
111
+ super.layoutSubviews()
112
+ attachHost()
113
+ host?.view.frame = bounds
114
+ }
91
115
  public override func didMoveToWindow() {
92
116
  super.didMoveToWindow()
93
- guard let host else { return }
94
117
  if window == nil {
95
- host.willMove(toParent: nil)
96
- host.removeFromParent()
97
- return
98
- }
99
- var responder: UIResponder? = superview
100
- while let current = responder {
101
- if let parent = current as? UIViewController {
102
- if host.parent !== parent {
103
- host.willMove(toParent: nil)
104
- host.removeFromParent()
105
- parent.addChild(host)
106
- host.didMove(toParent: parent)
107
- }
108
- break
109
- }
110
- responder = current.next
118
+ detachHost()
119
+ } else {
120
+ attachHost()
121
+ // Fabric may finish connecting the responder chain after window insertion.
122
+ DispatchQueue.main.async { [weak self] in self?.attachHost() }
111
123
  }
112
124
  }
113
125
  }
@@ -0,0 +1,163 @@
1
+ import UIKit
2
+ import SwiftUI
3
+
4
+ private struct SwiftUIClusterAction: Decodable, Equatable, Identifiable {
5
+ let id: String
6
+ let title: String
7
+ let systemImage: String?
8
+ let disabled: Bool?
9
+ }
10
+
11
+ private struct SwiftUIClusterConfiguration: Equatable {
12
+ var actions: [SwiftUIClusterAction] = []
13
+ var expanded = false
14
+ var mergingEnabled = false
15
+ var spacing: CGFloat = 20
16
+ var tint: UIColor?
17
+ var duration = 0.45
18
+ var toggleLabel = "Actions"
19
+ }
20
+
21
+ private final class SwiftUIClusterModel: ObservableObject {
22
+ @Published var configuration = SwiftUIClusterConfiguration()
23
+ @Published var rightToLeft = false
24
+ var activate: (String) -> Void = { _ in }
25
+ var toggle: () -> Void = {}
26
+ }
27
+
28
+ @available(iOS 26.0, *)
29
+ private struct SwiftUIClusterContent: View {
30
+ @ObservedObject var model: SwiftUIClusterModel
31
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
32
+ @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
33
+ @Namespace private var glassNamespace
34
+
35
+ private var configuration: SwiftUIClusterConfiguration { model.configuration }
36
+
37
+ private func control(id: String, title: String, symbol: String, disabled: Bool,
38
+ action: @escaping () -> Void) -> some View {
39
+ Button(action: action) {
40
+ Image(systemName: UIImage(systemName: symbol) == nil ? "circle" : symbol)
41
+ .font(.system(size: 20, weight: .medium))
42
+ }
43
+ .buttonBorderShape(.circle)
44
+ .controlSize(.large)
45
+ .frame(width: 52, height: 52)
46
+ .disabled(disabled)
47
+ .accessibilityLabel(title)
48
+ .accessibilityIdentifier(id == "__toggle" ? "glass-cluster-toggle" : "glass-action-" + id)
49
+ .glassEffectID(id, in: glassNamespace)
50
+ }
51
+
52
+ private var row: some View {
53
+ HStack(spacing: 12) {
54
+ if configuration.expanded {
55
+ ForEach(configuration.actions) { item in
56
+ control(id: item.id, title: item.title, symbol: item.systemImage ?? "circle",
57
+ disabled: item.disabled == true) { model.activate(item.id) }
58
+ }
59
+ }
60
+ control(id: "__toggle", title: configuration.toggleLabel,
61
+ symbol: configuration.expanded ? "xmark" : "plus", disabled: false) { model.toggle() }
62
+ .accessibilityValue(configuration.expanded ? "Expanded" : "Collapsed")
63
+ }
64
+ }
65
+
66
+ @ViewBuilder private var styledRow: some View {
67
+ if reduceTransparency { row.buttonStyle(.bordered) }
68
+ else { row.buttonStyle(.glass) }
69
+ }
70
+
71
+ var body: some View {
72
+ Group {
73
+ if configuration.mergingEnabled && !reduceTransparency {
74
+ GlassEffectContainer(spacing: configuration.spacing) { styledRow }
75
+ } else { styledRow }
76
+ }
77
+ .tint(configuration.tint.map { Color(uiColor: $0) } ?? .accentColor)
78
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
79
+ .padding(.horizontal, 12)
80
+ .animation(reduceMotion || configuration.duration == 0 ? nil : .easeInOut(duration: configuration.duration),
81
+ value: configuration.expanded)
82
+ .environment(\.layoutDirection, model.rightToLeft ? .rightToLeft : .leftToRight)
83
+ }
84
+ }
85
+
86
+ /// Optional stock SwiftUI presentation. The default UIKit cluster is separate so
87
+ /// opting into this mode cannot change its accepted tint or native touch behavior.
88
+ @objc(ALGSwiftUIActionClusterView)
89
+ public final class ALGSwiftUIActionClusterView: UIView {
90
+ private let model = SwiftUIClusterModel()
91
+ private var host: UIViewController?
92
+ private var lastJSON = ""
93
+ private var actions: [SwiftUIClusterAction] = []
94
+ @objc public var onAction: ((String) -> Void)?
95
+ @objc public var onExpandedChange: ((Bool) -> Void)?
96
+
97
+ public override init(frame: CGRect) {
98
+ super.init(frame: frame)
99
+ model.activate = { [weak self] id in
100
+ guard let self, self.window != nil, self.model.configuration.expanded,
101
+ let item = self.actions.first(where: { $0.id == id }), item.disabled != true else { return }
102
+ self.onAction?(id)
103
+ }
104
+ model.toggle = { [weak self] in
105
+ guard let self, self.window != nil else { return }
106
+ self.onExpandedChange?(!self.model.configuration.expanded)
107
+ }
108
+ if #available(iOS 26.0, *) {
109
+ let controller = UIHostingController(rootView: SwiftUIClusterContent(model: model))
110
+ controller.view.backgroundColor = .clear
111
+ host = controller
112
+ }
113
+ }
114
+ required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
115
+
116
+ @objc public func configure(_ actionsJSON: String, expanded: Bool, mergingEnabled: Bool,
117
+ spacing: CGFloat, tint: UIColor?, duration: Double, toggleLabel: String) {
118
+ if actionsJSON != lastJSON {
119
+ actions = (try? JSONDecoder().decode([SwiftUIClusterAction].self, from: Data(actionsJSON.utf8))) ?? []
120
+ lastJSON = actionsJSON
121
+ }
122
+ let next = SwiftUIClusterConfiguration(actions: actions, expanded: expanded,
123
+ mergingEnabled: mergingEnabled, spacing: spacing.isFinite ? max(0, spacing) : 20,
124
+ tint: tint, duration: duration.isFinite ? max(0, duration) : 0, toggleLabel: toggleLabel)
125
+ // One publication per meaningful prop transaction; state stays React-controlled.
126
+ if model.configuration != next { model.configuration = next }
127
+ }
128
+
129
+ private func detachHost() {
130
+ guard let host, host.parent != nil else { return }
131
+ host.willMove(toParent: nil)
132
+ host.view.removeFromSuperview()
133
+ host.removeFromParent()
134
+ }
135
+ private func attachHost() {
136
+ guard window != nil, let host else { return }
137
+ var responder: UIResponder? = superview
138
+ while let current = responder, !(current is UIViewController) { responder = current.next }
139
+ guard let parent = responder as? UIViewController, parent !== host else { return }
140
+ if host.parent !== parent {
141
+ detachHost()
142
+ parent.addChild(host)
143
+ host.view.frame = bounds
144
+ addSubview(host.view)
145
+ host.didMove(toParent: parent)
146
+ }
147
+ }
148
+ public override func layoutSubviews() {
149
+ super.layoutSubviews()
150
+ let rightToLeft = effectiveUserInterfaceLayoutDirection == .rightToLeft
151
+ if model.rightToLeft != rightToLeft { model.rightToLeft = rightToLeft }
152
+ attachHost()
153
+ host?.view.frame = bounds
154
+ }
155
+ public override func didMoveToWindow() {
156
+ super.didMoveToWindow()
157
+ if window == nil { detachHost() }
158
+ else {
159
+ attachHost()
160
+ DispatchQueue.main.async { [weak self] in self?.attachHost() }
161
+ }
162
+ }
163
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@likith99/react-native-adaptive-liquid-glass",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Native iOS Liquid Glass surfaces and controls with standard Android counterparts.",
5
5
  "license": "MIT",
6
6
  "author": "Likith Kanneganti",
@@ -45,7 +45,7 @@
45
45
  ],
46
46
  "peerDependencies": {
47
47
  "react": ">=19.0.0",
48
- "react-native": ">=0.81.0 <0.87.0"
48
+ "react-native": ">=0.81.0"
49
49
  },
50
50
  "codegenConfig": {
51
51
  "name": "AdaptiveLiquidGlassSpec",
@@ -1,17 +1,31 @@
1
- import React from 'react';
1
+ import React, {useState} from 'react';
2
+ import type {LayoutChangeEvent} from 'react-native';
2
3
  import NativeCluster from './specs/ALGActionClusterNativeComponent';
3
4
  import Fallback from './fallback/GlassActionCluster';
4
5
  import {isLiquidGlassSupported} from './support';
5
6
  import {validateActions} from './validateActions';
6
7
  import type {GlassActionClusterProps} from './types';
7
8
  export default function GlassActionCluster({actions, onAction, onExpandedChange, tintColor,
8
- animationDuration = 0.45, pressFeedback: _pressFeedback, forceFallback, toggleLabel = 'Actions', style, ...props}: GlassActionClusterProps) {
9
+ iosImplementation = 'uikit',
10
+ animationDuration = 0.45, pressFeedback: _pressFeedback, forceFallback, toggleLabel = 'Actions', style, onLayout, ...props}: GlassActionClusterProps) {
11
+ const [width, setWidth] = useState<number | null>(null);
9
12
  validateActions(actions);
10
- if (forceFallback || !isLiquidGlassSupported()) {
13
+ // Match UIKit's 52-point controls, 12-point gaps and 12-point edge insets.
14
+ // Measure this component, not the screen: a split window or nested panel can
15
+ // be narrower. Keep the controlled expanded state across renderer changes.
16
+ const tooNarrow = width !== null && width < 76 + actions.length * 64;
17
+ const handleLayout = (event: LayoutChangeEvent) => {
18
+ const nextWidth = event.nativeEvent.layout.width;
19
+ if (Number.isFinite(nextWidth) && nextWidth > 0) setWidth(nextWidth);
20
+ onLayout?.(event);
21
+ };
22
+ if (forceFallback || !isLiquidGlassSupported() || tooNarrow) {
11
23
  return <Fallback {...props} actions={actions} onAction={onAction} onExpandedChange={onExpandedChange}
12
- style={style} toggleLabel={toggleLabel} />;
24
+ style={style} toggleLabel={toggleLabel} onLayout={handleLayout} />;
13
25
  }
14
26
  return <NativeCluster {...props} style={[{height: 88, minWidth: 80}, style]}
27
+ iosImplementation={iosImplementation}
28
+ onLayout={handleLayout}
15
29
  actionsJSON={JSON.stringify(actions)} glassTint={tintColor} duration={animationDuration} toggleLabel={toggleLabel}
16
30
  onAction={event => onAction(event.nativeEvent.id)}
17
31
  onExpandedChange={event => onExpandedChange(event.nativeEvent.expanded)} />;
@@ -6,7 +6,7 @@ import type {GlassActionClusterProps} from '../types';
6
6
  export default function GlassActionCluster({actions, expanded, onExpandedChange, onAction,
7
7
  toggleLabel = 'Actions', style, spacing: _spacing, tintColor: _tint, material: _material,
8
8
  interactive: _interactive, animationDuration: _duration, forceFallback: _force,
9
- mergingEnabled: _merging, pressFeedback: _pressFeedback, ...props}: GlassActionClusterProps) {
9
+ mergingEnabled: _merging, pressFeedback: _pressFeedback, iosImplementation: _implementation, ...props}: GlassActionClusterProps) {
10
10
  validateActions(actions);
11
11
  const dark = useColorScheme() === 'dark';
12
12
  const textStyle = {color: dark ? '#F5F5FA' : '#242630', fontWeight: '600' as const};
@@ -1,5 +1,6 @@
1
1
  import {codegenNativeComponent, type ViewProps, type ColorValue, type CodegenTypes} from 'react-native';
2
2
  export interface NativeProps extends ViewProps {
3
+ iosImplementation?: CodegenTypes.WithDefault<'uikit' | 'swiftui', 'uikit'>;
3
4
  actionsJSON?: string;
4
5
  expanded?: CodegenTypes.WithDefault<boolean, false>;
5
6
  mergingEnabled?: CodegenTypes.WithDefault<boolean, false>;
package/src/types.ts CHANGED
@@ -84,6 +84,9 @@ export interface GlassAction {
84
84
  disabled?: boolean;
85
85
  }
86
86
  export interface GlassActionClusterProps extends Omit<ViewProps, 'children'> {
87
+ /** iOS 26+: current UIKit material controls (default) or stock SwiftUI glass buttons.
88
+ * Older iOS, Android and narrow hosts use the standard fallback in either mode. */
89
+ iosImplementation?: 'uikit' | 'swiftui';
87
90
  actions: readonly GlassAction[];
88
91
  expanded: boolean;
89
92
  onExpandedChange: (expanded: boolean) => void;
@@ -91,9 +94,12 @@ export interface GlassActionClusterProps extends Omit<ViewProps, 'children'> {
91
94
  /** Enable shared glass merging and morphing. Defaults to false. */
92
95
  mergingEnabled?: boolean;
93
96
  spacing?: number;
94
- /** Native material tint. Default: neutral dark-mode shading; overrides replace it. */
97
+ /** UIKit: material tint (neutral dark-mode shading by default).
98
+ * SwiftUI: standard button tint; no custom default shading. */
95
99
  tintColor?: ColorValue;
100
+ /** UIKit only; SwiftUI uses its standard glass material. */
96
101
  material?: 'regular' | 'clear';
102
+ /** UIKit only; SwiftUI retains standard button feedback. Does not disable actions. */
97
103
  interactive?: boolean;
98
104
  /** @deprecated Ignored. Native glass feedback follows interactive; custom feedback was removed. */
99
105
  pressFeedback?: 'subtle' | 'native';