react-native-theme-reveal 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +734 -2
- package/android/build.gradle +32 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/baseline-prof.txt +4 -0
- package/android/src/main/java/expo/modules/themereveal/CapturePlan.kt +199 -0
- package/android/src/main/java/expo/modules/themereveal/CaptureRules.kt +50 -0
- package/android/src/main/java/expo/modules/themereveal/GpuSnapshot.kt +367 -0
- package/android/src/main/java/expo/modules/themereveal/KeptBitmaps.kt +102 -0
- package/android/src/main/java/expo/modules/themereveal/KeptTarget.kt +103 -0
- package/android/src/main/java/expo/modules/themereveal/Motion.kt +79 -0
- package/android/src/main/java/expo/modules/themereveal/Once.kt +13 -0
- package/android/src/main/java/expo/modules/themereveal/RenderThreadReveal.kt +107 -0
- package/android/src/main/java/expo/modules/themereveal/RevealOverlayView.kt +192 -0
- package/android/src/main/java/expo/modules/themereveal/SecondSnapshotWait.kt +112 -0
- package/android/src/main/java/expo/modules/themereveal/ShapePath.kt +47 -0
- package/android/src/main/java/expo/modules/themereveal/ThemeRevealModule.kt +904 -0
- package/android/src/main/java/expo/modules/themereveal/TransitionArgs.kt +50 -0
- package/android/src/main/java/expo/modules/themereveal/WindowSnapshot.kt +536 -0
- package/build/ThemeRevealModule.d.ts +11 -0
- package/build/ThemeRevealModule.d.ts.map +1 -0
- package/build/ThemeRevealModule.js +11 -0
- package/build/ThemeRevealModule.js.map +1 -0
- package/build/createThemeReveal.d.ts +39 -0
- package/build/createThemeReveal.d.ts.map +1 -0
- package/build/createThemeReveal.js +186 -0
- package/build/createThemeReveal.js.map +1 -0
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +5 -0
- package/build/index.js.map +1 -0
- package/build/originFromPress.d.ts +12 -0
- package/build/originFromPress.d.ts.map +1 -0
- package/build/originFromPress.js +26 -0
- package/build/originFromPress.js.map +1 -0
- package/build/resolveTransition.d.ts +13 -0
- package/build/resolveTransition.d.ts.map +1 -0
- package/build/resolveTransition.js +43 -0
- package/build/resolveTransition.js.map +1 -0
- package/build/store.d.ts +8 -0
- package/build/store.d.ts.map +1 -0
- package/build/store.js +23 -0
- package/build/store.js.map +1 -0
- package/build/types.d.ts +31 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Motion.swift +171 -0
- package/ios/RevealOverlay.swift +115 -0
- package/ios/SVGPath.swift +373 -0
- package/ios/ThemeReveal.podspec +26 -0
- package/ios/ThemeRevealModule.swift +264 -0
- package/mocks/ThemeReveal.js +10 -0
- package/package.json +67 -4
- package/src/ThemeRevealModule.ts +24 -0
- package/src/createThemeReveal.ts +262 -0
- package/src/index.ts +14 -0
- package/src/originFromPress.ts +40 -0
- package/src/resolveTransition.ts +63 -0
- package/src/store.ts +30 -0
- package/src/types.ts +17 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
struct PointRecord: Record {
|
|
5
|
+
@Field var x: Double = 0
|
|
6
|
+
@Field var y: Double = 0
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
struct ShapeRecord: Record {
|
|
10
|
+
@Field var path: String = ""
|
|
11
|
+
@Field var anchor: PointRecord = PointRecord()
|
|
12
|
+
@Field var inscribedRadius: Double = 0
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
struct TransitionRecord: Record {
|
|
16
|
+
@Field var type: String = ""
|
|
17
|
+
@Field var durationMs: Double = 0
|
|
18
|
+
@Field var origin: PointRecord?
|
|
19
|
+
@Field var shape: ShapeRecord?
|
|
20
|
+
@Field var direction: String?
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Snapshot from capture(), kept out of the window until prepare() shows it. presented is the
|
|
24
|
+
// window's top view controller at capture time, which a modal presented or dismissed since then
|
|
25
|
+
// changes.
|
|
26
|
+
private struct HeldCapture {
|
|
27
|
+
let view: UIView
|
|
28
|
+
weak var window: UIWindow?
|
|
29
|
+
weak var presented: UIViewController?
|
|
30
|
+
let takenAt: CFTimeInterval
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public class ThemeRevealModule: Module {
|
|
34
|
+
private var overlay: RevealOverlay?
|
|
35
|
+
private var held: HeldCapture?
|
|
36
|
+
private var captureExpiry: DispatchWorkItem?
|
|
37
|
+
// Offset, in the overlay, of the React root a tapped view's origin is measured in (pageX /
|
|
38
|
+
// pageY). Read at snapshot time like on Android, so a modal that moves or closes before start
|
|
39
|
+
// doesn't shift the origin.
|
|
40
|
+
private var originOffset: CGPoint?
|
|
41
|
+
private var started: Promise?
|
|
42
|
+
// A snapshot that never gets start() would freeze the screen; drop it.
|
|
43
|
+
private var failsafe: DispatchWorkItem?
|
|
44
|
+
|
|
45
|
+
public func definition() -> ModuleDefinition {
|
|
46
|
+
Name("ThemeReveal")
|
|
47
|
+
|
|
48
|
+
// The capture path ("view": snapshotView), or nil when no overlay shows.
|
|
49
|
+
AsyncFunction("prepare") { (viewTag: Int?) -> String? in
|
|
50
|
+
self.prepare(viewTag)
|
|
51
|
+
}.runOnQueue(.main)
|
|
52
|
+
|
|
53
|
+
AsyncFunction("start") { (transition: TransitionRecord, promise: Promise) in
|
|
54
|
+
self.start(transition, promise)
|
|
55
|
+
}.runOnQueue(.main)
|
|
56
|
+
|
|
57
|
+
// prepare only snapshots the key window, so the view tag isn't needed here.
|
|
58
|
+
AsyncFunction("capture") { (_: Int?) -> Bool in
|
|
59
|
+
self.capture()
|
|
60
|
+
}.runOnQueue(.main)
|
|
61
|
+
|
|
62
|
+
AsyncFunction("dropCapture") {
|
|
63
|
+
self.dropCapture()
|
|
64
|
+
}.runOnQueue(.main)
|
|
65
|
+
|
|
66
|
+
AsyncFunction("cancel") {
|
|
67
|
+
self.dropCapture()
|
|
68
|
+
self.removeOverlay()
|
|
69
|
+
}.runOnQueue(.main)
|
|
70
|
+
|
|
71
|
+
// No-op on iOS: snapshotView(afterScreenUpdates:) keeps no thread or bitmap a warm-up could start early. It's
|
|
72
|
+
// here so the JS call resolves on both platforms.
|
|
73
|
+
AsyncFunction("warmUp") {}
|
|
74
|
+
|
|
75
|
+
OnAppEntersBackground {
|
|
76
|
+
Self.onMain { self.dropCapture() }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
OnDestroy {
|
|
80
|
+
Self.onMain {
|
|
81
|
+
self.dropCapture()
|
|
82
|
+
self.removeOverlay()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private static func onMain(_ body: @escaping () -> Void) {
|
|
88
|
+
if Thread.isMainThread {
|
|
89
|
+
body()
|
|
90
|
+
} else {
|
|
91
|
+
DispatchQueue.main.async(execute: body)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Takes the snapshot now and keeps it out of the window, so the screen stays live while
|
|
96
|
+
// the finger is down.
|
|
97
|
+
private func capture() -> Bool {
|
|
98
|
+
dropCapture()
|
|
99
|
+
let active = UIApplication.shared.applicationState == .active
|
|
100
|
+
// An overlay on screen would end up in the snapshot, so the switch snapshots after it instead.
|
|
101
|
+
guard overlay == nil,
|
|
102
|
+
Motion.canAnimate(active: active, reduceMotion: UIAccessibility.isReduceMotionEnabled),
|
|
103
|
+
let window = Self.keyWindow(),
|
|
104
|
+
let view = RevealOverlay.snapshot(of: window) else {
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
held = HeldCapture(
|
|
108
|
+
view: view, window: window, presented: Self.topPresented(in: window), takenAt: CACurrentMediaTime())
|
|
109
|
+
let item = DispatchWorkItem { [weak self] in self?.dropCapture() }
|
|
110
|
+
captureExpiry = item
|
|
111
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Motion.captureTimeoutSeconds, execute: item)
|
|
112
|
+
return true
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private func dropCapture() {
|
|
116
|
+
captureExpiry?.cancel()
|
|
117
|
+
captureExpiry = nil
|
|
118
|
+
held = nil
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private func prepare(_ viewTag: Int?) -> String? {
|
|
122
|
+
removeOverlay()
|
|
123
|
+
let kept = held
|
|
124
|
+
dropCapture()
|
|
125
|
+
let active = UIApplication.shared.applicationState == .active
|
|
126
|
+
guard Motion.canAnimate(active: active, reduceMotion: UIAccessibility.isReduceMotionEnabled),
|
|
127
|
+
let window = Self.keyWindow(),
|
|
128
|
+
let next = Self.overlay(on: window, kept: kept) else {
|
|
129
|
+
return nil
|
|
130
|
+
}
|
|
131
|
+
overlay = next
|
|
132
|
+
originOffset = viewTag.flatMap { findView($0) }.flatMap(Self.reactRoot(of:)).flatMap { root in
|
|
133
|
+
root.window == nil ? nil : root.convert(CGPoint.zero, to: next.view)
|
|
134
|
+
}
|
|
135
|
+
let item = DispatchWorkItem { [weak self] in self?.removeOverlay() }
|
|
136
|
+
failsafe = item
|
|
137
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + Self.failsafeSeconds, execute: item)
|
|
138
|
+
return "view"
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private func start(_ transition: TransitionRecord, _ promise: Promise) {
|
|
142
|
+
guard let current = overlay else {
|
|
143
|
+
promise.resolve()
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
failsafe?.cancel()
|
|
147
|
+
failsafe = nil
|
|
148
|
+
started?.resolve()
|
|
149
|
+
started = promise
|
|
150
|
+
guard let window = current.view.window, let duration = Motion.durationSeconds(ms: transition.durationMs),
|
|
151
|
+
Motion.sameSize(current.size, window.bounds.size) else {
|
|
152
|
+
removeOverlay()
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
let onEnd = { [weak self, weak current] in
|
|
156
|
+
guard let self, let current, self.overlay === current else { return }
|
|
157
|
+
self.removeOverlay()
|
|
158
|
+
}
|
|
159
|
+
if !play(current, transition, duration, onEnd) {
|
|
160
|
+
removeOverlay()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private func play(
|
|
165
|
+
_ current: RevealOverlay, _ transition: TransitionRecord, _ duration: Double, _ onEnd: @escaping () -> Void
|
|
166
|
+
) -> Bool {
|
|
167
|
+
switch transition.type {
|
|
168
|
+
case "reveal", "inverseReveal":
|
|
169
|
+
guard let origin = transition.origin, origin.x.isFinite, origin.y.isFinite else { return false }
|
|
170
|
+
let point = toOverlay(CGPoint(x: origin.x, y: origin.y), current)
|
|
171
|
+
current.reveal(
|
|
172
|
+
Self.shape(of: transition.shape), origin: point, inverse: transition.type == "inverseReveal",
|
|
173
|
+
duration: duration, onEnd: onEnd)
|
|
174
|
+
case "wipe":
|
|
175
|
+
guard let direction = transition.direction.flatMap(WipeDirection.init(rawValue:)) else { return false }
|
|
176
|
+
current.wipe(direction, duration: duration, onEnd: onEnd)
|
|
177
|
+
case "crossfade":
|
|
178
|
+
current.crossfade(duration: duration, onEnd: onEnd)
|
|
179
|
+
default:
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
return true
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private func removeOverlay() {
|
|
186
|
+
failsafe?.cancel()
|
|
187
|
+
failsafe = nil
|
|
188
|
+
let pending = started
|
|
189
|
+
started = nil
|
|
190
|
+
let current = overlay
|
|
191
|
+
overlay = nil
|
|
192
|
+
originOffset = nil
|
|
193
|
+
current?.remove()
|
|
194
|
+
pending?.resolve()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Origin is relative to the tapped view's React root, or in window points if there's none.
|
|
198
|
+
private func toOverlay(_ point: CGPoint, _ current: RevealOverlay) -> CGPoint {
|
|
199
|
+
if let offset = originOffset {
|
|
200
|
+
return CGPoint(x: point.x + offset.x, y: point.y + offset.y)
|
|
201
|
+
}
|
|
202
|
+
return current.view.convert(point, from: nil)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The held capture if it still shows this window at its size, otherwise a snapshot taken
|
|
206
|
+
// now.
|
|
207
|
+
private static func overlay(on window: UIWindow, kept: HeldCapture?) -> RevealOverlay? {
|
|
208
|
+
if let kept, Motion.canUseCapture(
|
|
209
|
+
sameWindow: kept.window === window, samePresentation: kept.presented === topPresented(in: window),
|
|
210
|
+
captured: kept.view.frame.size, current: window.bounds.size,
|
|
211
|
+
ageSeconds: CACurrentMediaTime() - kept.takenAt) {
|
|
212
|
+
return RevealOverlay(snapshot: kept.view, window: window)
|
|
213
|
+
}
|
|
214
|
+
return RevealOverlay(window: window)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// A React Native <Modal> presents a view controller in the same window.
|
|
218
|
+
private static func topPresented(in window: UIWindow) -> UIViewController? {
|
|
219
|
+
var top = window.rootViewController
|
|
220
|
+
while let next = top?.presentedViewController {
|
|
221
|
+
top = next
|
|
222
|
+
}
|
|
223
|
+
return top
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private func findView(_ viewTag: Int) -> UIView? {
|
|
227
|
+
appContext?.findView(withTag: viewTag, ofType: UIView.self)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private static func keyWindow() -> UIWindow? {
|
|
231
|
+
UIApplication.shared.connectedScenes
|
|
232
|
+
.compactMap { $0 as? UIWindowScene }
|
|
233
|
+
.flatMap { $0.windows }
|
|
234
|
+
.first { $0.isKeyWindow }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// React Native measures pageX / pageY relative to the view its touch handler is attached
|
|
238
|
+
// to: the surface view, or a presented <Modal>'s view.
|
|
239
|
+
private static func reactRoot(of view: UIView) -> UIView? {
|
|
240
|
+
guard let handler: AnyClass = NSClassFromString("RCTSurfaceTouchHandler") else { return nil }
|
|
241
|
+
var candidate: UIView? = view
|
|
242
|
+
while let current = candidate {
|
|
243
|
+
if current.gestureRecognizers?.contains(where: { $0.isKind(of: handler) }) == true {
|
|
244
|
+
return current
|
|
245
|
+
}
|
|
246
|
+
candidate = current.superview
|
|
247
|
+
}
|
|
248
|
+
return nil
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private static func shape(of record: ShapeRecord?) -> RevealShape {
|
|
252
|
+
guard let record else { return .circle }
|
|
253
|
+
guard let shape = RevealShape.parse(
|
|
254
|
+
path: record.path,
|
|
255
|
+
anchor: CGPoint(x: record.anchor.x, y: record.anchor.y),
|
|
256
|
+
inscribedRadius: CGFloat(record.inscribedRadius)) else {
|
|
257
|
+
log.warn("ThemeReveal: cannot parse the shape path \"\(record.path)\", using the circle")
|
|
258
|
+
return .circle
|
|
259
|
+
}
|
|
260
|
+
return shape
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private static let failsafeSeconds = 1.0
|
|
264
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// jest-expo loads this instead of the native module. prepare resolves null and capture false, so switchTo applies
|
|
2
|
+
// the theme immediately without animating.
|
|
3
|
+
module.exports = {
|
|
4
|
+
prepare: async () => null,
|
|
5
|
+
start: async () => {},
|
|
6
|
+
cancel: async () => {},
|
|
7
|
+
warmUp: async () => {},
|
|
8
|
+
capture: async () => false,
|
|
9
|
+
dropCapture: async () => {},
|
|
10
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-theme-reveal",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Animate React Native theme switches from a native window snapshot",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"types": "build/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"build",
|
|
9
|
+
"android",
|
|
10
|
+
"ios",
|
|
11
|
+
"src",
|
|
12
|
+
"mocks",
|
|
13
|
+
"expo-module.config.json",
|
|
14
|
+
"!android/build",
|
|
15
|
+
"!android/.gradle",
|
|
16
|
+
"!android/.cxx",
|
|
17
|
+
"!android/src/test",
|
|
18
|
+
"!ios/build",
|
|
19
|
+
"!ios/Tests",
|
|
20
|
+
"!src/__tests__",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "expo-module build",
|
|
26
|
+
"clean": "expo-module clean",
|
|
27
|
+
"lint": "expo-module lint",
|
|
28
|
+
"test": "expo-module test",
|
|
29
|
+
"prepare": "expo-module prepare",
|
|
30
|
+
"prepublishOnly": "expo-module prepublishOnly",
|
|
31
|
+
"expo-module": "expo-module"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"react-native",
|
|
35
|
+
"expo",
|
|
36
|
+
"theme",
|
|
37
|
+
"dark-mode",
|
|
38
|
+
"transition",
|
|
39
|
+
"animation"
|
|
40
|
+
],
|
|
41
|
+
"repository": "https://github.com/chocky335/react-native-theme-reveal",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/chocky335/react-native-theme-reveal/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/chocky335/react-native-theme-reveal#readme",
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@testing-library/react-native": "^13.3.3",
|
|
49
|
+
"@types/react": "~19.2.10",
|
|
50
|
+
"babel-preset-expo": "~57.0.15",
|
|
51
|
+
"expo": "~57.0.29",
|
|
52
|
+
"expo-module-scripts": "^56.0.3",
|
|
53
|
+
"react": "19.2.3",
|
|
54
|
+
"react-native": "0.86.3",
|
|
55
|
+
"react-test-renderer": "19.2.3",
|
|
56
|
+
"typescript": "~5.9.2"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"expo": ">=57.0.0",
|
|
60
|
+
"react": "*",
|
|
61
|
+
"react-native": ">=0.86.0"
|
|
62
|
+
},
|
|
63
|
+
"overrides": {
|
|
64
|
+
"expo-module-scripts": {
|
|
65
|
+
"jest-expo": "~57.0.6",
|
|
66
|
+
"@react-native/jest-preset": "0.86.3"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { requireNativeModule } from 'expo'
|
|
2
|
+
|
|
3
|
+
import type { CapturePath, Transition } from './types'
|
|
4
|
+
|
|
5
|
+
type NativeThemeReveal = {
|
|
6
|
+
// The capture path, or null when no overlay shows.
|
|
7
|
+
prepare(viewTag: number | undefined): Promise<CapturePath | null>
|
|
8
|
+
start(transition: Transition): Promise<void>
|
|
9
|
+
cancel(): Promise<void>
|
|
10
|
+
warmUp(): Promise<void>
|
|
11
|
+
capture(viewTag: number | undefined): Promise<boolean>
|
|
12
|
+
dropCapture(): Promise<void>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const native = requireNativeModule<NativeThemeReveal>('ThemeReveal')
|
|
16
|
+
|
|
17
|
+
export default {
|
|
18
|
+
prepare: (viewTag?: number): Promise<CapturePath | null> => native.prepare(viewTag),
|
|
19
|
+
start: (transition: Transition): Promise<void> => native.start(transition),
|
|
20
|
+
cancel: (): Promise<void> => native.cancel(),
|
|
21
|
+
warmUp: (): Promise<void> => native.warmUp(),
|
|
22
|
+
capture: (viewTag?: number): Promise<boolean> => native.capture(viewTag),
|
|
23
|
+
dropCapture: (): Promise<void> => native.dropCapture(),
|
|
24
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect } from 'react'
|
|
2
|
+
import type { GestureResponderEvent } from 'react-native'
|
|
3
|
+
|
|
4
|
+
import ThemeRevealModule from './ThemeRevealModule'
|
|
5
|
+
import { pagePointOf } from './originFromPress'
|
|
6
|
+
import { resolveTransition } from './resolveTransition'
|
|
7
|
+
import { createStore, useStore } from './store'
|
|
8
|
+
import type { CapturePath, Point, Shape, Transition, TransitionType, WipeDirection } from './types'
|
|
9
|
+
|
|
10
|
+
export type SettledInfo = {
|
|
11
|
+
transition: TransitionType | null
|
|
12
|
+
// How the snapshot was taken, or null when there was none.
|
|
13
|
+
capture: CapturePath | null
|
|
14
|
+
prepareMs: number
|
|
15
|
+
totalMs: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ThemeRevealOptions<T> = {
|
|
19
|
+
getTheme: () => T
|
|
20
|
+
setTheme: (theme: T) => void
|
|
21
|
+
shape?: Shape
|
|
22
|
+
durations?: Partial<Record<TransitionType, number>>
|
|
23
|
+
transition?: TransitionType
|
|
24
|
+
wipeDirection?: WipeDirection
|
|
25
|
+
onError?: (error: unknown) => void
|
|
26
|
+
onSettled?: (info: SettledInfo) => void
|
|
27
|
+
// Prepare the native snapshot path when useStart mounts (Android: the copy thread, then the GPU snapshot on API
|
|
28
|
+
// 29+, or one kept window-size bitmap below that). Default true.
|
|
29
|
+
warmUp?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type SwitchOptions = {
|
|
33
|
+
transition?: TransitionType
|
|
34
|
+
shape?: Shape
|
|
35
|
+
wipeDirection?: WipeDirection
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Spread on a Pressable whose onPress calls switchTo. The snapshot is taken on press-in, and a press that ends
|
|
39
|
+
// without a switch frees it.
|
|
40
|
+
export type PressCapture = {
|
|
41
|
+
onPressIn(event: GestureResponderEvent): void
|
|
42
|
+
onPressOut(event?: GestureResponderEvent): void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ThemeReveal<T> = {
|
|
46
|
+
setOrigin(point: Point, viewTag?: number): void
|
|
47
|
+
capture(point: Point, viewTag?: number): void
|
|
48
|
+
dropCapture(): void
|
|
49
|
+
pressCapture: PressCapture
|
|
50
|
+
switchTo(theme: T, options?: SwitchOptions): Promise<void>
|
|
51
|
+
useStart(): void
|
|
52
|
+
useHeldTheme(): T | null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type PendingStart = {
|
|
56
|
+
id: number
|
|
57
|
+
transition: Transition
|
|
58
|
+
capture: CapturePath
|
|
59
|
+
startedAt: number
|
|
60
|
+
prepareMs: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type Origin = { point: Point; viewTag?: number }
|
|
64
|
+
|
|
65
|
+
type Captured = {
|
|
66
|
+
origin: Origin
|
|
67
|
+
expiry: ReturnType<typeof setTimeout>
|
|
68
|
+
drop?: ReturnType<typeof setTimeout>
|
|
69
|
+
// Touch time of the press-in, to tell this press's onPressOut apart from a late one from an earlier press.
|
|
70
|
+
pressedAt?: number
|
|
71
|
+
// Native freed the snapshot after the timeout. The origin stays until the press ends.
|
|
72
|
+
expired?: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const timestampOf = (event: GestureResponderEvent | undefined): number | undefined => {
|
|
76
|
+
const { timestamp }: { timestamp?: unknown } = event?.nativeEvent ?? {}
|
|
77
|
+
return typeof timestamp === 'number' && Number.isFinite(timestamp) ? timestamp : undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A capture no switch uses is freed after this long. Native also frees it on its own after the same time.
|
|
81
|
+
const CAPTURE_TIMEOUT_MS = 3000
|
|
82
|
+
|
|
83
|
+
type RevealState<T> = {
|
|
84
|
+
pending: PendingStart | null
|
|
85
|
+
held: { id: number; theme: T } | null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createThemeReveal<T>(options: ThemeRevealOptions<T>): ThemeReveal<T> {
|
|
89
|
+
const { getTheme, setTheme } = options
|
|
90
|
+
const warn = (error: unknown) => console.warn(error)
|
|
91
|
+
// A throwing user callback must not skip setTheme or leave an unhandled rejection.
|
|
92
|
+
const call = <A>(callback: ((arg: A) => void) | undefined, arg: A) => {
|
|
93
|
+
try {
|
|
94
|
+
callback?.(arg)
|
|
95
|
+
} catch (thrown) {
|
|
96
|
+
warn(thrown)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const onError = (error: unknown) => call(options.onError ?? warn, error)
|
|
100
|
+
const onSettled = (info: SettledInfo) => call(options.onSettled, info)
|
|
101
|
+
const store = createStore<RevealState<T>>({ pending: null, held: null })
|
|
102
|
+
let pendingOrigin: Origin | null = null
|
|
103
|
+
let captured: Captured | null = null
|
|
104
|
+
let latestId = 0
|
|
105
|
+
let warmedUp = false
|
|
106
|
+
|
|
107
|
+
const setOrigin = (point: Point, viewTag?: number) => {
|
|
108
|
+
pendingOrigin = { point, viewTag }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Forgets the JS side of the capture and returns it. Native keeps it until prepare, cancel or dropCapture.
|
|
112
|
+
const releaseCapture = () => {
|
|
113
|
+
const current = captured
|
|
114
|
+
captured = null
|
|
115
|
+
if (current) {
|
|
116
|
+
clearTimeout(current.expiry)
|
|
117
|
+
clearTimeout(current.drop)
|
|
118
|
+
}
|
|
119
|
+
return current
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const freeCapture = () => ThemeRevealModule.dropCapture().catch(onError)
|
|
123
|
+
|
|
124
|
+
const startCapture = (point: Point, viewTag: number | undefined, pressedAt: number | undefined) => {
|
|
125
|
+
releaseCapture()
|
|
126
|
+
const origin = { point, viewTag }
|
|
127
|
+
pendingOrigin = origin
|
|
128
|
+
// A long press keeps its point for the switch on release, but not the snapshot.
|
|
129
|
+
const current: Captured = {
|
|
130
|
+
origin,
|
|
131
|
+
pressedAt,
|
|
132
|
+
expiry: setTimeout(() => {
|
|
133
|
+
if (captured !== current) return
|
|
134
|
+
current.expired = true
|
|
135
|
+
freeCapture()
|
|
136
|
+
}, CAPTURE_TIMEOUT_MS),
|
|
137
|
+
}
|
|
138
|
+
captured = current
|
|
139
|
+
ThemeRevealModule.capture(viewTag).catch(onError)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const capture = (point: Point, viewTag?: number) => startCapture(point, viewTag, undefined)
|
|
143
|
+
|
|
144
|
+
const dropCapture = () => {
|
|
145
|
+
const current = releaseCapture()
|
|
146
|
+
if (current && pendingOrigin === current.origin) pendingOrigin = null
|
|
147
|
+
if (!current?.expired) freeCapture()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// On release React Native calls onPressOut before onPress in the same dispatch (or up to 130 ms after onPress
|
|
151
|
+
// for a short press), and a cancelled press gets onPressOut without onPress. So onPressOut only drops the
|
|
152
|
+
// capture in a later macrotask, and only if no switchTo has taken it by then. A late onPressOut whose touch is
|
|
153
|
+
// older than the capture's press-in belongs to an earlier press and leaves the capture alone.
|
|
154
|
+
const pressCapture: PressCapture = {
|
|
155
|
+
onPressIn: (event) => {
|
|
156
|
+
const origin = pagePointOf(event)
|
|
157
|
+
if (origin) startCapture(origin.point, origin.viewTag, timestampOf(event))
|
|
158
|
+
},
|
|
159
|
+
onPressOut: (event) => {
|
|
160
|
+
const current = captured
|
|
161
|
+
if (!current) return
|
|
162
|
+
const releasedAt = timestampOf(event)
|
|
163
|
+
if (current.pressedAt !== undefined && releasedAt !== undefined && releasedAt < current.pressedAt)
|
|
164
|
+
return
|
|
165
|
+
clearTimeout(current.drop)
|
|
166
|
+
current.drop = setTimeout(() => {
|
|
167
|
+
if (captured === current) dropCapture()
|
|
168
|
+
}, 0)
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const switchTo = async (theme: T, switchOptions?: SwitchOptions) => {
|
|
173
|
+
const held = releaseCapture()
|
|
174
|
+
const origin = pendingOrigin
|
|
175
|
+
pendingOrigin = null
|
|
176
|
+
const id = ++latestId
|
|
177
|
+
const startedAt = performance.now()
|
|
178
|
+
|
|
179
|
+
if (Object.is(getTheme(), theme)) {
|
|
180
|
+
if (held && !held.expired) freeCapture()
|
|
181
|
+
try {
|
|
182
|
+
await ThemeRevealModule.cancel()
|
|
183
|
+
} catch (error) {
|
|
184
|
+
onError(error)
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let capture: CapturePath | null = null
|
|
190
|
+
try {
|
|
191
|
+
capture = await ThemeRevealModule.prepare(origin?.viewTag)
|
|
192
|
+
} catch (error) {
|
|
193
|
+
onError(error)
|
|
194
|
+
}
|
|
195
|
+
if (id !== latestId) return
|
|
196
|
+
const prepareMs = performance.now() - startedAt
|
|
197
|
+
|
|
198
|
+
if (!capture) {
|
|
199
|
+
setTheme(theme)
|
|
200
|
+
onSettled({ transition: null, capture: null, prepareMs, totalMs: performance.now() - startedAt })
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const transition = resolveTransition({
|
|
205
|
+
requested: switchOptions?.transition,
|
|
206
|
+
fallback: options.transition,
|
|
207
|
+
origin: origin?.point ?? null,
|
|
208
|
+
shape: options.shape,
|
|
209
|
+
durations: options.durations,
|
|
210
|
+
wipeDirection: options.wipeDirection,
|
|
211
|
+
requestedShape: switchOptions?.shape,
|
|
212
|
+
requestedWipeDirection: switchOptions?.wipeDirection,
|
|
213
|
+
})
|
|
214
|
+
store.set({ held: { id, theme: getTheme() } })
|
|
215
|
+
try {
|
|
216
|
+
setTheme(theme)
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (store.get().held?.id === id) store.set({ held: null })
|
|
219
|
+
ThemeRevealModule.cancel().catch(onError)
|
|
220
|
+
throw error
|
|
221
|
+
}
|
|
222
|
+
store.set({ pending: { id, transition, capture, startedAt, prepareMs } })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Once per instance: start the native capture path early so the first switch doesn't pay for it.
|
|
226
|
+
const warmUp = () => {
|
|
227
|
+
if (warmedUp || options.warmUp === false) return
|
|
228
|
+
warmedUp = true
|
|
229
|
+
try {
|
|
230
|
+
ThemeRevealModule.warmUp().catch(onError)
|
|
231
|
+
} catch (error) {
|
|
232
|
+
onError(error)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const useStart = () => {
|
|
237
|
+
const pending = useStore(store, (state) => state.pending)
|
|
238
|
+
|
|
239
|
+
useEffect(warmUp, [])
|
|
240
|
+
|
|
241
|
+
useLayoutEffect(() => {
|
|
242
|
+
if (!pending || store.get().pending !== pending) return
|
|
243
|
+
store.set({ pending: null })
|
|
244
|
+
const { id, transition, capture, startedAt, prepareMs } = pending
|
|
245
|
+
ThemeRevealModule.start(transition)
|
|
246
|
+
.catch(onError)
|
|
247
|
+
.finally(() => {
|
|
248
|
+
if (store.get().held?.id === id) store.set({ held: null })
|
|
249
|
+
onSettled({
|
|
250
|
+
transition: transition.type,
|
|
251
|
+
capture,
|
|
252
|
+
prepareMs,
|
|
253
|
+
totalMs: performance.now() - startedAt,
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
}, [pending])
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const useHeldTheme = () => useStore(store, (state) => state.held?.theme ?? null)
|
|
260
|
+
|
|
261
|
+
return { setOrigin, capture, dropCapture, pressCapture, switchTo, useStart, useHeldTheme }
|
|
262
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { default as ThemeRevealModule } from './ThemeRevealModule'
|
|
2
|
+
export { createThemeReveal } from './createThemeReveal'
|
|
3
|
+
export { originFromPress } from './originFromPress'
|
|
4
|
+
export type { MeasurableView, PressOrigin } from './originFromPress'
|
|
5
|
+
export { resolveTransition } from './resolveTransition'
|
|
6
|
+
export type { ResolveTransitionInput } from './resolveTransition'
|
|
7
|
+
export type {
|
|
8
|
+
PressCapture,
|
|
9
|
+
SettledInfo,
|
|
10
|
+
SwitchOptions,
|
|
11
|
+
ThemeReveal,
|
|
12
|
+
ThemeRevealOptions,
|
|
13
|
+
} from './createThemeReveal'
|
|
14
|
+
export type { CapturePath, Point, Shape, Transition, TransitionType, WipeDirection } from './types'
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { GestureResponderEvent } from 'react-native'
|
|
2
|
+
|
|
3
|
+
import type { Point } from './types'
|
|
4
|
+
|
|
5
|
+
export type MeasurableView = {
|
|
6
|
+
measureInWindow(callback: (x: number, y: number, width: number, height: number) => void): void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type PressOrigin = { point: Point; viewTag?: number }
|
|
10
|
+
|
|
11
|
+
const isFiniteNumber = (value: unknown): value is number =>
|
|
12
|
+
typeof value === 'number' && Number.isFinite(value)
|
|
13
|
+
|
|
14
|
+
const viewTagOf = (event: GestureResponderEvent): { viewTag?: number } => {
|
|
15
|
+
const { target }: { target?: unknown } = event.nativeEvent
|
|
16
|
+
return typeof target === 'number' ? { viewTag: target } : {}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Touch point and target tag of a press event, or null when the event has no page point.
|
|
20
|
+
export function pagePointOf(event: GestureResponderEvent): PressOrigin | null {
|
|
21
|
+
const { pageX, pageY }: { pageX?: unknown; pageY?: unknown } = event.nativeEvent
|
|
22
|
+
if (!isFiniteNumber(pageX) || !isFiniteNumber(pageY)) return null
|
|
23
|
+
return { point: { x: pageX, y: pageY }, ...viewTagOf(event) }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function originFromPress(
|
|
27
|
+
event: GestureResponderEvent,
|
|
28
|
+
fallbackView?: MeasurableView | null,
|
|
29
|
+
): Promise<PressOrigin | null> {
|
|
30
|
+
const tag = viewTagOf(event)
|
|
31
|
+
const origin = pagePointOf(event)
|
|
32
|
+
|
|
33
|
+
if (origin) return Promise.resolve(origin)
|
|
34
|
+
if (!fallbackView) return Promise.resolve(null)
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
fallbackView.measureInWindow((x, y, width, height) => {
|
|
37
|
+
resolve({ point: { x: x + width / 2, y: y + height / 2 }, ...tag })
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
}
|