@blotch/angular-lynx-native 22.2.4 → 22.2.6
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/README.md +5 -2
- package/dist/android/run.d.ts +61 -0
- package/dist/android/run.d.ts.map +1 -0
- package/dist/android/run.js +241 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -2
- package/dist/ios/run.d.ts +61 -0
- package/dist/ios/run.d.ts.map +1 -0
- package/dist/ios/run.js +176 -0
- package/dist/process.d.ts +4 -1
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +9 -2
- package/dist/run.d.ts +21 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +1 -0
- package/package.json +1 -1
- package/templates/android/README.md +13 -0
- package/templates/android/app/build.gradle.kts +2 -0
- package/templates/android/app/src/main/java/dev/angularlynx/shell/DynamicColors.kt +165 -0
- package/templates/android/app/src/main/java/dev/angularlynx/shell/MainActivity.kt +64 -3
- package/templates/android/app/src/main/java/dev/angularlynx/shell/WebAuthModule.kt +77 -0
- package/templates/android/app/src/main/res/values/colors.xml +9 -0
- package/templates/android/app/src/main/res/values/themes.xml +1 -1
- package/templates/android/app/src/main/res/values-night/colors.xml +4 -0
- package/templates/android/app/src/main/res/values-night/themes.xml +1 -1
- package/templates/ios/App/BlurView.swift +149 -0
- package/templates/ios/App/GlassTransitions.swift +408 -0
- package/templates/ios/App/LynxViewController.swift +4 -1
- package/templates/ios/App/WebAuthModule.swift +95 -0
- package/templates/ios/App/XElementRegistry.swift +3 -2
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package dev.angularlynx.shell
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.Uri
|
|
5
|
+
import android.os.Handler
|
|
6
|
+
import android.os.Looper
|
|
7
|
+
import com.lynx.jsbridge.LynxMethod
|
|
8
|
+
import com.lynx.jsbridge.LynxModule
|
|
9
|
+
import com.lynx.react.bridge.Callback
|
|
10
|
+
import com.lynx.react.bridge.JavaOnlyMap
|
|
11
|
+
import com.lynx.react.bridge.ReadableMap
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The native module behind the runtime's `LynxWebAuthSession`, which calls it
|
|
15
|
+
* as `NativeModules.AngularLynxWebAuth` (from the background thread, where
|
|
16
|
+
* NativeModules lives).
|
|
17
|
+
*
|
|
18
|
+
* Lynx has no popups or redirects, so a sign-in page can't run inside the
|
|
19
|
+
* app. The activity opens it in a Custom Tab instead, and the page's redirect
|
|
20
|
+
* to the callback scheme comes back to it as a deep link, which is why the
|
|
21
|
+
* scheme has to be one of the app's `deepLinkSchemes`.
|
|
22
|
+
*/
|
|
23
|
+
class WebAuthModule(context: Context, param: Any?) : LynxModule(context, param) {
|
|
24
|
+
private val host = param as Host
|
|
25
|
+
private val main = Handler(Looper.getMainLooper())
|
|
26
|
+
|
|
27
|
+
interface Host {
|
|
28
|
+
/**
|
|
29
|
+
* Opens [url] and calls [done] once with the URL the page redirected
|
|
30
|
+
* to on [callbackScheme], or with why it didn't.
|
|
31
|
+
*/
|
|
32
|
+
fun startWebAuthSession(url: Uri, callbackScheme: String, done: (Result) -> Unit)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** How a session ended: the URL the page returned to, or why it didn't. */
|
|
36
|
+
sealed interface Result {
|
|
37
|
+
data class Returned(val url: String) : Result
|
|
38
|
+
data object Canceled : Result
|
|
39
|
+
data class Failed(val message: String) : Result
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Calls back once with `{ url }` or `{ error, message }`, where `error` is
|
|
44
|
+
* `canceled` or `failed`, as the iOS module does.
|
|
45
|
+
*/
|
|
46
|
+
@LynxMethod
|
|
47
|
+
fun start(request: ReadableMap, callback: Callback) {
|
|
48
|
+
val url = request.getString("url", "")
|
|
49
|
+
val scheme = request.getString("callbackScheme", "")
|
|
50
|
+
if (url.isEmpty() || scheme.isEmpty()) {
|
|
51
|
+
callback.invoke(failed("The sign-in needs a URL and a callback scheme."))
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
// Lynx calls modules off the main thread, and the activity starts the
|
|
55
|
+
// Custom Tab.
|
|
56
|
+
main.post {
|
|
57
|
+
host.startWebAuthSession(Uri.parse(url), scheme) { result ->
|
|
58
|
+
callback.invoke(
|
|
59
|
+
when (result) {
|
|
60
|
+
is Result.Returned -> JavaOnlyMap.from(mapOf("url" to result.url))
|
|
61
|
+
Result.Canceled -> JavaOnlyMap.from(
|
|
62
|
+
mapOf("error" to "canceled", "message" to "The sign-in was canceled."),
|
|
63
|
+
)
|
|
64
|
+
is Result.Failed -> failed(result.message)
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private fun failed(message: String) =
|
|
72
|
+
JavaOnlyMap.from(mapOf("error" to "failed", "message" to message))
|
|
73
|
+
|
|
74
|
+
companion object {
|
|
75
|
+
const val NAME = "AngularLynxWebAuth"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<resources>
|
|
3
|
+
<!--
|
|
4
|
+
The window background, which shows before the first Lynx frame and
|
|
5
|
+
behind the system bars. MainActivity sets it again when dark mode
|
|
6
|
+
switches while the app is open, from values-night's.
|
|
7
|
+
-->
|
|
8
|
+
<color name="window_background">#FFFFFFFF</color>
|
|
9
|
+
</resources>
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
light/dark page background instead of flashing.
|
|
7
7
|
-->
|
|
8
8
|
<style name="Theme.AngularLynxShell" parent="android:Theme.Material.Light.NoActionBar">
|
|
9
|
-
<item name="android:windowBackground">@
|
|
9
|
+
<item name="android:windowBackground">@color/window_background</item>
|
|
10
10
|
<item name="android:windowLightStatusBar">true</item>
|
|
11
11
|
<item name="android:windowLightNavigationBar">true</item>
|
|
12
12
|
</style>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="utf-8"?>
|
|
2
2
|
<resources>
|
|
3
3
|
<style name="Theme.AngularLynxShell" parent="android:Theme.Material.NoActionBar">
|
|
4
|
-
<item name="android:windowBackground">@
|
|
4
|
+
<item name="android:windowBackground">@color/window_background</item>
|
|
5
5
|
<item name="android:windowLightStatusBar">false</item>
|
|
6
6
|
<item name="android:windowLightNavigationBar">false</item>
|
|
7
7
|
</style>
|
|
@@ -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
|
+
}
|
|
@@ -13,8 +13,11 @@ final class LynxViewController: UIViewController {
|
|
|
13
13
|
|
|
14
14
|
let fetcher = BundleResourceFetcher.shared
|
|
15
15
|
let screenSize = UIScreen.main.bounds.size
|
|
16
|
+
// The shell's native modules, which the runtime's services call.
|
|
17
|
+
let config = LynxConfig(provider: LynxEnv.sharedInstance().config.templateProvider)
|
|
18
|
+
config.register(WebAuthModule.self)
|
|
16
19
|
let lynxView = LynxView { builder in
|
|
17
|
-
builder.config =
|
|
20
|
+
builder.config = config
|
|
18
21
|
builder.screenSize = screenSize
|
|
19
22
|
builder.fontScale = 1.0
|
|
20
23
|
builder.enableGenericResourceFetcher = .true
|