@capgo/capacitor-pretty-toast 8.1.18 → 8.1.21
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/README.md +21 -6
- package/android/src/main/java/com/toast/ToastOverlay.kt +10 -1
- package/android/src/main/java/com/toast/anim/CutoutMorphAnimator.kt +18 -0
- package/ios/Sources/PrettyToastPlugin/PrettyToastView.swift +42 -41
- package/ios/Sources/PrettyToastPlugin/ToastManager.swift +13 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
# @capgo/capacitor-pretty-toast
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Show beautiful toast notifications in your Capacitor app with native overlays on iOS and Android and a DOM renderer on web. Same `toast.*` API as react-native-pretty-toast.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
<a href="https://capgo.app/?ref=plugin_pretty_toast"><img src="https://capgo.app/readme-banner.svg?repo=Cap-go/capacitor-pretty-toast" alt="Capgo - Instant updates for Capacitor" /></a>
|
|
6
6
|
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
<div align="center">
|
|
8
|
+
<p><b>Capgo</b>: open-source live updates for Ionic and Capacitor apps. Ship OTA fixes and features instantly, without waiting for app store review.</p>
|
|
9
|
+
<h2><a href="https://capgo.app/register/?ref=plugin_pretty_toast">➡️ Get started for free</a></h2>
|
|
10
|
+
<p>14-day unlimited free trial. No credit card required</p>
|
|
11
|
+
<p><a href="https://capgo.app/consulting/?ref=plugin_pretty_toast">Missing a feature? We'll build the plugin for you 💪</a></p>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<img src="https://raw.githubusercontent.com/Cap-go/capacitor-pretty-toast/main/.github/assets/readme-demo.webp" alt="Animated Pretty Toast demo on an external device" width="300" />
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
## Key features
|
|
19
|
+
|
|
20
|
+
- **Variants**: `success()`, `error()`, `info()`, `warning()`, `loading()` and fully custom `show()`.
|
|
21
|
+
- **Promise toasts**: `promise()` shows loading, then success or error when your promise settles.
|
|
22
|
+
- **Live updates**: `update()` changes a toast in place, `dismiss()` and `dismissAll()` close them.
|
|
23
|
+
- **Queueing**: toasts queue by default, and `force` puts the new toast first and shows it once the current toast is dismissed.
|
|
24
|
+
- **Icons**: symbol icons and raw SVG through `icon`, and image URIs through `iconSource`.
|
|
25
|
+
- **Platforms**: iOS, Android and Web.
|
|
11
26
|
|
|
12
27
|
This package keeps the familiar `toast.*` surface from `react-native-pretty-toast`, but ships as a Capacitor plugin with:
|
|
13
28
|
|
|
@@ -66,7 +66,13 @@ class ToastOverlay(activity: Activity) {
|
|
|
66
66
|
var onPress: (() -> Unit)? = null
|
|
67
67
|
var onActionPress: (() -> Unit)? = null
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
// A `var`, not a `val`: show() calls destroy() on this same instance (not
|
|
70
|
+
// a fresh one) whenever useDynamicIsland changes, which shuts this down.
|
|
71
|
+
// loadIcon() rebuilds it on demand so a later custom-icon toast on the
|
|
72
|
+
// same overlay doesn't hit a RejectedExecutionException on a dead pool.
|
|
73
|
+
private var imageLoader: ExecutorService = newImageLoader()
|
|
74
|
+
|
|
75
|
+
private fun newImageLoader(): ExecutorService = Executors.newSingleThreadExecutor { r ->
|
|
70
76
|
Thread(r, "ToastIconLoader").apply { isDaemon = true }
|
|
71
77
|
}
|
|
72
78
|
// Bitmaps keyed by URI so a repeat show of the same custom icon paints
|
|
@@ -379,6 +385,9 @@ class ToastOverlay(activity: Activity) {
|
|
|
379
385
|
private fun loadIcon(built: ToastViewFactory.Built, uri: String) {
|
|
380
386
|
val targetW = built.icon.layoutParams.width.takeIf { it > 0 } ?: DEFAULT_ICON_TARGET_PX
|
|
381
387
|
val targetH = built.icon.layoutParams.height.takeIf { it > 0 } ?: DEFAULT_ICON_TARGET_PX
|
|
388
|
+
if (imageLoader.isShutdown) {
|
|
389
|
+
imageLoader = newImageLoader()
|
|
390
|
+
}
|
|
382
391
|
imageLoader.execute {
|
|
383
392
|
val bitmap = try {
|
|
384
393
|
when {
|
|
@@ -28,6 +28,17 @@ class CutoutMorphAnimator(
|
|
|
28
28
|
private var cornerAnimator: ValueAnimator? = null
|
|
29
29
|
private val cornerRadiiBuf = FloatArray(8)
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Set for the duration of dismiss()'s own pill.animate() run. A tap or
|
|
33
|
+
* release arriving in that window would otherwise reach snapBack(),
|
|
34
|
+
* which cancels the very same ViewPropertyAnimator instance (Android
|
|
35
|
+
* caches one per view) — cancel() never runs withEndAction, so the
|
|
36
|
+
* dismiss's own state reset and onEnd() callback would be lost forever,
|
|
37
|
+
* leaving the toast overlay's isDismissing flag stuck and every later
|
|
38
|
+
* toast permanently queued behind it.
|
|
39
|
+
*/
|
|
40
|
+
private var isDismissingAnim = false
|
|
41
|
+
|
|
31
42
|
/**
|
|
32
43
|
* Lower bound for the measured pill height — guards against racing
|
|
33
44
|
* the layout pass, where `pill.height` can briefly read as 0.
|
|
@@ -76,6 +87,7 @@ class CutoutMorphAnimator(
|
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
override fun dismiss(onEnd: () -> Unit) {
|
|
90
|
+
isDismissingAnim = true
|
|
79
91
|
onBeforeDismiss()
|
|
80
92
|
|
|
81
93
|
val expandedWidth = pill.width.toFloat().coerceAtLeast(1f)
|
|
@@ -110,6 +122,7 @@ class CutoutMorphAnimator(
|
|
|
110
122
|
.setDuration(MORPH_DURATION_MS)
|
|
111
123
|
.setInterpolator(MORPH_EASING)
|
|
112
124
|
.withEndAction {
|
|
125
|
+
isDismissingAnim = false
|
|
113
126
|
resetPillToExpandedResting()
|
|
114
127
|
onEnd()
|
|
115
128
|
}
|
|
@@ -123,6 +136,11 @@ class CutoutMorphAnimator(
|
|
|
123
136
|
}
|
|
124
137
|
|
|
125
138
|
override fun snapBack() {
|
|
139
|
+
// A dismiss already in flight owns this animator; cancelling it here
|
|
140
|
+
// would strand it (see isDismissingAnim above) instead of letting it
|
|
141
|
+
// finish and report back to ToastOverlay.
|
|
142
|
+
if (isDismissingAnim) return
|
|
143
|
+
|
|
126
144
|
// Release-without-dismiss during a morph drag: spring back to full
|
|
127
145
|
// size and restore content opacity.
|
|
128
146
|
pill.animate().cancel()
|
|
@@ -17,55 +17,56 @@ struct PrettyToastView: View {
|
|
|
17
17
|
let scaleX: CGFloat = isExpanded ? 1 : (layout.compactWidth / layout.expandedWidth)
|
|
18
18
|
let scaleY: CGFloat = isExpanded ? 1 : (layout.compactHeight / layout.expandedHeight)
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// A separate `.onTapGesture` alongside a low-threshold
|
|
21
|
+
// `.gesture(DragGesture())` on the same view is a classic SwiftUI
|
|
22
|
+
// trap: `minimumDistance: 2` makes the drag start recognizing on
|
|
23
|
+
// almost any real touch, taps included, and once it does, it took
|
|
24
|
+
// exclusive priority over the sibling tap gesture — so a plain
|
|
25
|
+
// tap never reached `wasTapped` and the toast could never be
|
|
26
|
+
// dismissed by tapping it. One gesture, one decision on release,
|
|
27
|
+
// fixes that: a short release is always a tap regardless of
|
|
28
|
+
// whether swipe-dismiss is enabled.
|
|
29
|
+
let dismissGesture = DragGesture(minimumDistance: 0).onEnded { value in
|
|
30
|
+
if window.enableSwipeDismiss && (value.translation.height < -8 || value.predictedEndTranslation.height < -40) {
|
|
22
31
|
window.swipeDismissRequested = true
|
|
32
|
+
} else {
|
|
33
|
+
window.wasTapped = true
|
|
23
34
|
}
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
ZStack {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
toastBackground()
|
|
39
|
+
.overlay {
|
|
40
|
+
toastContent(layout)
|
|
41
|
+
.frame(width: layout.expandedWidth, height: layout.expandedHeight)
|
|
42
|
+
.scaleEffect(x: scaleX, y: scaleY)
|
|
43
|
+
}
|
|
44
|
+
.frame(
|
|
45
|
+
width: isExpanded ? layout.expandedWidth : layout.compactWidth,
|
|
46
|
+
height: isExpanded ? layout.expandedHeight : layout.compactHeight
|
|
47
|
+
)
|
|
48
|
+
.opacity(layout.hasDynamicIsland ? 1 : (isExpanded ? 1 : 0))
|
|
49
|
+
.modifier(CapsuleOpacityModifier(
|
|
50
|
+
haveDynamicIsland: layout.hasDynamicIsland,
|
|
51
|
+
isExpanded: isExpanded
|
|
52
|
+
))
|
|
53
|
+
.modifier(GeometryGroupModifier())
|
|
54
|
+
.contentShape(Rectangle())
|
|
55
|
+
.gesture(dismissGesture)
|
|
56
|
+
.background(
|
|
57
|
+
GeometryReader { geo in
|
|
58
|
+
Color.clear.preference(
|
|
59
|
+
key: ToastFrameKey.self,
|
|
60
|
+
value: geo.frame(in: .named("overlayWindow"))
|
|
61
|
+
)
|
|
47
62
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
value: geo.frame(in: .named("overlayWindow"))
|
|
53
|
-
)
|
|
54
|
-
}
|
|
55
|
-
)
|
|
56
|
-
.onPreferenceChange(ToastFrameKey.self) { frame in
|
|
57
|
-
if window.toastHitFrame != frame {
|
|
58
|
-
window.toastHitFrame = frame
|
|
59
|
-
}
|
|
63
|
+
)
|
|
64
|
+
.onPreferenceChange(ToastFrameKey.self) { frame in
|
|
65
|
+
if window.toastHitFrame != frame {
|
|
66
|
+
window.toastHitFrame = frame
|
|
60
67
|
}
|
|
61
|
-
|
|
62
|
-
if window.enableSwipeDismiss {
|
|
63
|
-
pill.gesture(swipeGesture)
|
|
64
|
-
} else {
|
|
65
|
-
pill
|
|
66
68
|
}
|
|
67
|
-
|
|
68
|
-
.offset(y: layout.hasDynamicIsland ? (isExpanded ? layout.expandedTopOffset : layout.topOffset) : 0)
|
|
69
|
+
.offset(y: layout.hasDynamicIsland ? (isExpanded ? layout.expandedTopOffset : layout.topOffset) : 0)
|
|
69
70
|
}
|
|
70
71
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
71
72
|
.padding(.top, layout.hasDynamicIsland ? 0 : (isExpanded ? max(safeArea.top, 10) : 0))
|
|
@@ -84,11 +84,18 @@ import UIKit
|
|
|
84
84
|
self.cancelTimer()
|
|
85
85
|
if autoDismiss && duration > 0 {
|
|
86
86
|
let interval = TimeInterval(duration) / 1000.0
|
|
87
|
-
|
|
87
|
+
// `.common` mode, like the backdrop sampler below: `.default`-mode
|
|
88
|
+
// timers are suspended for as long as the run loop is in `.tracking`
|
|
89
|
+
// mode, which UIKit enters for any active touch/scroll — so a toast
|
|
90
|
+
// triggered while (or dismissed while) the user is scrolling would
|
|
91
|
+
// never time out, jamming every later toast behind it in the queue.
|
|
92
|
+
let timer = Timer(timeInterval: interval, repeats: false) { [weak self] _ in
|
|
88
93
|
DispatchQueue.main.async {
|
|
89
94
|
self?.dismiss()
|
|
90
95
|
}
|
|
91
96
|
}
|
|
97
|
+
RunLoop.main.add(timer, forMode: .common)
|
|
98
|
+
self.autoDismissTimer = timer
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
if !accessibilityAnnouncement.isEmpty {
|
|
@@ -148,11 +155,15 @@ import UIKit
|
|
|
148
155
|
cancelTimer()
|
|
149
156
|
if autoDismiss && duration > 0 {
|
|
150
157
|
let interval = TimeInterval(duration) / 1000.0
|
|
151
|
-
|
|
158
|
+
// See the matching comment in show(): `.common` mode so scrolling
|
|
159
|
+
// doesn't suspend the countdown.
|
|
160
|
+
let timer = Timer(timeInterval: interval, repeats: false) { [weak self] _ in
|
|
152
161
|
DispatchQueue.main.async {
|
|
153
162
|
self?.dismiss()
|
|
154
163
|
}
|
|
155
164
|
}
|
|
165
|
+
RunLoop.main.add(timer, forMode: .common)
|
|
166
|
+
autoDismissTimer = timer
|
|
156
167
|
}
|
|
157
168
|
}
|
|
158
169
|
|
package/package.json
CHANGED