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
package/README.md
CHANGED
|
@@ -1,3 +1,735 @@
|
|
|
1
|
-
#
|
|
1
|
+
# react-native-theme-reveal
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Animated theme switches for React Native, done with a native snapshot of the window: a reveal from the touch
|
|
4
|
+
point, an inverse reveal, a wipe or a crossfade.
|
|
5
|
+
|
|
6
|
+
<p align="center">
|
|
7
|
+
<a href="docs/media/hero-star-reveal.mp4"><img src="docs/media/hero-star-reveal.gif" width="300" alt="A star-shaped reveal grows from the touch point and shows the light theme over the dark one"></a>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
Right before your app applies the new theme, the package snapshots the window and lays the snapshot over your app.
|
|
11
|
+
The theme changes underneath, then the snapshot animates away. Your components render the new theme once, and
|
|
12
|
+
nothing has to interpolate colours per component.
|
|
13
|
+
|
|
14
|
+
- Expo module, New Architecture only. Bare React Native apps can use it through `install-expo-modules`, but I
|
|
15
|
+
haven't verified that setup.
|
|
16
|
+
- Android 8.0+ (API 26) and iOS 16.4+.
|
|
17
|
+
- No runtime JS dependencies besides React, React Native and Expo.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
Requirements:
|
|
22
|
+
- Expo SDK 57 or newer (React Native 0.86 or newer), New Architecture.
|
|
23
|
+
- iOS: deployment target 16.4 or higher.
|
|
24
|
+
- Android: installs on API 24 and up, but the animation needs API 26. On API 24 and 25 the theme switches without
|
|
25
|
+
animating.
|
|
26
|
+
|
|
27
|
+
In an Expo project:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npx expo install react-native-theme-reveal
|
|
31
|
+
npx expo prebuild
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
There's native code in here, so it won't run in Expo Go. Use a development build (`npx expo run:android`,
|
|
35
|
+
`npx expo run:ios`).
|
|
36
|
+
|
|
37
|
+
In a bare React Native project, set up Expo modules first (I haven't verified this setup):
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
npx install-expo-modules@latest
|
|
41
|
+
npm install react-native-theme-reveal
|
|
42
|
+
cd ios && pod install
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick start
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
import { useRef, useSyncExternalStore } from 'react'
|
|
49
|
+
import { Pressable, Text, View } from 'react-native'
|
|
50
|
+
import type { GestureResponderEvent } from 'react-native'
|
|
51
|
+
import { createThemeReveal, originFromPress } from 'react-native-theme-reveal'
|
|
52
|
+
|
|
53
|
+
type Theme = 'light' | 'dark'
|
|
54
|
+
|
|
55
|
+
// getTheme has to read the theme synchronously, hence a small store outside React.
|
|
56
|
+
let theme: Theme = 'light'
|
|
57
|
+
const listeners = new Set<() => void>()
|
|
58
|
+
const themeStore = {
|
|
59
|
+
get: () => theme,
|
|
60
|
+
set: (next: Theme) => {
|
|
61
|
+
theme = next
|
|
62
|
+
listeners.forEach((listener) => listener())
|
|
63
|
+
},
|
|
64
|
+
subscribe: (listener: () => void) => {
|
|
65
|
+
listeners.add(listener)
|
|
66
|
+
return () => {
|
|
67
|
+
listeners.delete(listener)
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// One instance per app, at module scope.
|
|
73
|
+
const reveal = createThemeReveal<Theme>({ getTheme: themeStore.get, setTheme: themeStore.set })
|
|
74
|
+
|
|
75
|
+
export default function App() {
|
|
76
|
+
const current = useSyncExternalStore(themeStore.subscribe, themeStore.get)
|
|
77
|
+
reveal.useStart() // once, near the root
|
|
78
|
+
const buttonRef = useRef<View>(null)
|
|
79
|
+
|
|
80
|
+
const onPress = async (event: GestureResponderEvent) => {
|
|
81
|
+
const origin = await originFromPress(event, buttonRef.current) // the ref is the fallback
|
|
82
|
+
if (origin) reveal.setOrigin(origin.point, origin.viewTag)
|
|
83
|
+
await reveal.switchTo(themeStore.get() === 'light' ? 'dark' : 'light')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<View style={{ flex: 1, backgroundColor: current === 'light' ? '#fff' : '#111' }}>
|
|
88
|
+
<Pressable ref={buttonRef} onPress={onPress}>
|
|
89
|
+
<Text style={{ color: current === 'light' ? '#111' : '#fff' }}>Switch theme</Text>
|
|
90
|
+
</Pressable>
|
|
91
|
+
</View>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- `getTheme` has to return the current theme synchronously. React state lags behind, so keep the theme somewhere
|
|
97
|
+
you can read right away, like the store above.
|
|
98
|
+
- `setTheme` has to update what React renders in the same tick, which the store above does.
|
|
99
|
+
- Create the instance at module scope, like above. Creating it during render trips the React hooks lint rules and
|
|
100
|
+
the React Compiler.
|
|
101
|
+
- `originFromPress` reads the touch point from the press event. Presses from a keyboard, switch control or screen
|
|
102
|
+
reader don't have one, so it measures the view you pass and uses its centre instead. That fallback is covered by
|
|
103
|
+
unit tests, but I haven't checked a TalkBack or VoiceOver activation on a real device.
|
|
104
|
+
- The default transition is `reveal` when there's an origin and `crossfade` when there isn't.
|
|
105
|
+
|
|
106
|
+
The example app in `example/` has every transition, a modal, the keyboard case and the benchmark screen.
|
|
107
|
+
|
|
108
|
+
## Snapshot on press-in
|
|
109
|
+
|
|
110
|
+
By default `switchTo` takes the snapshot when the press ends. With `reveal.pressCapture` the package takes it when
|
|
111
|
+
the press starts instead, so the snapshot is ready by the release and the animation starts sooner. Spread it on the
|
|
112
|
+
`Pressable` whose `onPress` calls `switchTo`:
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
<Pressable ref={buttonRef} {...reveal.pressCapture} onPress={onPress}>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- `onPressIn` takes the snapshot and keeps it off screen, so the screen stays live while the finger is down.
|
|
119
|
+
- `onPress` calls `switchTo`, which puts the held snapshot on screen right away.
|
|
120
|
+
- A press that ends without `onPress` (the finger slid off the button, or a scroll took over) only gets
|
|
121
|
+
`onPressOut`, which frees the snapshot. It does that in a later macrotask, because on release React Native calls
|
|
122
|
+
`onPressOut` either before `onPress` in the same dispatch or, for a short press, up to 130 ms after it. A late
|
|
123
|
+
`onPressOut` from an earlier press leaves the next press's snapshot alone.
|
|
124
|
+
- In `onPress`, call `switchTo` before any `await` that waits on native work. Otherwise the deferred `onPressOut`
|
|
125
|
+
can free the snapshot first, and the switch takes a new one at release as if you weren't using `pressCapture`.
|
|
126
|
+
`await originFromPress(event)` is fine when the event has a touch point.
|
|
127
|
+
- The press-in point becomes the origin for the next switch, like `setOrigin`. A later `setOrigin` call wins.
|
|
128
|
+
- Every press-in costs a full-window snapshot, even when the touch turns into a scroll, so don't use
|
|
129
|
+
`pressCapture` on buttons inside a scrolling list. On Android the press-in snapshot takes the same path as a
|
|
130
|
+
snapshot at release (see [How it works on Android](#how-it-works-on-android)):
|
|
131
|
+
- API 29 and up, the GPU snapshot: a window-size hardware bitmap (about 10 MB of GPU memory at 1080 x 2400) for
|
|
132
|
+
as long as the snapshot is held, and 2-8 ms on the main thread. It's ready 10-30 ms after the capture starts on
|
|
133
|
+
a Mi 11 Lite and a Pixel 8 Pro.
|
|
134
|
+
- API 26 to 28, `PixelCopy`: a window-size bitmap (about 10 MB) and 25-47 ms of GPU readback on a Mi 11 Lite.
|
|
135
|
+
The bitmap is the one the warm-up keeps, and it goes back there if no switch uses the snapshot.
|
|
136
|
+
|
|
137
|
+
The catch is that the snapshot shows the screen at press-in, not at release. In pixels, that means:
|
|
138
|
+
- The button shows up the way it looked just before the press, with no press feedback. React Native draws the
|
|
139
|
+
pressed style and the ripple in a frame after `onPressIn`, so the snapshot doesn't have them. At release the
|
|
140
|
+
feedback goes away at once, hidden under the snapshot.
|
|
141
|
+
- Without `pressCapture`, the snapshot at release shows the pressed look, so the old screen keeps the press
|
|
142
|
+
feedback until the animation uncovers it.
|
|
143
|
+
- Anything that moved during the press (a clock, a running animation, a progress bar) shows as it was at press-in.
|
|
144
|
+
The snapshot is one press old.
|
|
145
|
+
|
|
146
|
+
I checked this on a Mi 11 Lite and a Pixel 8 Pro with a button that has a pressed background colour and
|
|
147
|
+
`android_ripple`, using both the `PixelCopy` snapshot and the GPU snapshot. The first overlay frame matched the idle
|
|
148
|
+
button with `pressCapture` and the pressed button without it, and outside the reveal hole the press-in snapshot
|
|
149
|
+
matched the screen at press-in pixel for pixel. The screen stays live during the hold: with a 300 ms hold, the
|
|
150
|
+
pressed colour and the ripple stayed visible until the release on both phones. iOS takes its snapshot the same way
|
|
151
|
+
(`afterScreenUpdates: false`), but I couldn't check the look there because the simulator can't hold a press.
|
|
152
|
+
|
|
153
|
+
If you need the exact release frame, skip `pressCapture`.
|
|
154
|
+
|
|
155
|
+
The switch falls back to a new snapshot at release when:
|
|
156
|
+
- no switch used the held snapshot within 3 s. The package then frees it, or hands it back as the kept bitmap (see
|
|
157
|
+
[Warm-up](#warm-up)), so a long press doesn't hold on to a second full-window bitmap (about 10 MB at 1080 x 2400
|
|
158
|
+
on Android). The press-in point stays the origin.
|
|
159
|
+
- the window changed size, or the switch comes from another window (on Android, another modal).
|
|
160
|
+
- the app went to the background, `switchTo` got the current theme, or a newer press-in replaced the snapshot.
|
|
161
|
+
- another switch started while the press-in snapshot was being taken, so the snapshot could show the screen from
|
|
162
|
+
before that switch. That holds even when the other switch ended without an animation.
|
|
163
|
+
- on iOS, a modal was presented during the press, or finished closing during it.
|
|
164
|
+
- a switch animation was on screen at press-in. The package takes no snapshot then, since it would capture that
|
|
165
|
+
animation's current frame.
|
|
166
|
+
|
|
167
|
+
On Android, if the release comes before the press-in snapshot is ready, the switch waits for it and shows it. It
|
|
168
|
+
doesn't take a second one.
|
|
169
|
+
|
|
170
|
+
iOS has the same API. There the package takes the key window's `snapshotView(afterScreenUpdates: false)` at
|
|
171
|
+
press-in and adds it to the window at release. That snapshot is cheap on iOS anyway, so the gain is small. iOS
|
|
172
|
+
ignores the view tag for the press-in snapshot, because it always snapshots the key window. Modals open in that
|
|
173
|
+
same window, so the package also compares the window's top presented view controller. If a modal was presented
|
|
174
|
+
during the press, or finished closing during it, the switch takes a new snapshot. A modal that's still closing at
|
|
175
|
+
release counts as presented, so the switch uses the press-in snapshot. That snapshot shows the modal where it was
|
|
176
|
+
at press-in, even though it's partway through its closing animation by then.
|
|
177
|
+
|
|
178
|
+
## Transitions
|
|
179
|
+
|
|
180
|
+
| | |
|
|
181
|
+
|---|---|
|
|
182
|
+
| <a href="docs/media/reveal.mp4"><img src="docs/media/reveal.gif" width="220" alt="Reveal"></a> | `reveal`: a hole opens in the old screen at the origin and grows to show the new theme. It's a circle, or a shape you supply. Default 450 ms. |
|
|
183
|
+
| <a href="docs/media/inverse-reveal.mp4"><img src="docs/media/inverse-reveal.gif" width="220" alt="Inverse reveal"></a> | `inverseReveal`: the old screen stays visible only inside the shape, and the shape shrinks into the origin. This clip uses the star shape. Default 450 ms. |
|
|
184
|
+
| <a href="docs/media/wipe.mp4"><img src="docs/media/wipe.gif" width="220" alt="Wipe up"></a> | `wipe`: a straight edge sweeps across the screen and uncovers the new theme. `direction` is the way the edge travels, so with `up` the edge moves up and the new theme comes in from the bottom. Defaults to `up` and 400 ms. |
|
|
185
|
+
| <a href="docs/media/crossfade.mp4"><img src="docs/media/crossfade.gif" width="220" alt="Crossfade"></a> | `crossfade`: the old screen fades out. Default 300 ms. |
|
|
186
|
+
|
|
187
|
+
The clips are screen recordings of the example app (release build) on a Pixel 8 Pro, cut to 30 fps with the status
|
|
188
|
+
bar cropped out. Click one to get the MP4.
|
|
189
|
+
|
|
190
|
+
Motion:
|
|
191
|
+
- `reveal` grows the shape from 24 pt tall to whatever scale covers the farthest window corner, following
|
|
192
|
+
`scale(g) = from * (to / from) ^ (g ^ 1.4)` with `g` linear in time. `inverseReveal` runs the same curve
|
|
193
|
+
backwards.
|
|
194
|
+
- `wipe` and `crossfade` use a cubic ease in-out (`0.65, 0, 0.35, 1`).
|
|
195
|
+
|
|
196
|
+
## API
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
import {
|
|
200
|
+
createThemeReveal,
|
|
201
|
+
originFromPress,
|
|
202
|
+
resolveTransition,
|
|
203
|
+
ThemeRevealModule,
|
|
204
|
+
} from 'react-native-theme-reveal'
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### `createThemeReveal<T>(options): ThemeReveal<T>`
|
|
208
|
+
|
|
209
|
+
Create one instance per app, at module scope (outside render).
|
|
210
|
+
|
|
211
|
+
| Option | Type | Default | |
|
|
212
|
+
|---|---|---|---|
|
|
213
|
+
| `getTheme` | `() => T` | required | Returns the current theme. Must be synchronous. |
|
|
214
|
+
| `setTheme` | `(theme: T) => void` | required | Applies the theme. Called while the snapshot is on screen, and has to update what React renders in the same tick (see below). |
|
|
215
|
+
| `transition` | `'reveal' \| 'inverseReveal' \| 'wipe' \| 'crossfade'` | `reveal` with an origin, else `crossfade` | App-wide default. |
|
|
216
|
+
| `shape` | `Shape` | a circle | Default shape for `reveal` and `inverseReveal`. |
|
|
217
|
+
| `wipeDirection` | `'up' \| 'down' \| 'left' \| 'right'` | `'up'` | Default direction for `wipe`. |
|
|
218
|
+
| `durations` | `Partial<Record<TransitionType, number>>` | 450 / 450 / 400 / 300 ms | Per transition type, in ms. Anything non-finite, 0 or below, or over 10 000 falls back to the default. |
|
|
219
|
+
| `onError` | `(error: unknown) => void` | `console.warn` | Receives native errors. The theme still applies if the snapshot fails. |
|
|
220
|
+
| `onSettled` | `(info: SettledInfo) => void` | none | Called when a switch finishes, with `{ transition, capture, prepareMs, totalMs }`. |
|
|
221
|
+
| `warmUp` | `boolean` | `true` | Prepares the native snapshot path when `useStart` mounts. On API 29 and up that's the GPU snapshot, below that it keeps one window-size bitmap. See [Warm-up](#warm-up). |
|
|
222
|
+
|
|
223
|
+
- `setTheme` has to update what React renders in the same tick. Don't `await` anything before the update and don't
|
|
224
|
+
wrap it in `startTransition`. If the update is deferred, the animation runs over the old theme and the new one
|
|
225
|
+
appears all at once afterwards.
|
|
226
|
+
- If `setTheme` throws, the package removes the snapshot and releases the held theme, and `switchTo` rejects with
|
|
227
|
+
that error. That switch gets no `onSettled` call.
|
|
228
|
+
- In `onSettled`, `transition` is `null` when no snapshot was taken (the theme switched without animating). If a
|
|
229
|
+
snapshot was taken, `transition` is the type, even when native ended up showing no animation (say, Android's
|
|
230
|
+
animator duration scale is 0).
|
|
231
|
+
- `capture` says how the snapshot was taken: `'gpu'` (API 29 and up, see
|
|
232
|
+
[How it works on Android](#how-it-works-on-android)), `'pixelcopy'` (Android readback) or `'view'` (iOS
|
|
233
|
+
`snapshotView`). It's `null` when no snapshot was taken.
|
|
234
|
+
- If starting the animation fails, `onError` runs first, then `onSettled`.
|
|
235
|
+
- Same-theme calls get no `onSettled`. A call that a newer call replaced before its snapshot was taken gets no
|
|
236
|
+
`onSettled` either, but one replaced mid-animation still does.
|
|
237
|
+
- If `onError` or `onSettled` throws, the error goes to `console.warn` and the theme still applies.
|
|
238
|
+
|
|
239
|
+
### `reveal.setOrigin(point, viewTag?)`
|
|
240
|
+
|
|
241
|
+
Sets the origin for the next switch only. `point` is in `pageX` / `pageY` space and `viewTag` is the tapped view.
|
|
242
|
+
`originFromPress` returns both. Call it from the press handler, before `switchTo`.
|
|
243
|
+
- Android: `viewTag` tells the package which window to snapshot (see [Modals](#modals)).
|
|
244
|
+
- iOS: `viewTag` identifies the React root that `point` is measured in. Inside a `pageSheet` or `formSheet` modal
|
|
245
|
+
that root isn't at the top of the window, so pass the tag there. Without a tag, `point` is treated as window
|
|
246
|
+
points.
|
|
247
|
+
|
|
248
|
+
### `reveal.switchTo(theme, options?): Promise<void>`
|
|
249
|
+
|
|
250
|
+
Switches to `theme` with an animation.
|
|
251
|
+
|
|
252
|
+
| Option | Type | |
|
|
253
|
+
|---|---|---|
|
|
254
|
+
| `transition` | `TransitionType` | This switch only. |
|
|
255
|
+
| `shape` | `Shape` | This switch only. |
|
|
256
|
+
| `wipeDirection` | `WipeDirection` | This switch only. |
|
|
257
|
+
|
|
258
|
+
- The most specific choice wins: the `switchTo` option, then the factory option, then the default.
|
|
259
|
+
- `reveal` and `inverseReveal` need an origin, and fall back to `crossfade` without one.
|
|
260
|
+
- If `theme` is already current, `switchTo` removes any snapshot that's on screen, frees a press-in snapshot and
|
|
261
|
+
returns.
|
|
262
|
+
- The newest `switchTo` call wins. An older call still waiting for its snapshot does nothing.
|
|
263
|
+
- If the snapshot fails, `setTheme` still runs without animation. There's one case on Android API 29 to 33 where
|
|
264
|
+
it waits instead, see [How it works on Android](#how-it-works-on-android).
|
|
265
|
+
- The promise resolves once the theme is set, before the animation ends. Use `onSettled` if you need the end.
|
|
266
|
+
- It only rejects if `getTheme` or `setTheme` throws.
|
|
267
|
+
|
|
268
|
+
### `reveal.pressCapture`
|
|
269
|
+
|
|
270
|
+
`{ onPressIn(event), onPressOut() }` for a `Pressable`. See [Snapshot on press-in](#snapshot-on-press-in).
|
|
271
|
+
- `onPressIn` calls `capture` with the touch point (`pageX`, `pageY`) and `event.nativeEvent.target`. If the event
|
|
272
|
+
has no finite touch point, it does nothing and the switch takes its snapshot at release.
|
|
273
|
+
- `onPressOut` frees the press-in snapshot in a later macrotask, if no `switchTo` has used it by then. Pass it the
|
|
274
|
+
press event (`Pressable` does). An `onPressOut` whose touch is older than the snapshot's press-in is ignored.
|
|
275
|
+
|
|
276
|
+
### `reveal.capture(point, viewTag?)`
|
|
277
|
+
|
|
278
|
+
Takes the snapshot now and keeps it off screen for the next `switchTo`. It also sets `point` and `viewTag` as the
|
|
279
|
+
origin for the next switch, like `setOrigin`.
|
|
280
|
+
- A second `capture` replaces the first.
|
|
281
|
+
- If no switch uses the snapshot within 3 s, the package frees it. The point stays the origin until the press ends.
|
|
282
|
+
- If the native capture fails, the error goes to `onError` and the next switch takes its snapshot as usual.
|
|
283
|
+
|
|
284
|
+
### `reveal.dropCapture()`
|
|
285
|
+
|
|
286
|
+
Frees the press-in snapshot. It also clears the origin that `capture` set, unless `setOrigin` set a newer one.
|
|
287
|
+
|
|
288
|
+
### `reveal.useStart()`
|
|
289
|
+
|
|
290
|
+
Hook that starts the animation once React has committed the new theme. Mount it once, in a component that
|
|
291
|
+
re-renders when the theme changes (the root is a good spot). The first time it mounts, it also warms up the native
|
|
292
|
+
side (see [Warm-up](#warm-up)).
|
|
293
|
+
|
|
294
|
+
### `reveal.useHeldTheme(): T | null`
|
|
295
|
+
|
|
296
|
+
Hook that returns the previous theme while an animation is running, and `null` otherwise. Use it for anything
|
|
297
|
+
outside the snapshot that needs to keep the old look until the animation ends, like the system bar style:
|
|
298
|
+
|
|
299
|
+
```tsx
|
|
300
|
+
const held = reveal.useHeldTheme()
|
|
301
|
+
const barTheme = held ?? theme
|
|
302
|
+
|
|
303
|
+
return <StatusBar barStyle={barTheme === 'light' ? 'dark-content' : 'light-content'} />
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
The system bars stay live above the snapshot, so if you switch their style right away, they change before the
|
|
307
|
+
animation has shown the new theme.
|
|
308
|
+
|
|
309
|
+
### `originFromPress(event, fallbackView?): Promise<PressOrigin | null>`
|
|
310
|
+
|
|
311
|
+
Returns `{ point, viewTag }` for a press event, where `point` is the touch point (`pageX`, `pageY`) and `viewTag`
|
|
312
|
+
is `event.nativeEvent.target`. If the event has no finite touch point, it measures `fallbackView` with
|
|
313
|
+
`measureInWindow` and returns its centre, or `null` if you didn't pass a `fallbackView`.
|
|
314
|
+
|
|
315
|
+
Call it synchronously in the press handler, before any `await`, and pass a view that's mounted. React Native never
|
|
316
|
+
calls back `measureInWindow` for an unmounted view, so the promise would never settle.
|
|
317
|
+
|
|
318
|
+
### `resolveTransition(input): Transition`
|
|
319
|
+
|
|
320
|
+
Pure function that picks the transition, shape, direction and duration for a switch. `switchTo` uses it
|
|
321
|
+
internally, and it's exported for tests and custom flows.
|
|
322
|
+
|
|
323
|
+
### `ThemeRevealModule`
|
|
324
|
+
|
|
325
|
+
The native module, with `prepare(viewTag?)`, `start(transition)`, `cancel()`, `warmUp()`, `capture(viewTag?)` and
|
|
326
|
+
`dropCapture()`. `createThemeReveal` calls it for you, so normally you won't need it. `prepare` resolves to the
|
|
327
|
+
`CapturePath`, or `null` when it shows no snapshot.
|
|
328
|
+
|
|
329
|
+
### Types
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
type Point = { x: number; y: number }
|
|
333
|
+
type Shape = { path: string; anchor: Point; inscribedRadius: number }
|
|
334
|
+
type WipeDirection = 'up' | 'down' | 'left' | 'right'
|
|
335
|
+
type Transition =
|
|
336
|
+
| { type: 'reveal'; origin: Point; durationMs: number; shape?: Shape }
|
|
337
|
+
| { type: 'inverseReveal'; origin: Point; durationMs: number; shape?: Shape }
|
|
338
|
+
| { type: 'wipe'; direction: WipeDirection; durationMs: number }
|
|
339
|
+
| { type: 'crossfade'; durationMs: number }
|
|
340
|
+
type TransitionType = Transition['type']
|
|
341
|
+
type CapturePath = 'gpu' | 'pixelcopy' | 'view'
|
|
342
|
+
type SettledInfo = {
|
|
343
|
+
transition: TransitionType | null
|
|
344
|
+
capture: CapturePath | null
|
|
345
|
+
prepareMs: number
|
|
346
|
+
totalMs: number
|
|
347
|
+
}
|
|
348
|
+
type SwitchOptions = { transition?: TransitionType; shape?: Shape; wipeDirection?: WipeDirection }
|
|
349
|
+
type PressOrigin = { point: Point; viewTag?: number }
|
|
350
|
+
type PressCapture = { onPressIn(event: GestureResponderEvent): void; onPressOut(event?: GestureResponderEvent): void }
|
|
351
|
+
type MeasurableView = {
|
|
352
|
+
measureInWindow(callback: (x: number, y: number, width: number, height: number) => void): void
|
|
353
|
+
}
|
|
354
|
+
type ResolveTransitionInput = {
|
|
355
|
+
requested?: TransitionType // the switchTo option
|
|
356
|
+
fallback?: TransitionType // the factory option
|
|
357
|
+
origin: Point | null
|
|
358
|
+
shape?: Shape
|
|
359
|
+
durations?: Partial<Record<TransitionType, number>>
|
|
360
|
+
wipeDirection?: WipeDirection
|
|
361
|
+
requestedShape?: Shape
|
|
362
|
+
requestedWipeDirection?: WipeDirection
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
`ThemeRevealOptions<T>` is the options object for `createThemeReveal` (the table above) and `ThemeReveal<T>` is
|
|
367
|
+
what it returns. All of these types are exported.
|
|
368
|
+
|
|
369
|
+
## Shape format
|
|
370
|
+
|
|
371
|
+
A shape is SVG path data plus two numbers that position and scale it, all in path units.
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
const STAR: Shape = {
|
|
375
|
+
path:
|
|
376
|
+
'M50 0 L62.93 32.2 L97.55 34.55 L70.92 56.8 L79.39 90.45 L50 72 ' +
|
|
377
|
+
'L20.61 90.45 L29.08 56.8 L2.45 34.55 L37.07 32.2 Z',
|
|
378
|
+
anchor: { x: 50, y: 50 },
|
|
379
|
+
inscribedRadius: 21.99,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const onPress = async (event: GestureResponderEvent) => {
|
|
383
|
+
const origin = await originFromPress(event)
|
|
384
|
+
if (origin) reveal.setOrigin(origin.point, origin.viewTag) // a reveal with no origin falls back to crossfade
|
|
385
|
+
await reveal.switchTo('dark', { transition: 'reveal', shape: STAR })
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
- `path`: SVG path data, filled with the nonzero rule on both platforms.
|
|
390
|
+
- `anchor`: the point in the shape that lands on the origin.
|
|
391
|
+
- `inscribedRadius`: distance from the anchor to the nearest edge of the shape. The package uses it to work out
|
|
392
|
+
the scale at which the shape covers the whole window, so use a value at or below the real distance. Too large a
|
|
393
|
+
value makes the final scale too small, and whatever the shape missed snaps to the new theme at the end.
|
|
394
|
+
- Separate the arc flags (`a9 9 0 0 1 18 0`, not `a9 9 0 0118 0`). Android's parser can't split compact flags,
|
|
395
|
+
although iOS reads them fine.
|
|
396
|
+
- If the path doesn't parse, the package falls back to the circle and logs a warning naming the path. On Android,
|
|
397
|
+
a path with compact arc flags ends up as the circle.
|
|
398
|
+
|
|
399
|
+
## How it works on Android
|
|
400
|
+
|
|
401
|
+
How the package takes the snapshot depends on the API level.
|
|
402
|
+
|
|
403
|
+
On API 29 and up it draws the window's views again, on the GPU, into a hardware bitmap. No layout and no JS run for
|
|
404
|
+
it, and elevation shadows use the same light as the window. No pixels make the trip from the GPU to the CPU and
|
|
405
|
+
back.
|
|
406
|
+
|
|
407
|
+
On API 26 to 28 it reads the window back from the GPU with `PixelCopy` and then uploads it again. API 29 and up
|
|
408
|
+
falls back to `PixelCopy` too when:
|
|
409
|
+
- a window uses wide color mode, since the GPU snapshot is sRGB,
|
|
410
|
+
- a window has no hardware acceleration,
|
|
411
|
+
- the theme has no light values for shadows (`lightY`, `lightZ`, `lightRadius`, `ambientShadowAlpha`,
|
|
412
|
+
`spotShadowAlpha`),
|
|
413
|
+
- a view change is waiting for its frame while force dark can apply: a light theme in night mode, with
|
|
414
|
+
`forceDarkAllowed` or, from API 36, the system's color inversion,
|
|
415
|
+
- the GPU snapshot fails. If no snapshot comes within 250 ms, or the GPU snapshot throws, the app sticks with
|
|
416
|
+
`PixelCopy` until its process ends. The exception is a slow warm-up: if the 250 ms run out while the warm-up
|
|
417
|
+
still holds the copy thread, only that switch uses `PixelCopy` and the next one tries the GPU snapshot again. If
|
|
418
|
+
the warm-up is still holding the thread at a later switch, that switch goes straight to `PixelCopy` without
|
|
419
|
+
waiting the 250 ms.
|
|
420
|
+
|
|
421
|
+
A warm-up that never ends is a problem on API 29 to 33, because there the `PixelCopy` request runs on the copy
|
|
422
|
+
thread too. The `PixelCopy` snapshot then waits behind the warm-up, and so do the switch and `setTheme`. I haven't
|
|
423
|
+
measured this, since no test made the warm-up hang. On API 34 and up the `PixelCopy` request runs on the main
|
|
424
|
+
thread and doesn't wait. The package also frees each GPU snapshot's renderer on the copy thread, so if that thread
|
|
425
|
+
hangs, those renderers stay in memory until the process ends.
|
|
426
|
+
|
|
427
|
+
I compared the GPU snapshot with `PixelCopy` on a Mi 11 Lite (API 33) and a Pixel 8 Pro (API 37). On the home
|
|
428
|
+
screen, the benchmark screen, a screen with elevation shadows and a full-screen modal, not a single pixel differs.
|
|
429
|
+
With a transparent modal and a dim, 0.6% to 0.7% of the pixels differ by 1 or 2 out of 255 in a channel, at
|
|
430
|
+
translucent edges like text under the dim or the edge of the card's shadow. The GPU blends the dim and the dialog
|
|
431
|
+
in one pass, while `PixelCopy` reads back each window and the CPU blends them, so the rounding comes out
|
|
432
|
+
differently. You can't see it.
|
|
433
|
+
|
|
434
|
+
The GPU snapshot has a few other quirks:
|
|
435
|
+
- A view that changed since the window's last frame draws again first, so the snapshot can show the change one
|
|
436
|
+
frame before the screen does. Any window with such a change then redraws in full, so the change can't go
|
|
437
|
+
missing on screen. That's true during a press-in hold too.
|
|
438
|
+
- The TalkBack focus rectangle isn't in it, because the window draws that rectangle outside its views.
|
|
439
|
+
- A screenshot that draws the window into a software canvas (`View.draw`) while the overlay is up leaves the
|
|
440
|
+
overlay out, because a software canvas can't draw a hardware bitmap.
|
|
441
|
+
- I haven't checked a `WebView`, a `TextureView` or a `SurfaceView`, and no test compared the GPU snapshot with
|
|
442
|
+
the screen for them. The GPU snapshot draws the views in its own `HardwareRenderer`. A `WebView` draws through a
|
|
443
|
+
functor in that renderer, and a `SurfaceView` gets that renderer's frame number for its position. If your app
|
|
444
|
+
shows one of these, compare a snapshot with the screen.
|
|
445
|
+
|
|
446
|
+
The `PixelCopy` snapshot matches the screen exactly for the activity window. Under a transparent modal's dim on a
|
|
447
|
+
Pixel 8 Pro, 186 single pixels at text edges were off by up to 5 out of 255 in a channel (2%), from the CPU blend
|
|
448
|
+
of the dim and the dialog. The press-in snapshot has its own difference, see
|
|
449
|
+
[Snapshot on press-in](#snapshot-on-press-in).
|
|
450
|
+
|
|
451
|
+
Each switch reports which path it took in `onSettled`, as `capture`: `'gpu'` or `'pixelcopy'`.
|
|
452
|
+
|
|
453
|
+
The overlay lives in the `ViewOverlay` of the window's root view, in the activity window or, for a switch from a
|
|
454
|
+
modal, the dialog window. It draws above every view of that window, including any view the app or the system adds
|
|
455
|
+
during the animation. It takes no touches and is hidden from accessibility.
|
|
456
|
+
|
|
457
|
+
### Circle reveal on the RenderThread
|
|
458
|
+
|
|
459
|
+
On API 29 and up, the default circle (`reveal` or `inverseReveal` with no `shape`) animates on the RenderThread with
|
|
460
|
+
`ViewAnimationUtils.createCircularReveal`, so the UI thread doesn't redraw the overlay every frame.
|
|
461
|
+
- `reveal`: the overlay holds still while the next frame draws the new theme. In the frame after that, the package
|
|
462
|
+
takes a second GPU snapshot of the same window or windows, this time without the overlay. That second snapshot
|
|
463
|
+
goes on top of the first, and a circular clip on it grows from the origin.
|
|
464
|
+
- `inverseReveal`: a circular clip on the first snapshot shrinks into the origin. There's no second snapshot.
|
|
465
|
+
- Both follow the same radius curve as the UI-thread reveal (see [Transitions](#transitions)).
|
|
466
|
+
|
|
467
|
+
For `reveal`, this changes what you see:
|
|
468
|
+
- Inside the circle you get a still image of the new screen for the length of the reveal, where before it showed
|
|
469
|
+
the live app. An animation or a video in your app won't move inside the circle until the reveal ends.
|
|
470
|
+
- The circle starts 2 to 3 frames after `start`, once the second snapshot is in. The reveal keeps its duration,
|
|
471
|
+
so it also ends that much later.
|
|
472
|
+
|
|
473
|
+
The UI-thread reveal still runs:
|
|
474
|
+
- on API 26 to 28,
|
|
475
|
+
- when the first snapshot came from `PixelCopy` (see the list above),
|
|
476
|
+
- when the transition has a custom `shape`,
|
|
477
|
+
- for one switch, when the second snapshot doesn't come: no frame commit, no result within 200 ms of `start`, a
|
|
478
|
+
failed GPU capture, or a result for another window, size or position. The UI-thread reveal then starts at that
|
|
479
|
+
point, with the full duration. A failed second snapshot doesn't turn the GPU snapshot off,
|
|
480
|
+
- for one switch, when the RenderThread animator doesn't start,
|
|
481
|
+
- for every later `reveal` until the app process ends, once 3 `reveal` switches in a row have fallen back while
|
|
482
|
+
the window stayed open. Each fallback can cost a switch up to 200 ms. `inverseReveal` keeps the RenderThread,
|
|
483
|
+
because it takes no second snapshot.
|
|
484
|
+
|
|
485
|
+
`wipe` and `crossfade` always run on the UI thread.
|
|
486
|
+
|
|
487
|
+
During a press-in hold on API 29 and up, the package also draws the held GPU snapshot once, scaled down, into a
|
|
488
|
+
small offscreen target. That moves the upload of its texture out of the overlay's first frame.
|
|
489
|
+
|
|
490
|
+
## Modals
|
|
491
|
+
|
|
492
|
+
A switch from inside a React Native `<Modal>` animates too.
|
|
493
|
+
|
|
494
|
+
- Android: a modal is its own dialog window. Pass the tapped view's tag (`setOrigin(point, viewTag)`, which
|
|
495
|
+
`originFromPress` gives you) and the package snapshots the activity window, the dim behind the dialog and the
|
|
496
|
+
dialog itself, then puts the overlay in the dialog window. Without a `viewTag` it uses whichever window has
|
|
497
|
+
focus.
|
|
498
|
+
- iOS: a modal is in the same window, so the snapshot includes it.
|
|
499
|
+
|
|
500
|
+
## Reduce Motion and backgrounding
|
|
501
|
+
|
|
502
|
+
- iOS: with Reduce Motion on, the theme switches without animating.
|
|
503
|
+
- Android: with the animator duration scale at 0 (Remove animations), the theme switches without animating.
|
|
504
|
+
- The app has to be in the foreground. If it's paused or in the background, a switch applies the theme without
|
|
505
|
+
animating.
|
|
506
|
+
- If a snapshot never gets its animation start, it's removed after 1 s, so it can't get stuck on screen.
|
|
507
|
+
- A press-in snapshot is freed when the app goes to the background.
|
|
508
|
+
|
|
509
|
+
## Warm-up
|
|
510
|
+
|
|
511
|
+
The first switch in a process used to pay for work that later switches skip. To move that out of the way, with
|
|
512
|
+
`warmUp: true` (the default) `useStart()` calls the native `warmUp` once per instance, the first time it mounts.
|
|
513
|
+
On Android the warm-up starts the snapshot copy thread, off the main thread, and then prepares whichever path the
|
|
514
|
+
activity window's first switch will take (see [How it works on Android](#how-it-works-on-android)).
|
|
515
|
+
|
|
516
|
+
On API 29 and up (the GPU snapshot), the warm-up makes a 1 x 1 GPU snapshot of nothing on the copy thread: an
|
|
517
|
+
`ImageReader`, a `HardwareRenderer` and one drawn frame, which it then frees. It draws no view, so the window isn't
|
|
518
|
+
touched, and it keeps no bitmap, because each GPU snapshot makes its own hardware bitmap and frees it with the
|
|
519
|
+
snapshot. A switch that falls back to `PixelCopy` keeps its bitmap, the same as on API 26 to 28.
|
|
520
|
+
|
|
521
|
+
Then, 500 ms after the mount and once the main thread is next idle, it makes one capture target the size of the
|
|
522
|
+
activity window on the copy thread: an `ImageReader`, a `HardwareRenderer` with its surface, and one allocated
|
|
523
|
+
buffer. It holds on to that target until a snapshot of the activity window uses it, so that snapshot skips the
|
|
524
|
+
setup. I haven't measured what the kept target gains on its own. The package makes the next target the same way,
|
|
525
|
+
500 ms after a switch ends or after a press-in snapshot that no switch used. It waits while an overlay shows, a
|
|
526
|
+
snapshot runs or a press-in snapshot is held, and it tries again when the activity comes back to the foreground.
|
|
527
|
+
A switch that starts while a target is being made waits for it: its snapshot gets its image after that work ends,
|
|
528
|
+
11.5-23.8 ms on a Mi 11 Lite. The warm-up also loads most of the package's classes on its own thread, so the first
|
|
529
|
+
switch doesn't load them on the main thread.
|
|
530
|
+
- What it gains, on the first switch in a fresh process (3 runs each): on a Pixel 8 Pro (API 37) the main thread
|
|
531
|
+
spent 18.2-19.0 ms on the snapshot without the warm-up and 2.9-5.7 ms with it, and the first `ImageReader` in the
|
|
532
|
+
process accounted for 13.4-14.7 ms of that. The snapshot was ready 23.8-28.2 ms after the switch started without
|
|
533
|
+
the warm-up and 10.3-12.9 ms with it. On a Mi 11 Lite (API 33) the first snapshot was already fast (3.9-7.1 ms
|
|
534
|
+
on the main thread), and I couldn't measure any change from the warm-up.
|
|
535
|
+
- What it costs: 30-44 ms on the copy thread at mount on the Pixel 8 Pro and 4 ms on the Mi 11 Lite for the 1 x 1
|
|
536
|
+
snapshot. Each kept target adds 11.5-23.8 ms on the copy thread and 4.4-18.7 ms on the RenderThread on the Mi 11
|
|
537
|
+
Lite (from a system trace).
|
|
538
|
+
- The RenderThread draws your app's frames too, so on a slow phone one app frame can come late while the package
|
|
539
|
+
makes a target: about 0.5 s after the mount and about 0.5 s after each switch. That only matters if your app is
|
|
540
|
+
animating right then. The benchmark can't see it, because its screen sits still after a switch.
|
|
541
|
+
- Memory: one window-size GPU buffer between switches, with a started `HardwareRenderer` and its surface. The
|
|
542
|
+
buffer is 4 bytes per pixel, so about 10 MB at 1080x2400, and the package keeps one at most. I haven't checked it
|
|
543
|
+
with `dumpsys meminfo`.
|
|
544
|
+
- The package frees the kept target when:
|
|
545
|
+
- the app goes to the background (`onTrimMemory` at `TRIM_MEMORY_UI_HIDDEN` or above). Nothing is kept until the
|
|
546
|
+
next snapshot, and the package keeps a new target after that switch.
|
|
547
|
+
- the system is low on memory.
|
|
548
|
+
- the screen size changes.
|
|
549
|
+
- at the next prepare, the activity window has a different size or its snapshot no longer takes the GPU path.
|
|
550
|
+
If the GPU path is on, the package then makes a new target that fits.
|
|
551
|
+
- the activity is destroyed.
|
|
552
|
+
- the GPU snapshot turns itself off.
|
|
553
|
+
- the module is destroyed (a JS reload).
|
|
554
|
+
- A snapshot of a window with a different size frees the target and makes its own. The free runs on the copy
|
|
555
|
+
thread before that snapshot's image gets there, so the image can end up waiting for it.
|
|
556
|
+
|
|
557
|
+
On API 26 to 28 (`PixelCopy`), for an API 29+ activity window that takes `PixelCopy` (see
|
|
558
|
+
[How it works on Android](#how-it-works-on-android)), and in a process where the GPU snapshot switched itself off
|
|
559
|
+
before the warm-up ran, the warm-up allocates one bitmap the size of the window on the copy thread. Then it copies
|
|
560
|
+
the window into that bitmap once and keeps it. The first copy into a fresh bitmap is the slow one, so this way the
|
|
561
|
+
first switch gets a fast copy.
|
|
562
|
+
- Every `PixelCopy` snapshot copies into the kept bitmap and gets it back when the snapshot goes away, and so does
|
|
563
|
+
a press-in snapshot that no switch used. Inside a modal, the copy of the dialog window still allocates a new
|
|
564
|
+
bitmap each time.
|
|
565
|
+
- What it costs: one ARGB bitmap the size of the window, held while the app runs. At 4 bytes per pixel that's
|
|
566
|
+
about 10 MB at 1080x2400. The one copy at mount takes the RenderThread once, for 16-26 ms on a Mi 11 Lite and
|
|
567
|
+
39-60 ms on a Pixel 8 Pro (measured before those phones moved to the GPU snapshot). If your app animates in its
|
|
568
|
+
first frames, one of them can come late. GPU memory didn't grow: on a Pixel 8 Pro, `dumpsys meminfo` showed
|
|
569
|
+
100 MB of Graphics with and without the kept bitmap. I haven't measured other GPUs.
|
|
570
|
+
- The bitmap is freed when the app goes to the background (`onTrimMemory` at `TRIM_MEMORY_UI_HIDDEN` or above),
|
|
571
|
+
on low memory, when the screen size changes and when the activity is destroyed. Nothing is kept while the app is
|
|
572
|
+
in the background, and the next switch keeps a new bitmap.
|
|
573
|
+
|
|
574
|
+
If you have several instances, the warm-up runs as long as any of them has `warmUp: true`. With `warmUp: false`
|
|
575
|
+
there's no warm-up at all (no thread start, no GPU snapshot of nothing, no copy, no kept bitmap and no kept capture
|
|
576
|
+
target), and every snapshot allocates its own bitmap and its own target.
|
|
577
|
+
|
|
578
|
+
On iOS `warmUp` does nothing, since the snapshot there has no thread or bitmap to prepare. If the warm-up fails,
|
|
579
|
+
the error goes to `onError` and switches keep working.
|
|
580
|
+
|
|
581
|
+
### Baseline Profile
|
|
582
|
+
|
|
583
|
+
The Android library ships a Baseline Profile (`android/src/main/baseline-prof.txt`) with all of its classes and
|
|
584
|
+
methods in it. The Android Gradle Plugin packs it into the library's AAR and merges it into the app's
|
|
585
|
+
`assets/dexopt/baseline.prof`, so a Play install compiles that code ahead of time and the first switch doesn't run
|
|
586
|
+
it in the interpreter.
|
|
587
|
+
|
|
588
|
+
A local `adb install` doesn't apply profiles. To get the same result as Play, add `androidx.profileinstaller` 1.4.0
|
|
589
|
+
or later to the app (1.3.x does nothing on Android 15 and later) and run:
|
|
590
|
+
|
|
591
|
+
```sh
|
|
592
|
+
adb shell am broadcast -a androidx.profileinstaller.action.INSTALL_PROFILE <package>/androidx.profileinstaller.ProfileInstallReceiver
|
|
593
|
+
adb shell am force-stop <package>
|
|
594
|
+
adb shell cmd package compile -f -m speed-profile <package>
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
The benchmark in [`benchmark/`](benchmark/) compiles all of the app's code ahead of time for every library, so it
|
|
598
|
+
doesn't show what this profile does for a Play install.
|
|
599
|
+
|
|
600
|
+
## Limits
|
|
601
|
+
|
|
602
|
+
- Android 8.0+ (API 26). Below that the theme still switches, just without animation.
|
|
603
|
+
- New Architecture only.
|
|
604
|
+
- The keyboard isn't in the snapshot. On iOS it changes theme immediately, and on Android it keeps its own theme.
|
|
605
|
+
- Starting a second switch mid-animation ends the first one immediately. Android shows one frame of the end state.
|
|
606
|
+
On iOS the second snapshot captures the first animation's current frame, and the second animation starts from
|
|
607
|
+
that.
|
|
608
|
+
- Android modal: tapping while the modal is still sliding in shows about 2 frames of live content on top of it.
|
|
609
|
+
- Android modal: with stacked modals, only the target dialog ends up in the snapshot.
|
|
610
|
+
- Android modal: if the dialog window is smaller than the screen, the strip outside it stays live.
|
|
611
|
+
- Closing a modal in the same handler that switches ("close on select"). Switch once the modal has closed.
|
|
612
|
+
- Android: the switch doesn't animate.
|
|
613
|
+
- iOS: the reveal plays over a snapshot that still shows the modal.
|
|
614
|
+
- To follow the system theme, listen to `Appearance` and call `switchTo` without an origin, which gives you a
|
|
615
|
+
`crossfade`. The example app does this.
|
|
616
|
+
|
|
617
|
+
## Testing
|
|
618
|
+
|
|
619
|
+
Under `jest-expo` the package's native module mock loads automatically, and `switchTo` applies the theme
|
|
620
|
+
immediately without animating.
|
|
621
|
+
|
|
622
|
+
## Performance
|
|
623
|
+
|
|
624
|
+
Measured on 2026-11-08 with release builds, in two full passes (A and B). The tables below are from pass B.
|
|
625
|
+
Since then the code has only changed in the docs, code comments, debug log text and the module's destroy, and no
|
|
626
|
+
benchmark run takes a path whose behaviour those change. The method, raw dumps and scripts live in
|
|
627
|
+
[`benchmark/`](benchmark/), and the full tables of both passes, the history and the notes are in
|
|
628
|
+
[`benchmark/RESULTS.md`](benchmark/RESULTS.md).
|
|
629
|
+
|
|
630
|
+
In this build, on API 29 and up, the circle reveal runs on the RenderThread from a second snapshot (see
|
|
631
|
+
[Circle reveal on the RenderThread](#circle-reveal-on-the-renderthread)), so the inside of the circle is a still
|
|
632
|
+
image of the new screen for the reveal's 450 ms. The UI-thread reveal still runs on API 26 to 28, for a
|
|
633
|
+
`PixelCopy` snapshot, for a custom shape, and for one switch when the second snapshot fails.
|
|
634
|
+
|
|
635
|
+
Everything runs in one build of the example app, on the same screen, with a 450 ms circle reveal:
|
|
636
|
+
- `ours`: this package.
|
|
637
|
+
- `theme-switch-animation`: `react-native-theme-switch-animation` 0.8.0.
|
|
638
|
+
- `skia`: the Skia technique, `@shopify/react-native-skia` `makeImageFromView` with a circle mask animated by
|
|
639
|
+
Reanimated.
|
|
640
|
+
|
|
641
|
+
| Device | OS | Build | Runs per library |
|
|
642
|
+
|---|---|---|---|
|
|
643
|
+
| Xiaomi Mi 11 Lite (`M2101K9AG`) | Android 13 (API 33) | release, `arm64-v8a`, compiled with `speed` | warm: 1 warm-up + 6 interleaved rounds. Cold: 3 rounds |
|
|
644
|
+
| Pixel 8 Pro | Android 17 (API 37) | release, `arm64-v8a`, compiled with `speed` | warm: 1 warm-up + 6 interleaved rounds. Cold: 3 rounds |
|
|
645
|
+
|
|
646
|
+
A few things about how these were run:
|
|
647
|
+
- Before each pass the benchmark compiles the whole app ahead of time (`cmd package compile -f -m speed`), so all
|
|
648
|
+
three libraries run compiled code. That means it doesn't show what the [Baseline Profile](#baseline-profile)
|
|
649
|
+
does for a Play install.
|
|
650
|
+
- Each pass runs with two kinds of tap, the same for every library. `input tap` holds the press for about 0 ms,
|
|
651
|
+
which no finger does, so there's also a hold form that presses for 100 ms (`input swipe x y x y 100`). The clock
|
|
652
|
+
starts at the release (`onPress`) in both. Ours takes its snapshot on press-in
|
|
653
|
+
([Snapshot on press-in](#snapshot-on-press-in)), so the hold form is closer to what a finger gets.
|
|
654
|
+
- Cold means the first switch in a fresh process: the app is force-stopped 1 s before each run. The screen mounts
|
|
655
|
+
about 1.5 s before the tap, so the warm-up has finished by then. If your app's first switch comes sooner, it
|
|
656
|
+
won't look like this.
|
|
657
|
+
- Every `ours` run took the GPU snapshot (`capture=gpu`). The result files don't record a fallback to the
|
|
658
|
+
UI-thread reveal, but debug passes with the debug log on saw none: one on the Mi 11 Lite with this build, and
|
|
659
|
+
one on the Pixel 8 Pro with an earlier build of the same design.
|
|
660
|
+
- Both phones drew at 60 Hz, except Skia on the Pixel 8 Pro, which drew at 30 Hz.
|
|
661
|
+
|
|
662
|
+
Warm, `input tap` (median of 6). Lower is better in every column.
|
|
663
|
+
|
|
664
|
+
| Device | Library | Overlay frame (ms) | Snapshot, JS (ms) | Frame p50 (ms) | Frame p90 (ms) | Janky (%) | Late frames | Missed vsyncs | JS max gap (ms) |
|
|
665
|
+
|---|---|---|---|---|---|---|---|---|---|
|
|
666
|
+
| Mi 11 Lite | ours | 57.4 | 47.2 | 15.3 | 20.6 | 38 | 0 | 2 | 43.0 |
|
|
667
|
+
| Mi 11 Lite | theme-switch-animation | 88.7 | 73.2 | 17.7 | 24.0 | 67 | 0.5 | 3.5 | 45.0 |
|
|
668
|
+
| Mi 11 Lite | skia | 431.9 | 414.0 | 18.9 | 22.8 | 67 | 0 | 25 | 391.1 |
|
|
669
|
+
| Pixel 8 Pro | ours | 49.4 | 45.9 | 7.7 | 9.3 | 0 | 0 | 2 | 25.4 |
|
|
670
|
+
| Pixel 8 Pro | theme-switch-animation | 64.9 | 66.2 | 8.2 | 9.5 | 3 | 0 | 3 | 32.2 |
|
|
671
|
+
| Pixel 8 Pro | skia | 330.8 | 291.4 | 6.9 (1) | 8.9 (1) | 0 (1) | 0 (1) | 9.5 (1) | 267.9 |
|
|
672
|
+
|
|
673
|
+
Overlay frame and snapshot JS in all four forms, ours against `theme-switch-animation` (ms):
|
|
674
|
+
|
|
675
|
+
| Device | Form | Overlay frame: ours | Overlay frame: theme-switch-animation | Snapshot, JS: ours | Snapshot, JS: theme-switch-animation |
|
|
676
|
+
|---|---|---|---|---|---|
|
|
677
|
+
| Mi 11 Lite | Warm tap | 57.4 | 88.7 | 47.2 | 73.2 |
|
|
678
|
+
| Mi 11 Lite | Warm hold | 41.0 | 94.2 | 31.6 | 80.2 |
|
|
679
|
+
| Mi 11 Lite | Cold tap | 65.5 | 88.0 | 58.0 | 84.1 |
|
|
680
|
+
| Mi 11 Lite | Cold hold | 43.0 | 107.7 | 33.9 | 67.6 |
|
|
681
|
+
| Pixel 8 Pro | Warm tap | 49.4 | 64.9 | 45.9 | 66.2 |
|
|
682
|
+
| Pixel 8 Pro | Warm hold | 39.1 | 64.5 | 31.7 | 64.0 |
|
|
683
|
+
| Pixel 8 Pro | Cold tap | 53.8 | 89.5 | 47.5 | 87.6 |
|
|
684
|
+
| Pixel 8 Pro | Cold hold | 39.1 | 102.2 | 32.2 | 65.3 |
|
|
685
|
+
|
|
686
|
+
(1) Skia drew at 30 Hz on the Pixel 8 Pro, so its frame times, janky share, late frames and missed vsyncs there
|
|
687
|
+
can't be compared with the other two. Its missed vsyncs are counted in 33 ms intervals, so 9.5 is about 19 at
|
|
688
|
+
60 Hz. That's why the release rule leaves Skia out of the Pixel 8 Pro frame rows. With Skia in them, 7 more rows
|
|
689
|
+
would fail in pass B: frame p50 in all four forms, frame p90 in cold tap, and janky share in cold tap and cold
|
|
690
|
+
hold.
|
|
691
|
+
|
|
692
|
+
- Overlay frame: time from the release to the first frame with the overlay on screen. On the Mi 11 Lite a system
|
|
693
|
+
trace confirmed which frame that is. On the Pixel 8 Pro it rests on `gfxinfo` data alone.
|
|
694
|
+
- Snapshot, JS: time from the release until JS knows the overlay is up.
|
|
695
|
+
- JS max gap: longest gap between two `requestAnimationFrame` callbacks during the switch.
|
|
696
|
+
- On React Native 0.86 (Fabric), `theme-switch-animation` shows no visible circle: it reveals the old screen over
|
|
697
|
+
the old screen, and the new theme appears all at once at the end. Its timings are still real.
|
|
698
|
+
|
|
699
|
+
### Release rule
|
|
700
|
+
|
|
701
|
+
On each Android device, each of our medians must be no more than 5% above the best alternative's median. That's
|
|
702
|
+
16 rows per form, 64 in all. Pass A passes all 64 and pass B passes 63, and the overlay frame and snapshot JS pass
|
|
703
|
+
in every form on both phones in both passes.
|
|
704
|
+
|
|
705
|
+
The one row that fails is the Mi 11 Lite JS max gap in pass B's warm hold: 44.1 ms vs 39.7 ms for
|
|
706
|
+
`theme-switch-animation` (limit 41.7 ms). A system trace of 6 runs on the same build shows this package caused
|
|
707
|
+
none of the max gaps. On the Mi, our JS max gap per run is either fast (25.4-31.8 ms) or slow (41.8-51.2 ms) in 22
|
|
708
|
+
of 24 runs. In a slow run the system kept the app on the little CPU cores, and the app's own JS theme render took
|
|
709
|
+
46-53 ms there. `theme-switch-animation` renders about 50 ms later, after its software capture has pushed the CPU
|
|
710
|
+
clock up. Across the four full passes of this design, our median in this row was 35.0, 34.8, 37.8 and 44.1 ms,
|
|
711
|
+
against 42.2, 39.4, 39.8 and 39.7 ms. The only levers are a CPU boost and a later theme render. A boost games the
|
|
712
|
+
metric and a later render makes the switch slower, so the package does neither.
|
|
713
|
+
|
|
714
|
+
An earlier build of this design failed 5 cold tap rows in one of its two passes. This build keeps a capture target
|
|
715
|
+
between switches, and the cold tap rows pass in both passes. [`benchmark/RESULTS.md`](benchmark/RESULTS.md) has
|
|
716
|
+
the details.
|
|
717
|
+
|
|
718
|
+
### iOS (simulator)
|
|
719
|
+
|
|
720
|
+
These numbers are from the iOS 26.5 simulator on a Mac, not a phone, measured on 2026-10-28 before the press-in
|
|
721
|
+
snapshot existed. I haven't measured iOS again since. There are no frame metrics, because `xctrace` doesn't support
|
|
722
|
+
hitches on the simulator and this pass has no `CADisplayLink` frame probe.
|
|
723
|
+
|
|
724
|
+
| Library | Snapshot, JS (ms) | JS max gap (ms) |
|
|
725
|
+
|---|---|---|
|
|
726
|
+
| ours | 8.3 | 16.8 |
|
|
727
|
+
| theme-switch-animation | 11.8 | 16.9 |
|
|
728
|
+
| skia | 44.0 | 18.0 |
|
|
729
|
+
|
|
730
|
+
The benchmark script can't tap on the simulator, so on iOS "Snapshot, JS" is measured from the run call instead of
|
|
731
|
+
a touch.
|
|
732
|
+
|
|
733
|
+
## License
|
|
734
|
+
|
|
735
|
+
MIT
|