@likith99/react-native-adaptive-liquid-glass 0.1.1 → 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 +38 -3
- package/ios/ALGActionClusterComponentView.mm +37 -7
- package/ios/ALGActionClusterView.swift +11 -3
- package/ios/ALGButtonView.swift +40 -28
- package/ios/ALGSwiftUIActionClusterView.swift +163 -0
- package/package.json +1 -1
- package/src/GlassActionCluster.ios.tsx +18 -4
- package/src/fallback/GlassActionCluster.tsx +1 -1
- package/src/specs/ALGActionClusterNativeComponent.ts +1 -0
- package/src/types.ts +7 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
|
|
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
4
|
|
|
5
5
|
<p align="center">
|
|
6
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">
|
|
@@ -52,12 +52,36 @@ import {GlassView, GlassButton, GlassContainer, GlassActionCluster} from '@likit
|
|
|
52
52
|
|
|
53
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.
|
|
54
54
|
|
|
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; SwiftUI matched-geometry transitions
|
|
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.
|
|
56
56
|
|
|
57
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.
|
|
58
58
|
|
|
59
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.
|
|
60
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
|
+
|
|
61
85
|
## Native controls
|
|
62
86
|
|
|
63
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.
|
|
@@ -132,7 +156,7 @@ const [tab, setTab] = useState('home');
|
|
|
132
156
|
|
|
133
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.
|
|
134
158
|
|
|
135
|
-
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
|
|
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.
|
|
136
160
|
|
|
137
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.
|
|
138
162
|
|
|
@@ -141,3 +165,14 @@ Default host height is 80 points, minimum width 180. Supply sufficient width/hei
|
|
|
141
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.
|
|
142
166
|
|
|
143
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
package/ios/ALGButtonView.swift
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
80
|
-
model.
|
|
81
|
-
model.
|
|
82
|
-
model.
|
|
83
|
-
model.
|
|
84
|
-
model.
|
|
85
|
-
model.
|
|
86
|
-
model.
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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';
|