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,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
+ }