@capgo/capacitor-pretty-toast 8.1.8 → 8.1.9

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.
@@ -36,6 +36,24 @@ class PassThroughWindow: UIWindow, ObservableObject {
36
36
  return frame.insetBy(dx: -8, dy: -8).contains(point)
37
37
  }
38
38
 
39
+ func resolvedSafeAreaInsets(geometryInsets: EdgeInsets) -> UIEdgeInsets {
40
+ let geometrySafeArea = UIEdgeInsets(
41
+ top: geometryInsets.top,
42
+ left: geometryInsets.leading,
43
+ bottom: geometryInsets.bottom,
44
+ right: geometryInsets.trailing
45
+ )
46
+
47
+ guard let scene = windowScene else {
48
+ return mergedSafeAreaInsets(geometrySafeArea, safeAreaInsets)
49
+ }
50
+
51
+ return scene.windows
52
+ .filter { $0 !== self && !$0.isHidden }
53
+ .map(\.safeAreaInsets)
54
+ .reduce(mergedSafeAreaInsets(geometrySafeArea, safeAreaInsets), mergedSafeAreaInsets)
55
+ }
56
+
39
57
  // MARK: - Backdrop sampling
40
58
 
41
59
  func startBackdropSampling() {
@@ -137,3 +155,12 @@ class PassThroughWindow: UIWindow, ObservableObject {
137
155
  }
138
156
  }
139
157
  }
