@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,108 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Material rendering lives entirely in UIKit. Fabric only supplies props and children.
|
|
4
|
+
@objc(ALGSurfaceView)
|
|
5
|
+
public final class ALGSurfaceView: UIView {
|
|
6
|
+
private let effectView = UIVisualEffectView()
|
|
7
|
+
private var material = "regular"
|
|
8
|
+
private var interactive = false
|
|
9
|
+
private var glassTint: UIColor?
|
|
10
|
+
private var radius: CGFloat = 24
|
|
11
|
+
private var isContainer = false
|
|
12
|
+
private var mergingEnabled = false
|
|
13
|
+
private var spacing: CGFloat = 20
|
|
14
|
+
private var duration: Double = 0.35
|
|
15
|
+
private var dirty = true
|
|
16
|
+
private var mounted = false
|
|
17
|
+
private var resetInteraction = false
|
|
18
|
+
|
|
19
|
+
@objc public var reactContentView: UIView { effectView.contentView }
|
|
20
|
+
|
|
21
|
+
public override init(frame: CGRect) {
|
|
22
|
+
super.init(frame: frame)
|
|
23
|
+
addSubview(effectView)
|
|
24
|
+
NotificationCenter.default.addObserver(self, selector: #selector(accessibilityChanged),
|
|
25
|
+
name: UIAccessibility.reduceTransparencyStatusDidChangeNotification, object: nil)
|
|
26
|
+
}
|
|
27
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
|
|
28
|
+
deinit { NotificationCenter.default.removeObserver(self) }
|
|
29
|
+
|
|
30
|
+
@objc public func configure(_ material: String, interactive: Bool, tint: UIColor?,
|
|
31
|
+
radius: CGFloat, container: Bool, mergingEnabled: Bool, spacing: CGFloat, duration: Double, scheme: String) {
|
|
32
|
+
resetInteraction = resetInteraction || self.interactive != interactive || self.isContainer != container || self.mergingEnabled != mergingEnabled
|
|
33
|
+
dirty = dirty || self.material != material || self.interactive != interactive ||
|
|
34
|
+
self.glassTint != tint || self.isContainer != container || self.spacing != spacing || self.mergingEnabled != mergingEnabled
|
|
35
|
+
self.material = material
|
|
36
|
+
self.interactive = interactive
|
|
37
|
+
self.glassTint = tint
|
|
38
|
+
self.radius = max(0, radius)
|
|
39
|
+
self.isContainer = container
|
|
40
|
+
self.mergingEnabled = mergingEnabled
|
|
41
|
+
self.spacing = max(0, spacing)
|
|
42
|
+
self.duration = max(0, duration)
|
|
43
|
+
overrideUserInterfaceStyle = scheme == "dark" ? .dark : scheme == "light" ? .light : .unspecified
|
|
44
|
+
setNeedsLayout()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@objc private func accessibilityChanged() { dirty = true; setNeedsLayout() }
|
|
48
|
+
|
|
49
|
+
public override func didMoveToWindow() {
|
|
50
|
+
super.didMoveToWindow()
|
|
51
|
+
mounted = false
|
|
52
|
+
dirty = true
|
|
53
|
+
setNeedsLayout()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public override func layoutSubviews() {
|
|
57
|
+
super.layoutSubviews()
|
|
58
|
+
effectView.frame = bounds
|
|
59
|
+
if #available(iOS 26.0, *) {
|
|
60
|
+
effectView.cornerConfiguration = .uniformCorners(radius: .fixed(isContainer ? 0 : radius))
|
|
61
|
+
} else {
|
|
62
|
+
effectView.layer.cornerRadius = isContainer ? 0 : radius
|
|
63
|
+
effectView.clipsToBounds = !isContainer
|
|
64
|
+
}
|
|
65
|
+
guard window != nil, bounds.width > 0, bounds.height > 0, dirty else { return }
|
|
66
|
+
dirty = false
|
|
67
|
+
let animate = mounted && !resetInteraction && !UIAccessibility.isReduceMotionEnabled && duration > 0
|
|
68
|
+
if !mounted || resetInteraction { effectView.effect = UIVisualEffect() }
|
|
69
|
+
resetInteraction = false
|
|
70
|
+
mounted = true
|
|
71
|
+
let effect: UIVisualEffect
|
|
72
|
+
if #available(iOS 26.0, *), !UIAccessibility.isReduceTransparencyEnabled {
|
|
73
|
+
if isContainer {
|
|
74
|
+
if mergingEnabled {
|
|
75
|
+
let group = UIGlassContainerEffect()
|
|
76
|
+
group.spacing = spacing
|
|
77
|
+
effect = group
|
|
78
|
+
} else {
|
|
79
|
+
// Keep the children mounted, but remove shared glass compositing.
|
|
80
|
+
effect = UIVisualEffect()
|
|
81
|
+
}
|
|
82
|
+
} else if material != "none" {
|
|
83
|
+
let glass = UIGlassEffect(style: material == "clear" ? .clear : .regular)
|
|
84
|
+
glass.isInteractive = interactive
|
|
85
|
+
glass.tintColor = glassTint
|
|
86
|
+
effect = glass
|
|
87
|
+
} else { effect = UIVisualEffect() }
|
|
88
|
+
effectView.backgroundColor = .clear
|
|
89
|
+
} else if !UIAccessibility.isReduceTransparencyEnabled && !isContainer && material != "none" {
|
|
90
|
+
// Standard system material on pre-glass iOS. Keep the same contentView and
|
|
91
|
+
// dirty-prop gate, so layout/React updates do not allocate another blur.
|
|
92
|
+
effect = UIBlurEffect(style: material == "clear" ? .systemUltraThinMaterial : .systemMaterial)
|
|
93
|
+
effectView.backgroundColor = .clear
|
|
94
|
+
effectView.contentView.backgroundColor = glassTint ?? .clear
|
|
95
|
+
} else {
|
|
96
|
+
effect = UIVisualEffect()
|
|
97
|
+
effectView.backgroundColor = isContainer || material == "none" ? .clear : .secondarySystemBackground
|
|
98
|
+
}
|
|
99
|
+
if #available(iOS 26.0, *) {
|
|
100
|
+
effectView.contentView.backgroundColor = .clear
|
|
101
|
+
} else if UIAccessibility.isReduceTransparencyEnabled || isContainer || material == "none" {
|
|
102
|
+
effectView.contentView.backgroundColor = .clear
|
|
103
|
+
}
|
|
104
|
+
let apply = { self.effectView.effect = effect }
|
|
105
|
+
if animate { UIView.animate(withDuration: duration, animations: apply) } else { apply() }
|
|
106
|
+
effectView.contentView.isUserInteractionEnabled = true
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#import "ALGTabsComponentView.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 ALGTabsComponentView {
|
|
9
|
+
ALGTabsView *_tabs;
|
|
10
|
+
}
|
|
11
|
+
+ (ComponentDescriptorProvider)componentDescriptorProvider { return concreteComponentDescriptorProvider<ALGTabsComponentDescriptor>(); }
|
|
12
|
+
- (instancetype)initWithFrame:(CGRect)frame {
|
|
13
|
+
if (self = [super initWithFrame:frame]) {
|
|
14
|
+
_props = std::make_shared<const ALGTabsProps>();
|
|
15
|
+
_tabs = [[ALGTabsView alloc] initWithFrame:CGRectZero];
|
|
16
|
+
self.contentView = _tabs;
|
|
17
|
+
__weak ALGTabsComponentView *weakSelf = self;
|
|
18
|
+
_tabs.onSelectionChange = ^(NSString *identifier) {
|
|
19
|
+
ALGTabsComponentView *self = weakSelf;
|
|
20
|
+
if (self && self->_eventEmitter) std::static_pointer_cast<const ALGTabsEventEmitter>(self->_eventEmitter)->onSelectionChange({std::string(identifier.UTF8String)});
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return self;
|
|
24
|
+
}
|
|
25
|
+
- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps {
|
|
26
|
+
const auto &p = *std::static_pointer_cast<const ALGTabsProps>(props);
|
|
27
|
+
// selectionRevision forces this transaction when React rejects a native tap.
|
|
28
|
+
[_tabs configure:@(p.itemsJSON.c_str()) selectedValue:@(p.selectedValue.c_str()) disabled:p.disabled
|
|
29
|
+
tint:RCTUIColorFromSharedColor(p.glassTint) identifier:@(p.controlTestID.c_str())];
|
|
30
|
+
[super updateProps:props oldProps:oldProps];
|
|
31
|
+
}
|
|
32
|
+
+ (BOOL)shouldBeRecycled { return NO; }
|
|
33
|
+
@end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
private struct TabItem: Decodable {
|
|
4
|
+
let id: String
|
|
5
|
+
let title: String
|
|
6
|
+
let icon: String?
|
|
7
|
+
let systemImage: String?
|
|
8
|
+
let selectedSystemImage: String?
|
|
9
|
+
let badge: Badge?
|
|
10
|
+
let disabled: Bool?
|
|
11
|
+
let accessibilityLabel: String?
|
|
12
|
+
enum Badge: Decodable {
|
|
13
|
+
case number(Int), dot
|
|
14
|
+
init(from decoder: Decoder) throws {
|
|
15
|
+
let value = try decoder.singleValueContainer()
|
|
16
|
+
if let number = try? value.decode(Int.self) { self = .number(number) }
|
|
17
|
+
else if try value.decode(String.self) == "dot" { self = .dot }
|
|
18
|
+
else { throw DecodingError.dataCorruptedError(in: value, debugDescription: "Invalid badge") }
|
|
19
|
+
}
|
|
20
|
+
var text: String {
|
|
21
|
+
switch self { case .dot: return ""; case .number(let number): return number > 999 ? "999+" : String(number) }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
private final class TabDestination: UIViewController {
|
|
26
|
+
let tabID: String
|
|
27
|
+
init(id: String) { tabID = id; super.init(nibName: nil, bundle: nil) }
|
|
28
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
|
|
29
|
+
override func loadView() { view = UIView(); view.backgroundColor = .clear }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// The contained controller owns the native tab bar; React owns the screen above it.
|
|
33
|
+
@objc(ALGTabsView)
|
|
34
|
+
public final class ALGTabsView: UIView, UITabBarControllerDelegate {
|
|
35
|
+
private let controller = UITabBarController()
|
|
36
|
+
private var destinations: [String: TabDestination] = [:]
|
|
37
|
+
private var items: [TabItem] = []
|
|
38
|
+
private var lastJSON = ""
|
|
39
|
+
private var applying = false
|
|
40
|
+
private var disabled = false
|
|
41
|
+
@objc public var onSelectionChange: ((String) -> Void)?
|
|
42
|
+
|
|
43
|
+
public override init(frame: CGRect) {
|
|
44
|
+
super.init(frame: frame)
|
|
45
|
+
controller.delegate = self
|
|
46
|
+
controller.view.backgroundColor = .clear
|
|
47
|
+
controller.customizableViewControllers = []
|
|
48
|
+
if #available(iOS 17.0, *) { controller.traitOverrides.horizontalSizeClass = .compact }
|
|
49
|
+
if #available(iOS 18.0, *) { controller.mode = .tabBar }
|
|
50
|
+
if #available(iOS 26.0, *) { controller.tabBarMinimizeBehavior = .never }
|
|
51
|
+
}
|
|
52
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
|
|
53
|
+
|
|
54
|
+
@objc public func configure(_ json: String, selectedValue: String, disabled: Bool,
|
|
55
|
+
tint: UIColor?, identifier: String) {
|
|
56
|
+
applying = true
|
|
57
|
+
defer { applying = false }
|
|
58
|
+
self.disabled = disabled
|
|
59
|
+
let metadataChanged = json != lastJSON
|
|
60
|
+
if metadataChanged {
|
|
61
|
+
lastJSON = json
|
|
62
|
+
items = (try? JSONDecoder().decode([TabItem].self, from: Data(json.utf8))) ?? []
|
|
63
|
+
var next: [String: TabDestination] = [:]
|
|
64
|
+
let ordered = items.map { item -> TabDestination in
|
|
65
|
+
let destination = destinations[item.id] ?? TabDestination(id: item.id)
|
|
66
|
+
next[item.id] = destination
|
|
67
|
+
return destination
|
|
68
|
+
}
|
|
69
|
+
destinations = next
|
|
70
|
+
if #available(iOS 18.4, *) {
|
|
71
|
+
// Modern UIKit owns enabled state on UITab, not the legacy tabBarItem.
|
|
72
|
+
let tabs = ordered.map { destination in
|
|
73
|
+
controller.tabs.first { $0.identifier == destination.tabID } ??
|
|
74
|
+
UITab(title: "", image: nil, identifier: destination.tabID) { _ in destination }
|
|
75
|
+
}
|
|
76
|
+
if controller.tabs.map(\.identifier) != tabs.map(\.identifier) {
|
|
77
|
+
controller.setTabs(tabs, animated: false)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// UIKit adopts the UITab API from the app's deployment target, not just the running
|
|
81
|
+
// OS, so #available alone can leave an app built for an older target with tabs that
|
|
82
|
+
// UIKit ignores and no view controllers at all. Drive the legacy array whenever the
|
|
83
|
+
// controller's own contents do not match, which is a no-op once UITab took effect.
|
|
84
|
+
let currentIDs = controller.viewControllers?.compactMap { ($0 as? TabDestination)?.tabID } ?? []
|
|
85
|
+
if currentIDs != ordered.map(\.tabID) {
|
|
86
|
+
controller.setViewControllers(ordered, animated: false)
|
|
87
|
+
controller.customizableViewControllers = []
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for item in items {
|
|
91
|
+
guard let destination = destinations[item.id] else { continue }
|
|
92
|
+
if metadataChanged {
|
|
93
|
+
let preset: String
|
|
94
|
+
switch item.icon {
|
|
95
|
+
case "home": preset = "house"
|
|
96
|
+
case "search": preset = "magnifyingglass"
|
|
97
|
+
case "library": preset = "books.vertical"
|
|
98
|
+
case "favorites": preset = "heart"
|
|
99
|
+
case "inbox": preset = "tray"
|
|
100
|
+
case "settings": preset = "gearshape"
|
|
101
|
+
default: preset = "circle"
|
|
102
|
+
}
|
|
103
|
+
let symbol = item.systemImage ?? preset
|
|
104
|
+
destination.tabBarItem.title = item.title
|
|
105
|
+
destination.tabBarItem.image = UIImage(systemName: symbol) ?? UIImage(systemName: "circle")
|
|
106
|
+
destination.tabBarItem.selectedImage = item.selectedSystemImage.flatMap { UIImage(systemName: $0) }
|
|
107
|
+
destination.tabBarItem.badgeValue = item.badge?.text
|
|
108
|
+
}
|
|
109
|
+
// UIKit derives the tab button, its label and its selected trait from the tab itself.
|
|
110
|
+
// Neither UITab nor UITabBarItem declares a public accessibilityTraits property, so
|
|
111
|
+
// isEnabled is the only public control over the disabled state; see docs/accessibility.md.
|
|
112
|
+
let enabled = !disabled && item.disabled != true
|
|
113
|
+
destination.tabBarItem.isEnabled = enabled
|
|
114
|
+
destination.tabBarItem.accessibilityLabel = item.accessibilityLabel ?? item.title
|
|
115
|
+
destination.tabBarItem.accessibilityIdentifier = identifier.isEmpty ? item.id : "\(identifier)-\(item.id)"
|
|
116
|
+
if #available(iOS 18.4, *), let tab = controller.tab(forIdentifier: item.id) {
|
|
117
|
+
tab.title = item.title
|
|
118
|
+
tab.image = item.id == selectedValue
|
|
119
|
+
? (destination.tabBarItem.selectedImage ?? destination.tabBarItem.image)
|
|
120
|
+
: destination.tabBarItem.image
|
|
121
|
+
tab.badgeValue = item.badge?.text
|
|
122
|
+
tab.isEnabled = enabled
|
|
123
|
+
tab.preferredPlacement = .fixed
|
|
124
|
+
tab.accessibilityLabel = item.accessibilityLabel ?? item.title
|
|
125
|
+
tab.accessibilityIdentifier = destination.tabBarItem.accessibilityIdentifier
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Fall through to the view-controller path when UITab is not in effect, rather than
|
|
129
|
+
// silently skipping selection on an app whose deployment target predates it.
|
|
130
|
+
if #available(iOS 18.4, *), let selected = controller.tab(forIdentifier: selectedValue) {
|
|
131
|
+
if controller.selectedTab !== selected { controller.selectedTab = selected }
|
|
132
|
+
} else if let selected = destinations[selectedValue], controller.selectedViewController !== selected {
|
|
133
|
+
controller.selectedViewController = selected
|
|
134
|
+
}
|
|
135
|
+
controller.tabBar.tintColor = tint
|
|
136
|
+
controller.tabBar.accessibilityIdentifier = identifier
|
|
137
|
+
controller.view.isHidden = items.isEmpty
|
|
138
|
+
setNeedsLayout()
|
|
139
|
+
}
|
|
140
|
+
private func currentItem(_ destination: UIViewController) -> TabItem? {
|
|
141
|
+
guard let tab = destination as? TabDestination, destinations[tab.tabID] === tab else { return nil }
|
|
142
|
+
return items.first { $0.id == tab.tabID }
|
|
143
|
+
}
|
|
144
|
+
public func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
|
|
145
|
+
guard !applying, window != nil, !disabled, let item = currentItem(viewController) else { return false }
|
|
146
|
+
return item.disabled != true
|
|
147
|
+
}
|
|
148
|
+
public func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
|
|
149
|
+
guard !applying, window != nil, !disabled, let item = currentItem(viewController), item.disabled != true else { return }
|
|
150
|
+
onSelectionChange?(item.id)
|
|
151
|
+
}
|
|
152
|
+
@available(iOS 18.0, *)
|
|
153
|
+
public func tabBarController(_ tabBarController: UITabBarController, shouldSelectTab tab: UITab) -> Bool {
|
|
154
|
+
guard !applying, window != nil, !disabled, controller.tab(forIdentifier: tab.identifier) === tab else { return false }
|
|
155
|
+
return items.contains { $0.id == tab.identifier && $0.disabled != true }
|
|
156
|
+
}
|
|
157
|
+
@available(iOS 18.0, *)
|
|
158
|
+
public func tabBarController(_ tabBarController: UITabBarController, didSelectTab selectedTab: UITab, previousTab: UITab?) {
|
|
159
|
+
guard self.tabBarController(tabBarController, shouldSelectTab: selectedTab) else { return }
|
|
160
|
+
onSelectionChange?(selectedTab.identifier)
|
|
161
|
+
}
|
|
162
|
+
private func attachController() {
|
|
163
|
+
guard window != nil else { return }
|
|
164
|
+
var responder: UIResponder? = superview
|
|
165
|
+
while let current = responder, !(current is UIViewController) { responder = current.next }
|
|
166
|
+
guard let parent = responder as? UIViewController, parent !== controller else { return }
|
|
167
|
+
if controller.parent !== parent {
|
|
168
|
+
detachController()
|
|
169
|
+
parent.addChild(controller)
|
|
170
|
+
addSubview(controller.view)
|
|
171
|
+
controller.didMove(toParent: parent)
|
|
172
|
+
}
|
|
173
|
+
controller.view.frame = bounds
|
|
174
|
+
}
|
|
175
|
+
private func detachController() {
|
|
176
|
+
guard controller.parent != nil else { return }
|
|
177
|
+
controller.willMove(toParent: nil)
|
|
178
|
+
controller.view.removeFromSuperview()
|
|
179
|
+
controller.removeFromParent()
|
|
180
|
+
}
|
|
181
|
+
public override func didMoveToWindow() {
|
|
182
|
+
super.didMoveToWindow()
|
|
183
|
+
if window == nil { detachController() }
|
|
184
|
+
else {
|
|
185
|
+
attachController()
|
|
186
|
+
DispatchQueue.main.async { [weak self] in self?.attachController() }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
public override func layoutSubviews() {
|
|
190
|
+
super.layoutSubviews()
|
|
191
|
+
attachController()
|
|
192
|
+
controller.view.frame = bounds
|
|
193
|
+
}
|
|
194
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@likith99/react-native-adaptive-liquid-glass",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Native iOS Liquid Glass surfaces and controls with standard Android counterparts.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Likith Kanneganti",
|
|
7
|
+
"homepage": "https://github.com/likith099/liquedGlassRecreate#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/likith099/liquedGlassRecreate.git",
|
|
11
|
+
"directory": "packages/liquid-glass"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/likith099/liquedGlassRecreate/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"react-native",
|
|
18
|
+
"ios",
|
|
19
|
+
"android",
|
|
20
|
+
"liquid-glass",
|
|
21
|
+
"glass",
|
|
22
|
+
"uikit",
|
|
23
|
+
"swiftui",
|
|
24
|
+
"material",
|
|
25
|
+
"fabric",
|
|
26
|
+
"new-architecture",
|
|
27
|
+
"tabs",
|
|
28
|
+
"toolbar",
|
|
29
|
+
"menu",
|
|
30
|
+
"accessibility"
|
|
31
|
+
],
|
|
32
|
+
"main": "src/index.ts",
|
|
33
|
+
"react-native": "src/index.ts",
|
|
34
|
+
"types": "src/index.ts",
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"ios",
|
|
38
|
+
"android",
|
|
39
|
+
"!android/build",
|
|
40
|
+
"!android/.gradle",
|
|
41
|
+
"AdaptiveLiquidGlass.podspec",
|
|
42
|
+
"react-native.config.js",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"react": ">=19.0.0",
|
|
48
|
+
"react-native": ">=0.81.0 <0.87.0"
|
|
49
|
+
},
|
|
50
|
+
"codegenConfig": {
|
|
51
|
+
"name": "AdaptiveLiquidGlassSpec",
|
|
52
|
+
"type": "components",
|
|
53
|
+
"jsSrcsDir": "src/specs",
|
|
54
|
+
"android": {
|
|
55
|
+
"javaPackageName": "com.adaptiveglass"
|
|
56
|
+
},
|
|
57
|
+
"ios": {
|
|
58
|
+
"componentProvider": {
|
|
59
|
+
"ALGSurface": "ALGSurfaceComponentView",
|
|
60
|
+
"ALGButton": "ALGButtonComponentView",
|
|
61
|
+
"ALGSegmented": "ALGSegmentedComponentView",
|
|
62
|
+
"ALGSlider": "ALGSliderComponentView",
|
|
63
|
+
"ALGActionCluster": "ALGActionClusterComponentView",
|
|
64
|
+
"ALGMenu": "ALGMenuComponentView",
|
|
65
|
+
"ALGTabs": "ALGTabsComponentView"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"publishConfig": {
|
|
70
|
+
"access": "public",
|
|
71
|
+
"registry": "https://registry.npmjs.org/"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import NativeCluster from './specs/ALGActionClusterNativeComponent';
|
|
3
|
+
import Fallback from './fallback/GlassActionCluster';
|
|
4
|
+
import {isLiquidGlassSupported} from './support';
|
|
5
|
+
import {validateActions} from './validateActions';
|
|
6
|
+
import type {GlassActionClusterProps} from './types';
|
|
7
|
+
export default function GlassActionCluster({actions, onAction, onExpandedChange, tintColor,
|
|
8
|
+
animationDuration = 0.45, pressFeedback: _pressFeedback, forceFallback, toggleLabel = 'Actions', style, ...props}: GlassActionClusterProps) {
|
|
9
|
+
validateActions(actions);
|
|
10
|
+
if (forceFallback || !isLiquidGlassSupported()) {
|
|
11
|
+
return <Fallback {...props} actions={actions} onAction={onAction} onExpandedChange={onExpandedChange}
|
|
12
|
+
style={style} toggleLabel={toggleLabel} />;
|
|
13
|
+
}
|
|
14
|
+
return <NativeCluster {...props} style={[{height: 88, minWidth: 80}, style]}
|
|
15
|
+
actionsJSON={JSON.stringify(actions)} glassTint={tintColor} duration={animationDuration} toggleLabel={toggleLabel}
|
|
16
|
+
onAction={event => onAction(event.nativeEvent.id)}
|
|
17
|
+
onExpandedChange={event => onExpandedChange(event.nativeEvent.expanded)} />;
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {default} from './fallback/GlassActionCluster';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {useWindowDimensions} from 'react-native';
|
|
3
|
+
import adaptiveHeight from './adaptiveHeight';
|
|
4
|
+
import NativeButton from './specs/ALGButtonNativeComponent';
|
|
5
|
+
import GlassPressable from './GlassPressable';
|
|
6
|
+
import Fallback from './fallback/NativeGlassButton';
|
|
7
|
+
import {isLiquidGlassSupported} from './support';
|
|
8
|
+
import type {GlassButtonProps} from './types';
|
|
9
|
+
export default function GlassButton(props: GlassButtonProps) {
|
|
10
|
+
const {fontScale} = useWindowDimensions();
|
|
11
|
+
if (props.title === undefined) return <GlassPressable {...props} />;
|
|
12
|
+
if (props.forceFallback || !isLiquidGlassSupported()) return <Fallback {...props} />;
|
|
13
|
+
const {onPress, tintColor, forceFallback: _fallback, accessibilityLabel, accessibilityHint,
|
|
14
|
+
accessibilityState: _state, testID, style, ...nativeProps} = props;
|
|
15
|
+
return <NativeButton {...nativeProps} style={[{height: adaptiveHeight(64, 20, fontScale)}, style]} glassTint={tintColor}
|
|
16
|
+
accessible={false} controlLabel={accessibilityLabel ?? props.title} controlHint={accessibilityHint}
|
|
17
|
+
controlTestID={testID} onActivate={() => {if (!props.disabled && !props.loading) onPress();}} />;
|
|
18
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import GlassPressable from './GlassPressable';
|
|
3
|
+
import Fallback from './fallback/NativeGlassButton';
|
|
4
|
+
import type {GlassButtonProps} from './types';
|
|
5
|
+
export default function GlassButton(props: GlassButtonProps) {
|
|
6
|
+
return props.title === undefined ? <GlassPressable {...props} /> : <Fallback {...props} />;
|
|
7
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {View} from 'react-native';
|
|
3
|
+
import NativeSurface from './specs/ALGSurfaceNativeComponent';
|
|
4
|
+
import {isLiquidGlassSupported} from './support';
|
|
5
|
+
import type {GlassContainerProps} from './types';
|
|
6
|
+
export default function GlassContainer({forceFallback, mergingEnabled = false, spacing, ...props}: GlassContainerProps) {
|
|
7
|
+
return forceFallback || !isLiquidGlassSupported() ? <View {...props} /> :
|
|
8
|
+
<NativeSurface {...props} container mergingEnabled={mergingEnabled} spacing={spacing} />;
|
|
9
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {View} from 'react-native';
|
|
3
|
+
import type {GlassContainerProps} from './types';
|
|
4
|
+
export default function GlassContainer({spacing: _spacing, mergingEnabled: _merging, forceFallback: _force, ...props}: GlassContainerProps) {
|
|
5
|
+
return <View {...props} />;
|
|
6
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {useWindowDimensions} from 'react-native';
|
|
3
|
+
import adaptiveHeight from './adaptiveHeight';
|
|
4
|
+
import NativeMenu from './specs/ALGMenuNativeComponent';
|
|
5
|
+
import type {GlassMenuButtonProps, GlassMenuElement} from './types';
|
|
6
|
+
import {enabledMenuAction, validateMenuItems} from './menuTree';
|
|
7
|
+
|
|
8
|
+
export function validateMenu(title: string, items: readonly GlassMenuElement[]) {
|
|
9
|
+
if (!title.trim()) throw new Error('GlassMenuButton title must be nonempty.');
|
|
10
|
+
validateMenuItems(items);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function GlassMenuButton({title, items, onAction, systemImage, disabled = false,
|
|
14
|
+
tintColor, forceFallback = false, accessibilityLabel, accessibilityHint,
|
|
15
|
+
accessibilityState: _state, testID, style, ...props}: GlassMenuButtonProps) {
|
|
16
|
+
const {fontScale} = useWindowDimensions();
|
|
17
|
+
validateMenu(title, items);
|
|
18
|
+
return <NativeMenu {...props} accessible={false} style={[{height: adaptiveHeight(64, 20, fontScale), minWidth: 120}, style]}
|
|
19
|
+
title={title} systemImage={systemImage} itemsJSON={JSON.stringify(items)}
|
|
20
|
+
disabled={disabled || items.length === 0} glassTint={tintColor} forceFallback={forceFallback}
|
|
21
|
+
controlLabel={accessibilityLabel ?? title} controlHint={accessibilityHint} controlTestID={testID}
|
|
22
|
+
onMenuAction={event => {
|
|
23
|
+
const item = enabledMenuAction(items, event.nativeEvent.id);
|
|
24
|
+
if (!disabled && item) onAction(item.id);
|
|
25
|
+
}} />;
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {Platform, Pressable} from 'react-native';
|
|
3
|
+
import GlassView from './GlassView';
|
|
4
|
+
import type {GlassPressableProps} from './types';
|
|
5
|
+
export default function GlassPressable({children, style, contentStyle, material, tintColor, cornerRadius = 28,
|
|
6
|
+
fallbackStyle, forceFallback, disabled, accessibilityState, android_ripple, ...props}: GlassPressableProps) {
|
|
7
|
+
return <Pressable {...props} disabled={disabled} accessibilityRole="button"
|
|
8
|
+
accessibilityState={{...accessibilityState, disabled: !!disabled}}
|
|
9
|
+
android_ripple={android_ripple ?? {color: '#80808030', foreground: true}}
|
|
10
|
+
style={[{borderRadius: cornerRadius, overflow: Platform.OS === 'android' ? 'hidden' : 'visible'}, style]}>
|
|
11
|
+
<GlassView material={material} tintColor={tintColor} cornerRadius={cornerRadius}
|
|
12
|
+
interactive={!disabled} forceFallback={forceFallback} fallbackStyle={fallbackStyle}
|
|
13
|
+
style={[{minHeight: 52, paddingHorizontal: 22, paddingVertical: 14, justifyContent: 'center', alignItems: 'center'}, contentStyle]}>
|
|
14
|
+
{children}
|
|
15
|
+
</GlassView>
|
|
16
|
+
</Pressable>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {useWindowDimensions} from 'react-native';
|
|
3
|
+
import adaptiveHeight from './adaptiveHeight';
|
|
4
|
+
import NativeSegmented from './specs/ALGSegmentedNativeComponent';
|
|
5
|
+
import Fallback from './fallback/GlassSegmentedControl';
|
|
6
|
+
import {isLiquidGlassSupported} from './support';
|
|
7
|
+
import {validateSegments} from './validateSegments';
|
|
8
|
+
import type {GlassSegmentedControlProps} from './types';
|
|
9
|
+
export default function GlassSegmentedControl(props: GlassSegmentedControlProps) {
|
|
10
|
+
const {fontScale} = useWindowDimensions();
|
|
11
|
+
validateSegments(props.options, props.value);
|
|
12
|
+
if (fontScale > 1.3 || props.forceFallback || !isLiquidGlassSupported()) return <Fallback {...props} />;
|
|
13
|
+
const {options, value, onValueChange, tintColor, forceFallback: _fallback, accessibilityLabel,
|
|
14
|
+
accessibilityState: _state, testID, style, ...nativeProps} = props;
|
|
15
|
+
return <NativeSegmented {...nativeProps} style={[{height: adaptiveHeight(52, 18, fontScale)}, style]} accessible={false}
|
|
16
|
+
optionsJSON={JSON.stringify(options)} selectedValue={value ?? ''} glassTint={tintColor}
|
|
17
|
+
controlLabel={accessibilityLabel} controlTestID={testID} onSelectionChange={event => {
|
|
18
|
+
const option = options.find(item => item.value === event.nativeEvent.value);
|
|
19
|
+
if (!props.disabled && option && !option.disabled && option.value !== value) onValueChange(option.value);
|
|
20
|
+
}} />;
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {default} from './fallback/GlassSegmentedControl';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React, {useState} from 'react';
|
|
2
|
+
import NativeSlider from './specs/ALGSliderNativeComponent';
|
|
3
|
+
import {normalizeSliderValue, validateSlider} from './sliderMath';
|
|
4
|
+
import type {GlassSliderProps} from './types';
|
|
5
|
+
export default function GlassSlider({value, minimumValue = 0, maximumValue = 1, step = 0,
|
|
6
|
+
disabled, tintColor, onValueChange, onSlidingStart, onSlidingComplete, onSlidingCancel,
|
|
7
|
+
accessibilityLabel, accessibilityHint, accessibilityState: _state, testID, style, ...props}: GlassSliderProps) {
|
|
8
|
+
validateSlider(value, minimumValue, maximumValue, step);
|
|
9
|
+
const [revision, setRevision] = useState(0);
|
|
10
|
+
const normalize = (next: number) => normalizeSliderValue(next, minimumValue, maximumValue, step);
|
|
11
|
+
const reconcile = () => setRevision(previous => (previous + 1) % 2147483647);
|
|
12
|
+
return <NativeSlider {...props} style={[{height: 52}, style]} accessible={false}
|
|
13
|
+
value={normalize(value)} minimumValue={minimumValue} maximumValue={maximumValue} step={step}
|
|
14
|
+
disabled={disabled} glassTint={tintColor} revision={revision} controlLabel={accessibilityLabel}
|
|
15
|
+
controlHint={accessibilityHint} controlTestID={testID}
|
|
16
|
+
onSliderChange={event => {if (!disabled) onValueChange(normalize(event.nativeEvent.value));}}
|
|
17
|
+
onSliderStart={event => {if (!disabled) onSlidingStart?.(normalize(event.nativeEvent.value));}}
|
|
18
|
+
onSliderComplete={event => {
|
|
19
|
+
// Also force native reconciliation if the parent deliberately keeps the same value.
|
|
20
|
+
reconcile();
|
|
21
|
+
if (!disabled) onSlidingComplete?.(normalize(event.nativeEvent.value));
|
|
22
|
+
}}
|
|
23
|
+
onSliderCancel={event => {reconcile(); onSlidingCancel?.(normalize(event.nativeEvent.value));}} />;
|
|
24
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React, {useReducer} from 'react';
|
|
2
|
+
import {useWindowDimensions} from 'react-native';
|
|
3
|
+
import adaptiveHeight from './adaptiveHeight';
|
|
4
|
+
import NativeTabs from './specs/ALGTabsNativeComponent';
|
|
5
|
+
import {validateTabs} from './validateTabs';
|
|
6
|
+
import type {GlassTabBarProps} from './types';
|
|
7
|
+
|
|
8
|
+
export default function GlassTabBar({items, value, onValueChange, onTabReselect, disabled = false,
|
|
9
|
+
tintColor, testID, style, accessibilityState: _state, ...props}: GlassTabBarProps) {
|
|
10
|
+
const {fontScale} = useWindowDimensions();
|
|
11
|
+
validateTabs(items, value);
|
|
12
|
+
const [selectionRevision, reconcile] = useReducer((revision: number) => (revision + 1) & 0x7fffffff, 0);
|
|
13
|
+
return <NativeTabs {...props} accessible={false} style={[{height: adaptiveHeight(80, 24, fontScale), minWidth: 180}, style]}
|
|
14
|
+
itemsJSON={JSON.stringify(items)} selectedValue={value ?? ''} selectionRevision={selectionRevision}
|
|
15
|
+
disabled={disabled || items.length === 0} glassTint={tintColor} controlTestID={testID}
|
|
16
|
+
onSelectionChange={event => {
|
|
17
|
+
// A native tap may select provisionally. Always send a prop transaction,
|
|
18
|
+
// even if React rejects the request and the controlled value is unchanged.
|
|
19
|
+
reconcile();
|
|
20
|
+
const item = items.find(candidate => candidate.id === event.nativeEvent.id);
|
|
21
|
+
if (disabled || !item || item.disabled) return;
|
|
22
|
+
if (item.id === value) onTabReselect?.(item.id);
|
|
23
|
+
else onValueChange(item.id);
|
|
24
|
+
}} />;
|
|
25
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {useWindowDimensions} from 'react-native';
|
|
3
|
+
import adaptiveHeight from './adaptiveHeight';
|
|
4
|
+
import NativeMenu from './specs/ALGMenuNativeComponent';
|
|
5
|
+
import {enabledMenuAction, validateMenuItems} from './menuTree';
|
|
6
|
+
import type {GlassToolbarProps} from './types';
|
|
7
|
+
|
|
8
|
+
export default function GlassToolbar({items, onAction, disabled = false, maxVisibleItems = 3,
|
|
9
|
+
mergingEnabled = false, tintColor, forceFallback = false, accessibilityLabel, accessibilityHint,
|
|
10
|
+
accessibilityState: _state, testID, style, ...props}: GlassToolbarProps) {
|
|
11
|
+
const {fontScale} = useWindowDimensions();
|
|
12
|
+
validateMenuItems(items);
|
|
13
|
+
if (!Number.isInteger(maxVisibleItems) || maxVisibleItems < 0 || maxVisibleItems > 256) {
|
|
14
|
+
throw new Error('GlassToolbar maxVisibleItems must be an integer from 0 to 256.');
|
|
15
|
+
}
|
|
16
|
+
return <NativeMenu {...props} accessible={false} toolbar title="" itemsJSON={JSON.stringify(items)}
|
|
17
|
+
style={[{height: adaptiveHeight(64, 20, fontScale), minWidth: 64}, style]} disabled={disabled || items.length === 0}
|
|
18
|
+
maxVisibleItems={maxVisibleItems} mergingEnabled={mergingEnabled}
|
|
19
|
+
glassTint={tintColor} forceFallback={forceFallback} controlLabel={accessibilityLabel}
|
|
20
|
+
controlHint={accessibilityHint} controlTestID={testID}
|
|
21
|
+
onMenuAction={event => {
|
|
22
|
+
const item = enabledMenuAction(items, event.nativeEvent.id);
|
|
23
|
+
if (!disabled && item) onAction(item.id);
|
|
24
|
+
}} />;
|
|
25
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import NativeSurface from './specs/ALGSurfaceNativeComponent';
|
|
3
|
+
import Fallback from './fallback/GlassView';
|
|
4
|
+
import {isLiquidGlassSupported} from './support';
|
|
5
|
+
import type {GlassViewProps} from './types';
|
|
6
|
+
export default function GlassView({tintColor, cornerRadius = 24, fallbackStyle, forceFallback,
|
|
7
|
+
style, ...props}: GlassViewProps) {
|
|
8
|
+
if (forceFallback) {
|
|
9
|
+
return <Fallback {...props} style={style} tintColor={tintColor} cornerRadius={cornerRadius} fallbackStyle={fallbackStyle} />;
|
|
10
|
+
}
|
|
11
|
+
return <NativeSurface {...props} glassTint={tintColor} glassRadius={cornerRadius}
|
|
12
|
+
style={isLiquidGlassSupported() ? style : [style, fallbackStyle]} />;
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {default} from './fallback/GlassView';
|