@capgo/capacitor-pretty-toast 8.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/CapgoCapacitorPrettyToast.podspec +17 -0
- package/LICENSE +373 -0
- package/Package.swift +28 -0
- package/README.md +341 -0
- package/android/build.gradle +71 -0
- package/android/src/main/AndroidManifest.xml +6 -0
- package/android/src/main/java/com/toast/PrettyToastPlugin.kt +197 -0
- package/android/src/main/java/com/toast/ToastOverlay.kt +495 -0
- package/android/src/main/java/com/toast/anim/CutoutMorphAnimator.kt +235 -0
- package/android/src/main/java/com/toast/anim/SlideAnimator.kt +64 -0
- package/android/src/main/java/com/toast/anim/ToastAnimator.kt +23 -0
- package/android/src/main/java/com/toast/backdrop/BackdropSampler.kt +142 -0
- package/android/src/main/java/com/toast/backdrop/OutlineController.kt +100 -0
- package/android/src/main/java/com/toast/cutout/CutoutDetector.kt +88 -0
- package/android/src/main/java/com/toast/cutout/CutoutInfo.kt +28 -0
- package/android/src/main/java/com/toast/gesture/ToastGestureHandler.kt +68 -0
- package/android/src/main/java/com/toast/ui/IconMapper.kt +26 -0
- package/android/src/main/java/com/toast/ui/PassThroughFrameLayout.kt +53 -0
- package/android/src/main/java/com/toast/ui/ToastViewFactory.kt +224 -0
- package/android/src/main/java/com/toast/util/Density.kt +17 -0
- package/android/src/main/java/com/toast/util/StatusBarController.kt +24 -0
- package/android/src/main/java/com/toast/util/ToastConstants.kt +36 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/android/src/main/res/drawable/ic_arrow_downward.xml +9 -0
- package/android/src/main/res/drawable/ic_arrow_upward.xml +9 -0
- package/android/src/main/res/drawable/ic_cancel.xml +9 -0
- package/android/src/main/res/drawable/ic_check_circle.xml +9 -0
- package/android/src/main/res/drawable/ic_favorite.xml +9 -0
- package/android/src/main/res/drawable/ic_info.xml +9 -0
- package/android/src/main/res/drawable/ic_mail.xml +9 -0
- package/android/src/main/res/drawable/ic_notifications.xml +9 -0
- package/android/src/main/res/drawable/ic_touch_app.xml +9 -0
- package/android/src/main/res/drawable/ic_warning.xml +9 -0
- package/android/src/main/res/drawable/ic_wifi.xml +9 -0
- package/android/src/main/res/values/colors.xml +3 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/android/src/main/res/values/styles.xml +3 -0
- package/android/src/test/java/com/toast/PrettyToastPluginTest.kt +26 -0
- package/dist/docs.json +459 -0
- package/dist/esm/controller.d.ts +30 -0
- package/dist/esm/controller.js +271 -0
- package/dist/esm/controller.js.map +1 -0
- package/dist/esm/definitions.d.ts +144 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/driver.d.ts +19 -0
- package/dist/esm/driver.js +24 -0
- package/dist/esm/driver.js.map +1 -0
- package/dist/esm/icons.d.ts +14 -0
- package/dist/esm/icons.js +138 -0
- package/dist/esm/icons.js.map +1 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal-plugin.d.ts +2 -0
- package/dist/esm/internal-plugin.js +5 -0
- package/dist/esm/internal-plugin.js.map +1 -0
- package/dist/esm/internal-types.d.ts +31 -0
- package/dist/esm/internal-types.js +2 -0
- package/dist/esm/internal-types.js.map +1 -0
- package/dist/esm/toast.d.ts +1 -0
- package/dist/esm/toast.js +5 -0
- package/dist/esm/toast.js.map +1 -0
- package/dist/esm/web-renderer.d.ts +36 -0
- package/dist/esm/web-renderer.js +296 -0
- package/dist/esm/web-renderer.js.map +1 -0
- package/dist/esm/web.d.ts +10 -0
- package/dist/esm/web.js +28 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +770 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +773 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/PrettyToastPlugin/CustomHostingView.swift +13 -0
- package/ios/Sources/PrettyToastPlugin/PassThroughWindow.swift +143 -0
- package/ios/Sources/PrettyToastPlugin/PrettyToastColorParser.swift +94 -0
- package/ios/Sources/PrettyToastPlugin/PrettyToastPlugin.swift +138 -0
- package/ios/Sources/PrettyToastPlugin/PrettyToastView.swift +267 -0
- package/ios/Sources/PrettyToastPlugin/Toast.swift +29 -0
- package/ios/Sources/PrettyToastPlugin/ToastManager.swift +392 -0
- package/ios/Tests/PrettyToastPluginTests/PrettyToastPluginTests.swift +21 -0
- package/package.json +98 -0
- package/scripts/check-capacitor-plugin-wiring.mjs +254 -0
- package/scripts/deploy-example-capgo.mjs +86 -0
- package/scripts/test-ios.sh +14 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
package com.toast.anim
|
|
2
|
+
|
|
3
|
+
import android.animation.ValueAnimator
|
|
4
|
+
import android.graphics.drawable.GradientDrawable
|
|
5
|
+
import android.view.View
|
|
6
|
+
import android.widget.LinearLayout
|
|
7
|
+
import com.toast.cutout.CutoutInfo
|
|
8
|
+
import com.toast.util.Density
|
|
9
|
+
import com.toast.util.ToastConstants.MORPH_DURATION_MS
|
|
10
|
+
import com.toast.util.ToastConstants.MORPH_EASING
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Dynamic-Island-style morph animation. The pill scales + translates
|
|
14
|
+
* between the expanded capsule and a tight footprint over the cutout,
|
|
15
|
+
* while cornerRadii morph so the on-screen corner stays circular
|
|
16
|
+
* throughout the anisotropic scale.
|
|
17
|
+
*/
|
|
18
|
+
class CutoutMorphAnimator(
|
|
19
|
+
private val pill: LinearLayout,
|
|
20
|
+
private val content: View,
|
|
21
|
+
private val info: CutoutInfo,
|
|
22
|
+
private val expandedCornerRadius: Float,
|
|
23
|
+
private val density: Density,
|
|
24
|
+
private val onBeforeShow: () -> Unit = {},
|
|
25
|
+
private val onBeforeDismiss: () -> Unit = {},
|
|
26
|
+
) : ToastAnimator {
|
|
27
|
+
|
|
28
|
+
private var cornerAnimator: ValueAnimator? = null
|
|
29
|
+
private val cornerRadiiBuf = FloatArray(8)
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Lower bound for the measured pill height — guards against racing
|
|
33
|
+
* the layout pass, where `pill.height` can briefly read as 0.
|
|
34
|
+
*/
|
|
35
|
+
private val heightFloor: Float get() = density.dp(70f)
|
|
36
|
+
|
|
37
|
+
private fun expandedHeight(): Float = pill.height.toFloat().coerceAtLeast(heightFloor)
|
|
38
|
+
|
|
39
|
+
override fun show() {
|
|
40
|
+
onBeforeShow()
|
|
41
|
+
|
|
42
|
+
val expandedWidth = pill.layoutParams.width.toFloat()
|
|
43
|
+
val scaleX = info.collapsedWidth / expandedWidth
|
|
44
|
+
val scaleY = info.collapsedHeight / expandedHeight()
|
|
45
|
+
|
|
46
|
+
pill.pivotX = expandedWidth / 2f
|
|
47
|
+
pill.pivotY = 0f
|
|
48
|
+
pill.scaleX = scaleX
|
|
49
|
+
pill.scaleY = scaleY
|
|
50
|
+
// Translate so the collapsed pill starts centered over the cutout
|
|
51
|
+
// (zero offset for centered holes, non-zero for corner cameras).
|
|
52
|
+
pill.translationX = info.horizontalOffset
|
|
53
|
+
pill.translationY = 0f
|
|
54
|
+
pill.alpha = 1f
|
|
55
|
+
content.alpha = 0f
|
|
56
|
+
// Start with fully-capsule corners so the initial frame reads as a
|
|
57
|
+
// pill hugging the camera, not a tiny rectangle.
|
|
58
|
+
applyMorphCorners(1f)
|
|
59
|
+
|
|
60
|
+
// Material standard ease-in-out — gentle accel, soft landing.
|
|
61
|
+
// Duration matches iOS's `.bouncy(duration: 0.3)`.
|
|
62
|
+
pill.animate()
|
|
63
|
+
.scaleX(1f)
|
|
64
|
+
.scaleY(1f)
|
|
65
|
+
.translationX(0f)
|
|
66
|
+
.setDuration(MORPH_DURATION_MS)
|
|
67
|
+
.setInterpolator(MORPH_EASING)
|
|
68
|
+
.start()
|
|
69
|
+
animateMorphCorners(fromProgress = 1f, toProgress = 0f, durationMs = MORPH_DURATION_MS)
|
|
70
|
+
|
|
71
|
+
content.animate()
|
|
72
|
+
.alpha(1f)
|
|
73
|
+
.setDuration(210)
|
|
74
|
+
.setStartDelay(80)
|
|
75
|
+
.start()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
override fun dismiss(onEnd: () -> Unit) {
|
|
79
|
+
onBeforeDismiss()
|
|
80
|
+
|
|
81
|
+
val expandedWidth = pill.width.toFloat().coerceAtLeast(1f)
|
|
82
|
+
val expandedHeight = expandedHeight()
|
|
83
|
+
val scaleX = info.collapsedWidth / expandedWidth
|
|
84
|
+
val scaleY = info.collapsedHeight / expandedHeight
|
|
85
|
+
|
|
86
|
+
// Clear any stale drag translation and align pivots with show()
|
|
87
|
+
// so the morph collapses exactly onto the cutout.
|
|
88
|
+
pill.translationY = 0f
|
|
89
|
+
pill.pivotX = expandedWidth / 2f
|
|
90
|
+
pill.pivotY = 0f
|
|
91
|
+
|
|
92
|
+
content.animate()
|
|
93
|
+
.alpha(0f)
|
|
94
|
+
.setDuration(140)
|
|
95
|
+
.start()
|
|
96
|
+
|
|
97
|
+
// Infer current progress from pill.scaleY so the corner animation
|
|
98
|
+
// picks up from wherever the drag left it.
|
|
99
|
+
val minScaleY = info.collapsedHeight / expandedHeight
|
|
100
|
+
val currentProgress = if (minScaleY < 1f)
|
|
101
|
+
((1f - pill.scaleY) / (1f - minScaleY)).coerceIn(0f, 1f)
|
|
102
|
+
else 0f
|
|
103
|
+
|
|
104
|
+
// Scale down and fade out simultaneously — no two-step pop.
|
|
105
|
+
pill.animate()
|
|
106
|
+
.scaleX(scaleX)
|
|
107
|
+
.scaleY(scaleY)
|
|
108
|
+
.translationX(info.horizontalOffset)
|
|
109
|
+
.alpha(0f)
|
|
110
|
+
.setDuration(MORPH_DURATION_MS)
|
|
111
|
+
.setInterpolator(MORPH_EASING)
|
|
112
|
+
.withEndAction {
|
|
113
|
+
resetPillToExpandedResting()
|
|
114
|
+
onEnd()
|
|
115
|
+
}
|
|
116
|
+
.start()
|
|
117
|
+
animateMorphCorners(fromProgress = currentProgress, toProgress = 1f, durationMs = MORPH_DURATION_MS)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
override fun applyDrag(dy: Float, translationYOnDown: Float) {
|
|
121
|
+
if (dy >= 0f) return // drag morph is upward-only
|
|
122
|
+
applyDragMorph(-dy)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
override fun snapBack() {
|
|
126
|
+
// Release-without-dismiss during a morph drag: spring back to full
|
|
127
|
+
// size and restore content opacity.
|
|
128
|
+
pill.animate().cancel()
|
|
129
|
+
pill.animate()
|
|
130
|
+
.scaleX(1f)
|
|
131
|
+
.scaleY(1f)
|
|
132
|
+
.translationX(0f)
|
|
133
|
+
.setDuration(MORPH_DURATION_MS)
|
|
134
|
+
.setInterpolator(MORPH_EASING)
|
|
135
|
+
.start()
|
|
136
|
+
|
|
137
|
+
val minScaleY = info.collapsedHeight / expandedHeight()
|
|
138
|
+
val currentProgress = if (minScaleY < 1f)
|
|
139
|
+
((1f - pill.scaleY) / (1f - minScaleY)).coerceIn(0f, 1f)
|
|
140
|
+
else 0f
|
|
141
|
+
animateMorphCorners(currentProgress, 0f, MORPH_DURATION_MS)
|
|
142
|
+
|
|
143
|
+
content.animate().cancel()
|
|
144
|
+
content.animate()
|
|
145
|
+
.alpha(1f)
|
|
146
|
+
.setDuration(220)
|
|
147
|
+
.start()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Interpolates the pill's scale from fully expanded toward the cutout
|
|
152
|
+
* footprint. `upwardDistance` is positive px of finger travel up.
|
|
153
|
+
*/
|
|
154
|
+
private fun applyDragMorph(upwardDistance: Float) {
|
|
155
|
+
val expandedWidth = pill.width.toFloat().coerceAtLeast(1f)
|
|
156
|
+
val expandedHeight = expandedHeight()
|
|
157
|
+
val collapsedScaleX = info.collapsedWidth / expandedWidth
|
|
158
|
+
val collapsedScaleY = info.collapsedHeight / expandedHeight
|
|
159
|
+
|
|
160
|
+
val fullCollapseDistance = density.dp(140f)
|
|
161
|
+
val progress = (upwardDistance / fullCollapseDistance).coerceIn(0f, 1f)
|
|
162
|
+
|
|
163
|
+
pill.pivotX = expandedWidth / 2f
|
|
164
|
+
pill.pivotY = 0f
|
|
165
|
+
pill.scaleX = 1f - progress * (1f - collapsedScaleX)
|
|
166
|
+
pill.scaleY = 1f - progress * (1f - collapsedScaleY)
|
|
167
|
+
pill.translationX = info.horizontalOffset * progress
|
|
168
|
+
|
|
169
|
+
cornerAnimator?.cancel()
|
|
170
|
+
applyMorphCorners(progress)
|
|
171
|
+
// Fade content out quickly in the first half so the morph reads
|
|
172
|
+
// as a shape change, not text squeezing.
|
|
173
|
+
content.alpha = (1f - progress * 2f).coerceIn(0f, 1f)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Sets cornerRadii so the visually-rendered corners stay circular
|
|
178
|
+
* throughout the anisotropic scale. With a single cornerRadius,
|
|
179
|
+
* non-uniform scale turns corners into squished ellipses — we
|
|
180
|
+
* compensate by feeding pre-scale x/y radii that project to the
|
|
181
|
+
* desired visible radius after scaleX/scaleY.
|
|
182
|
+
*
|
|
183
|
+
* progress: 0 = fully expanded, 1 = fully collapsed to cutout.
|
|
184
|
+
*/
|
|
185
|
+
private fun applyMorphCorners(progress: Float) {
|
|
186
|
+
val bg = pill.background as? GradientDrawable ?: return
|
|
187
|
+
val pillW = pill.layoutParams.width.toFloat().coerceAtLeast(1f)
|
|
188
|
+
val pillH = expandedHeight()
|
|
189
|
+
val scaleX = 1f - progress * (1f - info.collapsedWidth / pillW)
|
|
190
|
+
val scaleY = 1f - progress * (1f - info.collapsedHeight / pillH)
|
|
191
|
+
|
|
192
|
+
val collapsedCap = minOf(info.collapsedWidth, info.collapsedHeight) / 2f
|
|
193
|
+
val visibleCap = expandedCornerRadius + (collapsedCap - expandedCornerRadius) * progress
|
|
194
|
+
|
|
195
|
+
val rx = visibleCap / scaleX
|
|
196
|
+
val ry = visibleCap / scaleY
|
|
197
|
+
// Reuse the buffer — applyMorphCorners runs per-frame during drag.
|
|
198
|
+
cornerRadiiBuf[0] = rx; cornerRadiiBuf[1] = ry
|
|
199
|
+
cornerRadiiBuf[2] = rx; cornerRadiiBuf[3] = ry
|
|
200
|
+
cornerRadiiBuf[4] = rx; cornerRadiiBuf[5] = ry
|
|
201
|
+
cornerRadiiBuf[6] = rx; cornerRadiiBuf[7] = ry
|
|
202
|
+
bg.cornerRadii = cornerRadiiBuf
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Drives `applyMorphCorners` in lockstep with the scale/translation
|
|
207
|
+
* animation — same duration + interpolator so the two stay in sync
|
|
208
|
+
* frame-for-frame.
|
|
209
|
+
*/
|
|
210
|
+
private fun animateMorphCorners(fromProgress: Float, toProgress: Float, durationMs: Long) {
|
|
211
|
+
cornerAnimator?.cancel()
|
|
212
|
+
cornerAnimator = ValueAnimator.ofFloat(fromProgress, toProgress).apply {
|
|
213
|
+
duration = durationMs
|
|
214
|
+
interpolator = MORPH_EASING
|
|
215
|
+
addUpdateListener { applyMorphCorners(it.animatedValue as Float) }
|
|
216
|
+
start()
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private fun resetPillToExpandedResting() {
|
|
221
|
+
pill.scaleX = 1f
|
|
222
|
+
pill.scaleY = 1f
|
|
223
|
+
pill.translationX = 0f
|
|
224
|
+
pill.alpha = 1f
|
|
225
|
+
content.alpha = 1f
|
|
226
|
+
(pill.background as? GradientDrawable)?.apply {
|
|
227
|
+
cornerRadii = null
|
|
228
|
+
cornerRadius = expandedCornerRadius
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
fun cancelPendingCallbacks() {
|
|
233
|
+
cornerAnimator?.cancel()
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
package com.toast.anim
|
|
2
|
+
|
|
3
|
+
import android.widget.LinearLayout
|
|
4
|
+
import androidx.dynamicanimation.animation.DynamicAnimation
|
|
5
|
+
import androidx.dynamicanimation.animation.SpringAnimation
|
|
6
|
+
import androidx.dynamicanimation.animation.SpringForce
|
|
7
|
+
import com.toast.util.Density
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fallback animation for devices without a top cutout — a spring-based
|
|
11
|
+
* drop-down from offscreen, with matching spring on snap-back and a
|
|
12
|
+
* faster damped spring on dismiss.
|
|
13
|
+
*/
|
|
14
|
+
class SlideAnimator(
|
|
15
|
+
private val pill: LinearLayout,
|
|
16
|
+
private val density: Density,
|
|
17
|
+
) : ToastAnimator {
|
|
18
|
+
|
|
19
|
+
private fun offscreenY(): Float = -density.dp(200f)
|
|
20
|
+
|
|
21
|
+
override fun show() {
|
|
22
|
+
pill.translationY = offscreenY()
|
|
23
|
+
pill.alpha = 1f
|
|
24
|
+
|
|
25
|
+
SpringAnimation(pill, DynamicAnimation.TRANSLATION_Y, 0f).apply {
|
|
26
|
+
spring.apply {
|
|
27
|
+
dampingRatio = 0.75f
|
|
28
|
+
stiffness = SpringForce.STIFFNESS_MEDIUM
|
|
29
|
+
}
|
|
30
|
+
start()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
override fun dismiss(onEnd: () -> Unit) {
|
|
35
|
+
val spring = SpringAnimation(pill, DynamicAnimation.TRANSLATION_Y, offscreenY()).apply {
|
|
36
|
+
spring.apply {
|
|
37
|
+
dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY
|
|
38
|
+
stiffness = SpringForce.STIFFNESS_MEDIUM
|
|
39
|
+
}
|
|
40
|
+
addEndListener { _, _, _, _ -> onEnd() }
|
|
41
|
+
}
|
|
42
|
+
spring.start()
|
|
43
|
+
|
|
44
|
+
pill.animate()
|
|
45
|
+
.alpha(0f)
|
|
46
|
+
.setDuration(250)
|
|
47
|
+
.start()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
override fun applyDrag(dy: Float, translationYOnDown: Float) {
|
|
51
|
+
if (dy >= 0f) return // upward drag only
|
|
52
|
+
pill.translationY = translationYOnDown + dy
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
override fun snapBack() {
|
|
56
|
+
SpringAnimation(pill, DynamicAnimation.TRANSLATION_Y, 0f).apply {
|
|
57
|
+
spring.apply {
|
|
58
|
+
dampingRatio = 0.75f
|
|
59
|
+
stiffness = SpringForce.STIFFNESS_MEDIUM
|
|
60
|
+
}
|
|
61
|
+
start()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
package com.toast.anim
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Common surface for the two toast animation strategies (cutout-morph
|
|
5
|
+
* and slide-spring) so the gesture handler doesn't branch on which one
|
|
6
|
+
* is active.
|
|
7
|
+
*/
|
|
8
|
+
interface ToastAnimator {
|
|
9
|
+
fun show()
|
|
10
|
+
fun dismiss(onEnd: () -> Unit)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Called on every ACTION_MOVE of an in-progress pill drag.
|
|
14
|
+
*
|
|
15
|
+
* @param dy raw finger delta since ACTION_DOWN (negative = upward).
|
|
16
|
+
* @param translationYOnDown pill.translationY captured at ACTION_DOWN
|
|
17
|
+
* — used by the slide animator to resume relative translation.
|
|
18
|
+
*/
|
|
19
|
+
fun applyDrag(dy: Float, translationYOnDown: Float)
|
|
20
|
+
|
|
21
|
+
/** Release without dismiss: restore to the resting expanded state. */
|
|
22
|
+
fun snapBack()
|
|
23
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
package com.toast.backdrop
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.Rect
|
|
6
|
+
import android.os.Handler
|
|
7
|
+
import android.os.Looper
|
|
8
|
+
import android.view.PixelCopy
|
|
9
|
+
import com.toast.util.Density
|
|
10
|
+
import java.lang.ref.WeakReference
|
|
11
|
+
|
|
12
|
+
enum class BackdropTint { COLORED, GRAY }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Samples the pixels beneath the toast pill on a 250 ms tick and flips
|
|
16
|
+
* [onTintChanged] between [BackdropTint.COLORED] (dark backdrop — use the
|
|
17
|
+
* toast's accent colour as the outline) and [BackdropTint.GRAY] (everything
|
|
18
|
+
* lighter — a faint neutral white outline).
|
|
19
|
+
*
|
|
20
|
+
* Mirrors the iOS sampler in `PassThroughWindow.swift`: a 32×8 px bitmap,
|
|
21
|
+
* Rec. 601 luminance, and flip points at 0.050 / 0.060 with hysteresis. iOS
|
|
22
|
+
* uses `CALayer.render(in:)`; we use [PixelCopy.request] against the
|
|
23
|
+
* activity's window, which reads the actual GPU-rendered surface (including
|
|
24
|
+
* our overlay — the self-captured black pill biases the average towards
|
|
25
|
+
* `COLORED` on dark backdrops, which is the correct outcome, and stays well
|
|
26
|
+
* above the flip threshold on any light backdrop where the surrounding
|
|
27
|
+
* pixels dominate the average).
|
|
28
|
+
*/
|
|
29
|
+
class BackdropSampler(
|
|
30
|
+
activity: Activity,
|
|
31
|
+
density: Density,
|
|
32
|
+
private val onTintChanged: (BackdropTint) -> Unit,
|
|
33
|
+
) {
|
|
34
|
+
private val activityRef = WeakReference(activity)
|
|
35
|
+
private val stripHeightPx = density.dpInt(SAMPLE_STRIP_HEIGHT_DP)
|
|
36
|
+
private val handler = Handler(Looper.getMainLooper())
|
|
37
|
+
private var bitmap: Bitmap? =
|
|
38
|
+
Bitmap.createBitmap(BITMAP_WIDTH, BITMAP_HEIGHT, Bitmap.Config.ARGB_8888)
|
|
39
|
+
private val pixels = IntArray(BITMAP_WIDTH * BITMAP_HEIGHT)
|
|
40
|
+
private val srcRect = Rect()
|
|
41
|
+
|
|
42
|
+
private var tickRunnable: Runnable? = null
|
|
43
|
+
private var inflight = false
|
|
44
|
+
private var stopped = false
|
|
45
|
+
private var currentTint: BackdropTint = BackdropTint.GRAY
|
|
46
|
+
|
|
47
|
+
fun start() {
|
|
48
|
+
stop()
|
|
49
|
+
stopped = false
|
|
50
|
+
sample()
|
|
51
|
+
val r = object : Runnable {
|
|
52
|
+
override fun run() {
|
|
53
|
+
sample()
|
|
54
|
+
handler.postDelayed(this, SAMPLE_INTERVAL_MS)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
tickRunnable = r
|
|
58
|
+
handler.postDelayed(r, SAMPLE_INTERVAL_MS)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Stops the tick loop and releases the backing bitmap. Safe to call
|
|
63
|
+
* multiple times. If a [PixelCopy] request is still in flight the
|
|
64
|
+
* bitmap is recycled inside the callback instead — recycling it here
|
|
65
|
+
* would crash the native copy.
|
|
66
|
+
*/
|
|
67
|
+
fun stop() {
|
|
68
|
+
stopped = true
|
|
69
|
+
tickRunnable?.let { handler.removeCallbacks(it) }
|
|
70
|
+
tickRunnable = null
|
|
71
|
+
if (!inflight) {
|
|
72
|
+
bitmap?.recycle()
|
|
73
|
+
bitmap = null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Current tint, so the overlay can sync a freshly-built pill. */
|
|
78
|
+
val tint: BackdropTint get() = currentTint
|
|
79
|
+
|
|
80
|
+
private fun sample() {
|
|
81
|
+
if (inflight || stopped) return
|
|
82
|
+
val activity = activityRef.get() ?: return
|
|
83
|
+
val window = activity.window ?: return
|
|
84
|
+
val decorView = window.decorView ?: return
|
|
85
|
+
val w = decorView.width
|
|
86
|
+
val h = decorView.height
|
|
87
|
+
if (w <= 0 || h <= 0) return
|
|
88
|
+
val dst = bitmap ?: return
|
|
89
|
+
srcRect.set(0, 0, w, stripHeightPx.coerceAtMost(h))
|
|
90
|
+
|
|
91
|
+
inflight = true
|
|
92
|
+
try {
|
|
93
|
+
PixelCopy.request(window, srcRect, dst, { result ->
|
|
94
|
+
inflight = false
|
|
95
|
+
// If stop() was called while the copy was in flight it
|
|
96
|
+
// deferred the recycle to us — do it now, and bail out of
|
|
97
|
+
// any further processing.
|
|
98
|
+
if (stopped) {
|
|
99
|
+
bitmap?.recycle()
|
|
100
|
+
bitmap = null
|
|
101
|
+
return@request
|
|
102
|
+
}
|
|
103
|
+
if (result == PixelCopy.SUCCESS) processBitmap(dst)
|
|
104
|
+
}, handler)
|
|
105
|
+
} catch (_: IllegalArgumentException) {
|
|
106
|
+
inflight = false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private fun processBitmap(src: Bitmap) {
|
|
111
|
+
src.getPixels(pixels, 0, BITMAP_WIDTH, 0, 0, BITMAP_WIDTH, BITMAP_HEIGHT)
|
|
112
|
+
|
|
113
|
+
var total = 0.0
|
|
114
|
+
for (p in pixels) {
|
|
115
|
+
val r = ((p shr 16) and 0xFF) / 255.0
|
|
116
|
+
val g = ((p shr 8) and 0xFF) / 255.0
|
|
117
|
+
val b = (p and 0xFF) / 255.0
|
|
118
|
+
total += 0.299 * r + 0.587 * g + 0.114 * b
|
|
119
|
+
}
|
|
120
|
+
val avg = total / pixels.size
|
|
121
|
+
|
|
122
|
+
// Single flip point at ~#0E (≈0.055), matching Apple's DI. ±0.005
|
|
123
|
+
// hysteresis stops backdrops right on the boundary flickering.
|
|
124
|
+
val next = when (currentTint) {
|
|
125
|
+
BackdropTint.COLORED -> if (avg > FLIP_HIGH) BackdropTint.GRAY else BackdropTint.COLORED
|
|
126
|
+
BackdropTint.GRAY -> if (avg < FLIP_LOW) BackdropTint.COLORED else BackdropTint.GRAY
|
|
127
|
+
}
|
|
128
|
+
if (next != currentTint) {
|
|
129
|
+
currentTint = next
|
|
130
|
+
onTintChanged(next)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
companion object {
|
|
135
|
+
private const val SAMPLE_INTERVAL_MS = 250L
|
|
136
|
+
private const val BITMAP_WIDTH = 32
|
|
137
|
+
private const val BITMAP_HEIGHT = 8
|
|
138
|
+
private const val SAMPLE_STRIP_HEIGHT_DP = 80f
|
|
139
|
+
private const val FLIP_LOW = 0.050
|
|
140
|
+
private const val FLIP_HIGH = 0.060
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
package com.toast.backdrop
|
|
2
|
+
|
|
3
|
+
import android.animation.ArgbEvaluator
|
|
4
|
+
import android.animation.ValueAnimator
|
|
5
|
+
import android.graphics.Color
|
|
6
|
+
import android.graphics.drawable.GradientDrawable
|
|
7
|
+
import android.view.animation.AccelerateDecelerateInterpolator
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Drives the outline colour on the toast pill's [GradientDrawable] stroke.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the iOS renderer in `PrettyToastView.swift`: on `.colored` tint the
|
|
13
|
+
* stroke takes the toast's accent at 20% alpha; on `.gray` it falls back to a
|
|
14
|
+
* near-invisible white at 6%. Tint changes crossfade over 300 ms via a
|
|
15
|
+
* `ValueAnimator` + `ArgbEvaluator`, matching the iOS
|
|
16
|
+
* `.animation(.easeInOut(duration: 0.3))` on stroke opacity.
|
|
17
|
+
*/
|
|
18
|
+
class OutlineController(
|
|
19
|
+
private val pillBackground: GradientDrawable,
|
|
20
|
+
private val strokeWidthPx: Int,
|
|
21
|
+
) {
|
|
22
|
+
private var accentColor: Int = Color.WHITE
|
|
23
|
+
private var currentTint: BackdropTint = BackdropTint.GRAY
|
|
24
|
+
private var currentStrokeColor: Int = Color.TRANSPARENT
|
|
25
|
+
private var animator: ValueAnimator? = null
|
|
26
|
+
/**
|
|
27
|
+
* When non-null, overrides the sampler-driven stroke with a fixed color.
|
|
28
|
+
* Used by the JS-facing `strokeColor` prop; treated as the full ARGB
|
|
29
|
+
* value (alpha included — no further opacity derivation).
|
|
30
|
+
*/
|
|
31
|
+
private var override: Int? = null
|
|
32
|
+
|
|
33
|
+
init {
|
|
34
|
+
applyStroke(strokeColorFor(currentTint))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fun setAccent(accent: Int) {
|
|
38
|
+
if (accent == accentColor) return
|
|
39
|
+
accentColor = accent
|
|
40
|
+
val target = strokeColorFor(currentTint)
|
|
41
|
+
animator?.cancel()
|
|
42
|
+
applyStroke(target)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fun setTint(tint: BackdropTint, animated: Boolean) {
|
|
46
|
+
if (tint == currentTint) return
|
|
47
|
+
currentTint = tint
|
|
48
|
+
if (override != null) return
|
|
49
|
+
val target = strokeColorFor(tint)
|
|
50
|
+
if (!animated) {
|
|
51
|
+
animator?.cancel()
|
|
52
|
+
applyStroke(target)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
animateStrokeTo(target)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fun setOverride(color: Int?) {
|
|
59
|
+
if (override == color) return
|
|
60
|
+
override = color
|
|
61
|
+
animator?.cancel()
|
|
62
|
+
applyStroke(color ?: strokeColorFor(currentTint))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private fun strokeColorFor(tint: BackdropTint): Int = override ?: when (tint) {
|
|
66
|
+
BackdropTint.COLORED -> withAlpha(accentColor, ACCENT_ALPHA_255)
|
|
67
|
+
BackdropTint.GRAY -> withAlpha(Color.WHITE, GRAY_ALPHA_255)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private fun withAlpha(color: Int, alpha: Int): Int =
|
|
71
|
+
(color and 0x00FFFFFF) or ((alpha and 0xFF) shl 24)
|
|
72
|
+
|
|
73
|
+
private fun applyStroke(color: Int) {
|
|
74
|
+
currentStrokeColor = color
|
|
75
|
+
pillBackground.setStroke(strokeWidthPx, color)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private fun animateStrokeTo(target: Int) {
|
|
79
|
+
animator?.cancel()
|
|
80
|
+
val start = currentStrokeColor
|
|
81
|
+
animator = ValueAnimator.ofObject(ArgbEvaluator(), start, target).apply {
|
|
82
|
+
duration = CROSSFADE_MS
|
|
83
|
+
interpolator = AccelerateDecelerateInterpolator()
|
|
84
|
+
addUpdateListener { a -> applyStroke(a.animatedValue as Int) }
|
|
85
|
+
start()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fun cancel() {
|
|
90
|
+
animator?.cancel()
|
|
91
|
+
animator = null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
companion object {
|
|
95
|
+
// 0.20 × 255 = 51, 0.06 × 255 ≈ 15 — match iOS stroke alphas.
|
|
96
|
+
private const val ACCENT_ALPHA_255 = 51
|
|
97
|
+
private const val GRAY_ALPHA_255 = 15
|
|
98
|
+
private const val CROSSFADE_MS = 300L
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
package com.toast.cutout
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.graphics.Rect
|
|
5
|
+
import android.os.Build
|
|
6
|
+
import android.view.RoundedCorner
|
|
7
|
+
import android.view.View
|
|
8
|
+
import androidx.core.view.ViewCompat
|
|
9
|
+
import androidx.core.view.WindowInsetsCompat
|
|
10
|
+
import com.toast.util.ToastConstants.COLLAPSED_CUTOUT_FACTOR
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Reads DisplayCutout + rounded-corner insets off the decor view and
|
|
14
|
+
* packages them into an immutable [CutoutInfo] snapshot.
|
|
15
|
+
*/
|
|
16
|
+
object CutoutDetector {
|
|
17
|
+
|
|
18
|
+
fun detect(activity: Activity, decorView: View, useDynamicIsland: Boolean): CutoutInfo {
|
|
19
|
+
val screenWidth = activity.resources.displayMetrics.widthPixels
|
|
20
|
+
val density = activity.resources.displayMetrics.density
|
|
21
|
+
val insets = ViewCompat.getRootWindowInsets(decorView)
|
|
22
|
+
|
|
23
|
+
val statusBarHeight = resolveStatusBarHeight(activity, insets)
|
|
24
|
+
val screenCornerRadius = resolveScreenCornerRadius(decorView)
|
|
25
|
+
|
|
26
|
+
val cutoutRect = if (useDynamicIsland) {
|
|
27
|
+
detectTopCutoutRect(decorView, statusBarHeight)
|
|
28
|
+
} else {
|
|
29
|
+
null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
val hasCutout = cutoutRect != null
|
|
33
|
+
|
|
34
|
+
val collapsedWidth: Float
|
|
35
|
+
val collapsedHeight: Float
|
|
36
|
+
val horizontalOffset: Float
|
|
37
|
+
if (cutoutRect != null) {
|
|
38
|
+
collapsedWidth = cutoutRect.width() * COLLAPSED_CUTOUT_FACTOR
|
|
39
|
+
collapsedHeight = cutoutRect.height() * COLLAPSED_CUTOUT_FACTOR
|
|
40
|
+
val screenCenterX = screenWidth / 2f
|
|
41
|
+
horizontalOffset = cutoutRect.centerX() - screenCenterX
|
|
42
|
+
} else {
|
|
43
|
+
collapsedWidth = 120f * density
|
|
44
|
+
collapsedHeight = 36f * density
|
|
45
|
+
horizontalOffset = 0f
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return CutoutInfo(
|
|
49
|
+
hasCutout = hasCutout,
|
|
50
|
+
rect = cutoutRect,
|
|
51
|
+
collapsedWidth = collapsedWidth,
|
|
52
|
+
collapsedHeight = collapsedHeight,
|
|
53
|
+
horizontalOffset = horizontalOffset,
|
|
54
|
+
screenCornerRadius = screenCornerRadius,
|
|
55
|
+
statusBarHeight = statusBarHeight,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private fun detectTopCutoutRect(decorView: View, statusBarHeight: Int): Rect? {
|
|
60
|
+
val insets = ViewCompat.getRootWindowInsets(decorView) ?: return null
|
|
61
|
+
val cutout = insets.displayCutout ?: return null
|
|
62
|
+
|
|
63
|
+
// Enable Dynamic-Island-style behavior for any top cutout —
|
|
64
|
+
// centered punch-hole, corner camera, or notch. The morph
|
|
65
|
+
// animation uses horizontal translation so it lands correctly
|
|
66
|
+
// on off-center holes.
|
|
67
|
+
val topRect = cutout.boundingRects.firstOrNull { it.top == 0 || it.top < statusBarHeight }
|
|
68
|
+
return topRect?.takeIf { !it.isEmpty }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private fun resolveStatusBarHeight(activity: Activity, insets: WindowInsetsCompat?): Int {
|
|
72
|
+
val fromInsets = insets?.getInsets(WindowInsetsCompat.Type.statusBars())?.top ?: 0
|
|
73
|
+
if (fromInsets > 0) return fromInsets
|
|
74
|
+
val resourceId = activity.resources.getIdentifier("status_bar_height", "dimen", "android")
|
|
75
|
+
return if (resourceId > 0) activity.resources.getDimensionPixelSize(resourceId) else 0
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private fun resolveScreenCornerRadius(decorView: View): Int {
|
|
79
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
80
|
+
val insets = decorView.rootWindowInsets
|
|
81
|
+
val topLeft = insets?.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT)
|
|
82
|
+
val topRight = insets?.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT)
|
|
83
|
+
val radius = maxOf(topLeft?.radius ?: 0, topRight?.radius ?: 0)
|
|
84
|
+
if (radius > 0) return radius
|
|
85
|
+
}
|
|
86
|
+
return 0
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
package com.toast.cutout
|
|
2
|
+
|
|
3
|
+
import android.graphics.Rect
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Snapshot of cutout/screen geometry captured once per toast show.
|
|
7
|
+
*
|
|
8
|
+
* Replaces the per-access computed properties the old monolithic overlay
|
|
9
|
+
* had for `collapsedWidth`, `collapsedHeight`, `cutoutHorizontalOffset`,
|
|
10
|
+
* etc. — we read these many times per animation frame, so paying the
|
|
11
|
+
* measurement cost once up-front matters.
|
|
12
|
+
*/
|
|
13
|
+
data class CutoutInfo(
|
|
14
|
+
val hasCutout: Boolean,
|
|
15
|
+
val rect: Rect?,
|
|
16
|
+
/** Width the pill morphs to when collapsed onto the cutout. */
|
|
17
|
+
val collapsedWidth: Float,
|
|
18
|
+
/** Height the pill morphs to when collapsed onto the cutout. */
|
|
19
|
+
val collapsedHeight: Float,
|
|
20
|
+
/**
|
|
21
|
+
* Horizontal translation (px) needed to slide a centered pill so
|
|
22
|
+
* its center aligns with the cutout. Zero for centered punch-holes,
|
|
23
|
+
* non-zero for corner cameras.
|
|
24
|
+
*/
|
|
25
|
+
val horizontalOffset: Float,
|
|
26
|
+
val screenCornerRadius: Int,
|
|
27
|
+
val statusBarHeight: Int,
|
|
28
|
+
)
|