@likith99/react-native-adaptive-liquid-glass 0.1.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 (77) hide show
  1. package/AdaptiveLiquidGlass.podspec +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/android/build.gradle +21 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/com/adaptiveglass/ALGMenuManager.kt +31 -0
  7. package/android/src/main/java/com/adaptiveglass/ALGMenuView.kt +184 -0
  8. package/android/src/main/java/com/adaptiveglass/ALGSliderManager.kt +32 -0
  9. package/android/src/main/java/com/adaptiveglass/ALGSliderView.kt +132 -0
  10. package/android/src/main/java/com/adaptiveglass/ALGTabsManager.kt +24 -0
  11. package/android/src/main/java/com/adaptiveglass/ALGTabsView.kt +161 -0
  12. package/android/src/main/java/com/adaptiveglass/AdaptiveLiquidGlassPackage.kt +11 -0
  13. package/android/src/main/res/drawable/alg_tab_circle.xml +3 -0
  14. package/android/src/main/res/drawable/alg_tab_favorites.xml +3 -0
  15. package/android/src/main/res/drawable/alg_tab_home.xml +3 -0
  16. package/android/src/main/res/drawable/alg_tab_inbox.xml +3 -0
  17. package/android/src/main/res/drawable/alg_tab_library.xml +3 -0
  18. package/android/src/main/res/drawable/alg_tab_search.xml +3 -0
  19. package/android/src/main/res/drawable/alg_tab_settings.xml +3 -0
  20. package/ios/ALGActionClusterComponentView.h +3 -0
  21. package/ios/ALGActionClusterComponentView.mm +47 -0
  22. package/ios/ALGActionClusterView.swift +252 -0
  23. package/ios/ALGButtonComponentView.h +3 -0
  24. package/ios/ALGButtonComponentView.mm +37 -0
  25. package/ios/ALGButtonView.swift +113 -0
  26. package/ios/ALGMenuComponentView.h +3 -0
  27. package/ios/ALGMenuComponentView.mm +34 -0
  28. package/ios/ALGMenuView.swift +221 -0
  29. package/ios/ALGSegmentedComponentView.h +3 -0
  30. package/ios/ALGSegmentedComponentView.mm +36 -0
  31. package/ios/ALGSegmentedView.swift +58 -0
  32. package/ios/ALGSliderComponentView.h +3 -0
  33. package/ios/ALGSliderComponentView.mm +47 -0
  34. package/ios/ALGSliderView.swift +102 -0
  35. package/ios/ALGSurfaceComponentView.h +3 -0
  36. package/ios/ALGSurfaceComponentView.mm +45 -0
  37. package/ios/ALGSurfaceView.swift +108 -0
  38. package/ios/ALGTabsComponentView.h +3 -0
  39. package/ios/ALGTabsComponentView.mm +33 -0
  40. package/ios/ALGTabsView.swift +194 -0
  41. package/package.json +73 -0
  42. package/react-native.config.js +3 -0
  43. package/src/GlassActionCluster.ios.tsx +18 -0
  44. package/src/GlassActionCluster.tsx +1 -0
  45. package/src/GlassButton.ios.tsx +18 -0
  46. package/src/GlassButton.tsx +7 -0
  47. package/src/GlassContainer.ios.tsx +9 -0
  48. package/src/GlassContainer.tsx +6 -0
  49. package/src/GlassMenuButton.tsx +26 -0
  50. package/src/GlassPressable.tsx +17 -0
  51. package/src/GlassSegmentedControl.ios.tsx +21 -0
  52. package/src/GlassSegmentedControl.tsx +1 -0
  53. package/src/GlassSlider.tsx +24 -0
  54. package/src/GlassTabBar.tsx +25 -0
  55. package/src/GlassToolbar.tsx +25 -0
  56. package/src/GlassView.ios.tsx +13 -0
  57. package/src/GlassView.tsx +1 -0
  58. package/src/adaptiveHeight.ts +15 -0
  59. package/src/fallback/GlassActionCluster.tsx +25 -0
  60. package/src/fallback/GlassSegmentedControl.tsx +26 -0
  61. package/src/fallback/GlassView.tsx +12 -0
  62. package/src/fallback/NativeGlassButton.tsx +21 -0
  63. package/src/index.ts +17 -0
  64. package/src/menuTree.ts +35 -0
  65. package/src/sliderMath.ts +12 -0
  66. package/src/specs/ALGActionClusterNativeComponent.ts +15 -0
  67. package/src/specs/ALGButtonNativeComponent.ts +15 -0
  68. package/src/specs/ALGMenuNativeComponent.ts +17 -0
  69. package/src/specs/ALGSegmentedNativeComponent.ts +12 -0
  70. package/src/specs/ALGSliderNativeComponent.ts +19 -0
  71. package/src/specs/ALGSurfaceNativeComponent.ts +13 -0
  72. package/src/specs/ALGTabsNativeComponent.ts +11 -0
  73. package/src/support.ts +5 -0
  74. package/src/types.ts +181 -0
  75. package/src/validateActions.ts +10 -0
  76. package/src/validateSegments.ts +11 -0
  77. package/src/validateTabs.ts +17 -0
