@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,18 @@
1
+ require 'json'
2
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
3
+ Pod::Spec.new do |s|
4
+ s.name = 'AdaptiveLiquidGlass'
5
+ s.version = package['version']
6
+ s.summary = package['description']
7
+ s.homepage = 'https://github.com/likith099/liquedGlassRecreate'
8
+ s.license = { :type => 'MIT', :file => 'LICENSE' }
9
+ s.author = 'Likith Kanneganti'
10
+ s.platforms = { :ios => '15.1' }
11
+ # Autolinked from node_modules; the source is declared for completeness.
12
+ s.source = { :git => 'https://github.com/likith099/liquedGlassRecreate.git', :tag => "v#{s.version}" }
13
+ s.source_files = 'ios/**/*.{h,m,mm,swift}'
14
+ s.private_header_files = 'ios/**/*.h'
15
+ s.swift_version = '5.0'
16
+ s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
17
+ install_modules_dependencies(s)
18
+ end
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Likith Kanneganti
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # @likith99/react-native-adaptive-liquid-glass
2
+
3
+ Native iOS Liquid Glass surfaces and UIKit action controls, with ordinary Android counterparts. No Expo dependency. React Native 0.81–0.86 / React 19 / Fabric; requires Xcode 26+, and iOS 15.1+ (glass on iOS 26+).
4
+
5
+ ## Install
6
+
7
+ Install the public package from npm. To test local changes instead, create an archive with `npm pack --workspace @likith99/react-native-adaptive-liquid-glass` and install the resulting `.tgz` file.
8
+
9
+ ```sh
10
+ npm install @likith99/react-native-adaptive-liquid-glass
11
+ cd ios && pod install
12
+ ```
13
+
14
+ Then rebuild both native apps; a JS-only reload is not enough the first time. Autolinking wires the iOS pod and the Android Gradle module, so there is nothing to register by hand. Android autolinks Kotlin SeekBar, PopupMenu, Toolbar, and Material BottomNavigationView bridges; surfaces and the other Android controls use React Native views and pressables.
15
+
16
+ ### Requirements
17
+
18
+ The host app must run **React Native 0.81 to 0.86 with the New Architecture (Fabric)** and React 19; these are Fabric codegen components and will not build on the old architecture. Builds are verified on 0.81.5 and 0.86.3. iOS needs Xcode 26+ and a **deployment target of iOS 15.1 or higher** — below that CocoaPods refuses to resolve the pod — with native glass on iOS 26+ and standard controls below it.
19
+
20
+ On React Native 0.81 specifically, the app must consume React Native's **prebuilt** iOS dependencies, which is the default (`RCT_USE_RN_DEP=1`) and what Expo SDK 54 does. Building React Native from source on 0.81 fails under Xcode 26 inside `Pods/fmt`, independently of this package. On **iOS 27 the host app must adopt the UIScene lifecycle**, otherwise iOS traps during scene creation before any UI appears; add a `UIApplicationSceneManifest` and a scene delegate that owns the window. Android needs no extra setup. Expo Modules are not used and no router is required.
21
+
22
+ ```tsx
23
+ import {GlassView, GlassButton, GlassContainer, GlassActionCluster} from '@likith99/react-native-adaptive-liquid-glass';
24
+
25
+ <GlassView interactive cornerRadius={24} style={{padding: 20}}>
26
+ <Text>React content inside native glass</Text>
27
+ </GlassView>
28
+
29
+ <GlassActionCluster
30
+ actions={[{id: 'save', title: 'Save', systemImage: 'bookmark'}]}
31
+ expanded={expanded}
32
+ onExpandedChange={setExpanded}
33
+ onAction={id => handleAction(id)}
34
+ />
35
+ ```
36
+
37
+ `GlassView` accepts material (`regular`, `clear`, `none`), interactive, tintColor, cornerRadius, colorScheme, animationDuration (seconds), fallbackStyle, and forceFallback. Use cornerRadius instead of style.borderRadius. `GlassPressable` adds custom React children, press semantics and Android ripple. `GlassButton` with a `title` uses a fully native SwiftUI button on iOS 26+. Existing children-based GlassButton calls remain compatible. `GlassContainer.mergingEnabled` defaults to false. Set it to true to allow nearby surfaces to join; `spacing` then controls the merging threshold, independently of layout gap. The action cluster also defaults to `mergingEnabled={false}`; enable it to opt into native glass merging during expansion and collapse. For a GlassContainer, place participating GlassViews inside it.
38
+
39
+ The UIKit action cluster retains controls by stable IDs and uses the same UIGlassEffect material host as GlassView. The toggle keeps its material, bounds, and opacity unchanged during expansion. UIKit owns the native finger-localized highlight and deformation whenever `interactive` is enabled. There is no custom press overlay, scale, or finger-follow animation. SF Symbols remain inside the effect content view, so UIKit deforms the icon and glass together. A tap recognizer observes activation without cancelling or delaying native glass touches. There is no separate UIControl or forced hit-test target. Default dark-mode actions apply a neutral black native material tint at 0.65 alpha to reduce lightness. This also darkens the resting material; it is not a highlight-strength percentage. Light mode uses transparent tint. An explicit tintColor overrides the default. UIKit still owns the highlight and provides no separate intensity setting. The old `pressFeedback` prop is deprecated and ignored. `interactive={false}` disables native press visuals without disabling callbacks. Android keeps its normal ripple. Opt-in merging uses UIGlassContainerEffect and animated UIKit geometry; SwiftUI matched-geometry transitions are no longer used. Supply unique nonempty action IDs; `__toggle` is reserved. Keep expanded controlled. iOS renders SF Symbols with accessible titles, while Android displays titles on ordinary buttons. Allow approximately `24 + 52 * (actions.length + 1) + 12 * actions.length` points of width and at least 80 of height. Arbitrary React children are supported by the surface, but not the action cluster.
40
+
41
+ Reduce Transparency replaces our glass surface/cluster materials with opaque backgrounds. Reduce Motion suppresses our native property/morph transitions. Use semantic text colors for your children; the package does not recolor arbitrary content. `forceFallback` previews the standard implementation on surfaces, buttons, selectors, and action clusters. The native slider has no `forceFallback` prop.
42
+
43
+ See `src/types.ts` for the complete typed API. The repository root README contains the demo commands, architecture, reference study, and verification results. Custom drag/stretch physics, a full native control suite, arbitrary React-tree morphing, physical-device profiling, and broader React Native compatibility remain future work. MIT licensed.
44
+
45
+ ## Native controls
46
+
47
+ `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.
48
+
49
+ `GlassSegmentedControl` accepts `options: {value, label, disabled?}[]`, controlled `value: string | null`, `onValueChange`, disabled, tintColor, colorScheme, forceFallback, and view layout props. Values must be unique and nonempty; a non-null selection must be present. It uses UISegmentedControl on iOS 26+ and selectable ripple buttons on Android. Default native host height: 52 points. Provide enough width for short labels, and larger heights for large accessibility text. Neither control enables shared glass merging.
50
+
51
+ ## Native slider
52
+
53
+ ```tsx
54
+ import {useState} from 'react';
55
+ import {GlassSlider} from '@likith99/react-native-adaptive-liquid-glass';
56
+
57
+ export function LevelControl() {
58
+ const [level, setLevel] = useState(40);
59
+ return <GlassSlider value={level} onValueChange={setLevel}
60
+ minimumValue={0} maximumValue={100} step={10}
61
+ accessibilityLabel="Level" />;
62
+ }
63
+ ```
64
+
65
+ `GlassSlider` uses UISlider on iOS (Liquid Glass on iOS 26+, standard native appearance on older versions) and SeekBar on Android. Defaults: minimum 0, maximum 1, step 0 (continuous), height 52 points. It also accepts `disabled`, `tintColor`, `onSlidingStart`, `onSlidingComplete`, `onSlidingCancel`, and view layout props. The native slider owns tracking and reconciles to the latest controlled `value` after release, including a rejected change. Update `value` in `onValueChange` to accept changes. Programmatic changes during tracking apply when it ends.
66
+
67
+ Values clamp and positive steps snap relative to the minimum; the maximum endpoint remains reachable. Require finite inputs, maximum greater than minimum, and step between zero and the range. Native positioning uses normalized Float precision on iOS and one million ticks on Android; this is not an arbitrary-precision numeric input. Accessibility increments by step or 5% of the range. Provide an accessible label.
68
+
69
+ Native cancellation, disabling, range/step changes, or detachment cancel an active drag. `onSlidingCancel` reports the restored latest controlled value and does not fire completion or undo values React already accepted. No custom thumb children, tick-mark API, neutral-track style, or shared merging group is added. Apple's [UIKit design session](https://developer.apple.com/videos/play/wwdc2025/284/) describes the native slider's system interaction.
70
+
71
+ ## Native menus
72
+
73
+ ```tsx
74
+ import {GlassMenuButton} from '@likith99/react-native-adaptive-liquid-glass';
75
+
76
+ <GlassMenuButton title="Actions" systemImage="ellipsis.circle"
77
+ items={[{id: 'save', title: 'Save', systemImage: 'bookmark'},
78
+ {id: 'remove', title: 'Remove', destructive: true}]}
79
+ onAction={id => console.log(id)} />
80
+ ```
81
+
82
+ iOS uses UIKit's native menu and glass button; older iOS, Reduce Transparency, or forceFallback uses a standard button. Android uses a native Button and PopupMenu. Items support disabled, destructive, and controlled checked flags. IDs must be unique and IDs/action titles nonempty. Empty items disable the trigger. SF Symbols are iOS-only. A selection emits its ID; update checked in your items to change the checkmark. Destructive is presentation only. Changing items, disabling, or detaching dismisses the native menu and stale selections are rejected. The default host is 64 points tall with a minimum width of 120; supply room for long titles and accessibility text. tintColor is a foreground accent (including enabled Android menu text; destructive entries retain their error color). Rebuild both native apps after installing this version.
83
+
84
+ `GlassMenuElement` extends the existing action type with `{kind: 'section', id, title, items}` and `{kind: 'submenu', id, title, items, disabled?, systemImage?}`. Sections may have an empty title. Groups must contain items; IDs are unique across all groups and leaves. Only enabled leaf actions emit callbacks; a disabled submenu blocks its descendants. UIKit displays inline sections; Android uses native groups and disabled title rows. One submenu level is supported on both platforms, with at most 8 total tree levels including sections and 256 elements. Submenus inside submenus are rejected. Existing flat arrays remain compatible.
85
+
86
+ ## Native toolbars
87
+
88
+ ```tsx
89
+ import {GlassToolbar} from '@likith99/react-native-adaptive-liquid-glass';
90
+
91
+ <GlassToolbar items={[
92
+ {id: 'save', title: 'Save', systemImage: 'square.and.arrow.down'},
93
+ {kind: 'submenu', id: 'sort', title: 'Sort', items: [
94
+ {id: 'name', title: 'By name', checked: true},
95
+ {id: 'recent', title: 'Most recent', checked: false},
96
+ ]},
97
+ {id: 'remove', title: 'Remove', destructive: true, placement: 'overflow'},
98
+ ]} maxVisibleItems={2} onAction={id => console.log(id)} />
99
+ ```
100
+
101
+ `GlassToolbar` uses UIKit UIToolbar / Android Toolbar. Root items are actions or submenus; their menus use the same tree contract above. `maxVisibleItems` defaults to 3 and is an upper bound; 0 places everything in overflow. `placement="overflow"` always hides a root item in the native overflow menu. Other items move there as space becomes limited; platforms may show different visible counts. The host defaults to height 64, minimum width 64. The screen owns safe-area insets and placement. Long titles should be allowed to move to overflow; provide explicit width/flex in horizontal layouts.
102
+
103
+ On iOS 26, `mergingEnabled` (default false) lets bar items share native glass backgrounds. `forceFallback` and Reduce Transparency use an opaque bar with hidden shared item backgrounds. Android uses its standard appearance and title actions; iOS uses SF Symbols when supplied, retaining titles as accessibility labels. `disabled` blocks all actions; individual items/submenus can also be disabled. Checkmarks remain controlled. `tintColor` sets a foreground accent; destructive actions use the native error color. Changing items or toolbar width dismisses open menus. Rebuild native apps for the extended Fabric bridge. No custom press animation or navigation-library dependency is introduced.
104
+
105
+ ## Native tabs
106
+
107
+ ```tsx
108
+ const [tab, setTab] = useState('home');
109
+ <GlassTabBar items={[
110
+ {id: 'home', title: 'Home', icon: 'home'},
111
+ {id: 'inbox', title: 'Inbox', icon: 'inbox', badge: 3},
112
+ ]} value={tab} onValueChange={setTab} onTabReselect={id => console.log(id)} />
113
+ ```
114
+
115
+ `GlassTabBar` uses a contained UITabBarController on iOS and Material BottomNavigationView 1.13.0 on Android. UIKit owns Liquid Glass on iOS 26+ and the standard tab appearance on earlier supported versions. Android resolves a consuming Material theme or falls back to Material 3 DayNight. No Expo or navigation library is required by the package. Rebuild both apps after installation.
116
+
117
+ Supply 1–5 items with unique nonempty IDs/titles and a `value` present in the list. Empty items require `value={null}`. Items accept icon presets (`home`, `search`, `library`, `favorites`, `inbox`, `settings`), iOS `systemImage`/`selectedSystemImage`, Android app drawable name `androidIcon`, numeric or `'dot'` badge, `disabled`, and `accessibilityLabel`. Numeric badges are nonnegative 32-bit integers; native display caps at 999+. Omitting a badge removes it.
118
+
119
+ Selection is controlled: update `value` in `onValueChange` to accept a tap. Rejected changes restore the supplied value after the React transaction; tapping the current tab calls only `onTabReselect`. Programmatic values emit no callbacks and may select disabled tabs. Global `disabled` blocks user taps. On the iOS 26.5 simulator, XCTest still reports disabled tabs as enabled despite native dimming and blocked activation; VoiceOver announcements need the planned accessibility audit. `tintColor` changes the selected foreground accent. Stable IDs preserve tab identity across reorder/replacement; native controls own press and selection animations.
120
+
121
+ Default host height is 80 points, minimum width 180. Supply sufficient width/height for your labels. The screen owns safe-area spacing outside the host and the content above it. This primitive does not own a navigation stack, React screens, automatic scroll minimization, or an iPad sidebar. It does not accept React children or `forceFallback`; the native system determines its appearance. The repository demo shows a React Navigation 7 custom tab-bar adapter using route keys, preventable `tabPress`, reselection, retained screen state, and back history.
122
+
123
+ ## Older iOS surfaces and performance
124
+
125
+ The deployment floor is iOS 15.1. Below iOS 26, GlassView and GlassPressable use UIKit system blur (`regular`: system material; `clear`: ultra-thin material). `material="none"` is transparent. Reduce Transparency uses an opaque semantic background; `forceFallback` explicitly selects the opaque React surface. `fallbackStyle` still applies below iOS 26 and can cover the blur if it supplies an opaque background. A translucent tint overlays the older-iOS blur. GlassContainer remains ordinary layout with no merging below iOS 26. Other controls retain standard UIKit or React implementations. iOS 18.6 is the installed fallback test runtime; iOS 15–17 and physical older devices remain unverified.
126
+
127
+ 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.
@@ -0,0 +1,21 @@
1
+ apply plugin: 'com.android.library'
2
+ // AGP 9 provides Kotlin itself; RN 0.86's default AGP 8 needs the plugin.
3
+ if (com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0].toInteger() < 9) {
4
+ apply plugin: 'org.jetbrains.kotlin.android'
5
+ }
6
+ apply plugin: 'com.facebook.react'
7
+
8
+ android {
9
+ namespace 'com.adaptiveglass'
10
+ compileSdk rootProject.ext.compileSdkVersion
11
+ defaultConfig { minSdk rootProject.ext.minSdkVersion }
12
+ }
13
+ react {
14
+ jsRootDir = file('../src')
15
+ libraryName = 'AdaptiveLiquidGlassSpec'
16
+ codegenJavaPackageName = 'com.adaptiveglass'
17
+ }
18
+ dependencies {
19
+ implementation 'com.facebook.react:react-android'
20
+ implementation 'com.google.android.material:material:1.13.0'
21
+ }
@@ -0,0 +1 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,31 @@
1
+ package com.adaptiveglass
2
+
3
+ import com.facebook.react.module.annotations.ReactModule
4
+ import com.facebook.react.uimanager.SimpleViewManager
5
+ import com.facebook.react.uimanager.ThemedReactContext
6
+ import com.facebook.react.viewmanagers.ALGMenuManagerDelegate
7
+ import com.facebook.react.viewmanagers.ALGMenuManagerInterface
8
+
9
+ @ReactModule(name = "ALGMenu")
10
+ class ALGMenuManager : SimpleViewManager<ALGMenuView>(), ALGMenuManagerInterface<ALGMenuView> {
11
+ private val delegate = ALGMenuManagerDelegate(this)
12
+ override fun getDelegate() = delegate
13
+ override fun getName() = "ALGMenu"
14
+ override fun createViewInstance(context: ThemedReactContext) = ALGMenuView(context)
15
+ override fun setTitle(view: ALGMenuView, value: String?) { view.title = value ?: "" }
16
+ override fun setItemsJSON(view: ALGMenuView, value: String?) { view.itemsJSON = value ?: "[]" }
17
+ override fun setToolbar(view: ALGMenuView, value: Boolean) { view.toolbar = value }
18
+ override fun setMaxVisibleItems(view: ALGMenuView, value: Int) { view.maxVisibleItems = value }
19
+ override fun setMergingEnabled(view: ALGMenuView, value: Boolean) { /* Native Android toolbar appearance. */ }
20
+ override fun setSystemImage(view: ALGMenuView, value: String?) { /* SF Symbols are iOS-only. */ }
21
+ override fun setDisabled(view: ALGMenuView, value: Boolean) { view.disabled = value }
22
+ override fun setForceFallback(view: ALGMenuView, value: Boolean) { /* Always uses the standard Android control. */ }
23
+ override fun setGlassTint(view: ALGMenuView, value: Int?) { view.glassTint = value }
24
+ override fun setControlLabel(view: ALGMenuView, value: String?) { view.controlLabel = value }
25
+ override fun setControlHint(view: ALGMenuView, value: String?) { view.controlHint = value }
26
+ override fun setControlTestID(view: ALGMenuView, value: String?) { view.controlTestID = value }
27
+ override fun onAfterUpdateTransaction(view: ALGMenuView) { super.onAfterUpdateTransaction(view); view.applyConfiguration() }
28
+ override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> = mutableMapOf(
29
+ "topMenuAction" to mapOf("registrationName" to "onMenuAction")
30
+ )
31
+ }
@@ -0,0 +1,184 @@
1
+ package com.adaptiveglass
2
+
3
+ import android.content.res.ColorStateList
4
+ import android.graphics.Color
5
+ import android.text.SpannableString
6
+ import android.text.Spanned
7
+ import android.text.style.ForegroundColorSpan
8
+ import android.util.TypedValue
9
+ import android.view.Menu
10
+ import android.view.MenuItem
11
+ import android.view.View
12
+ import android.widget.Button
13
+ import android.widget.FrameLayout
14
+ import android.widget.PopupMenu
15
+ import android.widget.Toolbar
16
+ import com.facebook.react.bridge.Arguments
17
+ import com.facebook.react.bridge.WritableMap
18
+ import com.facebook.react.uimanager.ReactAccessibilityDelegate
19
+ import com.facebook.react.uimanager.ThemedReactContext
20
+ import com.facebook.react.uimanager.UIManagerHelper
21
+ import com.facebook.react.uimanager.events.Event
22
+ import org.json.JSONArray
23
+
24
+ private data class MenuEntry(val id: String, val title: String, val kind: String,
25
+ val disabled: Boolean, val destructive: Boolean, val checked: Boolean?,
26
+ val items: List<MenuEntry>, val placement: String) {
27
+ val isGroup get() = kind == "section" || kind == "submenu"
28
+ }
29
+ private class MenuActionEvent(surfaceId: Int, tag: Int, private val itemId: String) : Event<MenuActionEvent>(surfaceId, tag) {
30
+ override fun getEventName() = "topMenuAction"
31
+ override fun canCoalesce() = false
32
+ override fun getEventData(): WritableMap = Arguments.createMap().apply { putString("id", itemId) }
33
+ }
34
+
35
+ class ALGMenuView(private val reactContext: ThemedReactContext) : FrameLayout(reactContext) {
36
+ private val button = Button(reactContext)
37
+ private val bar = Toolbar(reactContext)
38
+ private val defaultTextColors = button.textColors
39
+ var title = ""
40
+ var itemsJSON = "[]"
41
+ var disabled = false
42
+ var toolbar = false
43
+ var maxVisibleItems = 3
44
+ var glassTint: Int? = null
45
+ var controlLabel: String? = null
46
+ var controlHint: String? = null
47
+ var controlTestID: String? = null
48
+ private var appliedConfiguration: List<Any?>? = null
49
+ private var appliedJSON: String? = null
50
+ private var entries = emptyList<MenuEntry>()
51
+ private var revision = 0
52
+ private var popup: PopupMenu? = null
53
+ private var nextItemId = 1
54
+ private var lastBarWidth = -1
55
+
56
+ init {
57
+ button.isAllCaps = false
58
+ button.maxLines = 1
59
+ button.ellipsize = android.text.TextUtils.TruncateAt.END
60
+ addView(button, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
61
+ addView(bar, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
62
+ bar.visibility = View.GONE
63
+ button.setOnClickListener { showMenu() }
64
+ }
65
+ private fun decode(array: JSONArray): List<MenuEntry> = (0 until array.length()).map { index ->
66
+ val item = array.getJSONObject(index)
67
+ MenuEntry(item.getString("id"), item.getString("title"), item.optString("kind", "action"),
68
+ item.optBoolean("disabled"), item.optBoolean("destructive"),
69
+ if (item.has("checked")) item.getBoolean("checked") else null,
70
+ item.optJSONArray("items")?.let { decode(it) } ?: emptyList(), item.optString("placement", "automatic"))
71
+ }
72
+ fun applyConfiguration() {
73
+ val configuration = listOf(itemsJSON, disabled, toolbar, maxVisibleItems, glassTint, controlTestID)
74
+ val changed = configuration != appliedConfiguration
75
+ if (appliedJSON != itemsJSON) {
76
+ appliedJSON = itemsJSON
77
+ entries = try { decode(JSONArray(itemsJSON)) } catch (_: Exception) { emptyList() }
78
+ }
79
+ if (changed) { revision += 1; dismissMenus(); appliedConfiguration = configuration }
80
+ button.visibility = if (toolbar) View.GONE else View.VISIBLE
81
+ bar.visibility = if (toolbar) View.VISIBLE else View.GONE
82
+ button.text = title
83
+ button.isEnabled = !disabled && entries.isNotEmpty()
84
+ button.contentDescription = controlLabel?.takeIf { it.isNotEmpty() } ?: title
85
+ if (android.os.Build.VERSION.SDK_INT >= 26) button.tooltipText = controlHint
86
+ button.setTag(com.facebook.react.R.id.react_test_id, controlTestID)
87
+ ReactAccessibilityDelegate.setDelegate(button, button.isFocusable, button.importantForAccessibility)
88
+ bar.setTag(com.facebook.react.R.id.react_test_id, controlTestID)
89
+ ReactAccessibilityDelegate.setDelegate(bar, bar.isFocusable, bar.importantForAccessibility)
90
+ button.setTextColor(glassTint?.let { ColorStateList.valueOf(it) } ?: defaultTextColors)
91
+ if (toolbar && changed && width > 0) {
92
+ updateToolbar()
93
+ }
94
+ }
95
+ private fun updateToolbar() {
96
+ bar.dismissPopupMenus()
97
+ bar.menu.clear()
98
+ fill(bar.menu, entries, revision, toolbarRoot = true)
99
+ // Fabric owns this host's frame, so explicitly remeasure the native children.
100
+ requestLayout()
101
+ // A request made inside onLayout can be cleared as that layout finishes.
102
+ // Force the posted measurement so newly added native actions receive bounds.
103
+ post { if (isAttachedToWindow) { forceLayout(); bar.forceLayout(); measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
104
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); layout(left, top, right, bottom) } }
105
+ }
106
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
107
+ super.onLayout(changed, left, top, right, bottom)
108
+ // onSizeChanged may run before the frame's width getter reflects Fabric's
109
+ // final bounds. Budget actions only after layout has established those bounds.
110
+ if (toolbar && lastBarWidth != width) {
111
+ lastBarWidth = width
112
+ updateToolbar()
113
+ }
114
+ }
115
+ private fun enabledAction(id: String, nodes: List<MenuEntry>): MenuEntry? {
116
+ for (entry in nodes) {
117
+ if (entry.disabled) continue
118
+ if (entry.isGroup) { enabledAction(id, entry.items)?.let { return it } }
119
+ else if (entry.id == id) return entry
120
+ }
121
+ return null
122
+ }
123
+ private fun label(entry: MenuEntry): CharSequence {
124
+ val color = if (entry.destructive) TypedValue().let { value ->
125
+ if (android.os.Build.VERSION.SDK_INT >= 26 && context.theme.resolveAttribute(android.R.attr.colorError, value, true)) {
126
+ if (value.resourceId != 0) context.getColor(value.resourceId) else value.data
127
+ } else Color.rgb(180, 32, 32)
128
+ } else glassTint
129
+ return if (color != null && !entry.disabled) SpannableString(entry.title).apply {
130
+ setSpan(ForegroundColorSpan(color), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
131
+ } else entry.title
132
+ }
133
+ private fun fill(menu: Menu, nodes: List<MenuEntry>, version: Int, toolbarRoot: Boolean = false, group: Int = 0) {
134
+ var visibleCount = 0
135
+ val density = resources.displayMetrics.density
136
+ // Toolbar's presenter budgets against the screen, not necessarily a narrow
137
+ // embedded Fabric view. Bound its action candidates to this host's width.
138
+ var available = width - 32 * density - (if (nodes.size > 1) 56 * density else 0f)
139
+ for (entry in nodes) {
140
+ val nativeId = nextItemId++
141
+ if (entry.kind == "section") {
142
+ if (entry.title.isNotEmpty()) menu.add(nativeId, nativeId, Menu.NONE, entry.title).isEnabled = false
143
+ fill(menu, entry.items, version, group = nativeId)
144
+ continue
145
+ }
146
+ val item: MenuItem = if (entry.kind == "submenu") {
147
+ val submenu = menu.addSubMenu(group, nativeId, Menu.NONE, label(entry))
148
+ fill(submenu, entry.items, version)
149
+ submenu.item
150
+ } else menu.add(group, nativeId, Menu.NONE, label(entry))
151
+ item.isEnabled = !disabled && !entry.disabled
152
+ item.isCheckable = !entry.isGroup && entry.checked != null
153
+ item.isChecked = entry.checked == true
154
+ if (android.os.Build.VERSION.SDK_INT >= 26) item.contentDescription = entry.title
155
+ if (toolbarRoot) {
156
+ val required = maxOf(56 * density, button.paint.measureText(entry.title.uppercase()) + 32 * density)
157
+ val canShow = entry.placement != "overflow" && visibleCount < maxVisibleItems && required <= available
158
+ // This host has already reserved space for overflow. IF_ROOM can use a
159
+ // stale presenter budget while Fabric lays out an embedded toolbar.
160
+ item.setShowAsAction(if (canShow) MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_WITH_TEXT else MenuItem.SHOW_AS_ACTION_NEVER)
161
+ if (canShow) { visibleCount += 1; available -= required }
162
+ }
163
+ if (!entry.isGroup) item.setOnMenuItemClickListener {
164
+ if (!disabled && version == revision && isAttachedToWindow && enabledAction(entry.id, entries) != null) {
165
+ UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)?.dispatchEvent(
166
+ MenuActionEvent(UIManagerHelper.getSurfaceId(reactContext), id, entry.id))
167
+ }
168
+ true
169
+ }
170
+ }
171
+ if (android.os.Build.VERSION.SDK_INT >= 28) menu.setGroupDividerEnabled(true)
172
+ }
173
+ private fun showMenu() {
174
+ if (disabled || entries.isEmpty() || !isAttachedToWindow) return
175
+ dismissMenus()
176
+ val menu = PopupMenu(reactContext, button)
177
+ fill(menu.menu, entries, revision)
178
+ menu.setOnDismissListener { if (popup === menu) popup = null }
179
+ popup = menu
180
+ menu.show()
181
+ }
182
+ private fun dismissMenus() { popup?.dismiss(); bar.dismissPopupMenus() }
183
+ override fun onDetachedFromWindow() { dismissMenus(); super.onDetachedFromWindow() }
184
+ }
@@ -0,0 +1,32 @@
1
+ package com.adaptiveglass
2
+
3
+ import com.facebook.react.module.annotations.ReactModule
4
+ import com.facebook.react.uimanager.SimpleViewManager
5
+ import com.facebook.react.uimanager.ThemedReactContext
6
+ import com.facebook.react.viewmanagers.ALGSliderManagerDelegate
7
+ import com.facebook.react.viewmanagers.ALGSliderManagerInterface
8
+
9
+ @ReactModule(name = "ALGSlider")
10
+ class ALGSliderManager : SimpleViewManager<ALGSliderView>(), ALGSliderManagerInterface<ALGSliderView> {
11
+ private val delegate = ALGSliderManagerDelegate(this)
12
+ override fun getDelegate() = delegate
13
+ override fun getName() = "ALGSlider"
14
+ override fun createViewInstance(context: ThemedReactContext) = ALGSliderView(context)
15
+ override fun setValue(view: ALGSliderView, value: Double) { view.controlledValue = value }
16
+ override fun setMinimumValue(view: ALGSliderView, value: Double) { view.minimum = value }
17
+ override fun setMaximumValue(view: ALGSliderView, value: Double) { view.maximum = value }
18
+ override fun setStep(view: ALGSliderView, value: Double) { view.step = value }
19
+ override fun setDisabled(view: ALGSliderView, value: Boolean) { view.disabled = value }
20
+ override fun setGlassTint(view: ALGSliderView, value: Int?) { view.glassTint = value }
21
+ override fun setRevision(view: ALGSliderView, value: Int) { /* Reconcile in the transaction below. */ }
22
+ override fun setControlLabel(view: ALGSliderView, value: String?) { view.controlLabel = value }
23
+ override fun setControlHint(view: ALGSliderView, value: String?) { view.controlHint = value }
24
+ override fun setControlTestID(view: ALGSliderView, value: String?) { view.controlTestID = value }
25
+ override fun onAfterUpdateTransaction(view: ALGSliderView) { super.onAfterUpdateTransaction(view); view.applyConfiguration() }
26
+ override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> = mutableMapOf(
27
+ "topSliderChange" to mapOf("registrationName" to "onSliderChange"),
28
+ "topSliderStart" to mapOf("registrationName" to "onSliderStart"),
29
+ "topSliderComplete" to mapOf("registrationName" to "onSliderComplete"),
30
+ "topSliderCancel" to mapOf("registrationName" to "onSliderCancel")
31
+ )
32
+ }
@@ -0,0 +1,132 @@
1
+ package com.adaptiveglass
2
+
3
+ import android.content.res.ColorStateList
4
+ import android.view.MotionEvent
5
+ import android.view.View
6
+ import android.view.accessibility.AccessibilityNodeInfo
7
+ import android.widget.FrameLayout
8
+ import android.widget.SeekBar
9
+ import com.facebook.react.bridge.Arguments
10
+ import com.facebook.react.bridge.WritableMap
11
+ import com.facebook.react.uimanager.ThemedReactContext
12
+ import com.facebook.react.uimanager.UIManagerHelper
13
+ import com.facebook.react.uimanager.events.Event
14
+ import kotlin.math.roundToInt
15
+
16
+ private class SliderEvent(surfaceId: Int, tag: Int, private val kind: String, private val value: Double) : Event<SliderEvent>(surfaceId, tag) {
17
+ override fun getEventName() = kind
18
+ override fun canCoalesce() = false
19
+ override fun getEventData(): WritableMap = Arguments.createMap().apply { putDouble("value", value) }
20
+ }
21
+
22
+ class ALGSliderView(private val reactContext: ThemedReactContext) : FrameLayout(reactContext) {
23
+ private val seekBar = SeekBar(reactContext)
24
+ var controlledValue = 0.0
25
+ var minimum = 0.0
26
+ var maximum = 1.0
27
+ var step = 0.0
28
+ var disabled = false
29
+ var glassTint: Int? = null
30
+ var controlLabel: String? = null
31
+ var controlHint: String? = null
32
+ var controlTestID: String? = null
33
+ private var dragging = false
34
+ private var appliedMinimum = 0.0
35
+ private var appliedMaximum = 1.0
36
+ private var appliedStep = 0.0
37
+ private var lastEmitted: Double? = null
38
+ private val defaultTint = seekBar.progressTintList
39
+ private val defaultThumbTint = seekBar.thumbTintList
40
+
41
+ init {
42
+ seekBar.max = 1000000
43
+ addView(seekBar, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
44
+ seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
45
+ override fun onStartTrackingTouch(bar: SeekBar) {
46
+ if (disabled) return
47
+ dragging = true
48
+ lastEmitted = current()
49
+ parent?.requestDisallowInterceptTouchEvent(true)
50
+ emit("topSliderStart", current())
51
+ }
52
+ override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) {
53
+ if (!fromUser || disabled) return
54
+ val value = current()
55
+ if (!dragging) emit("topSliderStart", normalized(controlledValue))
56
+ if (step > 0) position(value)
57
+ if (value != lastEmitted) { lastEmitted = value; emit("topSliderChange", value) }
58
+ if (!dragging) emit("topSliderComplete", value)
59
+ }
60
+ override fun onStopTrackingTouch(bar: SeekBar) {
61
+ if (!dragging) return
62
+ val value = current()
63
+ dragging = false
64
+ parent?.requestDisallowInterceptTouchEvent(false)
65
+ emit("topSliderComplete", value)
66
+ }
67
+ })
68
+ seekBar.setOnTouchListener { _, event ->
69
+ if (event.actionMasked == MotionEvent.ACTION_CANCEL) cancel()
70
+ false
71
+ }
72
+ seekBar.accessibilityDelegate = object : View.AccessibilityDelegate() {
73
+ override fun performAccessibilityAction(host: View, action: Int, arguments: android.os.Bundle?): Boolean {
74
+ if (disabled) return false
75
+ val increment = if (step > 0) step else (maximum - minimum) / 20
76
+ val next = when (action) {
77
+ AccessibilityNodeInfo.ACTION_SCROLL_FORWARD -> normalized(controlledValue + increment)
78
+ AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> normalized(controlledValue - increment)
79
+ AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_PROGRESS.id -> {
80
+ if (arguments == null || !arguments.containsKey(AccessibilityNodeInfo.ACTION_ARGUMENT_PROGRESS_VALUE)) return false
81
+ normalized(arguments.getFloat(AccessibilityNodeInfo.ACTION_ARGUMENT_PROGRESS_VALUE).toDouble())
82
+ }
83
+ else -> return super.performAccessibilityAction(host, action, arguments)
84
+ }
85
+ emit("topSliderStart", normalized(controlledValue))
86
+ position(next)
87
+ if (next != normalized(controlledValue)) emit("topSliderChange", next)
88
+ emit("topSliderComplete", next)
89
+ return true
90
+ }
91
+ override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) {
92
+ super.onInitializeAccessibilityNodeInfo(host, info)
93
+ info.rangeInfo = AccessibilityNodeInfo.RangeInfo.obtain(AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_FLOAT,
94
+ minimum.toFloat(), maximum.toFloat(), current().toFloat())
95
+ if (android.os.Build.VERSION.SDK_INT >= 30) info.stateDescription = current().toString()
96
+ else info.text = current().toString()
97
+ }
98
+ }
99
+ }
100
+ private fun normalized(value: Double): Double {
101
+ val clamped = value.coerceIn(minimum, maximum)
102
+ if (clamped == maximum || step == 0.0) return clamped
103
+ // Match JS/Swift half-up rounding (the offset is always nonnegative).
104
+ return (minimum + kotlin.math.floor((clamped - minimum) / step + 0.5) * step).coerceIn(minimum, maximum)
105
+ }
106
+ private fun current() = normalized(minimum + seekBar.progress / 1000000.0 * (maximum - minimum))
107
+ private fun position(value: Double) { seekBar.progress = (((value - minimum) / (maximum - minimum)) * 1000000).roundToInt().coerceIn(0, 1000000) }
108
+ private fun emit(kind: String, value: Double) {
109
+ UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)?.dispatchEvent(
110
+ SliderEvent(UIManagerHelper.getSurfaceId(reactContext), id, kind, value))
111
+ }
112
+ private fun cancel() {
113
+ if (!dragging) return
114
+ dragging = false
115
+ parent?.requestDisallowInterceptTouchEvent(false)
116
+ position(normalized(controlledValue))
117
+ emit("topSliderCancel", normalized(controlledValue))
118
+ }
119
+ fun applyConfiguration() {
120
+ if (!listOf(controlledValue, minimum, maximum, step, maximum - minimum).all { it.isFinite() } || maximum <= minimum || step < 0 || step > maximum - minimum) return
121
+ if (dragging && (disabled || minimum != appliedMinimum || maximum != appliedMaximum || step != appliedStep)) cancel()
122
+ appliedMinimum = minimum; appliedMaximum = maximum; appliedStep = step
123
+ seekBar.isEnabled = !disabled
124
+ seekBar.contentDescription = controlLabel
125
+ seekBar.tooltipText = controlHint
126
+ seekBar.setTag(com.facebook.react.R.id.react_test_id, controlTestID)
127
+ seekBar.progressTintList = glassTint?.let { ColorStateList.valueOf(it) } ?: defaultTint
128
+ seekBar.thumbTintList = glassTint?.let { ColorStateList.valueOf(it) } ?: defaultThumbTint
129
+ if (!dragging) position(normalized(controlledValue))
130
+ }
131
+ override fun onDetachedFromWindow() { cancel(); super.onDetachedFromWindow() }
132
+ }
@@ -0,0 +1,24 @@
1
+ package com.adaptiveglass
2
+
3
+ import com.facebook.react.module.annotations.ReactModule
4
+ import com.facebook.react.uimanager.SimpleViewManager
5
+ import com.facebook.react.uimanager.ThemedReactContext
6
+ import com.facebook.react.viewmanagers.ALGTabsManagerDelegate
7
+ import com.facebook.react.viewmanagers.ALGTabsManagerInterface
8
+
9
+ @ReactModule(name = "ALGTabs")
10
+ class ALGTabsManager : SimpleViewManager<ALGTabsView>(), ALGTabsManagerInterface<ALGTabsView> {
11
+ private val delegate = ALGTabsManagerDelegate(this)
12
+ override fun getDelegate() = delegate
13
+ override fun getName() = "ALGTabs"
14
+ override fun createViewInstance(context: ThemedReactContext) = ALGTabsView(context)
15
+ override fun setItemsJSON(view: ALGTabsView, value: String?) { view.itemsJSON = value ?: "[]" }
16
+ override fun setSelectedValue(view: ALGTabsView, value: String?) { view.selectedValue = value ?: "" }
17
+ override fun setSelectionRevision(view: ALGTabsView, value: Int) { /* Reconcile in the transaction, including rejected taps. */ }
18
+ override fun setDisabled(view: ALGTabsView, value: Boolean) { view.disabled = value }
19
+ override fun setGlassTint(view: ALGTabsView, value: Int?) { view.glassTint = value }
20
+ override fun setControlTestID(view: ALGTabsView, value: String?) { view.controlTestID = value }
21
+ override fun onAfterUpdateTransaction(view: ALGTabsView) { super.onAfterUpdateTransaction(view); view.applyConfiguration() }
22
+ override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> = mutableMapOf(
23
+ "topSelectionChange" to mapOf("registrationName" to "onSelectionChange"))
24
+ }