@blotch/angular-lynx-native 22.2.3 → 22.2.5

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.
@@ -0,0 +1,149 @@
1
+ import Lynx
2
+ import UIKit
3
+ import XElement
4
+
5
+ /// XElement's `<blur-view>`, with what the shell adds to it:
6
+ ///
7
+ /// - `blur-material`, for UIKit's adaptive blur materials (iOS 13 and later).
8
+ /// Lynx's blur-view has only the `blur-effect` styles from before iOS 13
9
+ /// (`light`, `dark` and `extra-light`): one weight, in one appearance. The
10
+ /// bars, menus and alerts of iOS 13 to 25 drew on materials in a weight for
11
+ /// each role, which follow light and dark mode, here the blur-view's
12
+ /// `ios-user-interface-style`. The glass components draw their frosted
13
+ /// design, before iOS 26, on them.
14
+ /// - Liquid Glass that comes and goes inside a `glass-container` animates, as
15
+ /// UIKit's does when the change is animated: it materializes when it's
16
+ /// added or shown, dissolves when it's removed or hidden, and eases between
17
+ /// effects. Lynx's blur-view sets its effect at once, so glass popped in and
18
+ /// out. See GlassTransitions.
19
+ /// - `glass-effect-id`, which pairs two glass shapes across a change, as
20
+ /// SwiftUI's `glassEffectID` does: when one with an id goes and one with the
21
+ /// same id comes in the same update, they're one piece of glass changing
22
+ /// shape. A container morphs the first into the second; elsewhere, such as
23
+ /// from a button to the menu it opens, the second takes over from the
24
+ /// first, with its content fading out.
25
+ ///
26
+ /// `blur-material` is `ultra-thin`, `thin`, `regular`, `thick` or `chrome`
27
+ /// (`systemUltraThinMaterial` to `systemThickMaterial`, and the bars'
28
+ /// `systemChromeMaterial`). While it's set, it replaces what `blur-effect` and
29
+ /// `blur-radius` draw, with Apple's own blur. A host without this element,
30
+ /// such as Lynx Explorer, ignores both attributes: it draws the `blur-effect`,
31
+ /// so the components set both, and its glass pops in and out as before.
32
+ ///
33
+ /// XElementRegistry registers this class for `blur-view`, and everything else
34
+ /// is LynxUIBlurView's.
35
+ final class BlurView: LynxUIBlurView {
36
+ private static let materials: [String: UIBlurEffect.Style] = [
37
+ "ultra-thin": .systemUltraThinMaterial,
38
+ "thin": .systemThinMaterial,
39
+ "regular": .systemMaterial,
40
+ "thick": .systemThickMaterial,
41
+ "chrome": .systemChromeMaterial,
42
+ ]
43
+
44
+ /// The attributes this class adds, as Lynx finds a Swift element's: an
45
+ /// Objective-C one declares them with LYNX_PROP_SETTER. Lynx looks for an
46
+ /// attribute here first, then in LynxUIBlurView's.
47
+ @objc static func propSetterLookUp() -> [[String]] {
48
+ [
49
+ ["blur-material", NSStringFromSelector(#selector(setBlurMaterial(_:requestReset:)))],
50
+ ["glass-effect-id", NSStringFromSelector(#selector(setGlassEffectId(_:requestReset:)))],
51
+ ]
52
+ }
53
+
54
+ override func createView() -> UIVisualEffectView? {
55
+ // LynxUIBlurView's own sets up its state too. Its view, a plain
56
+ // UIVisualEffectView, is replaced by one that can hold the material and
57
+ // tell its transitions apart from Lynx's changes.
58
+ _ = super.createView()
59
+ return BlurEffectView()
60
+ }
61
+
62
+ private var effectView: BlurEffectView? { view() as? BlurEffectView }
63
+
64
+ /// Lynx passes the value as it is (an `id`), and `NSNull` with
65
+ /// `requestReset` when the attribute is removed.
66
+ @objc func setBlurMaterial(_ value: Any?, requestReset: Bool) {
67
+ let material = requestReset ? nil : (value as? String).flatMap { Self.materials[$0] }
68
+ effectView?.material = material
69
+ }
70
+
71
+ @objc func setGlassEffectId(_ value: Any?, requestReset: Bool) {
72
+ effectView?.glassEffectId = requestReset ? nil : value as? String
73
+ }
74
+
75
+ /// LynxUIBlurView sets a new effect here when `blur-effect` or a glass
76
+ /// attribute changed: in a container, the change animates.
77
+ override func propsDidUpdate() {
78
+ let drawn = effectView?.lynxEffect
79
+ super.propsDidUpdate()
80
+ if #available(iOS 26.0, *), let effectView, effectView.lynxEffect !== drawn {
81
+ GlassTransitions.shared.effectChanged(effectView, from: drawn)
82
+ }
83
+ }
84
+ }
85
+
86
+ /// A blur-view's view. It draws `material` while one is set, and otherwise
87
+ /// the effect LynxUIBlurView gives it. LynxUIBlurView sets its effect
88
+ /// whenever `blur-effect`, `blur-radius` or the glass attributes change, so
89
+ /// the view keeps that effect aside, to restore when the material goes and to
90
+ /// animate the glass to.
91
+ final class BlurEffectView: UIVisualEffectView {
92
+ var material: UIBlurEffect.Style? {
93
+ didSet {
94
+ guard material != oldValue else { return }
95
+ // Cleared first, as LynxUIBlurView clears it: UIKit reads a blur
96
+ // effect's settings (Lynx's radius) only when its style changes.
97
+ super.effect = nil
98
+ super.effect = material.map { UIBlurEffect(style: $0) } ?? lynxEffect
99
+ }
100
+ }
101
+
102
+ /// The `glass-effect-id` attribute.
103
+ var glassEffectId: String?
104
+
105
+ /// The effect Lynx last set, which a transition may not be drawing yet.
106
+ private(set) var lynxEffect: UIVisualEffect?
107
+
108
+ override var effect: UIVisualEffect? {
109
+ get { super.effect }
110
+ set {
111
+ lynxEffect = newValue
112
+ if material == nil {
113
+ super.effect = newValue
114
+ }
115
+ }
116
+ }
117
+
118
+ /// Draws `effect` while leaving the one Lynx set alone: for a transition,
119
+ /// which starts from nothing or from the effect Lynx just replaced.
120
+ func draw(_ effect: UIVisualEffect?) {
121
+ super.effect = effect
122
+ }
123
+
124
+ /// UIKit calls this as Lynx inserts the view, or the element around it, and
125
+ /// as it removes one, while the view is still on screen, and as an overlay
126
+ /// shows or hides with the view in it. LynxUI has a hook of its own, but its
127
+ /// header promises a window that's nil on removal, which Swift can't take.
128
+ override func willMove(toWindow newWindow: UIWindow?) {
129
+ super.willMove(toWindow: newWindow)
130
+ if #available(iOS 26.0, *) {
131
+ GlassTransitions.shared.view(self, willMoveFrom: window, to: newWindow)
132
+ }
133
+ }
134
+
135
+ /// Lynx's `visibility`. Hiding takes the glass off screen at once, so the
136
+ /// transition needs to know before, while it can still see it.
137
+ override var isHidden: Bool {
138
+ willSet {
139
+ if #available(iOS 26.0, *), newValue, !isHidden {
140
+ GlassTransitions.shared.viewWillHide(self)
141
+ }
142
+ }
143
+ didSet {
144
+ if #available(iOS 26.0, *), !isHidden, oldValue {
145
+ GlassTransitions.shared.viewDidShow(self)
146
+ }
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,408 @@
1
+ import UIKit
2
+
3
+ /// Animates Liquid Glass coming and going, as UIKit animates its own glass
4
+ /// and SwiftUI's GlassEffectContainer does:
5
+ ///
6
+ /// - In a `glass-container`, a shape that's added, or whose `visibility` turns
7
+ /// visible, materializes: its effect animates from nothing to the glass, and
8
+ /// its content fades in. One that's removed, or hidden, dissolves in place.
9
+ /// - A shape that goes and one with the same `glass-effect-id` that comes, in
10
+ /// the same update, are one piece of glass changing shape, as SwiftUI's
11
+ /// `glassEffectID` pairs them. In the same container, the new one grows out
12
+ /// of the old one's frame and corners while a copy of the old one flows into
13
+ /// it and dissolves, and the container merges the two as they move. Across
14
+ /// containers or windows, such as a button and the menu it opens in an
15
+ /// overlay, the new one takes over where it is, from the old one's corners,
16
+ /// with the old content fading out over it: a component that grows one out
17
+ /// of the other moves it there itself, as the menus and popovers do.
18
+ /// - In a container, a shape whose effect changes (its tint, its style, glass
19
+ /// turned off) eases to the new one.
20
+ ///
21
+ /// Lynx's blur-view sets its effect directly, outside any animation, so all
22
+ /// of these happened in one frame. Only glass in a container that was already
23
+ /// on screen materializes or dissolves: a screen that appears with its glass,
24
+ /// such as a new page, draws it at once, as UIKit does. Under Reduce Motion
25
+ /// nothing moves: a pair cross-fades in place.
26
+ ///
27
+ /// Lynx tells the shell about each change while it applies an update (see
28
+ /// BlurView), before it has laid out the elements it added, and removes an
29
+ /// element by detaching it, so a removal and a move look alike until the
30
+ /// update ends. The coordinator writes each change down and settles them all
31
+ /// at the end of the run loop pass, before Core Animation commits it: what
32
+ /// was on screen and isn't anymore went, what wasn't and is came, and an
33
+ /// element that left and came back was moved.
34
+ @available(iOS 26.0, *)
35
+ final class GlassTransitions {
36
+ static let shared = GlassTransitions()
37
+
38
+ /// How long a shape takes to materialize or dissolve, and its content to
39
+ /// fade, in seconds: UIKit's own default animation length.
40
+ private static let fadeDuration: TimeInterval = 0.35
41
+ /// The spring a pair moves on: a little longer than a fade, with a touch of
42
+ /// bounce, as SwiftUI's default animation.
43
+ private static let morphDuration: TimeInterval = 0.5
44
+ private static let morphBounce: CGFloat = 0.15
45
+
46
+ /// What a view looked like when it went: enough to draw a copy of it (a
47
+ /// ghost) where it was, since Lynx has already taken the view itself away.
48
+ private struct Departure {
49
+ /// The container it was in, if it was in one that stays on screen.
50
+ weak var container: BlurEffectView?
51
+ let effect: UIVisualEffect
52
+ /// Its frame on the screen, for a ghost in its container.
53
+ let frame: CGRect
54
+ let cornerRadius: CGFloat
55
+ let cornerCurve: CALayerCornerCurve
56
+ let content: UIView?
57
+ let id: String?
58
+ }
59
+
60
+ /// A view that changed during this run loop pass.
61
+ private final class Change {
62
+ weak var view: BlurEffectView?
63
+ /// Whether it was drawing when the pass began.
64
+ let wasShown: Bool
65
+ /// Whether its last change took it off screen.
66
+ var leaving: Bool
67
+ /// Its look as it went, if it was glass that can still be seen going.
68
+ var departure: Departure?
69
+
70
+ init(view: BlurEffectView, wasShown: Bool, leaving: Bool) {
71
+ self.view = view
72
+ self.wasShown = wasShown
73
+ self.leaving = leaving
74
+ }
75
+ }
76
+
77
+ private var changes: [ObjectIdentifier: Change] = [:]
78
+ private var observer: CFRunLoopObserver?
79
+
80
+ // MARK: - Changes, as Lynx applies them
81
+
82
+ /// The view joins a window, leaves one (`to` is nil), or moves from one to
83
+ /// another, as an element a context menu lifts into its overlay does.
84
+ func view(_ view: BlurEffectView, willMoveFrom from: UIWindow?, to: UIWindow?) {
85
+ if to == nil {
86
+ note(view, wasShown: !view.isHidden, leaving: true)
87
+ } else {
88
+ note(view, wasShown: from != nil && !view.isHidden, leaving: false)
89
+ }
90
+ }
91
+
92
+ func viewWillHide(_ view: BlurEffectView) {
93
+ note(view, wasShown: view.window != nil, leaving: true)
94
+ }
95
+
96
+ func viewDidShow(_ view: BlurEffectView) {
97
+ note(view, wasShown: false, leaving: false)
98
+ }
99
+
100
+ /// Lynx gave the view a new effect: one in a container that's on screen,
101
+ /// and that isn't coming or going in this pass, eases from the old one.
102
+ func effectChanged(_ view: BlurEffectView, from old: UIVisualEffect?) {
103
+ guard changes[ObjectIdentifier(view)] == nil, !view.isHidden,
104
+ view.window != nil, let container = container(of: view), isSettled(container)
105
+ else { return }
106
+ let new = view.lynxEffect
107
+ UIView.performWithoutAnimation { view.draw(old) }
108
+ UIView.animate(
109
+ withDuration: Self.fadeDuration, delay: 0, options: [.allowUserInteraction, .curveEaseInOut]
110
+ ) {
111
+ view.draw(new)
112
+ }
113
+ }
114
+
115
+ /// Writes the change down. The first change in a pass says whether the
116
+ /// view was on screen when it began; a view that leaves is copied then,
117
+ /// while it still draws.
118
+ private func note(_ view: BlurEffectView, wasShown: Bool, leaving: Bool) {
119
+ let key = ObjectIdentifier(view)
120
+ let change: Change
121
+ if let noted = changes[key] {
122
+ change = noted
123
+ change.leaving = leaving
124
+ } else {
125
+ change = Change(view: view, wasShown: wasShown, leaving: leaving)
126
+ changes[key] = change
127
+ }
128
+ if leaving, change.wasShown, change.departure == nil {
129
+ change.departure = departure(of: view)
130
+ }
131
+ scheduleSettle()
132
+ }
133
+
134
+ /// The view's look as it goes, if anything could show it going: a
135
+ /// container that stays, or a shape with its id coming somewhere else.
136
+ private func departure(of view: BlurEffectView) -> Departure? {
137
+ guard view.lynxEffect is UIGlassEffect, let effect = view.effect,
138
+ let screen = view.window?.screen
139
+ else { return nil }
140
+ let container = container(of: view).flatMap { container in
141
+ isSettled(container) && changes[ObjectIdentifier(container)]?.leaving != true
142
+ ? container : nil
143
+ }
144
+ guard container != nil || view.glassEffectId != nil else { return nil }
145
+ return Departure(
146
+ container: container,
147
+ effect: effect,
148
+ frame: view.convert(view.bounds, to: screen.coordinateSpace),
149
+ // As last drawn: an overlay that hides with its glass lays it out at
150
+ // nothing in the same update, and Lynx has applied that by now.
151
+ cornerRadius: (view.layer.presentation() ?? view.layer).cornerRadius,
152
+ cornerCurve: view.layer.cornerCurve,
153
+ content: view.contentView.snapshotView(afterScreenUpdates: false),
154
+ id: view.glassEffectId)
155
+ }
156
+
157
+ // MARK: - The end of the pass
158
+
159
+ /// Settles the changes at the end of this run loop pass, after Lynx has
160
+ /// applied the whole update and before Core Animation commits it (its
161
+ /// observer has order 2,000,000), so the first frame drawn already shows
162
+ /// the transition's start.
163
+ private func scheduleSettle() {
164
+ guard observer == nil else { return }
165
+ let observer = CFRunLoopObserverCreateWithHandler(
166
+ kCFAllocatorDefault,
167
+ CFRunLoopActivity.beforeWaiting.rawValue | CFRunLoopActivity.exit.rawValue,
168
+ false, 1_000_000
169
+ ) { [weak self] _, _ in
170
+ self?.settle()
171
+ }
172
+ CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes)
173
+ self.observer = observer
174
+ }
175
+
176
+ private func settle() {
177
+ if let observer { CFRunLoopObserverInvalidate(observer) }
178
+ observer = nil
179
+ let changes = Array(self.changes.values)
180
+ self.changes = [:]
181
+
182
+ var departures: [Departure] = []
183
+ var arrivals: [(view: BlurEffectView, container: BlurEffectView?)] = []
184
+ for change in changes {
185
+ let view = change.view
186
+ let shown = view.map { $0.window != nil && !$0.isHidden } ?? false
187
+ if change.wasShown, !shown, let departure = change.departure {
188
+ departures.append(departure)
189
+ } else if !change.wasShown, shown, let view, view.lynxEffect is UIGlassEffect {
190
+ // Glass in a container that was already on screen materializes, and
191
+ // glass with an id can take over from a shape that went.
192
+ let container = container(of: view).flatMap {
193
+ isSettled($0, in: changes) ? $0 : nil
194
+ }
195
+ if container != nil || view.glassEffectId != nil {
196
+ arrivals.append((view, container))
197
+ }
198
+ }
199
+ }
200
+
201
+ // Pair each shape that came with one that went under the same id, from
202
+ // its own container first.
203
+ for (view, container) in arrivals {
204
+ let index = view.glassEffectId.flatMap { id in
205
+ departures.firstIndex { $0.id == id && $0.container === container && container != nil }
206
+ ?? departures.firstIndex { $0.id == id }
207
+ }
208
+ if let index {
209
+ let departure = departures.remove(at: index)
210
+ if let container, departure.container === container {
211
+ morph(departure, into: view, in: container)
212
+ } else {
213
+ takeOver(from: departure, view)
214
+ }
215
+ } else if container != nil {
216
+ materialize(view)
217
+ }
218
+ }
219
+ for departure in departures where departure.container?.window != nil {
220
+ dissolve(departure)
221
+ }
222
+ }
223
+
224
+ // MARK: - Transitions
225
+
226
+ private func materialize(_ view: BlurEffectView) {
227
+ let effect = view.lynxEffect
228
+ UIView.performWithoutAnimation {
229
+ view.draw(nil)
230
+ view.contentView.alpha = 0
231
+ }
232
+ UIView.animate(
233
+ withDuration: Self.fadeDuration, delay: 0, options: [.allowUserInteraction, .curveEaseOut]
234
+ ) {
235
+ view.draw(effect)
236
+ view.contentView.alpha = 1
237
+ }
238
+ }
239
+
240
+ /// A copy of the shape that went, where it was, which dissolves.
241
+ private func dissolve(_ departure: Departure) {
242
+ guard let ghost = ghost(of: departure) else { return }
243
+ UIView.animate(withDuration: Self.fadeDuration, delay: 0, options: [.curveEaseIn]) {
244
+ ghost.effect = nil
245
+ ghost.contentView.alpha = 0
246
+ } completion: { _ in
247
+ ghost.removeFromSuperview()
248
+ }
249
+ }
250
+
251
+ /// `view` grows out of the shape that went, in the same container, and the
252
+ /// shape that went, as a ghost, flows into it while it dissolves. The
253
+ /// container draws the two as one glass shape that changes.
254
+ private func morph(_ departure: Departure, into view: BlurEffectView, in container: BlurEffectView) {
255
+ guard !UIAccessibility.isReduceMotionEnabled, let superview = view.superview,
256
+ let screen = view.window?.screen.coordinateSpace,
257
+ CATransform3DIsIdentity(view.layer.transform), let ghost = ghost(of: departure)
258
+ else {
259
+ // A cross-fade: no movement under Reduce Motion, nor for a view with a
260
+ // transform, whose frame isn't its own to animate.
261
+ materialize(view)
262
+ dissolve(departure)
263
+ return
264
+ }
265
+ let frame = view.frame
266
+ let cornerRadius = view.layer.cornerRadius
267
+ let target = superview.convert(frame, to: container.contentView)
268
+ UIView.performWithoutAnimation {
269
+ view.frame = screen.convert(departure.frame, to: superview)
270
+ view.layer.cornerRadius = departure.cornerRadius
271
+ view.contentView.alpha = 0
272
+ }
273
+ UIView.animate(
274
+ springDuration: Self.morphDuration, bounce: Self.morphBounce, initialSpringVelocity: 0,
275
+ delay: 0, options: [.allowUserInteraction]
276
+ ) {
277
+ view.frame = frame
278
+ view.layer.cornerRadius = cornerRadius
279
+ ghost.frame = target
280
+ ghost.layer.cornerRadius = cornerRadius
281
+ }
282
+ fadeContent(of: view)
283
+ // The old content goes quickly, and the old glass dissolves into the new
284
+ // as they move.
285
+ UIView.animate(withDuration: Self.fadeDuration / 2, delay: 0, options: [.curveEaseIn]) {
286
+ ghost.contentView.alpha = 0
287
+ }
288
+ UIView.animate(withDuration: Self.morphDuration, delay: 0, options: [.curveEaseInOut]) {
289
+ ghost.effect = nil
290
+ } completion: { _ in
291
+ ghost.removeFromSuperview()
292
+ }
293
+ }
294
+
295
+ /// `view` takes over from the shape that went somewhere else, another
296
+ /// container or window, where it is: it starts from that shape's corners,
297
+ /// and the old content fades out over it as its own fades in. The two
298
+ /// can't merge, and the old one's frame isn't known by then (an overlay
299
+ /// that hides with its glass lays it out at nothing in the same update), so
300
+ /// a component that grows one out of the other places it there itself, as
301
+ /// the menus and popovers do. The old glass isn't drawn again: the new
302
+ /// continues it.
303
+ private func takeOver(from departure: Departure, _ view: BlurEffectView) {
304
+ guard !UIAccessibility.isReduceMotionEnabled, let superview = view.superview else {
305
+ materialize(view)
306
+ return
307
+ }
308
+ let cornerRadius = view.layer.cornerRadius
309
+ // Corners rounder than half the shape draw as half of it, a capsule.
310
+ let startRadius = min(departure.cornerRadius, min(view.bounds.width, view.bounds.height) / 2)
311
+ // The old content goes over the new shape rather than in it: a visual
312
+ // effect view's content view is the only place for subviews, and the new
313
+ // content fading in there would take it along.
314
+ let content = departure.content
315
+ UIView.performWithoutAnimation {
316
+ view.layer.cornerRadius = startRadius
317
+ view.contentView.alpha = 0
318
+ if let content {
319
+ content.center = view.center
320
+ content.isUserInteractionEnabled = false
321
+ superview.insertSubview(content, aboveSubview: view)
322
+ }
323
+ }
324
+ UIView.animate(
325
+ springDuration: Self.morphDuration, bounce: Self.morphBounce, initialSpringVelocity: 0,
326
+ delay: 0, options: [.allowUserInteraction]
327
+ ) {
328
+ view.layer.cornerRadius = cornerRadius
329
+ }
330
+ // A cross-fade, at once: the shape stays where it is, so the new content
331
+ // has room from the start, and a menu closing into its button has faded
332
+ // its own already.
333
+ fadeContent(of: view, after: 0)
334
+ if let content {
335
+ UIView.animate(withDuration: Self.fadeDuration / 2, delay: 0, options: [.curveEaseIn]) {
336
+ content.alpha = 0
337
+ } completion: { _ in
338
+ content.removeFromSuperview()
339
+ }
340
+ }
341
+ }
342
+
343
+ /// The new shape's content fades in, by default once the old one's has
344
+ /// gone and the shape has grown some.
345
+ private func fadeContent(of view: BlurEffectView, after delay: TimeInterval = fadeDuration / 2) {
346
+ UIView.animate(
347
+ withDuration: Self.fadeDuration, delay: delay,
348
+ options: [.allowUserInteraction, .curveEaseOut]
349
+ ) {
350
+ view.contentView.alpha = 1
351
+ }
352
+ }
353
+
354
+ /// A glass shape like the one that went, with a picture of its content, in
355
+ /// its container. It sits above the container's own children, where Lynx's
356
+ /// index-based inserts don't see it, and it merges with them as any glass
357
+ /// in the container does.
358
+ private func ghost(of departure: Departure) -> UIVisualEffectView? {
359
+ guard let container = departure.container,
360
+ let screen = container.window?.screen.coordinateSpace
361
+ else { return nil }
362
+ let ghost = UIVisualEffectView(effect: departure.effect)
363
+ ghost.frame = screen.convert(departure.frame, to: container.contentView)
364
+ ghost.layer.cornerRadius = departure.cornerRadius
365
+ ghost.layer.cornerCurve = departure.cornerCurve
366
+ ghost.clipsToBounds = true
367
+ ghost.isUserInteractionEnabled = false
368
+ ghost.accessibilityElementsHidden = true
369
+ if let content = departure.content {
370
+ // Centered, so it stays where it was as the ghost grows around it.
371
+ content.center = CGPoint(x: ghost.bounds.midX, y: ghost.bounds.midY)
372
+ content.autoresizingMask = [
373
+ .flexibleLeftMargin, .flexibleRightMargin, .flexibleTopMargin, .flexibleBottomMargin,
374
+ ]
375
+ ghost.contentView.addSubview(content)
376
+ }
377
+ container.contentView.addSubview(ghost)
378
+ return ghost
379
+ }
380
+
381
+ // MARK: - Containers
382
+
383
+ /// The glass container `view` is in: the nearest blur-view around it, if
384
+ /// that's a container. Glass in other glass isn't in a container, and
385
+ /// neither is glass in a scroll view, whose cells come and go as it
386
+ /// scrolls.
387
+ private func container(of view: UIView) -> BlurEffectView? {
388
+ var ancestor = view.superview
389
+ while let current = ancestor, !(current is UIScrollView) {
390
+ if let blur = current as? BlurEffectView {
391
+ return blur.lynxEffect is UIGlassContainerEffect ? blur : nil
392
+ }
393
+ ancestor = current.superview
394
+ }
395
+ return nil
396
+ }
397
+
398
+ /// Whether `container` was on screen before this pass, rather than coming
399
+ /// with the glass in it.
400
+ private func isSettled(_ container: BlurEffectView) -> Bool {
401
+ container.window != nil && changes[ObjectIdentifier(container)].map { $0.wasShown } != false
402
+ }
403
+
404
+ private func isSettled(_ container: BlurEffectView, in changes: [Change]) -> Bool {
405
+ container.window != nil && !container.isHidden
406
+ && !changes.contains { $0.view === container && !$0.wasShown }
407
+ }
408
+ }
@@ -39,6 +39,21 @@ final class LynxViewController: UIViewController {
39
39
  controller.lynxView?.updateGlobalProps(with: controller.globalProps())
40
40
  }
41
41
  }
42
+
43
+ // Send the accessibility settings again when the user changes one while
44
+ // the app is open, so the components adapt at once.
45
+ for name in [
46
+ UIAccessibility.reduceMotionStatusDidChangeNotification,
47
+ UIAccessibility.reduceTransparencyStatusDidChangeNotification,
48
+ UIAccessibility.darkerSystemColorsStatusDidChangeNotification,
49
+ ] {
50
+ NotificationCenter.default.addObserver(
51
+ self, selector: #selector(accessibilitySettingsDidChange), name: name, object: nil)
52
+ }
53
+ }
54
+
55
+ @objc private func accessibilitySettingsDidChange() {
56
+ lynxView?.updateGlobalProps(with: globalProps())
42
57
  }
43
58
 
44
59
  @available(iOS, deprecated: 17.0, message: "registerForTraitChanges in viewDidLoad replaces it")
@@ -80,9 +95,10 @@ final class LynxViewController: UIViewController {
80
95
  }
81
96
 
82
97
  /// The global props the runtime reads: `theme` (LynxTheme), `isNotchScreen`
83
- /// (LynxSafeArea) and `appLocale` (LynxLocale), plus the safe-area insets.
84
- /// The Android shell sends the same keys, and Lynx Explorer sends theme and
85
- /// isNotchScreen too.
98
+ /// (LynxSafeArea), `appLocale` (LynxLocale), the accessibility settings
99
+ /// (LynxAccessibilitySettings) and `osVersion` (LynxSystemInfo), plus the
100
+ /// safe-area insets. The Android shell sends the same keys, and Lynx
101
+ /// Explorer sends theme and isNotchScreen too.
86
102
  private func globalProps() -> LynxTemplateData {
87
103
  let insets = view.safeAreaInsets
88
104
  var props: [String: Any] = [
@@ -96,6 +112,16 @@ final class LynxViewController: UIViewController {
96
112
  "isNotchScreen": insets.top > 20 || insets.bottom > 0 || insets.left > 0
97
113
  || insets.right > 0,
98
114
  "theme": traitCollection.userInterfaceStyle == .dark ? "Dark" : "Light",
115
+ // Lynx has no API for these. The glass components adapt what they draw
116
+ // to them, and follow Reduce Motion in their own animations.
117
+ "reduceMotion": UIAccessibility.isReduceMotionEnabled,
118
+ "reduceTransparency": UIAccessibility.isReduceTransparencyEnabled,
119
+ // Increase Contrast.
120
+ "increaseContrast": UIAccessibility.isDarkerSystemColorsEnabled,
121
+ // Lynx builds the main thread's SystemInfo, where Angular runs, without
122
+ // the OS version, so LynxSystemInfo reads it from here. The glass
123
+ // components need it: blur-view draws Liquid Glass only from iOS 26.
124
+ "osVersion": UIDevice.current.systemVersion,
99
125
  ]
100
126
  // The user's first language as a BCP 47 tag, such as "fr-FR". iOS
101
127
  // restarts the app when the language changes, so there's no update to send.
@@ -11,7 +11,8 @@ import XElement
11
11
  /// support, so the frameworks build leaves it out
12
12
  /// (scripts/build-lynx-ios-frameworks.sh) and the shell registers the same
13
13
  /// classes under the same names here, before the first LynxConfig or LynxView
14
- /// exists.
14
+ /// exists. `<blur-view>` is XElement's with UIKit's blur materials and glass
15
+ /// transitions added (BlurView).
15
16
  enum XElementRegistry {
16
17
  static func register() {
17
18
  let elements: [(String, AnyClass)] = [
@@ -19,7 +20,7 @@ enum XElementRegistry {
19
20
  ("textarea", LynxUITextArea.self),
20
21
  ("overlay", LynxUIOverlay.self),
21
22
  ("svg", LynxUISVG.self),
22
- ("blur-view", LynxUIBlurView.self),
23
+ ("blur-view", BlurView.self),
23
24
  ("viewpager", LynxUIViewPager.self),
24
25
  ("viewpager-item", LynxUIViewPagerItem.self),
25
26
  ("refresh", LynxUIRefresh.self),