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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +734 -2
  3. package/android/build.gradle +32 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/baseline-prof.txt +4 -0
  6. package/android/src/main/java/expo/modules/themereveal/CapturePlan.kt +199 -0
  7. package/android/src/main/java/expo/modules/themereveal/CaptureRules.kt +50 -0
  8. package/android/src/main/java/expo/modules/themereveal/GpuSnapshot.kt +367 -0
  9. package/android/src/main/java/expo/modules/themereveal/KeptBitmaps.kt +102 -0
  10. package/android/src/main/java/expo/modules/themereveal/KeptTarget.kt +103 -0
  11. package/android/src/main/java/expo/modules/themereveal/Motion.kt +79 -0
  12. package/android/src/main/java/expo/modules/themereveal/Once.kt +13 -0
  13. package/android/src/main/java/expo/modules/themereveal/RenderThreadReveal.kt +107 -0
  14. package/android/src/main/java/expo/modules/themereveal/RevealOverlayView.kt +192 -0
  15. package/android/src/main/java/expo/modules/themereveal/SecondSnapshotWait.kt +112 -0
  16. package/android/src/main/java/expo/modules/themereveal/ShapePath.kt +47 -0
  17. package/android/src/main/java/expo/modules/themereveal/ThemeRevealModule.kt +904 -0
  18. package/android/src/main/java/expo/modules/themereveal/TransitionArgs.kt +50 -0
  19. package/android/src/main/java/expo/modules/themereveal/WindowSnapshot.kt +536 -0
  20. package/build/ThemeRevealModule.d.ts +11 -0
  21. package/build/ThemeRevealModule.d.ts.map +1 -0
  22. package/build/ThemeRevealModule.js +11 -0
  23. package/build/ThemeRevealModule.js.map +1 -0
  24. package/build/createThemeReveal.d.ts +39 -0
  25. package/build/createThemeReveal.d.ts.map +1 -0
  26. package/build/createThemeReveal.js +186 -0
  27. package/build/createThemeReveal.js.map +1 -0
  28. package/build/index.d.ts +9 -0
  29. package/build/index.d.ts.map +1 -0
  30. package/build/index.js +5 -0
  31. package/build/index.js.map +1 -0
  32. package/build/originFromPress.d.ts +12 -0
  33. package/build/originFromPress.d.ts.map +1 -0
  34. package/build/originFromPress.js +26 -0
  35. package/build/originFromPress.js.map +1 -0
  36. package/build/resolveTransition.d.ts +13 -0
  37. package/build/resolveTransition.d.ts.map +1 -0
  38. package/build/resolveTransition.js +43 -0
  39. package/build/resolveTransition.js.map +1 -0
  40. package/build/store.d.ts +8 -0
  41. package/build/store.d.ts.map +1 -0
  42. package/build/store.js +23 -0
  43. package/build/store.js.map +1 -0
  44. package/build/types.d.ts +31 -0
  45. package/build/types.d.ts.map +1 -0
  46. package/build/types.js +2 -0
  47. package/build/types.js.map +1 -0
  48. package/expo-module.config.json +9 -0
  49. package/ios/Motion.swift +171 -0
  50. package/ios/RevealOverlay.swift +115 -0
  51. package/ios/SVGPath.swift +373 -0
  52. package/ios/ThemeReveal.podspec +26 -0
  53. package/ios/ThemeRevealModule.swift +264 -0
  54. package/mocks/ThemeReveal.js +10 -0
  55. package/package.json +67 -4
  56. package/src/ThemeRevealModule.ts +24 -0
  57. package/src/createThemeReveal.ts +262 -0
  58. package/src/index.ts +14 -0
  59. package/src/originFromPress.ts +40 -0
  60. package/src/resolveTransition.ts +63 -0
  61. package/src/store.ts +30 -0
  62. 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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }