@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.
- package/AdaptiveLiquidGlass.podspec +18 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/android/build.gradle +21 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/adaptiveglass/ALGMenuManager.kt +31 -0
- package/android/src/main/java/com/adaptiveglass/ALGMenuView.kt +184 -0
- package/android/src/main/java/com/adaptiveglass/ALGSliderManager.kt +32 -0
- package/android/src/main/java/com/adaptiveglass/ALGSliderView.kt +132 -0
- package/android/src/main/java/com/adaptiveglass/ALGTabsManager.kt +24 -0
- package/android/src/main/java/com/adaptiveglass/ALGTabsView.kt +161 -0
- package/android/src/main/java/com/adaptiveglass/AdaptiveLiquidGlassPackage.kt +11 -0
- package/android/src/main/res/drawable/alg_tab_circle.xml +3 -0
- package/android/src/main/res/drawable/alg_tab_favorites.xml +3 -0
- package/android/src/main/res/drawable/alg_tab_home.xml +3 -0
- package/android/src/main/res/drawable/alg_tab_inbox.xml +3 -0
- package/android/src/main/res/drawable/alg_tab_library.xml +3 -0
- package/android/src/main/res/drawable/alg_tab_search.xml +3 -0
- package/android/src/main/res/drawable/alg_tab_settings.xml +3 -0
- package/ios/ALGActionClusterComponentView.h +3 -0
- package/ios/ALGActionClusterComponentView.mm +47 -0
- package/ios/ALGActionClusterView.swift +252 -0
- package/ios/ALGButtonComponentView.h +3 -0
- package/ios/ALGButtonComponentView.mm +37 -0
- package/ios/ALGButtonView.swift +113 -0
- package/ios/ALGMenuComponentView.h +3 -0
- package/ios/ALGMenuComponentView.mm +34 -0
- package/ios/ALGMenuView.swift +221 -0
- package/ios/ALGSegmentedComponentView.h +3 -0
- package/ios/ALGSegmentedComponentView.mm +36 -0
- package/ios/ALGSegmentedView.swift +58 -0
- package/ios/ALGSliderComponentView.h +3 -0
- package/ios/ALGSliderComponentView.mm +47 -0
- package/ios/ALGSliderView.swift +102 -0
- package/ios/ALGSurfaceComponentView.h +3 -0
- package/ios/ALGSurfaceComponentView.mm +45 -0
- package/ios/ALGSurfaceView.swift +108 -0
- package/ios/ALGTabsComponentView.h +3 -0
- package/ios/ALGTabsComponentView.mm +33 -0
- package/ios/ALGTabsView.swift +194 -0
- package/package.json +73 -0
- package/react-native.config.js +3 -0
- package/src/GlassActionCluster.ios.tsx +18 -0
- package/src/GlassActionCluster.tsx +1 -0
- package/src/GlassButton.ios.tsx +18 -0
- package/src/GlassButton.tsx +7 -0
- package/src/GlassContainer.ios.tsx +9 -0
- package/src/GlassContainer.tsx +6 -0
- package/src/GlassMenuButton.tsx +26 -0
- package/src/GlassPressable.tsx +17 -0
- package/src/GlassSegmentedControl.ios.tsx +21 -0
- package/src/GlassSegmentedControl.tsx +1 -0
- package/src/GlassSlider.tsx +24 -0
- package/src/GlassTabBar.tsx +25 -0
- package/src/GlassToolbar.tsx +25 -0
- package/src/GlassView.ios.tsx +13 -0
- package/src/GlassView.tsx +1 -0
- package/src/adaptiveHeight.ts +15 -0
- package/src/fallback/GlassActionCluster.tsx +25 -0
- package/src/fallback/GlassSegmentedControl.tsx +26 -0
- package/src/fallback/GlassView.tsx +12 -0
- package/src/fallback/NativeGlassButton.tsx +21 -0
- package/src/index.ts +17 -0
- package/src/menuTree.ts +35 -0
- package/src/sliderMath.ts +12 -0
- package/src/specs/ALGActionClusterNativeComponent.ts +15 -0
- package/src/specs/ALGButtonNativeComponent.ts +15 -0
- package/src/specs/ALGMenuNativeComponent.ts +17 -0
- package/src/specs/ALGSegmentedNativeComponent.ts +12 -0
- package/src/specs/ALGSliderNativeComponent.ts +19 -0
- package/src/specs/ALGSurfaceNativeComponent.ts +13 -0
- package/src/specs/ALGTabsNativeComponent.ts +11 -0
- package/src/support.ts +5 -0
- package/src/types.ts +181 -0
- package/src/validateActions.ts +10 -0
- package/src/validateSegments.ts +11 -0
- 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,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,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
|