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,102 @@
|
|
|
1
|
+
package expo.modules.themereveal
|
|
2
|
+
|
|
3
|
+
import android.content.ComponentCallbacks2
|
|
4
|
+
|
|
5
|
+
// Window-size bitmaps kept for the next PixelCopy capture so it doesn't have to allocate one. Only bitmaps that no
|
|
6
|
+
// overlay or held press-in capture shows live here: a capture takes one out, and it comes back when its overlay
|
|
7
|
+
// goes away or its held capture is dropped. A bitmap PixelCopy can't copy into (a GPU snapshot) is freed when it
|
|
8
|
+
// comes back. Main thread only. Generic over the bitmap type so JVM tests can run it.
|
|
9
|
+
internal class KeptBitmaps<B : Any>(
|
|
10
|
+
private val capacity: Int,
|
|
11
|
+
private val widthOf: (B) -> Int,
|
|
12
|
+
private val heightOf: (B) -> Int,
|
|
13
|
+
private val isFreed: (B) -> Boolean,
|
|
14
|
+
private val reusable: (B) -> Boolean,
|
|
15
|
+
private val free: (B) -> Unit,
|
|
16
|
+
) {
|
|
17
|
+
// Off until a warm-up asks for a kept bitmap. While off, every returned bitmap is freed.
|
|
18
|
+
var keep = false
|
|
19
|
+
|
|
20
|
+
private val kept = ArrayList<B>(capacity)
|
|
21
|
+
|
|
22
|
+
// App is hidden or the activity is gone, so keep nothing until the next capture.
|
|
23
|
+
private var hidden = false
|
|
24
|
+
private var width = 0
|
|
25
|
+
private var height = 0
|
|
26
|
+
private var screenWidthDp = 0
|
|
27
|
+
private var screenHeightDp = 0
|
|
28
|
+
|
|
29
|
+
val size: Int get() = kept.size
|
|
30
|
+
|
|
31
|
+
// Kept bitmap of this size, or null. Kept bitmaps of another size get freed, since the window changed size.
|
|
32
|
+
fun take(width: Int, height: Int): B? {
|
|
33
|
+
hidden = false
|
|
34
|
+
this.width = width
|
|
35
|
+
this.height = height
|
|
36
|
+
val index = kept.indexOfFirst { matches(it) }
|
|
37
|
+
if (index < 0) {
|
|
38
|
+
clear()
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
return kept.removeAt(index)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A bitmap whose overlay is gone. Kept if keep is on, it's reusable, it has the size last asked for and there's
|
|
45
|
+
// room.
|
|
46
|
+
fun give(bitmap: B) {
|
|
47
|
+
if (isFreed(bitmap)) return
|
|
48
|
+
if (keep && !hidden && kept.size < capacity && reusable(bitmap) && matches(bitmap) && kept.none { it === bitmap }) {
|
|
49
|
+
kept.add(bitmap)
|
|
50
|
+
} else {
|
|
51
|
+
free(bitmap)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Whether a warm-up needs to allocate a bitmap of this size. Kept bitmaps of another size don't count: fill frees
|
|
56
|
+
// them.
|
|
57
|
+
fun hasRoom(width: Int, height: Int): Boolean =
|
|
58
|
+
keep && !hidden && kept.count { widthOf(it) == width && heightOf(it) == height } < capacity
|
|
59
|
+
|
|
60
|
+
// A warm-up's new bitmap. Returns whether it's kept; if not, it's freed.
|
|
61
|
+
fun fill(bitmap: B): Boolean {
|
|
62
|
+
if (!hasRoom(widthOf(bitmap), heightOf(bitmap))) {
|
|
63
|
+
free(bitmap)
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
width = widthOf(bitmap)
|
|
67
|
+
height = heightOf(bitmap)
|
|
68
|
+
kept.filterNot { matches(it) }.forEach(free)
|
|
69
|
+
kept.retainAll { matches(it) }
|
|
70
|
+
kept.add(bitmap)
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fun onTrimMemory(level: Int) {
|
|
75
|
+
if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) hide()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Frees the kept bitmaps, and the ones that come back until the next capture or show.
|
|
79
|
+
fun hide() {
|
|
80
|
+
hidden = true
|
|
81
|
+
clear()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// App is back in the foreground (a warm-up from a resumed activity), so keep bitmaps again.
|
|
85
|
+
fun show() {
|
|
86
|
+
hidden = false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fun onScreenSize(widthDp: Int, heightDp: Int) {
|
|
90
|
+
val known = screenWidthDp != 0 || screenHeightDp != 0
|
|
91
|
+
if (known && (widthDp != screenWidthDp || heightDp != screenHeightDp)) clear()
|
|
92
|
+
screenWidthDp = widthDp
|
|
93
|
+
screenHeightDp = heightDp
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fun clear() {
|
|
97
|
+
kept.forEach(free)
|
|
98
|
+
kept.clear()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private fun matches(bitmap: B): Boolean = widthOf(bitmap) == width && heightOf(bitmap) == height
|
|
102
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
package expo.modules.themereveal
|
|
2
|
+
|
|
3
|
+
import android.content.ComponentCallbacks2
|
|
4
|
+
|
|
5
|
+
// GPU capture target kept between switches for the next first snapshot (GpuSnapshot.prepareOutput): at most one, made
|
|
6
|
+
// in idle time, so the capture skips creating a renderer, surface and buffer. The capture takes it, and frees it if
|
|
7
|
+
// the window has another size (freeLater, in a later main message). It's freed when the app is hidden or low on
|
|
8
|
+
// memory, the screen changes size, the activity or module goes away, or the GPU path turns off. Main thread only.
|
|
9
|
+
// Generic over the target type so JVM tests can run it.
|
|
10
|
+
internal class KeptTarget<T : Any>(private val free: (T) -> Unit, private val freeLater: (T) -> Unit = free) {
|
|
11
|
+
// Off until a warm-up turns it on (the warmUp option).
|
|
12
|
+
var keep = false
|
|
13
|
+
|
|
14
|
+
private var held: T? = null
|
|
15
|
+
private var width = 0
|
|
16
|
+
private var height = 0
|
|
17
|
+
// Prepares in flight, including ones a clear made stale: their buffers exist until they end.
|
|
18
|
+
private var inFlight = 0
|
|
19
|
+
|
|
20
|
+
// A prepare that started before a clear ends with a stale ticket, so its target is freed.
|
|
21
|
+
private var ticket = 0
|
|
22
|
+
|
|
23
|
+
// App is hidden or the activity is gone, so keep nothing until the next capture or warm-up.
|
|
24
|
+
private var hidden = false
|
|
25
|
+
private var screenWidthDp = 0
|
|
26
|
+
private var screenHeightDp = 0
|
|
27
|
+
|
|
28
|
+
val size: Int get() = if (held == null) 0 else 1
|
|
29
|
+
|
|
30
|
+
// A prepare is holding the copy thread right now, stale or not.
|
|
31
|
+
val preparing: Boolean get() = inFlight > 0
|
|
32
|
+
|
|
33
|
+
// Whether a prepare of a width x height target needs to start now. gpuOn: the window's next capture takes the GPU
|
|
34
|
+
// path.
|
|
35
|
+
fun wants(width: Int, height: Int, gpuOn: Boolean): Boolean =
|
|
36
|
+
keep && !hidden && gpuOn && held == null && inFlight == 0 && width > 0 && height > 0
|
|
37
|
+
|
|
38
|
+
// A prepare starts. Its result goes to ready along with the ticket returned here.
|
|
39
|
+
fun started(): Int {
|
|
40
|
+
inFlight++
|
|
41
|
+
return ticket
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A prepare ended with its target, or null if it failed. Kept only if nothing cleared the target since the prepare
|
|
45
|
+
// started, keeping is on and nothing else is kept. Freed otherwise.
|
|
46
|
+
fun ready(ticket: Int, target: T?, width: Int, height: Int) {
|
|
47
|
+
val current = ticket == this.ticket
|
|
48
|
+
if (inFlight > 0) inFlight--
|
|
49
|
+
if (target == null) return
|
|
50
|
+
if (!current || !keep || hidden || held != null) {
|
|
51
|
+
free(target)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
held = target
|
|
55
|
+
this.width = width
|
|
56
|
+
this.height = height
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Next capture of width x height: the kept target if it has that size. One of another size goes to freeLater.
|
|
60
|
+
fun take(width: Int, height: Int): T? {
|
|
61
|
+
hidden = false
|
|
62
|
+
val target = held ?: return null
|
|
63
|
+
held = null
|
|
64
|
+
if (width == this.width && height == this.height) return target
|
|
65
|
+
freeLater(target)
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Frees a kept target the window's next capture can't use (another size, or no GPU path).
|
|
70
|
+
fun drop(width: Int, height: Int, gpuOn: Boolean) {
|
|
71
|
+
if (held != null && (!gpuOn || width != this.width || height != this.height)) clear()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fun onTrimMemory(level: Int) {
|
|
75
|
+
if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) hide()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Frees the target and keeps none until the next capture or show.
|
|
79
|
+
fun hide() {
|
|
80
|
+
hidden = true
|
|
81
|
+
clear()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A warm-up from a mounted UI: keep a target again.
|
|
85
|
+
fun show() {
|
|
86
|
+
hidden = false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fun onScreenSize(widthDp: Int, heightDp: Int) {
|
|
90
|
+
val known = screenWidthDp != 0 || screenHeightDp != 0
|
|
91
|
+
if (known && (widthDp != screenWidthDp || heightDp != screenHeightDp)) clear()
|
|
92
|
+
screenWidthDp = widthDp
|
|
93
|
+
screenHeightDp = heightDp
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Frees the kept target, and the one a running prepare makes.
|
|
97
|
+
fun clear() {
|
|
98
|
+
ticket++
|
|
99
|
+
val target = held ?: return
|
|
100
|
+
held = null
|
|
101
|
+
free(target)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
package expo.modules.themereveal
|
|
2
|
+
|
|
3
|
+
import android.os.Build
|
|
4
|
+
import kotlin.math.hypot
|
|
5
|
+
import kotlin.math.max
|
|
6
|
+
import kotlin.math.min
|
|
7
|
+
import kotlin.math.pow
|
|
8
|
+
import kotlin.math.roundToInt
|
|
9
|
+
|
|
10
|
+
internal data class Frame(val left: Int, val top: Int, val right: Int, val bottom: Int) {
|
|
11
|
+
val isEmpty: Boolean get() = right <= left || bottom <= top
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
internal object Motion {
|
|
15
|
+
private const val GROWTH_CURVE = 1.4
|
|
16
|
+
private const val EASE_X1 = 0.65f
|
|
17
|
+
private const val EASE_Y1 = 0f
|
|
18
|
+
private const val EASE_X2 = 0.35f
|
|
19
|
+
private const val EASE_Y2 = 1f
|
|
20
|
+
private const val EASE_STEPS = 24
|
|
21
|
+
private const val MAX_DURATION_MS = 10_000L
|
|
22
|
+
|
|
23
|
+
fun growth(g: Double, from: Double, to: Double): Double = from * (to / from).pow(g.pow(GROWTH_CURVE))
|
|
24
|
+
|
|
25
|
+
fun shrink(g: Double, from: Double, to: Double): Double = max(0.0, growth(1 - g, from, to) - from * g)
|
|
26
|
+
|
|
27
|
+
// For an animator whose radius runs from r0 to r1 as r0 + (r1 - r0) * fraction: the fraction that gives growth's
|
|
28
|
+
// radius at time g. r1 > r0 > 0.
|
|
29
|
+
fun growthFraction(g: Double, r0: Double, r1: Double): Double =
|
|
30
|
+
((growth(g.coerceIn(0.0, 1.0), r0, r1) - r0) / (r1 - r0)).coerceIn(0.0, 1.0)
|
|
31
|
+
|
|
32
|
+
// For an animator whose radius runs from r1 to 0 as r1 * (1 - fraction): the fraction that gives shrink's radius
|
|
33
|
+
// at time g. r1 > r0 > 0.
|
|
34
|
+
fun shrinkFraction(g: Double, r0: Double, r1: Double): Double =
|
|
35
|
+
(1 - shrink(g.coerceIn(0.0, 1.0), r0, r1) / r1).coerceIn(0.0, 1.0)
|
|
36
|
+
|
|
37
|
+
fun coverScale(originX: Float, originY: Float, width: Int, height: Int, inscribedRadiusPx: Float): Float =
|
|
38
|
+
hypot(max(originX, width - originX), max(originY, height - originY)) / inscribedRadiusPx
|
|
39
|
+
|
|
40
|
+
fun easeInOut(t: Float): Float {
|
|
41
|
+
if (t <= 0f) return 0f
|
|
42
|
+
if (t >= 1f) return 1f
|
|
43
|
+
var lo = 0f
|
|
44
|
+
var hi = 1f
|
|
45
|
+
repeat(EASE_STEPS) {
|
|
46
|
+
val mid = (lo + hi) / 2
|
|
47
|
+
if (bezier(mid, EASE_X1, EASE_X2) < t) lo = mid else hi = mid
|
|
48
|
+
}
|
|
49
|
+
return bezier((lo + hi) / 2, EASE_Y1, EASE_Y2)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Non-finite, zero, negative or overlong durations are invalid: the overlay would sit on screen too long.
|
|
53
|
+
fun durationMs(ms: Double): Long? =
|
|
54
|
+
if (!ms.isFinite() || ms > MAX_DURATION_MS) null else ms.toLong().takeIf { it > 0 }
|
|
55
|
+
|
|
56
|
+
fun canAnimate(apiLevel: Int, resumed: Boolean): Boolean = apiLevel >= Build.VERSION_CODES.O && resumed
|
|
57
|
+
|
|
58
|
+
fun sameSize(snapshotW: Int, snapshotH: Int, w: Int, h: Int): Boolean = snapshotW == w && snapshotH == h
|
|
59
|
+
|
|
60
|
+
fun dialogFrame(decorX: Int, decorY: Int, decorW: Int, decorH: Int, screenW: Int, screenH: Int): Frame =
|
|
61
|
+
Frame(max(decorX, 0), max(decorY, 0), min(decorX + decorW, screenW), min(decorY + decorH, screenH))
|
|
62
|
+
|
|
63
|
+
fun dimAlpha(dimAmount: Float): Int = if (dimAmount.isNaN()) 0 else (dimAmount.coerceIn(0f, 1f) * 255).roundToInt()
|
|
64
|
+
|
|
65
|
+
fun toOverlay(
|
|
66
|
+
xPx: Float,
|
|
67
|
+
yPx: Float,
|
|
68
|
+
rootScreenX: Int,
|
|
69
|
+
rootScreenY: Int,
|
|
70
|
+
overlayScreenX: Int,
|
|
71
|
+
overlayScreenY: Int,
|
|
72
|
+
): FloatArray =
|
|
73
|
+
floatArrayOf(xPx + (rootScreenX - overlayScreenX), yPx + (rootScreenY - overlayScreenY))
|
|
74
|
+
|
|
75
|
+
private fun bezier(s: Float, p1: Float, p2: Float): Float {
|
|
76
|
+
val u = 1 - s
|
|
77
|
+
return 3 * u * u * s * p1 + 3 * u * s * s * p2 + s * s * s
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
package expo.modules.themereveal
|
|
2
|
+
|
|
3
|
+
// Runs action on the first call to run and never again, even if it throws. For an action several triggers race for.
|
|
4
|
+
// Main thread only.
|
|
5
|
+
internal class Once(private val action: () -> Unit) {
|
|
6
|
+
private var done = false
|
|
7
|
+
|
|
8
|
+
fun run() {
|
|
9
|
+
if (done) return
|
|
10
|
+
done = true
|
|
11
|
+
action()
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
package expo.modules.themereveal
|
|
2
|
+
|
|
3
|
+
import android.animation.TimeInterpolator
|
|
4
|
+
import android.os.Build
|
|
5
|
+
|
|
6
|
+
// Interpolators for ViewAnimationUtils.createCircularReveal that reproduce the UI-thread reveal's radius curves.
|
|
7
|
+
// RenderNodeAnimator bakes a non-native interpolator into a lookup table, so the animation still runs on the
|
|
8
|
+
// RenderThread.
|
|
9
|
+
internal class GrowthInterpolator(private val r0: Float, private val r1: Float) : TimeInterpolator {
|
|
10
|
+
override fun getInterpolation(input: Float): Float =
|
|
11
|
+
Motion.growthFraction(input.toDouble(), r0.toDouble(), r1.toDouble()).toFloat()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
internal class ShrinkInterpolator(private val r0: Float, private val r1: Float) : TimeInterpolator {
|
|
15
|
+
override fun getInterpolation(input: Float): Float =
|
|
16
|
+
Motion.shrinkFraction(input.toDouble(), r0.toDouble(), r1.toDouble()).toFloat()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Where a reveal's animation runs.
|
|
20
|
+
internal enum class RevealPath {
|
|
21
|
+
// A second snapshot of the new screen, revealed by a circular clip that grows on the RenderThread.
|
|
22
|
+
RENDER_THREAD_REVEAL,
|
|
23
|
+
|
|
24
|
+
// A circular clip on the old snapshot that shrinks on the RenderThread.
|
|
25
|
+
RENDER_THREAD_INVERSE,
|
|
26
|
+
|
|
27
|
+
// RevealOverlayView's ValueAnimator, which re-records the overlay every frame.
|
|
28
|
+
UI_THREAD,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
internal object RevealPlan {
|
|
32
|
+
// Only the default circle can run on the RenderThread, since createCircularReveal only clips to a circle. Needs
|
|
33
|
+
// the GPU capture (API 29+): the second snapshot of the new screen is a GPU capture too, so both look the same.
|
|
34
|
+
// With animators off, the UI-thread path keeps today's behavior. secondSnapshotOff (FallbackCounter.off): the
|
|
35
|
+
// reveal's second snapshot failed too often in this process. The inverse reveal doesn't take one, so it stays.
|
|
36
|
+
fun path(
|
|
37
|
+
type: String,
|
|
38
|
+
customShape: Boolean,
|
|
39
|
+
apiLevel: Int,
|
|
40
|
+
snapshot: CapturePath,
|
|
41
|
+
animatorsEnabled: Boolean,
|
|
42
|
+
secondSnapshotOff: Boolean,
|
|
43
|
+
): RevealPath {
|
|
44
|
+
if (customShape || apiLevel < Build.VERSION_CODES.Q || snapshot != CapturePath.GPU || !animatorsEnabled) {
|
|
45
|
+
return RevealPath.UI_THREAD
|
|
46
|
+
}
|
|
47
|
+
return when (type) {
|
|
48
|
+
"reveal" -> if (secondSnapshotOff) RevealPath.UI_THREAD else RevealPath.RENDER_THREAD_REVEAL
|
|
49
|
+
"inverseReveal" -> RevealPath.RENDER_THREAD_INVERSE
|
|
50
|
+
else -> RevealPath.UI_THREAD
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Default circle's start and end radius in px for a reveal from (x, y) in a width x height overlay, matching what
|
|
55
|
+
// the UI-thread reveal draws: ShapePath.circle() has radius 1 and height 2, so it starts at startHeightPx / 2 and
|
|
56
|
+
// ends at the cover radius.
|
|
57
|
+
fun circleRadii(x: Float, y: Float, width: Int, height: Int, startHeightPx: Float): FloatArray =
|
|
58
|
+
floatArrayOf(startHeightPx / 2f, Motion.coverScale(x, y, width, height, 1f))
|
|
59
|
+
|
|
60
|
+
// Second snapshot can go over the first: the overlay is the same view, still in the window, and the snapshot is a
|
|
61
|
+
// GPU capture of the same window at the same size and position.
|
|
62
|
+
fun secondMatches(
|
|
63
|
+
sameOverlay: Boolean,
|
|
64
|
+
attached: Boolean,
|
|
65
|
+
sameHost: Boolean,
|
|
66
|
+
gpu: Boolean,
|
|
67
|
+
width: Int,
|
|
68
|
+
height: Int,
|
|
69
|
+
firstWidth: Int,
|
|
70
|
+
firstHeight: Int,
|
|
71
|
+
sameOffset: Boolean,
|
|
72
|
+
): Boolean =
|
|
73
|
+
sameOverlay && attached && sameHost && gpu && Motion.sameSize(width, height, firstWidth, firstHeight) && sameOffset
|
|
74
|
+
|
|
75
|
+
// Circle has to grow from a positive, finite radius.
|
|
76
|
+
fun radiiUsable(r0: Float, r1: Float): Boolean = r0 > 0f && r1 > r0 && r1.isFinite()
|
|
77
|
+
|
|
78
|
+
// A fallback only counts toward FallbackCounter if it tells us something about the second snapshot, i.e. the
|
|
79
|
+
// overlay is still in an open window of a resumed activity. In the background no frame comes, so the wait times
|
|
80
|
+
// out anyway.
|
|
81
|
+
fun countsFallBack(windowOk: Boolean, resumed: Boolean): Boolean = windowOk && resumed
|
|
82
|
+
|
|
83
|
+
// Fallbacks in a row that turn off the reveal's second snapshot (FallbackCounter).
|
|
84
|
+
const val FALLBACKS_TO_OFF = 3
|
|
85
|
+
|
|
86
|
+
// A held GPU snapshot gets its texture imported during the hold so the overlay's first frame doesn't have to.
|
|
87
|
+
fun preImports(apiLevel: Int, snapshot: CapturePath): Boolean =
|
|
88
|
+
apiLevel >= Build.VERSION_CODES.Q && snapshot == CapturePath.GPU
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Counts consecutive RenderThread reveals that fell back to the UI-thread reveal. After limit of them, off stays
|
|
92
|
+
// true for the rest of the process: each such fallback cost the switch up to SecondSnapshotWait.TIMEOUT_MS. Main
|
|
93
|
+
// thread only.
|
|
94
|
+
internal class FallbackCounter(private val limit: Int) {
|
|
95
|
+
var off = false
|
|
96
|
+
private set
|
|
97
|
+
private var inARow = 0
|
|
98
|
+
|
|
99
|
+
fun fellBack() {
|
|
100
|
+
inARow++
|
|
101
|
+
if (inARow >= limit) off = true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
fun revealed() {
|
|
105
|
+
inARow = 0
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
package expo.modules.themereveal
|
|
2
|
+
|
|
3
|
+
import android.animation.Animator
|
|
4
|
+
import android.animation.AnimatorListenerAdapter
|
|
5
|
+
import android.animation.ValueAnimator
|
|
6
|
+
import android.annotation.SuppressLint
|
|
7
|
+
import android.content.Context
|
|
8
|
+
import android.graphics.Bitmap
|
|
9
|
+
import android.graphics.Canvas
|
|
10
|
+
import android.graphics.Matrix
|
|
11
|
+
import android.graphics.Path
|
|
12
|
+
import android.graphics.RectF
|
|
13
|
+
import android.graphics.Region
|
|
14
|
+
import android.os.Build
|
|
15
|
+
import android.view.MotionEvent
|
|
16
|
+
import android.view.View
|
|
17
|
+
import android.view.animation.LinearInterpolator
|
|
18
|
+
|
|
19
|
+
internal enum class WipeDirection { UP, DOWN, LEFT, RIGHT }
|
|
20
|
+
|
|
21
|
+
@SuppressLint("ViewConstructor")
|
|
22
|
+
internal class RevealOverlayView(context: Context, private val snapshot: Bitmap) : View(context) {
|
|
23
|
+
private enum class Mode { STILL, REVEAL, INVERSE_REVEAL, WIPE, CROSSFADE }
|
|
24
|
+
|
|
25
|
+
private val hole = Path()
|
|
26
|
+
private val holeMatrix = Matrix()
|
|
27
|
+
private val wipeClip = RectF()
|
|
28
|
+
private var mode = Mode.STILL
|
|
29
|
+
private var shape: ShapePath? = null
|
|
30
|
+
private var originX = 0f
|
|
31
|
+
private var originY = 0f
|
|
32
|
+
private var fromScale = 0.0
|
|
33
|
+
private var toScale = 0.0
|
|
34
|
+
private var scale = 0f
|
|
35
|
+
private var wipeDirection = WipeDirection.UP
|
|
36
|
+
private var animator: ValueAnimator? = null
|
|
37
|
+
|
|
38
|
+
val snapshotWidth: Int get() = snapshot.width
|
|
39
|
+
val snapshotHeight: Int get() = snapshot.height
|
|
40
|
+
|
|
41
|
+
// The path that took the snapshot.
|
|
42
|
+
val snapshotPath: CapturePath get() = WindowSnapshot.pathOf(snapshot)
|
|
43
|
+
|
|
44
|
+
// Height in px of the shape when the reveal starts.
|
|
45
|
+
val startHeightPx: Float get() = START_HEIGHT_DP * resources.displayMetrics.density
|
|
46
|
+
|
|
47
|
+
init {
|
|
48
|
+
isClickable = false
|
|
49
|
+
isFocusable = false
|
|
50
|
+
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@SuppressLint("ClickableViewAccessibility")
|
|
54
|
+
override fun dispatchTouchEvent(event: MotionEvent): Boolean = false
|
|
55
|
+
|
|
56
|
+
fun reveal(shape: ShapePath, x: Float, y: Float, inverse: Boolean, durationMs: Long, onEnd: () -> Unit) {
|
|
57
|
+
this.shape = shape
|
|
58
|
+
originX = x
|
|
59
|
+
originY = y
|
|
60
|
+
fromScale = (startHeightPx / shape.bounds.height()).toDouble()
|
|
61
|
+
toScale = Motion.coverScale(x, y, snapshot.width, snapshot.height, shape.effectiveInscribedRadius).toDouble()
|
|
62
|
+
play(if (inverse) Mode.INVERSE_REVEAL else Mode.REVEAL, durationMs, onEnd)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Debug only, slow: the snapshot's color at (x, y), read back to the CPU. Null if it can't be read.
|
|
66
|
+
fun pixelAt(x: Int, y: Int): Int? {
|
|
67
|
+
if (snapshot.isRecycled || x !in 0 until snapshot.width || y !in 0 until snapshot.height) return null
|
|
68
|
+
return try {
|
|
69
|
+
val copy = snapshot.copy(Bitmap.Config.ARGB_8888, false) ?: return null
|
|
70
|
+
try {
|
|
71
|
+
copy.getPixel(x, y)
|
|
72
|
+
} finally {
|
|
73
|
+
copy.recycle()
|
|
74
|
+
}
|
|
75
|
+
} catch (_: RuntimeException) {
|
|
76
|
+
null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fun wipe(direction: WipeDirection, durationMs: Long, onEnd: () -> Unit) {
|
|
81
|
+
wipeDirection = direction
|
|
82
|
+
wipeClip.set(0f, 0f, snapshot.width.toFloat(), snapshot.height.toFloat())
|
|
83
|
+
play(Mode.WIPE, durationMs, onEnd)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fun crossfade(durationMs: Long, onEnd: () -> Unit) {
|
|
87
|
+
setLayerType(LAYER_TYPE_HARDWARE, null)
|
|
88
|
+
play(Mode.CROSSFADE, durationMs, onEnd)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Stops an animation from an earlier start and shows the whole snapshot again, static.
|
|
92
|
+
fun still() {
|
|
93
|
+
if (mode == Mode.STILL && animator == null) return
|
|
94
|
+
stopAnimator()
|
|
95
|
+
mode = Mode.STILL
|
|
96
|
+
alpha = 1f
|
|
97
|
+
setLayerType(LAYER_TYPE_NONE, null)
|
|
98
|
+
invalidate()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Stops the animation and hands the snapshot off, either to the kept bitmaps or to be freed.
|
|
102
|
+
fun release(drop: (Bitmap) -> Unit) {
|
|
103
|
+
stopAnimator()
|
|
104
|
+
drop(snapshot)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private fun stopAnimator() {
|
|
108
|
+
animator?.removeAllUpdateListeners()
|
|
109
|
+
animator?.removeAllListeners()
|
|
110
|
+
animator?.cancel()
|
|
111
|
+
animator = null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private fun play(next: Mode, durationMs: Long, onEnd: () -> Unit) {
|
|
115
|
+
stopAnimator()
|
|
116
|
+
mode = next
|
|
117
|
+
onFrame(0f)
|
|
118
|
+
animator = ValueAnimator.ofFloat(0f, 1f).apply {
|
|
119
|
+
duration = durationMs
|
|
120
|
+
interpolator = LinearInterpolator()
|
|
121
|
+
addUpdateListener { onFrame(it.animatedFraction) }
|
|
122
|
+
addListener(object : AnimatorListenerAdapter() {
|
|
123
|
+
override fun onAnimationEnd(animation: Animator) = onEnd()
|
|
124
|
+
})
|
|
125
|
+
start()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private fun onFrame(fraction: Float) {
|
|
130
|
+
when (mode) {
|
|
131
|
+
Mode.REVEAL -> scale = Motion.growth(fraction.toDouble(), fromScale, toScale).toFloat()
|
|
132
|
+
Mode.INVERSE_REVEAL -> scale = Motion.shrink(fraction.toDouble(), fromScale, toScale).toFloat()
|
|
133
|
+
Mode.WIPE -> moveWipeEdge(Motion.easeInOut(fraction))
|
|
134
|
+
Mode.CROSSFADE -> {
|
|
135
|
+
alpha = 1f - Motion.easeInOut(fraction)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
Mode.STILL -> return
|
|
139
|
+
}
|
|
140
|
+
invalidate()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private fun moveWipeEdge(progress: Float) {
|
|
144
|
+
val w = snapshot.width.toFloat()
|
|
145
|
+
val h = snapshot.height.toFloat()
|
|
146
|
+
when (wipeDirection) {
|
|
147
|
+
WipeDirection.UP -> wipeClip.set(0f, 0f, w, h * (1f - progress))
|
|
148
|
+
WipeDirection.DOWN -> wipeClip.set(0f, h * progress, w, h)
|
|
149
|
+
WipeDirection.LEFT -> wipeClip.set(0f, 0f, w * (1f - progress), h)
|
|
150
|
+
WipeDirection.RIGHT -> wipeClip.set(w * progress, 0f, w, h)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
override fun onDraw(canvas: Canvas) {
|
|
155
|
+
if (snapshot.isRecycled) return
|
|
156
|
+
// A software canvas (a screenshot drawn with View.draw, say) throws on a hardware bitmap, so leave it out.
|
|
157
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && snapshot.config == Bitmap.Config.HARDWARE &&
|
|
158
|
+
!canvas.isHardwareAccelerated
|
|
159
|
+
) {
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
when (mode) {
|
|
163
|
+
Mode.REVEAL -> if (scale > 0f) clipOut(canvas, shapeAtScale())
|
|
164
|
+
Mode.INVERSE_REVEAL -> canvas.clipPath(shapeAtScale())
|
|
165
|
+
Mode.WIPE -> canvas.clipRect(wipeClip)
|
|
166
|
+
Mode.CROSSFADE, Mode.STILL -> Unit
|
|
167
|
+
}
|
|
168
|
+
canvas.drawBitmap(snapshot, 0f, 0f, null)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private fun shapeAtScale(): Path {
|
|
172
|
+
val current = shape ?: return hole.apply { reset() }
|
|
173
|
+
holeMatrix.setTranslate(-current.anchorX, -current.anchorY)
|
|
174
|
+
holeMatrix.postScale(scale, scale)
|
|
175
|
+
holeMatrix.postTranslate(originX, originY)
|
|
176
|
+
current.path.transform(holeMatrix, hole)
|
|
177
|
+
return hole
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private fun clipOut(canvas: Canvas, path: Path) {
|
|
181
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
182
|
+
canvas.clipOutPath(path)
|
|
183
|
+
} else {
|
|
184
|
+
@Suppress("DEPRECATION")
|
|
185
|
+
canvas.clipPath(path, Region.Op.DIFFERENCE)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private companion object {
|
|
190
|
+
const val START_HEIGHT_DP = 24f
|
|
191
|
+
}
|
|
192
|
+
}
|