@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,113 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
private final class ButtonModel: ObservableObject {
|
|
5
|
+
@Published var title = ""
|
|
6
|
+
@Published var symbol = ""
|
|
7
|
+
@Published var prominent = false
|
|
8
|
+
@Published var disabled = false
|
|
9
|
+
@Published var loading = false
|
|
10
|
+
@Published var tint: UIColor?
|
|
11
|
+
@Published var label = ""
|
|
12
|
+
@Published var hint = ""
|
|
13
|
+
@Published var identifier = ""
|
|
14
|
+
var activate: () -> Void = {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@available(iOS 26.0, *)
|
|
18
|
+
private struct NativeButtonContent: View {
|
|
19
|
+
@ObservedObject var model: ButtonModel
|
|
20
|
+
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
|
21
|
+
|
|
22
|
+
private var button: some View {
|
|
23
|
+
Button {
|
|
24
|
+
if !model.disabled && !model.loading { model.activate() }
|
|
25
|
+
} label: {
|
|
26
|
+
HStack(spacing: 8) {
|
|
27
|
+
if model.loading { ProgressView().accessibilityHidden(true) }
|
|
28
|
+
else if !model.symbol.isEmpty { Image(systemName: model.symbol).accessibilityHidden(true) }
|
|
29
|
+
Text(model.title)
|
|
30
|
+
}
|
|
31
|
+
.frame(maxWidth: .infinity)
|
|
32
|
+
}
|
|
33
|
+
.disabled(model.disabled || model.loading)
|
|
34
|
+
.accessibilityLabel(model.label.isEmpty ? model.title : model.label)
|
|
35
|
+
.accessibilityHint(model.hint)
|
|
36
|
+
.accessibilityValue(model.loading ? "Loading" : "")
|
|
37
|
+
.accessibilityIdentifier(model.identifier)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
var body: some View {
|
|
41
|
+
Group {
|
|
42
|
+
if reduceTransparency {
|
|
43
|
+
if model.prominent { button.buttonStyle(.borderedProminent) }
|
|
44
|
+
else { button.buttonStyle(.bordered) }
|
|
45
|
+
} else if model.prominent {
|
|
46
|
+
button.buttonStyle(.glassProminent)
|
|
47
|
+
} else {
|
|
48
|
+
button.buttonStyle(.glass)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
.controlSize(.large)
|
|
52
|
+
.tint(model.tint.map { Color(uiColor: $0) } ?? .accentColor)
|
|
53
|
+
.padding(.horizontal, 4)
|
|
54
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@objc(ALGButtonView)
|
|
59
|
+
public final class ALGButtonView: UIView {
|
|
60
|
+
private let model = ButtonModel()
|
|
61
|
+
private var host: UIViewController?
|
|
62
|
+
@objc public var onActivate: (() -> Void)?
|
|
63
|
+
|
|
64
|
+
public override init(frame: CGRect) {
|
|
65
|
+
super.init(frame: frame)
|
|
66
|
+
model.activate = { [weak self] in self?.onActivate?() }
|
|
67
|
+
if #available(iOS 26.0, *) {
|
|
68
|
+
let controller = UIHostingController(rootView: NativeButtonContent(model: model))
|
|
69
|
+
controller.view.backgroundColor = .clear
|
|
70
|
+
host = controller
|
|
71
|
+
addSubview(controller.view)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
|
|
75
|
+
|
|
76
|
+
@objc public func configure(_ title: String, symbol: String, prominent: Bool, disabled: Bool,
|
|
77
|
+
loading: Bool, tint: UIColor?, scheme: String, label: String, hint: String, identifier: String) {
|
|
78
|
+
model.title = title
|
|
79
|
+
model.symbol = symbol
|
|
80
|
+
model.prominent = prominent
|
|
81
|
+
model.disabled = disabled
|
|
82
|
+
model.loading = loading
|
|
83
|
+
model.tint = tint
|
|
84
|
+
model.label = label
|
|
85
|
+
model.hint = hint
|
|
86
|
+
model.identifier = identifier
|
|
87
|
+
overrideUserInterfaceStyle = scheme == "dark" ? .dark : scheme == "light" ? .light : .unspecified
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
public override func layoutSubviews() { super.layoutSubviews(); host?.view.frame = bounds }
|
|
91
|
+
public override func didMoveToWindow() {
|
|
92
|
+
super.didMoveToWindow()
|
|
93
|
+
guard let host else { return }
|
|
94
|
+
if window == nil {
|
|
95
|
+
host.willMove(toParent: nil)
|
|
96
|
+
host.removeFromParent()
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
var responder: UIResponder? = superview
|
|
100
|
+
while let current = responder {
|
|
101
|
+
if let parent = current as? UIViewController {
|
|
102
|
+
if host.parent !== parent {
|
|
103
|
+
host.willMove(toParent: nil)
|
|
104
|
+
host.removeFromParent()
|
|
105
|
+
parent.addChild(host)
|
|
106
|
+
host.didMove(toParent: parent)
|
|
107
|
+
}
|
|
108
|
+
break
|
|
109
|
+
}
|
|
110
|
+
responder = current.next
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#import "ALGMenuComponentView.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 ALGMenuComponentView {
|
|
9
|
+
ALGMenuView *_menu;
|
|
10
|
+
}
|
|
11
|
+
+ (ComponentDescriptorProvider)componentDescriptorProvider { return concreteComponentDescriptorProvider<ALGMenuComponentDescriptor>(); }
|
|
12
|
+
- (instancetype)initWithFrame:(CGRect)frame {
|
|
13
|
+
if (self = [super initWithFrame:frame]) {
|
|
14
|
+
_props = std::make_shared<const ALGMenuProps>();
|
|
15
|
+
_menu = [[ALGMenuView alloc] initWithFrame:CGRectZero];
|
|
16
|
+
self.contentView = _menu;
|
|
17
|
+
__weak ALGMenuComponentView *weakSelf = self;
|
|
18
|
+
_menu.onAction = ^(NSString *identifier) {
|
|
19
|
+
ALGMenuComponentView *self = weakSelf;
|
|
20
|
+
if (self && self->_eventEmitter) std::static_pointer_cast<const ALGMenuEventEmitter>(self->_eventEmitter)->onMenuAction({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 ALGMenuProps>(props);
|
|
27
|
+
[_menu configure:@(p.title.c_str()) itemsJSON:@(p.itemsJSON.c_str()) symbol:@(p.systemImage.c_str())
|
|
28
|
+
disabled:p.disabled tint:RCTUIColorFromSharedColor(p.glassTint) forceFallback:p.forceFallback
|
|
29
|
+
label:@(p.controlLabel.c_str()) hint:@(p.controlHint.c_str()) identifier:@(p.controlTestID.c_str())
|
|
30
|
+
toolbar:p.toolbar maxVisibleItems:p.maxVisibleItems mergingEnabled:p.mergingEnabled];
|
|
31
|
+
[super updateProps:props oldProps:oldProps];
|
|
32
|
+
}
|
|
33
|
+
+ (BOOL)shouldBeRecycled { return NO; }
|
|
34
|
+
@end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
private struct MenuItem: Decodable, Equatable {
|
|
4
|
+
let id: String
|
|
5
|
+
let title: String
|
|
6
|
+
let kind: String?
|
|
7
|
+
let systemImage: String?
|
|
8
|
+
let disabled: Bool?
|
|
9
|
+
let destructive: Bool?
|
|
10
|
+
let checked: Bool?
|
|
11
|
+
let items: [MenuItem]?
|
|
12
|
+
let placement: String?
|
|
13
|
+
var isGroup: Bool { kind == "section" || kind == "submenu" }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@objc(ALGMenuView)
|
|
17
|
+
public final class ALGMenuView: UIView {
|
|
18
|
+
private let button = UIButton(type: .system)
|
|
19
|
+
private let bar = UIToolbar()
|
|
20
|
+
private var items: [MenuItem] = []
|
|
21
|
+
private var lastJSON = ""
|
|
22
|
+
private var revision = 0
|
|
23
|
+
private var title = ""
|
|
24
|
+
private var symbol = ""
|
|
25
|
+
private var tint: UIColor?
|
|
26
|
+
private var forceFallback = false
|
|
27
|
+
private var disabled = false
|
|
28
|
+
private var toolbar = false
|
|
29
|
+
private var maxVisibleItems = 3
|
|
30
|
+
private var mergingEnabled = false
|
|
31
|
+
private var identifier = ""
|
|
32
|
+
private var configured = false
|
|
33
|
+
private var needsBarUpdate = true
|
|
34
|
+
private var lastWidth: CGFloat = -1
|
|
35
|
+
@objc public var onAction: ((String) -> Void)?
|
|
36
|
+
|
|
37
|
+
public override init(frame: CGRect) {
|
|
38
|
+
super.init(frame: frame)
|
|
39
|
+
addSubview(button)
|
|
40
|
+
addSubview(bar)
|
|
41
|
+
bar.isHidden = true
|
|
42
|
+
button.showsMenuAsPrimaryAction = true
|
|
43
|
+
button.changesSelectionAsPrimaryAction = false
|
|
44
|
+
// Keeping the authored order needs iOS 16; below it UIKit picks the order itself.
|
|
45
|
+
if #available(iOS 16.0, *) { button.preferredMenuElementOrder = .fixed }
|
|
46
|
+
button.titleLabel?.adjustsFontForContentSizeCategory = true
|
|
47
|
+
NotificationCenter.default.addObserver(self, selector: #selector(updateAppearance),
|
|
48
|
+
name: UIAccessibility.reduceTransparencyStatusDidChangeNotification, object: nil)
|
|
49
|
+
NotificationCenter.default.addObserver(self, selector: #selector(updateAppearance),
|
|
50
|
+
name: UIContentSizeCategory.didChangeNotification, object: nil)
|
|
51
|
+
}
|
|
52
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
|
|
53
|
+
deinit { NotificationCenter.default.removeObserver(self) }
|
|
54
|
+
|
|
55
|
+
@objc public func configure(_ title: String, itemsJSON: String, symbol: String, disabled: Bool,
|
|
56
|
+
tint: UIColor?, forceFallback: Bool, label: String, hint: String, identifier: String,
|
|
57
|
+
toolbar: Bool, maxVisibleItems: Int, mergingEnabled: Bool) {
|
|
58
|
+
let appearanceChanged = !configured || self.title != title || self.symbol != symbol || self.tint != tint || self.forceFallback != forceFallback
|
|
59
|
+
let structureChanged = lastJSON != itemsJSON || self.toolbar != toolbar || self.disabled != disabled ||
|
|
60
|
+
self.maxVisibleItems != maxVisibleItems || self.mergingEnabled != mergingEnabled || self.identifier != identifier
|
|
61
|
+
self.title = title; self.symbol = symbol; self.tint = tint; self.forceFallback = forceFallback
|
|
62
|
+
self.disabled = disabled; self.toolbar = toolbar; self.maxVisibleItems = maxVisibleItems
|
|
63
|
+
self.mergingEnabled = mergingEnabled; self.identifier = identifier
|
|
64
|
+
if lastJSON != itemsJSON {
|
|
65
|
+
lastJSON = itemsJSON
|
|
66
|
+
items = (try? JSONDecoder().decode([MenuItem].self, from: Data(itemsJSON.utf8))) ?? []
|
|
67
|
+
}
|
|
68
|
+
if structureChanged || appearanceChanged {
|
|
69
|
+
revision += 1
|
|
70
|
+
dismissMenus()
|
|
71
|
+
button.menu = UIMenu(children: elements(items, revision: revision))
|
|
72
|
+
needsBarUpdate = true
|
|
73
|
+
}
|
|
74
|
+
button.isHidden = toolbar; bar.isHidden = !toolbar
|
|
75
|
+
button.isEnabled = !disabled && !items.isEmpty
|
|
76
|
+
button.accessibilityLabel = label.isEmpty ? title : label
|
|
77
|
+
button.accessibilityHint = hint
|
|
78
|
+
button.accessibilityIdentifier = identifier
|
|
79
|
+
bar.accessibilityIdentifier = identifier
|
|
80
|
+
// Keep individual native controls exposed to accessibility.
|
|
81
|
+
bar.isAccessibilityElement = false
|
|
82
|
+
if appearanceChanged { updateAppearance() }
|
|
83
|
+
configured = true
|
|
84
|
+
setNeedsLayout()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private func enabledAction(_ id: String, in nodes: [MenuItem]) -> MenuItem? {
|
|
88
|
+
for item in nodes where item.disabled != true {
|
|
89
|
+
if item.isGroup {
|
|
90
|
+
if let found = enabledAction(id, in: item.items ?? []) { return found }
|
|
91
|
+
} else if item.id == id { return item }
|
|
92
|
+
}
|
|
93
|
+
return nil
|
|
94
|
+
}
|
|
95
|
+
private func action(_ item: MenuItem, revision: Int) -> UIAction {
|
|
96
|
+
var attributes: UIMenuElement.Attributes = []
|
|
97
|
+
if item.disabled == true || item.isGroup { attributes.insert(.disabled) }
|
|
98
|
+
if item.destructive == true { attributes.insert(.destructive) }
|
|
99
|
+
return UIAction(title: item.title, image: item.systemImage.flatMap { UIImage(systemName: $0) },
|
|
100
|
+
identifier: UIAction.Identifier(item.id), attributes: attributes, state: item.checked == true ? .on : .off) { [weak self] _ in
|
|
101
|
+
guard let self, self.window != nil, !self.disabled, self.revision == revision,
|
|
102
|
+
self.enabledAction(item.id, in: self.items) != nil else { return }
|
|
103
|
+
self.onAction?(item.id)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
private func elements(_ nodes: [MenuItem], revision: Int) -> [UIMenuElement] {
|
|
107
|
+
nodes.map { item in
|
|
108
|
+
if item.isGroup && item.disabled != true {
|
|
109
|
+
return UIMenu(title: item.title, image: item.systemImage.flatMap { UIImage(systemName: $0) },
|
|
110
|
+
identifier: UIMenu.Identifier(item.id), options: item.kind == "section" ? [.displayInline] : [],
|
|
111
|
+
children: elements(item.items ?? [], revision: revision))
|
|
112
|
+
}
|
|
113
|
+
return action(item, revision: revision)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
@objc private func updateAppearance() {
|
|
117
|
+
var configuration: UIButton.Configuration
|
|
118
|
+
let appearance = UIToolbarAppearance()
|
|
119
|
+
if #available(iOS 26.0, *), !forceFallback, !UIAccessibility.isReduceTransparencyEnabled {
|
|
120
|
+
configuration = .glass()
|
|
121
|
+
appearance.configureWithDefaultBackground()
|
|
122
|
+
bar.isTranslucent = true
|
|
123
|
+
} else {
|
|
124
|
+
configuration = .tinted()
|
|
125
|
+
appearance.configureWithOpaqueBackground()
|
|
126
|
+
bar.isTranslucent = false
|
|
127
|
+
}
|
|
128
|
+
configuration.title = title
|
|
129
|
+
configuration.image = symbol.isEmpty ? nil : UIImage(systemName: symbol)
|
|
130
|
+
configuration.imagePadding = 8
|
|
131
|
+
configuration.cornerStyle = .capsule
|
|
132
|
+
configuration.buttonSize = .large
|
|
133
|
+
configuration.baseForegroundColor = tint
|
|
134
|
+
button.configuration = configuration
|
|
135
|
+
bar.tintColor = tint
|
|
136
|
+
bar.standardAppearance = appearance
|
|
137
|
+
bar.scrollEdgeAppearance = appearance
|
|
138
|
+
bar.compactAppearance = appearance
|
|
139
|
+
needsBarUpdate = true
|
|
140
|
+
setNeedsLayout()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private func updateBar() {
|
|
144
|
+
guard toolbar, bounds.width > 0, needsBarUpdate || lastWidth != bounds.width else { return }
|
|
145
|
+
dismissMenus()
|
|
146
|
+
needsBarUpdate = false; lastWidth = bounds.width
|
|
147
|
+
let font = UIFont.preferredFont(forTextStyle: .body)
|
|
148
|
+
func width(_ item: MenuItem) -> CGFloat {
|
|
149
|
+
let hasSymbol = item.systemImage.flatMap { UIImage(systemName: $0) } != nil
|
|
150
|
+
return hasSymbol ? 52 : max(52, ceil((item.title as NSString).size(withAttributes: [.font: font]).width) + 40)
|
|
151
|
+
}
|
|
152
|
+
// Reserve overflow space whenever there are hidden items. On narrow layouts,
|
|
153
|
+
// move actions to a native menu instead of clipping or shrinking their labels.
|
|
154
|
+
var visible: [MenuItem] = []
|
|
155
|
+
var used: CGFloat = 0
|
|
156
|
+
let available = max(0, bounds.width - 32)
|
|
157
|
+
for item in items where item.placement != "overflow" {
|
|
158
|
+
let candidate = width(item)
|
|
159
|
+
let needsOverflow = visible.count + 1 < items.count
|
|
160
|
+
if visible.count < maxVisibleItems && used + candidate + (needsOverflow ? 60 : 0) <= available {
|
|
161
|
+
visible.append(item); used += candidate
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
let visibleIDs = Set(visible.map(\.id))
|
|
165
|
+
let overflow = items.filter { !visibleIDs.contains($0.id) }
|
|
166
|
+
var controls = visible.map { item -> UIBarButtonItem in
|
|
167
|
+
let control: UIBarButtonItem
|
|
168
|
+
if item.isGroup {
|
|
169
|
+
let menu = UIMenu(children: elements(item.items ?? [], revision: revision))
|
|
170
|
+
if let image = item.systemImage.flatMap({ UIImage(systemName: $0) }) {
|
|
171
|
+
control = UIBarButtonItem(image: image, menu: menu)
|
|
172
|
+
} else { control = UIBarButtonItem(title: item.title, menu: menu) }
|
|
173
|
+
} else {
|
|
174
|
+
control = UIBarButtonItem(primaryAction: action(item, revision: revision))
|
|
175
|
+
if control.image != nil { control.title = nil }
|
|
176
|
+
control.isSelected = item.checked == true
|
|
177
|
+
}
|
|
178
|
+
control.width = width(item)
|
|
179
|
+
control.isEnabled = !disabled && item.disabled != true
|
|
180
|
+
control.accessibilityLabel = item.title
|
|
181
|
+
control.accessibilityIdentifier = identifier.isEmpty ? item.id : "\(identifier)-\(item.id)"
|
|
182
|
+
if item.destructive == true { control.tintColor = .systemRed }
|
|
183
|
+
return control
|
|
184
|
+
}
|
|
185
|
+
if !overflow.isEmpty {
|
|
186
|
+
let more = UIBarButtonItem(image: UIImage(systemName: "ellipsis"), menu: UIMenu(children: elements(overflow, revision: revision)))
|
|
187
|
+
more.accessibilityLabel = "More actions"
|
|
188
|
+
more.accessibilityIdentifier = identifier.isEmpty ? "toolbar-overflow" : "\(identifier)-overflow"
|
|
189
|
+
more.isEnabled = !disabled
|
|
190
|
+
more.width = 52
|
|
191
|
+
controls.append(more)
|
|
192
|
+
}
|
|
193
|
+
for control in controls {
|
|
194
|
+
if #available(iOS 16.0, *) { control.preferredMenuElementOrder = .fixed }
|
|
195
|
+
if #available(iOS 26.0, *) {
|
|
196
|
+
control.sharesBackground = mergingEnabled
|
|
197
|
+
control.hidesSharedBackground = forceFallback || UIAccessibility.isReduceTransparencyEnabled
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
bar.setItems(controls, animated: false)
|
|
201
|
+
}
|
|
202
|
+
private func dismissMenus() {
|
|
203
|
+
func dismiss(in view: UIView) {
|
|
204
|
+
for interaction in view.interactions {
|
|
205
|
+
(interaction as? UIContextMenuInteraction)?.dismissMenu()
|
|
206
|
+
}
|
|
207
|
+
for child in view.subviews { dismiss(in: child) }
|
|
208
|
+
}
|
|
209
|
+
dismiss(in: self)
|
|
210
|
+
}
|
|
211
|
+
public override func layoutSubviews() {
|
|
212
|
+
super.layoutSubviews()
|
|
213
|
+
button.frame = bounds.insetBy(dx: 4, dy: min(8, bounds.height / 4))
|
|
214
|
+
bar.frame = bounds
|
|
215
|
+
updateBar()
|
|
216
|
+
}
|
|
217
|
+
public override func didMoveToWindow() {
|
|
218
|
+
super.didMoveToWindow()
|
|
219
|
+
if window == nil { dismissMenus() }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#import "ALGSegmentedComponentView.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 ALGSegmentedComponentView {
|
|
9
|
+
ALGSegmentedView *_segmented;
|
|
10
|
+
}
|
|
11
|
+
+ (ComponentDescriptorProvider)componentDescriptorProvider {
|
|
12
|
+
return concreteComponentDescriptorProvider<ALGSegmentedComponentDescriptor>();
|
|
13
|
+
}
|
|
14
|
+
- (instancetype)initWithFrame:(CGRect)frame {
|
|
15
|
+
if (self = [super initWithFrame:frame]) {
|
|
16
|
+
_props = std::make_shared<const ALGSegmentedProps>();
|
|
17
|
+
_segmented = [[ALGSegmentedView alloc] initWithFrame:CGRectZero];
|
|
18
|
+
self.contentView = _segmented;
|
|
19
|
+
__weak ALGSegmentedComponentView *weakSelf = self;
|
|
20
|
+
_segmented.onSelectionChange = ^(NSString *value) {
|
|
21
|
+
ALGSegmentedComponentView *strongSelf = weakSelf;
|
|
22
|
+
if (!strongSelf || !strongSelf->_eventEmitter) return;
|
|
23
|
+
std::static_pointer_cast<const ALGSegmentedEventEmitter>(strongSelf->_eventEmitter)->onSelectionChange({std::string(value.UTF8String)});
|
|
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 ALGSegmentedProps>(props);
|
|
30
|
+
NSString *scheme = p.colorScheme == ALGSegmentedColorScheme::Dark ? @"dark" : p.colorScheme == ALGSegmentedColorScheme::Light ? @"light" : @"system";
|
|
31
|
+
[_segmented configure:@(p.optionsJSON.c_str()) selectedValue:@(p.selectedValue.c_str()) disabled:p.disabled
|
|
32
|
+
tint:RCTUIColorFromSharedColor(p.glassTint) scheme:scheme label:@(p.controlLabel.c_str()) identifier:@(p.controlTestID.c_str())];
|
|
33
|
+
[super updateProps:props oldProps:oldProps];
|
|
34
|
+
}
|
|
35
|
+
+ (BOOL)shouldBeRecycled { return NO; }
|
|
36
|
+
@end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
private struct SegmentOption: Decodable {
|
|
4
|
+
let value: String
|
|
5
|
+
let label: String
|
|
6
|
+
let disabled: Bool?
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/// Native segmented-control interaction and selected-thumb rendering belong to UIKit.
|
|
10
|
+
@objc(ALGSegmentedView)
|
|
11
|
+
public final class ALGSegmentedView: UIView {
|
|
12
|
+
private let control = UISegmentedControl(items: [])
|
|
13
|
+
private var options: [SegmentOption] = []
|
|
14
|
+
private var lastJSON = ""
|
|
15
|
+
private var selectedValue = ""
|
|
16
|
+
@objc public var onSelectionChange: ((String) -> Void)?
|
|
17
|
+
|
|
18
|
+
public override init(frame: CGRect) {
|
|
19
|
+
super.init(frame: frame)
|
|
20
|
+
addSubview(control)
|
|
21
|
+
control.addTarget(self, action: #selector(selectionChanged), for: .valueChanged)
|
|
22
|
+
}
|
|
23
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
|
|
24
|
+
|
|
25
|
+
@objc public func configure(_ json: String, selectedValue: String, disabled: Bool,
|
|
26
|
+
tint: UIColor?, scheme: String, label: String, identifier: String) {
|
|
27
|
+
if lastJSON != json {
|
|
28
|
+
options = (try? JSONDecoder().decode([SegmentOption].self, from: Data(json.utf8))) ?? []
|
|
29
|
+
control.removeAllSegments()
|
|
30
|
+
for (index, option) in options.enumerated() {
|
|
31
|
+
control.insertSegment(withTitle: option.label, at: index, animated: false)
|
|
32
|
+
control.setEnabled(!(option.disabled ?? false), forSegmentAt: index)
|
|
33
|
+
}
|
|
34
|
+
lastJSON = json
|
|
35
|
+
}
|
|
36
|
+
self.selectedValue = selectedValue
|
|
37
|
+
control.selectedSegmentIndex = options.firstIndex { $0.value == selectedValue } ?? UISegmentedControl.noSegment
|
|
38
|
+
control.isEnabled = !disabled
|
|
39
|
+
control.selectedSegmentTintColor = tint
|
|
40
|
+
control.accessibilityLabel = label.isEmpty ? nil : label
|
|
41
|
+
control.accessibilityIdentifier = identifier
|
|
42
|
+
overrideUserInterfaceStyle = scheme == "dark" ? .dark : scheme == "light" ? .light : .unspecified
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@objc private func selectionChanged() {
|
|
46
|
+
let index = control.selectedSegmentIndex
|
|
47
|
+
guard control.isEnabled, options.indices.contains(index), !(options[index].disabled ?? false) else { return }
|
|
48
|
+
let next = options[index].value
|
|
49
|
+
// React remains authoritative, including when a callback declines a change.
|
|
50
|
+
control.selectedSegmentIndex = options.firstIndex { $0.value == selectedValue } ?? UISegmentedControl.noSegment
|
|
51
|
+
if next != selectedValue { onSelectionChange?(next) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public override func layoutSubviews() {
|
|
55
|
+
super.layoutSubviews()
|
|
56
|
+
control.frame = bounds.insetBy(dx: 0, dy: max(0, (bounds.height - 44) / 2))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#import "ALGSliderComponentView.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 ALGSliderComponentView {
|
|
9
|
+
ALGSliderView *_slider;
|
|
10
|
+
}
|
|
11
|
+
+ (ComponentDescriptorProvider)componentDescriptorProvider {
|
|
12
|
+
return concreteComponentDescriptorProvider<ALGSliderComponentDescriptor>();
|
|
13
|
+
}
|
|
14
|
+
- (instancetype)initWithFrame:(CGRect)frame {
|
|
15
|
+
if (self = [super initWithFrame:frame]) {
|
|
16
|
+
_props = std::make_shared<const ALGSliderProps>();
|
|
17
|
+
_slider = [[ALGSliderView alloc] initWithFrame:CGRectZero];
|
|
18
|
+
self.contentView = _slider;
|
|
19
|
+
__weak ALGSliderComponentView *weakSelf = self;
|
|
20
|
+
_slider.onChange = ^(double value) {
|
|
21
|
+
ALGSliderComponentView *self = weakSelf;
|
|
22
|
+
if (self && self->_eventEmitter) std::static_pointer_cast<const ALGSliderEventEmitter>(self->_eventEmitter)->onSliderChange({value});
|
|
23
|
+
};
|
|
24
|
+
_slider.onStart = ^(double value) {
|
|
25
|
+
ALGSliderComponentView *self = weakSelf;
|
|
26
|
+
if (self && self->_eventEmitter) std::static_pointer_cast<const ALGSliderEventEmitter>(self->_eventEmitter)->onSliderStart({value});
|
|
27
|
+
};
|
|
28
|
+
_slider.onComplete = ^(double value) {
|
|
29
|
+
ALGSliderComponentView *self = weakSelf;
|
|
30
|
+
if (self && self->_eventEmitter) std::static_pointer_cast<const ALGSliderEventEmitter>(self->_eventEmitter)->onSliderComplete({value});
|
|
31
|
+
};
|
|
32
|
+
_slider.onCancel = ^(double value) {
|
|
33
|
+
ALGSliderComponentView *self = weakSelf;
|
|
34
|
+
if (self && self->_eventEmitter) std::static_pointer_cast<const ALGSliderEventEmitter>(self->_eventEmitter)->onSliderCancel({value});
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return self;
|
|
38
|
+
}
|
|
39
|
+
- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps {
|
|
40
|
+
const auto &p = *std::static_pointer_cast<const ALGSliderProps>(props);
|
|
41
|
+
[_slider configure:p.value minimum:p.minimumValue maximum:p.maximumValue step:p.step disabled:p.disabled
|
|
42
|
+
tint:RCTUIColorFromSharedColor(p.glassTint) label:@(p.controlLabel.c_str())
|
|
43
|
+
hint:@(p.controlHint.c_str()) identifier:@(p.controlTestID.c_str())];
|
|
44
|
+
[super updateProps:props oldProps:oldProps];
|
|
45
|
+
}
|
|
46
|
+
+ (BOOL)shouldBeRecycled { return NO; }
|
|
47
|
+
@end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
private final class AccessibleGlassSlider: UISlider {
|
|
4
|
+
var adjust: ((Double) -> Void)?
|
|
5
|
+
override func accessibilityIncrement() { adjust?(1) }
|
|
6
|
+
override func accessibilityDecrement() { adjust?(-1) }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
@objc(ALGSliderView)
|
|
10
|
+
public final class ALGSliderView: UIView {
|
|
11
|
+
private let slider = AccessibleGlassSlider()
|
|
12
|
+
private var minimum = 0.0
|
|
13
|
+
private var maximum = 1.0
|
|
14
|
+
private var step = 0.0
|
|
15
|
+
private var controlledValue = 0.0
|
|
16
|
+
private var dragging = false
|
|
17
|
+
private var lastEmitted: Double?
|
|
18
|
+
@objc public var onChange: ((Double) -> Void)?
|
|
19
|
+
@objc public var onStart: ((Double) -> Void)?
|
|
20
|
+
@objc public var onComplete: ((Double) -> Void)?
|
|
21
|
+
@objc public var onCancel: ((Double) -> Void)?
|
|
22
|
+
|
|
23
|
+
public override init(frame: CGRect) {
|
|
24
|
+
super.init(frame: frame)
|
|
25
|
+
addSubview(slider)
|
|
26
|
+
slider.addTarget(self, action: #selector(start), for: .touchDown)
|
|
27
|
+
slider.addTarget(self, action: #selector(change), for: .valueChanged)
|
|
28
|
+
slider.addTarget(self, action: #selector(finish), for: [.touchUpInside, .touchUpOutside])
|
|
29
|
+
slider.addTarget(self, action: #selector(cancel), for: .touchCancel)
|
|
30
|
+
slider.adjust = { [weak self] direction in
|
|
31
|
+
guard let self, self.slider.isEnabled else { return }
|
|
32
|
+
self.onStart?(self.controlledValue)
|
|
33
|
+
let increment = self.step > 0 ? self.step : (self.maximum - self.minimum) / 20
|
|
34
|
+
let value = self.normalized(self.controlledValue + direction * increment)
|
|
35
|
+
self.position(value)
|
|
36
|
+
if value != self.controlledValue { self.onChange?(value) }
|
|
37
|
+
self.onComplete?(value)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
|
|
41
|
+
|
|
42
|
+
@objc public func configure(_ value: Double, minimum: Double, maximum: Double, step: Double,
|
|
43
|
+
disabled: Bool, tint: UIColor?, label: String, hint: String, identifier: String) {
|
|
44
|
+
guard minimum.isFinite, maximum.isFinite, value.isFinite, step.isFinite,
|
|
45
|
+
(maximum - minimum).isFinite, maximum > minimum, step >= 0, step <= maximum - minimum else { return }
|
|
46
|
+
if dragging && (disabled || self.minimum != minimum || self.maximum != maximum || self.step != step) { cancel() }
|
|
47
|
+
self.minimum = minimum
|
|
48
|
+
self.maximum = maximum
|
|
49
|
+
self.step = step
|
|
50
|
+
controlledValue = normalized(value)
|
|
51
|
+
slider.isEnabled = !disabled
|
|
52
|
+
slider.minimumTrackTintColor = tint
|
|
53
|
+
slider.accessibilityLabel = label.isEmpty ? nil : label
|
|
54
|
+
slider.accessibilityHint = hint.isEmpty ? nil : hint
|
|
55
|
+
slider.accessibilityIdentifier = identifier
|
|
56
|
+
// UIKit keeps ownership of the thumb during tracking, avoiding JS echo jitter.
|
|
57
|
+
if !dragging { position(controlledValue) }
|
|
58
|
+
}
|
|
59
|
+
private func normalized(_ value: Double) -> Double {
|
|
60
|
+
let clamped = min(maximum, max(minimum, value))
|
|
61
|
+
if clamped == maximum || step == 0 { return clamped }
|
|
62
|
+
return min(maximum, max(minimum, minimum + ((clamped - minimum) / step).rounded() * step))
|
|
63
|
+
}
|
|
64
|
+
private var current: Double { normalized(minimum + Double(slider.value) * (maximum - minimum)) }
|
|
65
|
+
private func position(_ value: Double) {
|
|
66
|
+
slider.value = Float((value - minimum) / (maximum - minimum))
|
|
67
|
+
slider.accessibilityValue = String(format: "%.6g", value)
|
|
68
|
+
}
|
|
69
|
+
@objc private func start() {
|
|
70
|
+
guard slider.isEnabled, !dragging else { return }
|
|
71
|
+
dragging = true
|
|
72
|
+
lastEmitted = current
|
|
73
|
+
onStart?(current)
|
|
74
|
+
}
|
|
75
|
+
@objc private func change() {
|
|
76
|
+
guard slider.isEnabled else { return }
|
|
77
|
+
let value = current
|
|
78
|
+
// Snap only for stepped sliders; preserve UIKit momentum for continuous tracking.
|
|
79
|
+
if step > 0 { position(value) }
|
|
80
|
+
else { slider.accessibilityValue = String(format: "%.6g", value) }
|
|
81
|
+
if value != lastEmitted { lastEmitted = value; onChange?(value) }
|
|
82
|
+
if !dragging { onComplete?(value) }
|
|
83
|
+
}
|
|
84
|
+
@objc private func finish() {
|
|
85
|
+
guard dragging else { return }
|
|
86
|
+
change()
|
|
87
|
+
let value = current
|
|
88
|
+
dragging = false
|
|
89
|
+
onComplete?(value)
|
|
90
|
+
}
|
|
91
|
+
@objc private func cancel() {
|
|
92
|
+
guard dragging else { return }
|
|
93
|
+
dragging = false
|
|
94
|
+
position(controlledValue)
|
|
95
|
+
onCancel?(controlledValue)
|
|
96
|
+
}
|
|
97
|
+
public override func didMoveToWindow() {
|
|
98
|
+
super.didMoveToWindow()
|
|
99
|
+
if window == nil { cancel() }
|
|
100
|
+
}
|
|
101
|
+
public override func layoutSubviews() { super.layoutSubviews(); slider.frame = bounds }
|
|
102
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#import "ALGSurfaceComponentView.h"
|
|
2
|
+
#import <react/renderer/components/AdaptiveLiquidGlassSpec/ComponentDescriptors.h>
|
|
3
|
+
#import <react/renderer/components/AdaptiveLiquidGlassSpec/Props.h>
|
|
4
|
+
#import <React/RCTConversions.h>
|
|
5
|
+
#import "AdaptiveLiquidGlass-Swift.h"
|
|
6
|
+
|
|
7
|
+
using namespace facebook::react;
|
|
8
|
+
|
|
9
|
+
@implementation ALGSurfaceComponentView {
|
|
10
|
+
ALGSurfaceView *_glass;
|
|
11
|
+
}
|
|
12
|
+
+ (ComponentDescriptorProvider)componentDescriptorProvider {
|
|
13
|
+
return concreteComponentDescriptorProvider<ALGSurfaceComponentDescriptor>();
|
|
14
|
+
}
|
|
15
|
+
- (instancetype)initWithFrame:(CGRect)frame {
|
|
16
|
+
if (self = [super initWithFrame:frame]) {
|
|
17
|
+
_props = std::make_shared<const ALGSurfaceProps>();
|
|
18
|
+
_glass = [[ALGSurfaceView alloc] initWithFrame:CGRectZero];
|
|
19
|
+
self.contentView = _glass;
|
|
20
|
+
}
|
|
21
|
+
return self;
|
|
22
|
+
}
|
|
23
|
+
- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps {
|
|
24
|
+
const auto &p = *std::static_pointer_cast<const ALGSurfaceProps>(props);
|
|
25
|
+
NSString *material = p.material == ALGSurfaceMaterial::Clear ? @"clear" : p.material == ALGSurfaceMaterial::None ? @"none" : @"regular";
|
|
26
|
+
NSString *scheme = p.colorScheme == ALGSurfaceColorScheme::Dark ? @"dark" : p.colorScheme == ALGSurfaceColorScheme::Light ? @"light" : @"system";
|
|
27
|
+
[_glass configure:material interactive:p.interactive tint:RCTUIColorFromSharedColor(p.glassTint)
|
|
28
|
+
radius:p.glassRadius container:p.container mergingEnabled:p.mergingEnabled spacing:p.spacing duration:p.animationDuration scheme:scheme];
|
|
29
|
+
[super updateProps:props oldProps:oldProps];
|
|
30
|
+
}
|
|
31
|
+
- (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)child index:(NSInteger)index {
|
|
32
|
+
[_glass.reactContentView insertSubview:child atIndex:index];
|
|
33
|
+
}
|
|
34
|
+
- (void)layoutSubviews {
|
|
35
|
+
[super layoutSubviews];
|
|
36
|
+
// Fabric's default contentView frame excludes padding. React children already
|
|
37
|
+
// carry Yoga's padding offsets, so the material must occupy the full bounds.
|
|
38
|
+
_glass.frame = self.bounds;
|
|
39
|
+
}
|
|
40
|
+
- (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)child index:(NSInteger)index {
|
|
41
|
+
[child removeFromSuperview];
|
|
42
|
+
}
|
|
43
|
+
// UIKit effects retain state; a fresh instance also avoids stale effects on remount.
|
|
44
|
+
+ (BOOL)shouldBeRecycled { return NO; }
|
|
45
|
+
@end
|