@@ -0,0 +1,161 @@
1
+ package com.adaptiveglass
2
+
3
+ import android.content.res.ColorStateList
4
+ import android.content.res.Configuration
5
+ import android.util.TypedValue
6
+ import android.view.ContextThemeWrapper
7
+ import android.view.Menu
8
+ import android.view.MenuItem
9
+ import android.view.View
10
+ import android.widget.FrameLayout
11
+ import androidx.core.view.ViewCompat
12
+ import com.facebook.react.bridge.Arguments
13
+ import com.facebook.react.bridge.WritableMap
14
+ import com.facebook.react.uimanager.ReactAccessibilityDelegate
15
+ import com.facebook.react.uimanager.ThemedReactContext
16
+ import com.facebook.react.uimanager.UIManagerHelper
17
+ import com.facebook.react.uimanager.events.Event
18
+ import com.google.android.material.bottomnavigation.BottomNavigationView
19
+ import com.google.android.material.navigation.NavigationBarView
20
+ import org.json.JSONArray
21
+
22
+ private data class TabEntry(val id: String, val title: String, val icon: String,
23
+ val androidIcon: String, val badge: String?, val disabled: Boolean, val label: String)
24
+ private class TabSelectionEvent(surfaceId: Int, tag: Int, private val tabId: String) : Event<TabSelectionEvent>(surfaceId, tag) {
25
+ override fun getEventName() = "topSelectionChange"
26
+ override fun canCoalesce() = false
27
+ override fun getEventData(): WritableMap = Arguments.createMap().apply { putString("id", tabId) }
28
+ }
29
+
30
+ class ALGTabsView(private val reactContext: ThemedReactContext) : FrameLayout(reactContext) {
31
+ var itemsJSON = "[]"
32
+ var selectedValue = ""
33
+ var disabled = false
34
+ var glassTint: Int? = null
35
+ var controlTestID: String? = null
36
+ private var entries = emptyList<TabEntry>()
37
+ private var nativeIds = mutableMapOf<String, Int>()
38
+ private var appliedJSON: String? = null
39
+ private var applying = false
40
+ private var bar = makeBar()
41
+ private var defaultIconTint = bar.itemIconTintList
42
+ private var defaultTextTint = bar.itemTextColor
43
+
44
+ init { addView(bar, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) }
45
+ private fun makeBar(): BottomNavigationView {
46
+ val themeFlag = TypedValue()
47
+ val themed = if (context.theme.resolveAttribute(com.google.android.material.R.attr.isMaterialTheme, themeFlag, true) && themeFlag.data != 0) context
48
+ else ContextThemeWrapper(context, com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar)
49
+ return BottomNavigationView(themed).apply {
50
+ labelVisibilityMode = NavigationBarView.LABEL_VISIBILITY_LABELED
51
+ // Labels scale with the system text size.
52
+ setLabelFontScalingEnabled(true)
53
+ setLabelMaxLines(2)
54
+ isItemHorizontalTranslationEnabled = false
55
+ // The React screen owns safe-area padding, including the bottom system inset.
56
+ ViewCompat.setOnApplyWindowInsetsListener(this) { _, insets -> insets }
57
+ setOnItemSelectedListener { item ->
58
+ if (applying) true else emitSelection(item)
59
+ }
60
+ setOnItemReselectedListener { item -> if (!applying) emitSelection(item) }
61
+ }
62
+ }
63
+ private fun emitSelection(item: MenuItem): Boolean {
64
+ if (disabled || !isAttachedToWindow || bar.menu.findItem(item.itemId) !== item) return false
65
+ val entry = entries.firstOrNull { nativeIds[it.id] == item.itemId && !it.disabled } ?: return false
66
+ UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)?.dispatchEvent(
67
+ TabSelectionEvent(UIManagerHelper.getSurfaceId(reactContext), id, entry.id))
68
+ return true // Material owns the provisional native indicator animation.
69
+ }
70
+ fun applyConfiguration() {
71
+ applying = true
72
+ try {
73
+ if (appliedJSON != itemsJSON) {
74
+ appliedJSON = itemsJSON
75
+ val previousOrder = entries.map { it.id }
76
+ entries = try {
77
+ val array = JSONArray(itemsJSON)
78
+ require(array.length() <= 5)
79
+ (0 until array.length()).map { index ->
80
+ val item = array.getJSONObject(index)
81
+ TabEntry(item.getString("id"), item.getString("title"), item.optString("icon"), item.optString("androidIcon"),
82
+ if (item.has("badge")) item.get("badge").toString() else null,
83
+ item.optBoolean("disabled"), item.optString("accessibilityLabel", item.getString("title")))
84
+ }
85
+ } catch (_: Exception) { emptyList() }
86
+ val retained = entries.associate { it.id to (nativeIds[it.id] ?: View.generateViewId()) }.toMutableMap()
87
+ for ((key, nativeId) in nativeIds) if (!retained.containsKey(key)) bar.removeBadge(nativeId)
88
+ nativeIds = retained
89
+ val rebuild = previousOrder != entries.map { it.id } || bar.menu.size() != entries.size
90
+ if (rebuild) bar.menu.clear()
91
+ entries.forEachIndexed { index, entry ->
92
+ val nativeId = nativeIds.getValue(entry.id)
93
+ val item = if (rebuild) bar.menu.add(Menu.NONE, nativeId, index, entry.title) else bar.menu.findItem(nativeId)
94
+ item.apply {
95
+ title = entry.title
96
+ val custom = if (entry.androidIcon.isEmpty()) 0 else resources.getIdentifier(entry.androidIcon, "drawable", context.packageName)
97
+ setIcon(if (custom != 0) custom else when (entry.icon) {
98
+ "home" -> R.drawable.alg_tab_home
99
+ "search" -> R.drawable.alg_tab_search
100
+ "library" -> R.drawable.alg_tab_library
101
+ "favorites" -> R.drawable.alg_tab_favorites
102
+ "inbox" -> R.drawable.alg_tab_inbox
103
+ "settings" -> R.drawable.alg_tab_settings
104
+ else -> R.drawable.alg_tab_circle
105
+ })
106
+ if (android.os.Build.VERSION.SDK_INT >= 26) contentDescription = entry.label
107
+ }
108
+ if (entry.badge == null) bar.removeBadge(nativeId)
109
+ else bar.getOrCreateBadge(nativeId).apply {
110
+ isVisible = true
111
+ maxCharacterCount = 4
112
+ if (entry.badge == "dot") clearNumber() else number = entry.badge.toIntOrNull() ?: 0
113
+ }
114
+ }
115
+ }
116
+ for (entry in entries) {
117
+ val item = bar.menu.findItem(nativeIds.getValue(entry.id)) ?: continue
118
+ item.isEnabled = !disabled && !entry.disabled
119
+ if (entry.id == selectedValue) item.isChecked = true
120
+ }
121
+ bar.visibility = if (entries.isEmpty()) View.INVISIBLE else View.VISIBLE
122
+ bar.setTag(com.facebook.react.R.id.react_test_id, controlTestID)
123
+ ReactAccessibilityDelegate.setDelegate(bar, bar.isFocusable, bar.importantForAccessibility)
124
+ fun tint(default: ColorStateList?): ColorStateList? {
125
+ val accent = glassTint ?: return default
126
+ return ColorStateList(arrayOf(intArrayOf(-android.R.attr.state_enabled), intArrayOf(android.R.attr.state_checked), intArrayOf()),
127
+ intArrayOf(default?.getColorForState(intArrayOf(-android.R.attr.state_enabled), accent) ?: accent,
128
+ accent, default?.defaultColor ?: accent))
129
+ }
130
+ bar.itemIconTintList = tint(defaultIconTint)
131
+ bar.itemTextColor = tint(defaultTextTint)
132
+ requestLayout()
133
+ post {
134
+ if (isAttachedToWindow) {
135
+ forceNativeLayout()
136
+ // Material derives each active indicator's layout params from its item view's width
137
+ // at the moment the item is checked. The first selection happens while the bar is
138
+ // still unmeasured, so that width is 0, the indicator clamps to 0x0 and stays a stub
139
+ // beside the icon until a later tap re-checks the item. Re-assigning the desired
140
+ // width makes every item recompute against its real width. React Native swallows the
141
+ // requestLayout that changing those params triggers, so lay the bar out again.
142
+ bar.itemActiveIndicatorWidth = bar.itemActiveIndicatorWidth
143
+ forceNativeLayout()
144
+ }
145
+ }
146
+ } finally { applying = false }
147
+ }
148
+ private fun forceNativeLayout() {
149
+ forceLayout(); bar.forceLayout()
150
+ measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY))
151
+ layout(left, top, right, bottom)
152
+ }
153
+ override fun onConfigurationChanged(newConfig: Configuration) {
154
+ super.onConfigurationChanged(newConfig)
155
+ // Re-resolve Material day/night and text resources without changing route IDs.
156
+ removeView(bar); bar = makeBar()
157
+ defaultIconTint = bar.itemIconTintList; defaultTextTint = bar.itemTextColor
158
+ addView(bar, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
159
+ appliedJSON = null; applyConfiguration()
160
+ }
161
+ }
@@ -0,0 +1,11 @@
1
+ package com.adaptiveglass
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ class AdaptiveLiquidGlassPackage : ReactPackage {
9
+ override fun createNativeModules(context: ReactApplicationContext): List<NativeModule> = emptyList()
10
+ override fun createViewManagers(context: ReactApplicationContext): List<ViewManager<*, *>> = listOf(ALGSliderManager(), ALGMenuManager(), ALGTabsManager())
11
+ }
@@ -0,0 +1,3 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
2
+ <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M12,4 A8,8 0,1 0,12,20 A8,8 0,1 0,12,4" />
3
+ </vector>
@@ -0,0 +1,3 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
2
+ <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M12,21 L3,12 C-2,5 7,-1 12,6 C17,-1 26,5 21,12 Z" />
3
+ </vector>
@@ -0,0 +1,3 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
2
+ <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M3,11 L12,3 L21,11 L19,11 L19,21 L14,21 L14,14 L10,14 L10,21 L5,21 L5,11 Z" />
3
+ </vector>
@@ -0,0 +1,3 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
2
+ <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M5,3 L19,3 L23,15 L23,21 L1,21 L1,15 Z M6,5 L3,14 L8,14 L10,17 L14,17 L16,14 L21,14 L18,5 Z" />
3
+ </vector>
@@ -0,0 +1,3 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
2
+ <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M3,3 L8,3 L8,21 L3,21 Z M10,3 L15,3 L15,21 L10,21 Z M16,4 L19,3 L23,20 L20,21 Z" />
3
+ </vector>
@@ -0,0 +1,3 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
2
+ <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M10,3 A7,7 0,1 0,10,17 A7,7 0,1 0,10,3 M10,5 A5,5 0,1 1,10,15 A5,5 0,1 1,10,5 M15,14 L22,21 L20,23 L13,16 Z" />
3
+ </vector>
@@ -0,0 +1,3 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
2
+ <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M10,1 L14,1 L15,5 L18,6 L22,5 L24,9 L21,12 L22,15 L24,17 L22,21 L18,20 L15,21 L14,24 L10,24 L9,21 L6,20 L2,21 L0,17 L3,14 L3,10 L0,7 L2,3 L6,5 L9,4 Z M12,8 A4,4 0,1 0,12,16 A4,4 0,1 0,12,8" />
3
+ </vector>
@@ -0,0 +1,3 @@
1
+ #import <React/RCTViewComponentView.h>
2
+ @interface ALGActionClusterComponentView : RCTViewComponentView
3
+ @end
@@ -0,0 +1,47 @@
1
+ #import "ALGActionClusterComponentView.h"
2
+ #import <react/renderer/components/AdaptiveLiquidGlassSpec/ComponentDescriptors.h>
3
+ #import <react/renderer/components/AdaptiveLiquidGlassSpec/Props.h>
4
+ #import <react/renderer/components/AdaptiveLiquidGlassSpec/EventEmitters.h>
5
+ #import <React/RCTConversions.h>
6
+ #import "AdaptiveLiquidGlass-Swift.h"
7
+
8
+ using namespace facebook::react;
9
+
10
+ @implementation ALGActionClusterComponentView {
11
+ ALGActionClusterView *_cluster;
12
+ }
13
+ + (ComponentDescriptorProvider)componentDescriptorProvider {
14
+ return concreteComponentDescriptorProvider<ALGActionClusterComponentDescriptor>();
15
+ }
16
+ - (instancetype)initWithFrame:(CGRect)frame {
17
+ if (self = [super initWithFrame:frame]) {
18
+ _props = std::make_shared<const ALGActionClusterProps>();
19
+ _cluster = [[ALGActionClusterView alloc] initWithFrame:CGRectZero];
20
+ self.contentView = _cluster;
21
+ __weak ALGActionClusterComponentView *weakSelf = self;
22
+ _cluster.onAction = ^(NSString *identifier) {
23
+ ALGActionClusterComponentView *strongSelf = weakSelf;
24
+ if (!strongSelf || !strongSelf->_eventEmitter) return;
25
+ auto emitter = std::static_pointer_cast<const ALGActionClusterEventEmitter>(strongSelf->_eventEmitter);
26
+ emitter->onAction({std::string(identifier.UTF8String)});
27
+ };
28
+ _cluster.onExpandedChange = ^(BOOL expanded) {
29
+ ALGActionClusterComponentView *strongSelf = weakSelf;
30
+ if (!strongSelf || !strongSelf->_eventEmitter) return;
31
+ auto emitter = std::static_pointer_cast<const ALGActionClusterEventEmitter>(strongSelf->_eventEmitter);
32
+ emitter->onExpandedChange({static_cast<bool>(expanded)});
33
+ };
34
+ }
35
+ return self;
36
+ }
37
+ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps {
38
+ 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()]];
44
+ [super updateProps:props oldProps:oldProps];
45
+ }
46
+ + (BOOL)shouldBeRecycled { return NO; }
47
+ @end
@@ -0,0 +1,252 @@
1
+ import UIKit
2
+
3
+ private struct GlassAction: Decodable, Equatable {
4
+ let id: String
5
+ let title: String
6
+ let systemImage: String?
7
+ let disabled: Bool?
8
+ }
9
+
10
+ /// The same plain glass-view hierarchy as GlassView. UIKit owns hit testing,
11
+ /// localized lighting, and deformation of both the material and its content.
12
+ private final class ClusterItem: UIView {
13
+ // Use the native material tint to restrain dark-mode lightness. This shades
14
+ // the whole material, including its resting appearance; UIKit still owns
15
+ // the highlight and deformation. Explicit caller tints take precedence.
16
+ private static let defaultTint = UIColor { traits in
17
+ traits.userInterfaceStyle == .dark ? UIColor(white: 0, alpha: 0.65) : .clear
18
+ }
19
+ let surface = ALGSurfaceView()
20
+ let icon = UIImageView()
21
+ var onPress: (() -> Void)?
22
+ private var enabled = true
23
+ private var symbol = ""
24
+ private var appliedMaterial: String?
25
+ private var appliedTint: UIColor?
26
+ private var appliedInteractive: Bool?
27
+ private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(activateTap(_:)))
28
+
29
+ override init(frame: CGRect) {
30
+ super.init(frame: frame)
31
+ addSubview(surface)
32
+ icon.contentMode = .center
33
+ icon.tintColor = .label
34
+ icon.isUserInteractionEnabled = false
35
+ surface.reactContentView.addSubview(icon)
36
+ isAccessibilityElement = true
37
+ accessibilityTraits = .button
38
+ // Observe activation without cancelling or delaying UIKit's glass touches.
39
+ tap.cancelsTouchesInView = false
40
+ tap.delaysTouchesBegan = false
41
+ tap.delaysTouchesEnded = false
42
+ addGestureRecognizer(tap)
43
+ }
44
+ required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
45
+ @objc private func activateTap(_ recognizer: UITapGestureRecognizer) {
46
+ guard recognizer.state == .ended, enabled,
47
+ point(inside: recognizer.location(in: self), with: nil) else { return }
48
+ onPress?()
49
+ }
50
+ override func accessibilityActivate() -> Bool {
51
+ guard enabled, !isHidden, isUserInteractionEnabled else { return false }
52
+ onPress?()
53
+ return true
54
+ }
55
+ func configure(symbol: String, title: String, identifier: String, enabled: Bool,
56
+ material: String, tint: UIColor?, interactive: Bool, toggle: Bool = false) {
57
+ if self.symbol != symbol {
58
+ self.symbol = symbol
59
+ let config = UIImage.SymbolConfiguration(pointSize: toggle ? 22 : 20, weight: .medium)
60
+ icon.image = (UIImage(systemName: symbol, withConfiguration: config)
61
+ ?? UIImage(systemName: "circle", withConfiguration: config))?.withRenderingMode(.alwaysTemplate)
62
+ }
63
+ self.enabled = enabled
64
+ tap.isEnabled = enabled
65
+ icon.alpha = enabled ? 1 : 0.4
66
+ accessibilityLabel = title
67
+ accessibilityIdentifier = identifier
68
+ accessibilityTraits = enabled ? .button : [.button, .notEnabled]
69
+ // Match GlassView's native material, without an independent press treatment.
70
+ let responds = interactive && enabled
71
+ let materialTint = tint ?? Self.defaultTint
72
+ if appliedMaterial != material || appliedTint != materialTint || appliedInteractive != responds {
73
+ appliedMaterial = material; appliedTint = materialTint; appliedInteractive = responds
74
+ surface.configure(material, interactive: responds, tint: materialTint, radius: 26,
75
+ container: false, mergingEnabled: false, spacing: 0, duration: 0, scheme: "system")
76
+ }
77
+ }
78
+ override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
79
+ let dx = point.x - bounds.midX, dy = point.y - bounds.midY
80
+ return dx * dx + dy * dy <= pow(min(bounds.width, bounds.height) / 2, 2)
81
+ }
82
+ override func layoutSubviews() {
83
+ super.layoutSubviews()
84
+ surface.frame = bounds
85
+ icon.bounds = CGRect(origin: .zero, size: bounds.size)
86
+ icon.center = CGPoint(x: bounds.midX, y: bounds.midY)
87
+ }
88
+ }
89
+
90
+ /// Stable UIKit controls and material hosts. Both surfaces and action icons now
91
+ /// use ALGSurfaceView; expansion never changes the toggle's material or opacity.
92
+ @objc(ALGActionClusterView)
93
+ public final class ALGActionClusterView: UIView {
94
+ private let container = ALGSurfaceView()
95
+ private let toggle = ClusterItem()
96
+ private var items: [String: ClusterItem] = [:]
97
+ private var actions: [GlassAction] = []
98
+ private var lastJSON = ""
99
+ private var expanded = false
100
+ private var mergingEnabled = false
101
+ private var spacing: CGFloat = 20
102
+ private var material = "regular"
103
+ private var glassTint: UIColor?
104
+ private var interactive = true
105
+ private var toggleLabel = "Actions"
106
+ private var duration = 0.45
107
+ private var lastSize = CGSize.zero
108
+ private var animationGeneration = 0
109
+ private var configured = false
110
+ @objc public var onAction: ((String) -> Void)?
111
+ @objc public var onExpandedChange: ((Bool) -> Void)?
112
+
113
+ public override init(frame: CGRect) {
114
+ super.init(frame: frame)
115
+ addSubview(container)
116
+ container.reactContentView.addSubview(toggle)
117
+ toggle.onPress = { [weak self] in
118
+ guard let self else { return }
119
+ self.onExpandedChange?(!self.expanded)
120
+ }
121
+ NotificationCenter.default.addObserver(self, selector: #selector(accessibilityChanged),
122
+ name: UIAccessibility.reduceTransparencyStatusDidChangeNotification, object: nil)
123
+ NotificationCenter.default.addObserver(self, selector: #selector(accessibilityChanged),
124
+ name: UIAccessibility.reduceMotionStatusDidChangeNotification, object: nil)
125
+ }
126
+ required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
127
+ deinit { NotificationCenter.default.removeObserver(self) }
128
+
129
+ @objc public func configure(_ actionsJSON: String, expanded: Bool, mergingEnabled: Bool, spacing: CGFloat,
130
+ tint: UIColor?, material: String, interactive: Bool, duration: Double, toggleLabel: String) {
131
+ var nextActions = actions
132
+ if lastJSON != actionsJSON {
133
+ nextActions = (try? JSONDecoder().decode([GlassAction].self, from: Data(actionsJSON.utf8))) ?? []
134
+ lastJSON = actionsJSON
135
+ }
136
+ let actionsChanged = nextActions != actions
137
+ let groupChanged = !configured || self.mergingEnabled != mergingEnabled || self.spacing != max(0, spacing)
138
+ let appearanceChanged = !configured || self.glassTint != tint || self.material != material || self.interactive != interactive
139
+ let labelChanged = self.toggleLabel != toggleLabel
140
+ let expansionChanged = self.expanded != expanded
141
+ let wasConfigured = configured
142
+ self.expanded = expanded
143
+ self.mergingEnabled = mergingEnabled
144
+ self.spacing = max(0, spacing)
145
+ self.material = material
146
+ self.glassTint = tint
147
+ self.interactive = interactive
148
+ self.duration = duration.isFinite ? max(0, duration) : 0
149
+ self.toggleLabel = toggleLabel
150
+ configured = true
151
+
152
+ if actionsChanged {
153
+ let nextIDs = Set(nextActions.map(\.id))
154
+ for id in Array(items.keys) where !nextIDs.contains(id) { items.removeValue(forKey: id)?.removeFromSuperview() }
155
+ for action in nextActions where items[action.id] == nil {
156
+ let item = ClusterItem()
157
+ item.isHidden = true
158
+ item.frame = toggleFrame
159
+ item.onPress = { [weak self] in
160
+ guard let self, self.expanded,
161
+ let current = self.actions.first(where: { $0.id == action.id }), !(current.disabled ?? false) else { return }
162
+ self.onAction?(action.id)
163
+ }
164
+ items[action.id] = item
165
+ }
166
+ actions = nextActions
167
+ container.reactContentView.bringSubviewToFront(toggle)
168
+ }
169
+ if groupChanged {
170
+ container.configure("regular", interactive: false, tint: nil, radius: 0, container: true,
171
+ mergingEnabled: mergingEnabled, spacing: self.spacing, duration: 0, scheme: "system")
172
+ }
173
+ if appearanceChanged || actionsChanged || labelChanged { updateAppearance() }
174
+ toggle.accessibilityValue = expanded ? "Expanded" : "Collapsed"
175
+ if expansionChanged || actionsChanged || groupChanged || !wasConfigured {
176
+ updatePositions(animated: expansionChanged && wasConfigured && !groupChanged && !actionsChanged)
177
+ }
178
+ }
179
+
180
+ private func updateAppearance() {
181
+ toggle.configure(symbol: "plus", title: toggleLabel, identifier: "glass-cluster-toggle", enabled: true,
182
+ material: material, tint: glassTint, interactive: interactive, toggle: true)
183
+ for action in actions {
184
+ items[action.id]?.configure(symbol: action.systemImage ?? "circle", title: action.title,
185
+ identifier: "glass-action-" + action.id, enabled: !(action.disabled ?? false),
186
+ material: material, tint: glassTint, interactive: interactive)
187
+ }
188
+ }
189
+ private var toggleFrame: CGRect {
190
+ CGRect(x: bounds.width - 12 - 52, y: (bounds.height - 52) / 2, width: 52, height: 52)
191
+ }
192
+ private func actionFrame(at index: Int) -> CGRect {
193
+ toggleFrame.offsetBy(dx: -CGFloat(actions.count - index) * 64, dy: 0)
194
+ }
195
+ private func updatePositions(animated: Bool) {
196
+ guard bounds.width > 0, bounds.height > 0 else { setNeedsLayout(); return }
197
+ animationGeneration += 1
198
+ let generation = animationGeneration
199
+ let animate = animated && window != nil && duration > 0 && !UIAccessibility.isReduceMotionEnabled
200
+ toggle.frame = toggleFrame
201
+ for (index, action) in actions.enumerated() {
202
+ guard let item = items[action.id] else { continue }
203
+ // Hidden controls cannot receive taps or accessibility focus during collapse.
204
+ item.isUserInteractionEnabled = expanded
205
+ item.accessibilityElementsHidden = !expanded
206
+ if expanded && item.isHidden {
207
+ item.frame = mergingEnabled && animate ? toggleFrame : actionFrame(at: index)
208
+ item.alpha = mergingEnabled || !animate ? 1 : 0
209
+ if item.superview == nil { container.reactContentView.addSubview(item) }
210
+ item.isHidden = false
211
+ }
212
+ }
213
+ container.reactContentView.bringSubviewToFront(toggle)
214
+ let changes = {
215
+ for (index, action) in self.actions.enumerated() {
216
+ guard let item = self.items[action.id] else { continue }
217
+ item.frame = self.mergingEnabled && !self.expanded ? self.toggleFrame : self.actionFrame(at: index)
218
+ item.alpha = self.mergingEnabled || self.expanded ? 1 : 0
219
+ }
220
+ // Rotate only the glyph. The toggle's glass stays at the same bounds/opacity.
221
+ self.toggle.icon.transform = CGAffineTransform(rotationAngle: self.expanded ? .pi / 4 : 0)
222
+ }
223
+ let finish: (Bool) -> Void = { [weak self] _ in
224
+ guard let self, generation == self.animationGeneration else { return }
225
+ for item in self.items.values {
226
+ item.isHidden = !self.expanded
227
+ // Retain identity in the dictionary, but remove collapsed controls from
228
+ // UIKit's hierarchy so accessibility cannot discover hidden actions.
229
+ if !self.expanded { item.removeFromSuperview() }
230
+ }
231
+ }
232
+ if animate {
233
+ UIView.animate(withDuration: duration, delay: 0,
234
+ options: [.beginFromCurrentState, .allowUserInteraction, .curveEaseInOut], animations: changes, completion: finish)
235
+ } else {
236
+ for item in items.values { item.layer.removeAllAnimations() }
237
+ toggle.icon.layer.removeAllAnimations()
238
+ UIView.performWithoutAnimation(changes)
239
+ finish(true)
240
+ }
241
+ }
242
+ @objc private func accessibilityChanged() { updateAppearance(); updatePositions(animated: false) }
243
+ public override func layoutSubviews() {
244
+ super.layoutSubviews()
245
+ container.frame = bounds
246
+ if lastSize != bounds.size { lastSize = bounds.size; updatePositions(animated: false) }
247
+ }
248
+ public override func didMoveToWindow() {
249
+ super.didMoveToWindow()
250
+ updatePositions(animated: false)
251
+ }
252
+ }
@@ -0,0 +1,3 @@
1
+ #import <React/RCTViewComponentView.h>
2
+ @interface ALGButtonComponentView : RCTViewComponentView
3
+ @end
@@ -0,0 +1,37 @@
1
+ #import "ALGButtonComponentView.h"
2
+ #import <react/renderer/components/AdaptiveLiquidGlassSpec/ComponentDescriptors.h>
3
+ #import <react/renderer/components/AdaptiveLiquidGlassSpec/Props.h>
4
+ #import <react/renderer/components/AdaptiveLiquidGlassSpec/EventEmitters.h>
5
+ #import <React/RCTConversions.h>
6
+ #import "AdaptiveLiquidGlass-Swift.h"
7
+ using namespace facebook::react;
8
+ @implementation ALGButtonComponentView {
9
+ ALGButtonView *_button;
10
+ }
11
+ + (ComponentDescriptorProvider)componentDescriptorProvider {
12
+ return concreteComponentDescriptorProvider<ALGButtonComponentDescriptor>();
13
+ }
14
+ - (instancetype)initWithFrame:(CGRect)frame {
15
+ if (self = [super initWithFrame:frame]) {
16
+ _props = std::make_shared<const ALGButtonProps>();
17
+ _button = [[ALGButtonView alloc] initWithFrame:CGRectZero];
18
+ self.contentView = _button;
19
+ __weak ALGButtonComponentView *weakSelf = self;
20
+ _button.onActivate = ^{
21
+ ALGButtonComponentView *strongSelf = weakSelf;
22
+ if (!strongSelf || !strongSelf->_eventEmitter) return;
23
+ std::static_pointer_cast<const ALGButtonEventEmitter>(strongSelf->_eventEmitter)->onActivate({true});
24
+ };
25
+ }
26
+ return self;
27
+ }
28
+ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps {
29
+ const auto &p = *std::static_pointer_cast<const ALGButtonProps>(props);
30
+ NSString *scheme = p.colorScheme == ALGButtonColorScheme::Dark ? @"dark" : p.colorScheme == ALGButtonColorScheme::Light ? @"light" : @"system";
31
+ [_button configure:@(p.title.c_str()) symbol:@(p.systemImage.c_str()) prominent:p.variant == ALGButtonVariant::Prominent
32
+ disabled:p.disabled loading:p.loading tint:RCTUIColorFromSharedColor(p.glassTint) scheme:scheme
33
+ label:@(p.controlLabel.c_str()) hint:@(p.controlHint.c_str()) identifier:@(p.controlTestID.c_str())];
34
+ [super updateProps:props oldProps:oldProps];
35
+ }
36
+ + (BOOL)shouldBeRecycled { return NO; }
37
+ @end