@capgo/capacitor-pretty-toast 8.1.18 → 8.1.20

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 CHANGED
@@ -1,13 +1,28 @@
1
1
  # @capgo/capacitor-pretty-toast
2
2
 
3
- Native-first pretty toast notifications for Capacitor and the web.
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
- ## Demo
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
- <img
8
- src="./media/pretty-toast-external-device.webp"
9
- alt="Animated Pretty Toast demo on an external device"
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` shows one right away.
24
+ - **Icons**: symbol icons, raw SVG 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
- private val imageLoader: ExecutorService = Executors.newSingleThreadExecutor { r ->
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
- let swipeGesture = DragGesture(minimumDistance: 2).onEnded { value in
21
- if value.translation.height < -8 || value.predictedEndTranslation.height < -40 {
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
- Group {
28
- let pill = toastBackground()
29
- .overlay {
30
- toastContent(layout)
31
- .frame(width: layout.expandedWidth, height: layout.expandedHeight)
32
- .scaleEffect(x: scaleX, y: scaleY)
33
- }
34
- .frame(
35
- width: isExpanded ? layout.expandedWidth : layout.compactWidth,
36
- height: isExpanded ? layout.expandedHeight : layout.compactHeight
37
- )
38
- .opacity(layout.hasDynamicIsland ? 1 : (isExpanded ? 1 : 0))
39
- .modifier(CapsuleOpacityModifier(
40
- haveDynamicIsland: layout.hasDynamicIsland,
41
- isExpanded: isExpanded
42
- ))
43
- .modifier(GeometryGroupModifier())
44
- .contentShape(Rectangle())
45
- .onTapGesture {
46
- window.wasTapped = true
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
- .background(
49
- GeometryReader { geo in
50
- Color.clear.preference(
51
- key: ToastFrameKey.self,
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
- self.autoDismissTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
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
- autoDismissTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-pretty-toast",
3
- "version": "8.1.18",
3
+ "version": "8.1.20",
4
4
  "description": "Native-first pretty toast notifications for Capacitor and the web",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",