158
+
159
+ private func mergedSafeAreaInsets(_ lhs: UIEdgeInsets, _ rhs: UIEdgeInsets) -> UIEdgeInsets {
160
+ UIEdgeInsets(
161
+ top: max(lhs.top, rhs.top),
162
+ left: max(lhs.left, rhs.left),
163
+ bottom: max(lhs.bottom, rhs.bottom),
164
+ right: max(lhs.right, rhs.right)
165
+ )
166
+ }
@@ -5,26 +5,17 @@ struct PrettyToastView: View {
5
5
  @State private var measuredContentHeight: CGFloat = 0
6
6
 
7
7
  var body: some View {
8
- GeometryReader {
9
- let safeArea = $0.safeAreaInsets
10
- let size = $0.size
11
-
12
- let haveDynamicIsland: Bool = safeArea.top >= 59 && window.useDynamicIsland
13
- let dynamicIslandWidth: CGFloat = 120
14
- let dynamicIslandHeight: CGFloat = 36
15
- let topOffset: CGFloat = 11 + max((safeArea.top - 59), 0)
16
- // Nudge up 0.5pt so the centered stroke clears the DI's top line
17
- // instead of sitting half-behind it.
18
- let expandedTopOffset: CGFloat = topOffset - 0.5
19
-
20
- let expandedWidth = size.width - (topOffset * 2)
21
- let baseHeight: CGFloat = haveDynamicIsland ? 90 : 70
22
- let baseContentArea: CGFloat = haveDynamicIsland ? (baseHeight - dynamicIslandHeight - 12) : (baseHeight - 20)
23
- let overflow = max(0, measuredContentHeight - baseContentArea)
24
- let expandedHeight: CGFloat = baseHeight + overflow
8
+ GeometryReader { proxy in
9
+ let safeArea = window.resolvedSafeAreaInsets(geometryInsets: proxy.safeAreaInsets)
10
+ let layout = PrettyToastLayout(
11
+ size: proxy.size,
12
+ safeAreaTop: safeArea.top,
13
+ measuredContentHeight: measuredContentHeight,
14
+ useDynamicIsland: window.useDynamicIsland
15
+ )
25
16
 
26
- let scaleX: CGFloat = isExpanded ? 1 : (dynamicIslandWidth / expandedWidth)
27
- let scaleY: CGFloat = isExpanded ? 1 : (dynamicIslandHeight / expandedHeight)
17
+ let scaleX: CGFloat = isExpanded ? 1 : (layout.compactWidth / layout.expandedWidth)
18
+ let scaleY: CGFloat = isExpanded ? 1 : (layout.compactHeight / layout.expandedHeight)
28
19
 
29
20
  let swipeGesture = DragGesture(minimumDistance: 2).onEnded { value in
30
21
  if value.translation.height < -8 || value.predictedEndTranslation.height < -40 {
@@ -36,17 +27,17 @@ struct PrettyToastView: View {
36
27
  Group {
37
28
  let pill = toastBackground()
38
29
  .overlay {
39
- toastContent(haveDynamicIsland, expandedWidth: expandedWidth)
40
- .frame(width: expandedWidth, height: expandedHeight)
30
+ toastContent(layout)
31
+ .frame(width: layout.expandedWidth, height: layout.expandedHeight)
41
32
  .scaleEffect(x: scaleX, y: scaleY)
42
33
  }
43
34
  .frame(
44
- width: isExpanded ? expandedWidth : dynamicIslandWidth,
45
- height: isExpanded ? expandedHeight : dynamicIslandHeight
35
+ width: isExpanded ? layout.expandedWidth : layout.compactWidth,
36
+ height: isExpanded ? layout.expandedHeight : layout.compactHeight
46
37
  )
47
- .opacity(haveDynamicIsland ? 1 : (isExpanded ? 1 : 0))
38
+ .opacity(layout.hasDynamicIsland ? 1 : (isExpanded ? 1 : 0))
48
39
  .modifier(CapsuleOpacityModifier(
49
- haveDynamicIsland: haveDynamicIsland,
40
+ haveDynamicIsland: layout.hasDynamicIsland,
50
41
  isExpanded: isExpanded
51
42
  ))
52
43
  .modifier(GeometryGroupModifier())
@@ -74,10 +65,10 @@ struct PrettyToastView: View {
74
65
  pill
75
66
  }
76
67
  }
77
- .offset(y: haveDynamicIsland ? (isExpanded ? expandedTopOffset : topOffset) : 0)
68
+ .offset(y: layout.hasDynamicIsland ? (isExpanded ? layout.expandedTopOffset : layout.topOffset) : 0)
78
69
  }
79
70
  .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
80
- .padding(.top, haveDynamicIsland ? 0 : (isExpanded ? max(safeArea.top, 10) : 0))
71
+ .padding(.top, layout.hasDynamicIsland ? 0 : (isExpanded ? max(safeArea.top, 10) : 0))
81
72
  .ignoresSafeArea()
82
73
  .coordinateSpace(name: "overlayWindow")
83
74
  .animation(.bouncy(duration: 0.3, extraBounce: 0), value: isExpanded)
@@ -85,83 +76,79 @@ struct PrettyToastView: View {
85
76
  }
86
77
 
87
78
  @ViewBuilder
88
- func toastContent(_ haveDynamicIsland: Bool, expandedWidth: CGFloat) -> some View {
79
+ func toastContent(_ layout: PrettyToastLayout) -> some View {
89
80
  if let toast = window.toast {
90
81
  VStack(spacing: 0) {
91
- if haveDynamicIsland && !toast.message.isEmpty {
92
- Spacer(minLength: 0)
82
+ if layout.contentTopClearance > 0 {
83
+ Spacer(minLength: layout.contentTopClearance)
93
84
  }
94
85
 
95
- HStack(spacing: 10) {
96
- ToastIconView(toast: toast, isExpanded: isExpanded)
97
- .frame(width: 50)
98
-
99
- VStack(alignment: .leading, spacing: 4) {
100
- Text(toast.title)
101
- .font(.callout)
102
- .fontWeight(.semibold)
103
- .foregroundStyle(.white)
104
-
105
- if !toast.message.isEmpty {
106
- Text(toast.message)
107
- .font(.caption)
108
- .foregroundColor(.white.opacity(0.6))
109
- }
110
- }
111
- .frame(maxWidth: .infinity, alignment: .leading)
112
-
113
- if let label = toast.actionLabel, !label.isEmpty {
114
- Button(action: { window.actionTapped = true }) {
115
- Text(label)
116
- .font(.footnote)
117
- .fontWeight(.semibold)
118
- .foregroundStyle(toast.accentColor)
119
- .padding(.horizontal, 12)
120
- .padding(.vertical, 6)
121
- .background(
122
- Capsule().fill(Color.white.opacity(0.12))
123
- )
124
- }
125
- .buttonStyle(.plain)
126
- }
127
- }
86
+ toastRow(toast)
128
87
  }
129
88
  .padding(.horizontal, 20)
130
- .padding(.bottom, haveDynamicIsland && !toast.message.isEmpty ? 12 : 0)
89
+ .padding(.bottom, layout.contentBottomPadding)
131
90
  .compositingGroup()
132
91
  .blur(radius: isExpanded ? 0 : 5)
133
92
  .opacity(isExpanded ? 1 : 0)
134
93
 
135
94
  // Hidden measurer — drives measuredContentHeight so the pill grows
136
95
  // for overflowing text.
137
- HStack(spacing: 10) {
138
- Color.clear.frame(width: 50, height: 1)
96
+ toastRow(toast, isMeasuring: true)
97
+ .padding(.horizontal, 20)
98
+ .fixedSize(horizontal: false, vertical: true)
99
+ .background(
100
+ GeometryReader { geo in
101
+ Color.clear.preference(
102
+ key: ContentHeightKey.self,
103
+ value: geo.size.height
104
+ )
105
+ }
106
+ )
107
+ .hidden()
108
+ .onPreferenceChange(ContentHeightKey.self) { height in
109
+ measuredContentHeight = height
110
+ }
111
+ }
112
+ }
139
113
 
140
- VStack(alignment: .leading, spacing: 4) {
141
- Text(toast.title)
142
- .font(.callout)
143
- .fontWeight(.semibold)
114
+ @ViewBuilder
115
+ private func toastRow(_ toast: Toast, isMeasuring: Bool = false) -> some View {
116
+ HStack(spacing: 10) {
117
+ ToastIconView(toast: toast, isExpanded: isMeasuring ? true : isExpanded)
118
+ .frame(width: 50)
144
119
 
145
- if !toast.message.isEmpty {
146
- Text(toast.message)
147
- .font(.caption)
148
- }
120
+ VStack(alignment: .leading, spacing: 4) {
121
+ Text(toast.title)
122
+ .font(.callout)
123
+ .fontWeight(.semibold)
124
+ .foregroundStyle(.white)
125
+
126
+ if !toast.message.isEmpty {
127
+ Text(toast.message)
128
+ .font(.caption)
129
+ .foregroundColor(.white.opacity(0.6))
149
130
  }
150
- .frame(maxWidth: .infinity, alignment: .leading)
151
131
  }
152
- .padding(.horizontal, 20)
153
- .fixedSize(horizontal: false, vertical: true)
154
- .background(
155
- GeometryReader { geo in
156
- Color.clear.preference(
157
- key: ContentHeightKey.self,
158
- value: geo.size.height
159
- )
160
- }
161
- )
162
- .hidden()
163
- .onPreferenceChange(ContentHeightKey.self) { height in
164
- measuredContentHeight = height
132
+ .frame(maxWidth: .infinity, alignment: .leading)
133
+
134
+ if let label = toast.actionLabel, !label.isEmpty {
135
+ Button(action: {
136
+ if !isMeasuring {
137
+ window.actionTapped = true
138
+ }
139
+ }, label: {
140
+ Text(label)
141
+ .font(.footnote)
142
+ .fontWeight(.semibold)
143
+ .foregroundStyle(toast.accentColor)
144
+ .padding(.horizontal, 12)
145
+ .padding(.vertical, 6)
146
+ .background(
147
+ Capsule().fill(Color.white.opacity(0.12))
148
+ )
149
+ })
150
+ .buttonStyle(.plain)
151
+ .allowsHitTesting(!isMeasuring)
165
152
  }
166
153
  }
167
154
  }
@@ -220,6 +207,51 @@ struct PrettyToastView: View {
220
207
  }
221
208
  }
222
209
 
210
+ struct PrettyToastLayout {
211
+ static let dynamicIslandSafeAreaThreshold: CGFloat = 59
212
+ static let compactIslandWidth: CGFloat = 120
213
+ static let compactIslandHeight: CGFloat = 36
214
+ private static let dynamicIslandBaseHeight: CGFloat = 90
215
+ private static let standardBaseHeight: CGFloat = 70
216
+ private static let topOffsetBase: CGFloat = 11
217
+ private static let expandedTopNudge: CGFloat = -0.5
218
+ private static let dynamicIslandContentGap: CGFloat = 2
219
+ private static let dynamicIslandBottomPadding: CGFloat = 12
220
+ private static let standardContentInset: CGFloat = 20
221
+
222
+ let hasDynamicIsland: Bool
223
+ let compactWidth: CGFloat
224
+ let compactHeight: CGFloat
225
+ let topOffset: CGFloat
226
+ let expandedTopOffset: CGFloat
227
+ let expandedWidth: CGFloat
228
+ let expandedHeight: CGFloat
229
+ let contentTopClearance: CGFloat
230
+ let contentBottomPadding: CGFloat
231
+ let baseContentArea: CGFloat
232
+
233
+ init(size: CGSize, safeAreaTop: CGFloat, measuredContentHeight: CGFloat, useDynamicIsland: Bool) {
234
+ hasDynamicIsland = safeAreaTop >= Self.dynamicIslandSafeAreaThreshold && useDynamicIsland
235
+ compactWidth = Self.compactIslandWidth
236
+ compactHeight = Self.compactIslandHeight
237
+ topOffset = Self.topOffsetBase + max(safeAreaTop - Self.dynamicIslandSafeAreaThreshold, 0)
238
+ // Nudge up 0.5pt so the centered stroke clears the DI's top line
239
+ // instead of sitting half-behind it.
240
+ expandedTopOffset = topOffset + Self.expandedTopNudge
241
+ expandedWidth = max(1, size.width - (topOffset * 2))
242
+
243
+ let baseHeight = hasDynamicIsland ? Self.dynamicIslandBaseHeight : Self.standardBaseHeight
244
+ contentTopClearance = hasDynamicIsland ? Self.compactIslandHeight + Self.dynamicIslandContentGap : 0
245
+ contentBottomPadding = hasDynamicIsland ? Self.dynamicIslandBottomPadding : 0
246
+ let reservedContentInset = hasDynamicIsland
247
+ ? contentTopClearance + contentBottomPadding
248
+ : Self.standardContentInset
249
+ baseContentArea = max(1, baseHeight - reservedContentInset)
250
+ let overflow = max(0, measuredContentHeight - baseContentArea)
251
+ expandedHeight = baseHeight + overflow
252
+ }
253
+ }
254
+
223
255
  struct ToastIconView: View {
224
256
  let toast: Toast
225
257
  let isExpanded: Bool
@@ -1,4 +1,5 @@
1
1
  import XCTest
2
+ import CoreGraphics
2
3
  @testable import PrettyToastPlugin
3
4
 
4
5
  final class PrettyToastPluginTests: XCTestCase {
@@ -18,4 +19,50 @@ final class PrettyToastPluginTests: XCTestCase {
18
19
  let color = PrettyToastColorParser.parse("#FF0000")
19
20
  XCTAssertNotNil(color)
20
21
  }
22
+
23
+ func testDynamicIslandLayoutReservesCutoutClearanceBeforeTextMeasurement() {
24
+ let layout = PrettyToastLayout(
25
+ size: CGSize(width: 402, height: 874),
26
+ safeAreaTop: 62,
27
+ measuredContentHeight: 0,
28
+ useDynamicIsland: true
29
+ )
30
+
31
+ XCTAssertTrue(layout.hasDynamicIsland)
32
+ XCTAssertGreaterThan(layout.contentTopClearance, PrettyToastLayout.compactIslandHeight)
33
+ XCTAssertEqual(layout.contentBottomPadding, 12)
34
+ XCTAssertGreaterThan(layout.baseContentArea, 0)
35
+ }
36
+
37
+ func testDynamicIslandLayoutGrowsWithoutChangingCutoutClearance() {
38
+ let initialLayout = PrettyToastLayout(
39
+ size: CGSize(width: 402, height: 874),
40
+ safeAreaTop: 62,
41
+ measuredContentHeight: 0,
42
+ useDynamicIsland: true
43
+ )
44
+ let overflowingContentHeight = initialLayout.baseContentArea + 24
45
+ let measuredLayout = PrettyToastLayout(
46
+ size: CGSize(width: 402, height: 874),
47
+ safeAreaTop: 62,
48
+ measuredContentHeight: overflowingContentHeight,
49
+ useDynamicIsland: true
50
+ )
51
+
52
+ XCTAssertEqual(measuredLayout.contentTopClearance, initialLayout.contentTopClearance)
53
+ XCTAssertEqual(measuredLayout.expandedHeight, initialLayout.expandedHeight + 24, accuracy: 0.001)
54
+ }
55
+
56
+ func testStandardLayoutDoesNotApplyDynamicIslandClearance() {
57
+ let layout = PrettyToastLayout(
58
+ size: CGSize(width: 390, height: 844),
59
+ safeAreaTop: 47,
60
+ measuredContentHeight: 0,
61
+ useDynamicIsland: true
62
+ )
63
+
64
+ XCTAssertFalse(layout.hasDynamicIsland)
65
+ XCTAssertEqual(layout.contentTopClearance, 0)
66
+ XCTAssertEqual(layout.contentBottomPadding, 0)
67
+ }
21
68
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-pretty-toast",
3
- "version": "8.1.8",
3
+ "version": "8.1.9",
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",