@nativescript/core 9.1.0-alpha.1 → 9.1.0-alpha.10

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 (231) hide show
  1. package/application/application.android.d.ts +16 -0
  2. package/application/application.android.js +6 -1
  3. package/application/application.android.js.map +1 -1
  4. package/application/application.d.ts +6 -0
  5. package/application/application.ios.d.ts +6 -2
  6. package/application/application.ios.js +104 -12
  7. package/application/application.ios.js.map +1 -1
  8. package/globals/index.js +4 -1
  9. package/globals/index.js.map +1 -1
  10. package/http/http-interfaces.d.ts +9 -10
  11. package/http/http-interfaces.js.map +1 -1
  12. package/http/http-request/http-request-common.d.ts +1 -0
  13. package/http/http-request/http-request-common.js +8 -0
  14. package/http/http-request/http-request-common.js.map +1 -1
  15. package/http/http-request/index.android.d.ts +1 -2
  16. package/http/http-request/index.android.js +54 -260
  17. package/http/http-request/index.android.js.map +1 -1
  18. package/http/http-request/index.d.ts +1 -1
  19. package/http/http-request/index.ios.d.ts +1 -2
  20. package/http/http-request/index.ios.js +35 -214
  21. package/http/http-request/index.ios.js.map +1 -1
  22. package/http/http-request-internal/http-request-internal-common.d.ts +2 -0
  23. package/http/http-request-internal/http-request-internal-common.js +12 -0
  24. package/http/http-request-internal/http-request-internal-common.js.map +1 -0
  25. package/http/http-request-internal/index.android.d.ts +10 -0
  26. package/http/http-request-internal/index.android.js +204 -0
  27. package/http/http-request-internal/index.android.js.map +1 -0
  28. package/http/http-request-internal/index.d.ts +28 -0
  29. package/http/http-request-internal/index.ios.d.ts +10 -0
  30. package/http/http-request-internal/index.ios.js +181 -0
  31. package/http/http-request-internal/index.ios.js.map +1 -0
  32. package/http/index.d.ts +1 -1
  33. package/http/index.js.map +1 -1
  34. package/image-source/index.android.js +2 -2
  35. package/image-source/index.android.js.map +1 -1
  36. package/image-source/index.ios.js +2 -2
  37. package/image-source/index.ios.js.map +1 -1
  38. package/package.json +3 -1
  39. package/platforms/android/widgets-release.aar +0 -0
  40. package/platforms/ios/src/NativeScriptEmbedder.h +1 -2
  41. package/platforms/ios/src/NativeScriptEmbedder.m +0 -2
  42. package/platforms/ios/src/NativeScriptMainWindow.swift +0 -4
  43. package/platforms/ios/src/NativeScriptUtils.h +0 -2
  44. package/platforms/ios/src/NativeScriptUtils.m +0 -2
  45. package/platforms/ios/src/UIView+NativeScript.h +0 -2
  46. package/platforms/ios/src/UIView+NativeScript.m +0 -2
  47. package/references.d.ts +0 -1
  48. package/ui/action-bar/index.ios.js +0 -1
  49. package/ui/action-bar/index.ios.js.map +1 -1
  50. package/ui/builder/index.js +9 -1
  51. package/ui/builder/index.js.map +1 -1
  52. package/ui/button/index.android.d.ts +2 -9
  53. package/ui/button/index.android.js +7 -24
  54. package/ui/button/index.android.js.map +1 -1
  55. package/ui/button/index.ios.d.ts +2 -9
  56. package/ui/button/index.ios.js +17 -72
  57. package/ui/button/index.ios.js.map +1 -1
  58. package/ui/core/bindable/index.js +2 -1
  59. package/ui/core/bindable/index.js.map +1 -1
  60. package/ui/core/view/index.android.d.ts +1 -0
  61. package/ui/core/view/index.android.js +38 -29
  62. package/ui/core/view/index.android.js.map +1 -1
  63. package/ui/core/view/index.d.ts +18 -2
  64. package/ui/core/view/index.ios.d.ts +2 -1
  65. package/ui/core/view/index.ios.js +22 -28
  66. package/ui/core/view/index.ios.js.map +1 -1
  67. package/ui/core/view/view-common.d.ts +14 -2
  68. package/ui/core/view/view-common.js +18 -6
  69. package/ui/core/view/view-common.js.map +1 -1
  70. package/ui/core/view/view-helper/index.d.ts +1 -0
  71. package/ui/core/view/view-helper/index.ios.d.ts +24 -0
  72. package/ui/core/view/view-helper/index.ios.js +58 -11
  73. package/ui/core/view/view-helper/index.ios.js.map +1 -1
  74. package/ui/core/view/view-interfaces.d.ts +10 -0
  75. package/ui/core/view-base/index.d.ts +24 -9
  76. package/ui/core/view-base/index.js +50 -29
  77. package/ui/core/view-base/index.js.map +1 -1
  78. package/ui/frame/fragment.transitions.android.d.ts +13 -13
  79. package/ui/frame/fragment.transitions.android.js.map +1 -1
  80. package/ui/frame/frame-common.d.ts +2 -1
  81. package/ui/frame/frame-common.js +10 -0
  82. package/ui/frame/frame-common.js.map +1 -1
  83. package/ui/frame/frame-helper-for-android.d.ts +4 -4
  84. package/ui/frame/frame-helper-for-android.js +17 -8
  85. package/ui/frame/frame-helper-for-android.js.map +1 -1
  86. package/ui/frame/index.android.d.ts +6 -11
  87. package/ui/frame/index.android.js +18 -59
  88. package/ui/frame/index.android.js.map +1 -1
  89. package/ui/frame/index.d.ts +12 -21
  90. package/ui/frame/index.ios.d.ts +2 -2
  91. package/ui/frame/index.ios.js.map +1 -1
  92. package/ui/index.js +0 -1
  93. package/ui/index.js.map +1 -1
  94. package/ui/label/index.ios.d.ts +2 -5
  95. package/ui/label/index.ios.js +12 -44
  96. package/ui/label/index.ios.js.map +1 -1
  97. package/ui/layouts/absolute-layout/index.ios.js +5 -1
  98. package/ui/layouts/absolute-layout/index.ios.js.map +1 -1
  99. package/ui/layouts/dock-layout/index.ios.js +5 -1
  100. package/ui/layouts/dock-layout/index.ios.js.map +1 -1
  101. package/ui/layouts/flexbox-layout/index.ios.js +5 -1
  102. package/ui/layouts/flexbox-layout/index.ios.js.map +1 -1
  103. package/ui/layouts/grid-layout/index.ios.js +5 -1
  104. package/ui/layouts/grid-layout/index.ios.js.map +1 -1
  105. package/ui/layouts/layout-base.android.d.ts +3 -10
  106. package/ui/layouts/layout-base.android.js +7 -24
  107. package/ui/layouts/layout-base.android.js.map +1 -1
  108. package/ui/layouts/layout-base.ios.js.map +1 -1
  109. package/ui/layouts/liquid-glass/index.ios.d.ts +3 -1
  110. package/ui/layouts/liquid-glass/index.ios.js +51 -4
  111. package/ui/layouts/liquid-glass/index.ios.js.map +1 -1
  112. package/ui/layouts/liquid-glass-container/index.ios.d.ts +1 -0
  113. package/ui/layouts/liquid-glass-container/index.ios.js +50 -4
  114. package/ui/layouts/liquid-glass-container/index.ios.js.map +1 -1
  115. package/ui/layouts/stack-layout/index.ios.js +5 -1
  116. package/ui/layouts/stack-layout/index.ios.js.map +1 -1
  117. package/ui/layouts/wrap-layout/index.ios.js +5 -1
  118. package/ui/layouts/wrap-layout/index.ios.js.map +1 -1
  119. package/ui/list-view/index.d.ts +13 -1
  120. package/ui/list-view/index.ios.d.ts +3 -1
  121. package/ui/list-view/index.ios.js +48 -10
  122. package/ui/list-view/index.ios.js.map +1 -1
  123. package/ui/list-view/list-view-common.d.ts +14 -0
  124. package/ui/list-view/list-view-common.js +4 -0
  125. package/ui/list-view/list-view-common.js.map +1 -1
  126. package/ui/page/index.d.ts +1 -1
  127. package/ui/page/index.ios.d.ts +1 -0
  128. package/ui/page/index.ios.js +31 -17
  129. package/ui/page/index.ios.js.map +1 -1
  130. package/ui/page/page-common.d.ts +5 -2
  131. package/ui/page/page-common.js.map +1 -1
  132. package/ui/repeater/index.d.ts +2 -1
  133. package/ui/repeater/index.js.map +1 -1
  134. package/ui/scroll-view/index.d.ts +13 -0
  135. package/ui/scroll-view/index.ios.js +21 -7
  136. package/ui/scroll-view/index.ios.js.map +1 -1
  137. package/ui/scroll-view/scroll-view-common.d.ts +6 -0
  138. package/ui/scroll-view/scroll-view-common.js +8 -0
  139. package/ui/scroll-view/scroll-view-common.js.map +1 -1
  140. package/ui/segmented-bar/segmented-bar-common.d.ts +5 -1
  141. package/ui/segmented-bar/segmented-bar-common.js.map +1 -1
  142. package/ui/styling/background.ios.js +4 -0
  143. package/ui/styling/background.ios.js.map +1 -1
  144. package/ui/styling/style/index.d.ts +1 -0
  145. package/ui/styling/style/index.js.map +1 -1
  146. package/ui/styling/style-properties.d.ts +1 -0
  147. package/ui/styling/style-properties.js +41 -32
  148. package/ui/styling/style-properties.js.map +1 -1
  149. package/ui/styling/style-scope.js +1 -1
  150. package/ui/styling/style-scope.js.map +1 -1
  151. package/ui/tab-view/index.ios.d.ts +10 -1
  152. package/ui/tab-view/index.ios.js +21 -4
  153. package/ui/tab-view/index.ios.js.map +1 -1
  154. package/ui/tab-view/tab-view-common.d.ts +5 -1
  155. package/ui/tab-view/tab-view-common.js.map +1 -1
  156. package/ui/text-base/index.android.d.ts +3 -10
  157. package/ui/text-base/index.android.js +8 -25
  158. package/ui/text-base/index.android.js.map +1 -1
  159. package/ui/text-field/index.ios.d.ts +3 -9
  160. package/ui/text-field/index.ios.js +47 -31
  161. package/ui/text-field/index.ios.js.map +1 -1
  162. package/ui/text-view/index.ios.d.ts +2 -9
  163. package/ui/text-view/index.ios.js +20 -72
  164. package/ui/text-view/index.ios.js.map +1 -1
  165. package/ui/transition/index.d.ts +2 -1
  166. package/ui/transition/modal-transition.ios.js +33 -5
  167. package/ui/transition/modal-transition.ios.js.map +1 -1
  168. package/ui/transition/page-transition.ios.d.ts +5 -0
  169. package/ui/transition/page-transition.ios.js +296 -33
  170. package/ui/transition/page-transition.ios.js.map +1 -1
  171. package/ui/transition/shared-transition-helper.android.d.ts +3 -0
  172. package/ui/transition/shared-transition-helper.android.js +9 -0
  173. package/ui/transition/shared-transition-helper.android.js.map +1 -1
  174. package/ui/transition/shared-transition-helper.d.ts +7 -0
  175. package/ui/transition/shared-transition-helper.ios.d.ts +36 -0
  176. package/ui/transition/shared-transition-helper.ios.js +765 -89
  177. package/ui/transition/shared-transition-helper.ios.js.map +1 -1
  178. package/ui/transition/shared-transition.d.ts +66 -0
  179. package/ui/transition/shared-transition.js +57 -2
  180. package/ui/transition/shared-transition.js.map +1 -1
  181. package/utils/constants.ios.js.map +1 -1
  182. package/utils/native-helper.android.d.ts +66 -2
  183. package/utils/native-helper.android.js.map +1 -1
  184. package/utils/native-helper.ios.d.ts +107 -4
  185. package/utils/native-helper.ios.js +17 -6
  186. package/utils/native-helper.ios.js.map +1 -1
  187. package/utils/native-helper.types.d.ts +2 -0
  188. package/utils/platform-check.d.ts +1 -1
  189. package/utils/platform-check.js.map +1 -1
  190. package/application-settings/index.macos.d.ts +0 -1
  191. package/application-settings/index.macos.js +0 -2
  192. package/application-settings/index.macos.js.map +0 -1
  193. package/file-system/file-system-access.macos.d.ts +0 -1
  194. package/file-system/file-system-access.macos.js +0 -2
  195. package/file-system/file-system-access.macos.js.map +0 -1
  196. package/http/http-request/index.macos.d.ts +0 -3
  197. package/http/http-request/index.macos.js +0 -168
  198. package/http/http-request/index.macos.js.map +0 -1
  199. package/http/http-shared.d.ts +0 -3
  200. package/http/http-shared.js +0 -2
  201. package/http/http-shared.js.map +0 -1
  202. package/http/index.macos.d.ts +0 -8
  203. package/http/index.macos.js +0 -70
  204. package/http/index.macos.js.map +0 -1
  205. package/index.macos.d.ts +0 -9
  206. package/index.macos.js +0 -8
  207. package/index.macos.js.map +0 -1
  208. package/platform/device/index.macos.d.ts +0 -17
  209. package/platform/device/index.macos.js +0 -50
  210. package/platform/device/index.macos.js.map +0 -1
  211. package/platform/index.macos.d.ts +0 -14
  212. package/platform/index.macos.js +0 -15
  213. package/platform/index.macos.js.map +0 -1
  214. package/platform/screen/index.macos.d.ts +0 -14
  215. package/platform/screen/index.macos.js +0 -30
  216. package/platform/screen/index.macos.js.map +0 -1
  217. package/text/index.macos.d.ts +0 -1
  218. package/text/index.macos.js +0 -2
  219. package/text/index.macos.js.map +0 -1
  220. package/ui/dialogs/index.macos.d.ts +0 -43
  221. package/ui/dialogs/index.macos.js +0 -134
  222. package/ui/dialogs/index.macos.js.map +0 -1
  223. package/utils/constants.macos.d.ts +0 -2
  224. package/utils/constants.macos.js +0 -6
  225. package/utils/constants.macos.js.map +0 -1
  226. package/utils/index.macos.d.ts +0 -23
  227. package/utils/index.macos.js +0 -62
  228. package/utils/index.macos.js.map +0 -1
  229. package/utils/native-helper.macos.d.ts +0 -17
  230. package/utils/native-helper.macos.js +0 -61
  231. package/utils/native-helper.macos.js.map +0 -1
@@ -3,6 +3,115 @@ import { isNumber } from '../../utils/types';
3
3
  import { Screen } from '../../platform';
4
4
  import { CORE_ANIMATION_DEFAULTS } from '../../utils/animation-helpers';
5
5
  import { ios as iOSUtils } from '../../utils/native-helper';
6
+ import { Color } from '../../color';
7
+ /**
8
+ * Apply a drop shadow behind the destination view during an interactive
9
+ * dismiss so it looks elevated above the source (Apple Music–style).
10
+ *
11
+ * Shadow + rounded corners can't coexist on the same CALayer on iOS — both
12
+ * `masksToBounds = true` and `layer.mask` clip the shadow as part of the
13
+ * compositing pipeline. So the shadow lives on a SIBLING CALayer inserted
14
+ * below the modal's layer in its superlayer (the same pattern NS uses for
15
+ * box-shadow via `outerShadowContainerLayer`). The modal keeps its own
16
+ * `cornerRadius` + `masksToBounds` so subviews still clip to the rounded
17
+ * shape.
18
+ *
19
+ * Because the shadow layer is a sibling (not a child), it doesn't inherit
20
+ * the modal's transform automatically — `syncInteractiveDismissShadow`
21
+ * mirrors transform/position/bounds during the drag.
22
+ *
23
+ * Falsy `shadowConfig` is a no-op.
24
+ */
25
+ export function applyInteractiveDismissShadow(presentedView, shadowConfig) {
26
+ if (!presentedView?.layer || !shadowConfig)
27
+ return;
28
+ const layer = presentedView.layer;
29
+ const superlayer = layer.superlayer;
30
+ if (!superlayer)
31
+ return;
32
+ const bounds = layer.bounds;
33
+ if (bounds.size.width <= 0 || bounds.size.height <= 0)
34
+ return;
35
+ // Idempotent.
36
+ if (presentedView.__sharedTransitionShadowLayer)
37
+ return;
38
+ const cfg = shadowConfig === true ? {} : shadowConfig;
39
+ const colorInput = cfg.color ?? '#000';
40
+ let cgColor;
41
+ try {
42
+ const nsColor = typeof colorInput === 'string' ? new Color(colorInput) : colorInput;
43
+ cgColor = nsColor.ios?.CGColor || nsColor.CGColor || UIColor.blackColor.CGColor;
44
+ }
45
+ catch (_) {
46
+ cgColor = UIColor.blackColor.CGColor;
47
+ }
48
+ const opacity = isNumber(cfg.opacity) ? cfg.opacity : 0.3;
49
+ const radius = isNumber(cfg.radius) ? cfg.radius : 30;
50
+ const offsetX = isNumber(cfg.offset?.x) ? cfg.offset.x : 0;
51
+ const offsetY = isNumber(cfg.offset?.y) ? cfg.offset.y : 8;
52
+ const cornerRadius = layer.cornerRadius || 0;
53
+ const roundedRectPath = UIBezierPath.bezierPathWithRoundedRectCornerRadius(CGRectMake(0, 0, bounds.size.width, bounds.size.height), cornerRadius).CGPath;
54
+ const shadowLayer = CALayer.layer();
55
+ // Match the modal's geometry so shadow tracks 1:1.
56
+ shadowLayer.anchorPoint = layer.anchorPoint;
57
+ shadowLayer.bounds = bounds;
58
+ shadowLayer.position = layer.position;
59
+ shadowLayer.transform = layer.transform;
60
+ shadowLayer.shadowColor = cgColor;
61
+ shadowLayer.shadowOpacity = opacity;
62
+ shadowLayer.shadowRadius = radius;
63
+ shadowLayer.shadowOffset = CGSizeMake(offsetX, offsetY);
64
+ // Explicit shadowPath = shadow renders from this shape outward;
65
+ // the layer itself stays fully transparent.
66
+ shadowLayer.shadowPath = roundedRectPath;
67
+ CATransaction.begin();
68
+ CATransaction.setDisableActions(true);
69
+ superlayer.insertSublayerBelow(shadowLayer, layer);
70
+ CATransaction.commit();
71
+ presentedView.__sharedTransitionShadowLayer = shadowLayer;
72
+ }
73
+ /**
74
+ * Mirror the modal layer's geometry onto the sibling shadow layer so the
75
+ * shadow tracks the interactive drag. Called from the gesture handlers
76
+ * after the modal's state has been updated (either via direct transform
77
+ * in morph mode, or via `interactiveController.updateInteractiveTransition`
78
+ * for the non-morph modal/page case driven by UIViewPropertyAnimator).
79
+ *
80
+ * Reads the layer's **presentation** values when an animation is in flight
81
+ * — model values would point at the animation's end state and snap the
82
+ * shadow there. Falls back to model values when no animation is running.
83
+ */
84
+ export function syncInteractiveDismissShadow(presentedView) {
85
+ const shadowLayer = presentedView?.__sharedTransitionShadowLayer;
86
+ if (!shadowLayer || !presentedView?.layer)
87
+ return;
88
+ const layer = presentedView.layer;
89
+ const pres = typeof layer.presentationLayer === 'function' ? layer.presentationLayer() : null;
90
+ const src = pres || layer;
91
+ const b = src.bounds;
92
+ CATransaction.begin();
93
+ CATransaction.setDisableActions(true);
94
+ shadowLayer.bounds = b;
95
+ shadowLayer.position = src.position;
96
+ shadowLayer.anchorPoint = src.anchorPoint;
97
+ shadowLayer.transform = src.transform;
98
+ shadowLayer.shadowPath = UIBezierPath.bezierPathWithRoundedRectCornerRadius(CGRectMake(0, 0, b.size.width, b.size.height), src.cornerRadius || 0).CGPath;
99
+ CATransaction.commit();
100
+ }
101
+ /**
102
+ * Remove the sibling shadow layer. Safe to call repeatedly / when no
103
+ * shadow was applied.
104
+ */
105
+ export function removeInteractiveDismissShadow(presentedView) {
106
+ const shadowLayer = presentedView?.__sharedTransitionShadowLayer;
107
+ if (!shadowLayer)
108
+ return;
109
+ CATransaction.begin();
110
+ CATransaction.setDisableActions(true);
111
+ shadowLayer.removeFromSuperlayer();
112
+ CATransaction.commit();
113
+ presentedView.__sharedTransitionShadowLayer = null;
114
+ }
6
115
  export class SharedTransitionHelper {
7
116
  static animate(state, transitionContext, type) {
8
117
  const transition = state.instance;
@@ -34,6 +143,10 @@ export class SharedTransitionHelper {
34
143
  independent: [],
35
144
  };
36
145
  }
146
+ // Track every matched source view so cleanup can restore alpha,
147
+ // including duplicates (same album surfacing in multiple lists)
148
+ // that we hide but don't create a snapshot for.
149
+ transition.sharedElements._allPresentingViews = sharedElements.slice();
37
150
  if (SharedTransition.DEBUG) {
38
151
  console.log(` ${type}: Present`);
39
152
  console.log(`1. Found sharedTransitionTags to animate:`, sharedElementTags);
@@ -46,12 +159,22 @@ export class SharedTransitionHelper {
46
159
  const pageEndTags = pageEnd?.sharedTransitionTags || {};
47
160
  // console.log('pageEndIndependentTags:', pageEndIndependentTags);
48
161
  const positionSharedTags = async () => {
162
+ const processedTags = new Set();
49
163
  for (const presentingView of sharedElements) {
50
164
  const presentingSharedElement = presentingView.ios;
51
- // console.log('fromTarget instanceof UIImageView:', fromTarget instanceof UIImageView)
52
- // TODO: discuss whether we should check if UIImage/UIImageView type to always snapshot images or if other view types could be duped/added vs. snapshotted
53
- // Note: snapshot may be most efficient/simple
54
- // console.log('---> ', presentingView.sharedTransitionTag, ': ', presentingSharedElement)
165
+ const tag = presentingView.sharedTransitionTag;
166
+ if (processedTags.has(tag)) {
167
+ // The same item can appear in multiple lists on the source page
168
+ // (e.g., a featured album also surfacing in "Recently Played").
169
+ // Each instance has the same sharedTransitionTag. Only the first
170
+ // one becomes the snapshot origin; the rest must still be hidden
171
+ // so they don't show through behind the animating snapshot.
172
+ if (presentingSharedElement) {
173
+ presentingSharedElement.alpha = 0;
174
+ }
175
+ continue;
176
+ }
177
+ processedTags.add(tag);
55
178
  const presentedView = presented.find((v) => v.sharedTransitionTag === presentingView.sharedTransitionTag);
56
179
  const presentedSharedElement = presentedView.ios;
57
180
  const pageEndProps = pageEndTags[presentingView.sharedTransitionTag];
@@ -60,15 +183,25 @@ export class SharedTransitionHelper {
60
183
  await pageEndProps?.callback(presentedView, 'present');
61
184
  }
62
185
  // treat images differently...
186
+ let imageSourceChangeListener;
63
187
  if (presentedSharedElement instanceof UIImageView) {
64
- // in case the image is loaded async, we need to update the snapshot when it changes
65
- // todo: remove listener on transition end
66
- presentedView.on('imageSourceChange', () => {
188
+ // In case the image is loaded async, keep the snapshot's image in sync with
189
+ // the destination view's image. We hold a ref so we can detach the listener
190
+ // on cleanup (the View would otherwise hold a strong ref via the observers
191
+ // map and the snapshot closure would leak).
192
+ imageSourceChangeListener = () => {
67
193
  snapshot.image = iOSUtils.snapshotView(presentedSharedElement, Screen.mainScreen.scale);
68
194
  snapshot.tintColor = presentedSharedElement.tintColor;
69
- });
195
+ };
196
+ presentedView.on('imageSourceChange', imageSourceChangeListener);
70
197
  snapshot.tintColor = presentedSharedElement.tintColor;
71
198
  snapshot.contentMode = presentedSharedElement.contentMode;
199
+ // Seed the snapshot with the source's already-loaded image so the very
200
+ // first frame of the animation isn't blank if the destination image is
201
+ // still loading.
202
+ if (presentingSharedElement instanceof UIImageView) {
203
+ snapshot.image = presentingSharedElement.image;
204
+ }
72
205
  }
73
206
  iOSUtils.copyLayerProperties(snapshot, presentingSharedElement, pageEndProps?.propertiesToMatch);
74
207
  snapshot.clipsToBounds = true;
@@ -96,86 +229,115 @@ export class SharedTransitionHelper {
96
229
  startOpacity: presentedView.opacity,
97
230
  endOpacity: presentingView.opacity,
98
231
  propertiesToMatch: pageEndProps?.propertiesToMatch,
232
+ imageSourceChangeListener,
99
233
  });
100
234
  // set initial opacity to match the source view opacity
101
235
  snapshot.alpha = presentingView.opacity;
102
- // hide both while animating within the transition context
103
- presentingView.opacity = 0;
104
- presentedView.opacity = 0;
236
+ // Hide both while animating in the transition context.
237
+ //
238
+ // We mutate native alpha directly rather than NS `opacity` so the NS-side
239
+ // style remains the user's intended value (typically 1). If we instead set
240
+ // `view.opacity = 0` and then a transition was interrupted before cleanup
241
+ // (or the page was reused), `startOpacity` here could capture 0 from a prior
242
+ // run and the cleanup below would leave the destination invisible.
243
+ presentingSharedElement.alpha = 0;
244
+ presentedSharedElement.alpha = 0;
105
245
  }
106
246
  };
107
247
  const positionIndependentTags = async () => {
108
- // independent tags
109
- for (const tag in pageEndTags) {
110
- // only handle if independent (otherwise it's shared between both pages and handled above)
111
- if (!sharedElementTags.includes(tag)) {
112
- // only consider start when there's a matching end
113
- const pageStartIndependentProps = pageStart?.sharedTransitionTags ? pageStart?.sharedTransitionTags[tag] : null;
114
- // console.log('start:', tag, pageStartIndependentProps);
115
- const pageEndProps = pageEndTags[tag];
116
- let independentView = presenting.find((v) => v.sharedTransitionTag === tag);
117
- let isPresented = false;
248
+ // "Independent" tags appear on only one of the two pages (or were
249
+ // explicitly enumerated in pageEnd.sharedTransitionTags). We auto-discover
250
+ // them so authors don't have to enumerate every tag manually:
251
+ // - source-only (orphan on presenting): fade *out* on present, fade
252
+ // back in on dismiss. e.g., a "FEATURED ALBUM" label on a hero card
253
+ // that has no counterpart on the destination page.
254
+ // - destination-only (orphan on presented): fade *in* on present, fade
255
+ // back out on dismiss. e.g., metadata that only exists on the detail
256
+ // page.
257
+ // Authors can override per-tag via pageStart / pageEnd `sharedTransitionTags`
258
+ // (e.g. add `y: -20` to slide while fading), or opt a view out entirely
259
+ // with `sharedTransitionIgnore`.
260
+ const orphanTags = [];
261
+ const seenOrphan = new Set();
262
+ const addOrphan = (tag) => {
263
+ if (!tag || seenOrphan.has(tag) || sharedElementTags.includes(tag))
264
+ return;
265
+ seenOrphan.add(tag);
266
+ orphanTags.push(tag);
267
+ };
268
+ for (const v of presenting)
269
+ addOrphan(v.sharedTransitionTag);
270
+ for (const v of presented)
271
+ addOrphan(v.sharedTransitionTag);
272
+ // Tags the user listed in pageEnd.sharedTransitionTags but that don't
273
+ // match a real view are silently skipped further down — keep them in
274
+ // the iteration so per-tag config still applies to existing orphans.
275
+ for (const tag in pageEndTags)
276
+ addOrphan(tag);
277
+ for (const tag of orphanTags) {
278
+ const pageStartIndependentProps = pageStart?.sharedTransitionTags ? pageStart?.sharedTransitionTags[tag] : null;
279
+ const pageEndProps = pageEndTags[tag];
280
+ let independentView = presenting.find((v) => v.sharedTransitionTag === tag);
281
+ let isPresented = false;
282
+ if (!independentView) {
283
+ independentView = presented.find((v) => v.sharedTransitionTag === tag);
118
284
  if (!independentView) {
119
- independentView = presented.find((v) => v.sharedTransitionTag === tag);
120
- if (!independentView) {
121
- break;
122
- }
123
- isPresented = true;
124
- }
125
- const independentSharedElement = independentView.ios;
126
- if (pageEndProps?.callback) {
127
- await pageEndProps?.callback(independentView, 'present');
128
- }
129
- // let snapshot: UIImageView;
130
- // if (isPresented) {
131
- // snapshot = UIImageView.alloc().init();
132
- // } else {
133
- const snapshot = UIImageView.alloc().initWithImage(iOSUtils.snapshotView(independentSharedElement, Screen.mainScreen.scale));
134
- // }
135
- if (independentSharedElement instanceof UIImageView) {
136
- // in case the image is loaded async, we need to update the snapshot when it changes
137
- // todo: remove listener on transition end
138
- // if (isPresented) {
139
- // independentView.on('imageSourceChange', () => {
140
- // snapshot.image = iOSNativeHelper.snapshotView(independentSharedElement, Screen.mainScreen.scale);
141
- // snapshot.tintColor = independentSharedElement.tintColor;
142
- // });
143
- // }
144
- snapshot.tintColor = independentSharedElement.tintColor;
145
- snapshot.contentMode = independentSharedElement.contentMode;
146
- }
147
- snapshot.clipsToBounds = true;
148
- const startFrame = independentSharedElement.convertRectToView(independentSharedElement.bounds, transitionContext.containerView);
149
- const startFrameRect = getRectFromProps(pageStartIndependentProps);
150
- // adjust for any specified start positions
151
- const startFrameAdjusted = CGRectMake(startFrame.origin.x + startFrameRect.x, startFrame.origin.y + startFrameRect.y, startFrame.size.width, startFrame.size.height);
152
- // console.log('startFrameAdjusted:', tag, iOSNativeHelper.printCGRect(startFrameAdjusted));
153
- // if (pageStartIndependentProps?.scale) {
154
- // snapshot.transform = CGAffineTransformConcat(CGAffineTransformMakeTranslation(startFrameAdjusted.origin.x, startFrameAdjusted.origin.y), CGAffineTransformMakeScale(pageStartIndependentProps.scale.x, pageStartIndependentProps.scale.y))
155
- // } else {
156
- snapshot.frame = startFrame; //startFrameAdjusted;
157
- // }
158
- if (SharedTransition.DEBUG) {
159
- console.log('---> ', independentView.sharedTransitionTag, ' frame:', iOSUtils.printCGRect(snapshot.frame));
285
+ // Tag declared in config but no matching view; skip (don't
286
+ // break — later tags might still resolve).
287
+ continue;
160
288
  }
161
- const endFrameRect = getRectFromProps(pageEndProps);
162
- const endFrame = CGRectMake(startFrame.origin.x + endFrameRect.x, startFrame.origin.y + endFrameRect.y, startFrame.size.width, startFrame.size.height);
163
- // console.log('endFrame:', tag, iOSNativeHelper.printCGRect(endFrame));
164
- transition.sharedElements.independent.push({
165
- view: independentView,
166
- isPresented,
167
- startFrame,
168
- snapshot,
169
- endFrame,
170
- startTransform: independentSharedElement.transform,
171
- scale: pageEndProps.scale,
172
- startOpacity: independentView.opacity,
173
- endOpacity: isNumber(pageEndProps.opacity) ? pageEndProps.opacity : 0,
174
- propertiesToMatch: pageEndProps?.propertiesToMatch,
175
- zIndex: isNumber(pageEndProps?.zIndex) ? pageEndProps.zIndex : 0,
176
- });
177
- independentView.opacity = 0;
289
+ isPresented = true;
290
+ }
291
+ const independentSharedElement = independentView.ios;
292
+ if (pageEndProps?.callback) {
293
+ await pageEndProps?.callback(independentView, 'present');
294
+ }
295
+ const snapshot = UIImageView.alloc().initWithImage(iOSUtils.snapshotView(independentSharedElement, Screen.mainScreen.scale));
296
+ if (independentSharedElement instanceof UIImageView) {
297
+ snapshot.tintColor = independentSharedElement.tintColor;
298
+ snapshot.contentMode = independentSharedElement.contentMode;
299
+ }
300
+ snapshot.clipsToBounds = true;
301
+ // Copy layer properties (cornerRadius, borderWidth/Color)
302
+ // from the source view so the snapshot renders with the
303
+ // same rounded corners. Without this, source-side
304
+ // thumbnails/buttons appear with sharp square corners
305
+ // during a non-morph interactive dismiss — the snapshot
306
+ // is what the user sees through the dismissing modal,
307
+ // and its layer is freshly created with cornerRadius 0.
308
+ iOSUtils.copyLayerProperties(snapshot, independentSharedElement, pageEndProps?.propertiesToMatch);
309
+ const startFrame = independentSharedElement.convertRectToView(independentSharedElement.bounds, transitionContext.containerView);
310
+ snapshot.frame = startFrame;
311
+ if (SharedTransition.DEBUG) {
312
+ console.log('---> ', independentView.sharedTransitionTag, ' frame:', iOSUtils.printCGRect(snapshot.frame));
178
313
  }
314
+ const endFrameRect = getRectFromProps(pageEndProps);
315
+ const endFrame = CGRectMake(startFrame.origin.x + (endFrameRect.x || 0), startFrame.origin.y + (endFrameRect.y || 0), startFrame.size.width, startFrame.size.height);
316
+ // Opacity defaults are side-aware: source-only orphans fade out (1 → 0),
317
+ // destination-only orphans fade in (0 → 1). The values are read back
318
+ // during dismiss as `endOpacity → startOpacity` so the return phase is
319
+ // automatically symmetric. Author-supplied opacity always wins.
320
+ const startOpacity = isNumber(pageStartIndependentProps?.opacity) ? pageStartIndependentProps.opacity : isPresented ? 0 : independentView.opacity;
321
+ const endOpacity = isNumber(pageEndProps?.opacity) ? pageEndProps.opacity : isPresented ? independentView.opacity : 0;
322
+ // Snapshot's initial visible alpha. Default UIImageView alpha is 1;
323
+ // destination-only orphans need to start at 0 so the fade-in is
324
+ // visible, hence we always set it explicitly.
325
+ snapshot.alpha = startOpacity;
326
+ transition.sharedElements.independent.push({
327
+ view: independentView,
328
+ isPresented,
329
+ startFrame,
330
+ snapshot,
331
+ endFrame,
332
+ startTransform: independentSharedElement.transform,
333
+ scale: pageEndProps?.scale,
334
+ startOpacity,
335
+ endOpacity,
336
+ propertiesToMatch: pageEndProps?.propertiesToMatch,
337
+ zIndex: isNumber(pageEndProps?.zIndex) ? pageEndProps.zIndex : 0,
338
+ });
339
+ // Native alpha; see comment in positionSharedTags.
340
+ independentSharedElement.alpha = 0;
179
341
  }
180
342
  };
181
343
  // position all sharedTransitionTag elements
@@ -183,7 +345,7 @@ export class SharedTransitionHelper {
183
345
  await positionIndependentTags();
184
346
  // combine to order by zIndex and add to transition context
185
347
  const snapshotData = transition.sharedElements.presenting.concat(transition.sharedElements.independent);
186
- snapshotData.sort((a, b) => (a.zIndex > b.zIndex ? 1 : -1));
348
+ snapshotData.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
187
349
  if (SharedTransition.DEBUG) {
188
350
  console.log(`zIndex settings:`, snapshotData.map((s) => {
189
351
  return {
@@ -199,9 +361,41 @@ export class SharedTransitionHelper {
199
361
  // Important: always set after above shared element positions have had their start positions set
200
362
  transition.presented.view.alpha = isNumber(pageStart?.opacity) ? pageStart?.opacity : 0;
201
363
  transition.presented.view.frame = CGRectMake(startFrame.x, startFrame.y, startFrame.width, startFrame.height);
364
+ // Optional top-corner rounding for sheet-style transitions (e.g.
365
+ // a modal that's rounded at the bottom of the screen and flattens
366
+ // as it slides up). Setting maskedCorners and masksToBounds here
367
+ // (before the animation) so they're stable during the animated
368
+ // cornerRadius change.
369
+ if (isNumber(pageStart?.cornerRadius) || isNumber(pageEnd?.cornerRadius)) {
370
+ const layer = transition.presented.view.layer;
371
+ // top-left | top-right | bottom-left | bottom-right (CACornerMask bits)
372
+ layer.maskedCorners = 15;
373
+ layer.masksToBounds = true;
374
+ if (isNumber(pageStart?.cornerRadius)) {
375
+ layer.cornerRadius = pageStart.cornerRadius;
376
+ }
377
+ }
202
378
  const cleanupPresent = () => {
203
379
  for (const presented of transition.sharedElements.presented) {
204
- presented.view.opacity = presented.startOpacity;
380
+ // Detach the per-transition imageSourceChange listener registered
381
+ // during positionSharedTags so it doesn't leak (the snapshot is gone).
382
+ if (presented.imageSourceChangeListener) {
383
+ presented.view.off('imageSourceChange', presented.imageSourceChangeListener);
384
+ presented.imageSourceChangeListener = null;
385
+ }
386
+ // Restore the destination native alpha from the NS opacity (the source
387
+ // of truth). NS opacity wasn't modified during the transition, so this
388
+ // reflects the user's intended visibility (typically 1).
389
+ presented.view.ios.alpha = presented.view.opacity;
390
+ }
391
+ // Restore native alpha on every source view we touched — including
392
+ // duplicates that share a sharedTransitionTag and were hidden without
393
+ // a snapshot. Even though the source page is being removed, the View
394
+ // instances may be reused on subsequent navigations.
395
+ for (const sourceView of transition.sharedElements._allPresentingViews || []) {
396
+ if (sourceView?.ios) {
397
+ sourceView.ios.alpha = sourceView.opacity;
398
+ }
205
399
  }
206
400
  for (const presenting of transition.sharedElements.presenting) {
207
401
  presenting.snapshot.removeFromSuperview();
@@ -209,7 +403,7 @@ export class SharedTransitionHelper {
209
403
  for (const independent of transition.sharedElements.independent) {
210
404
  independent.snapshot.removeFromSuperview();
211
405
  if (independent.isPresented) {
212
- independent.view.opacity = independent.startOpacity;
406
+ independent.view.ios.alpha = independent.view.opacity;
213
407
  }
214
408
  }
215
409
  SharedTransition.updateState(transition.id, {
@@ -232,6 +426,12 @@ export class SharedTransitionHelper {
232
426
  transition.presented.view.alpha = isNumber(pageEnd?.opacity) ? pageEnd?.opacity : 1;
233
427
  const endFrame = getRectFromProps(pageEnd);
234
428
  transition.presented.view.frame = CGRectMake(endFrame.x, endFrame.y, endFrame.width, endFrame.height);
429
+ // Animate cornerRadius alongside the page frame. Implicit animation
430
+ // of cornerRadius is supported on iOS 11+, both inside
431
+ // UIView.animateWith… and UIViewPropertyAnimator's animations block.
432
+ if (isNumber(pageEnd?.cornerRadius)) {
433
+ transition.presented.view.layer.cornerRadius = pageEnd.cornerRadius;
434
+ }
235
435
  if (pageOut) {
236
436
  if (isNumber(pageOut.opacity)) {
237
437
  transition.presenting.view.alpha = pageOut?.opacity;
@@ -310,6 +510,23 @@ export class SharedTransitionHelper {
310
510
  });
311
511
  if (type === 'page') {
312
512
  transitionContext.containerView.insertSubviewBelowSubview(transition.presenting.view, transition.presented.view);
513
+ // UIKit's standard pop places the incoming source view off to the
514
+ // left so it can slide into position; for custom shared transitions
515
+ // (where the destination either slides down or morphs to source) we
516
+ // don't want that lateral slide. Pin the source to its fullscreen
517
+ // frame so it stays put underneath the animating destination.
518
+ // Users can still opt into a custom source animation via `pageOut`.
519
+ const sourceDefaults = getRectFromProps(null);
520
+ transition.presenting.view.frame = CGRectMake(0, 0, sourceDefaults.width, sourceDefaults.height);
521
+ }
522
+ // Morph + interactive dismiss: the gesture handler drives the
523
+ // animation via transforms and finalizes by calling cancel/finish
524
+ // on the transitionContext. Running the standard snapshot + spring
525
+ // rig here would race that and frequently call completeTransition(true)
526
+ // *before* the user's gesture ended — causing the dismiss to commit
527
+ // even on a cancel. Skip the rest of the dismiss setup entirely.
528
+ if (state.interactive?.dismiss?.morph && state.interactiveBegan) {
529
+ return;
313
530
  }
314
531
  // console.log('transitionContext.containerView.subviews.count:', transitionContext.containerView.subviews.count);
315
532
  if (SharedTransition.DEBUG) {
@@ -322,11 +539,21 @@ export class SharedTransitionHelper {
322
539
  const pageEndTags = pageEnd?.sharedTransitionTags || {};
323
540
  const pageReturn = state.pageReturn;
324
541
  for (const p of transition.sharedElements.presented) {
325
- p.view.opacity = 0;
542
+ // Use native alpha (not NS opacity) so we don't pollute the NS-side
543
+ // value if the dismiss is interrupted; cleanup restores from NS opacity.
544
+ p.view.ios.alpha = 0;
545
+ }
546
+ // Ensure top-corner masking + clipping is in place before we animate
547
+ // cornerRadius back toward the start value. If the present pass already
548
+ // set these, this is a harmless no-op.
549
+ if (isNumber(pageReturn?.cornerRadius)) {
550
+ const layer = transition.presented.view.layer;
551
+ layer.maskedCorners = 15; // top-left | top-right | bottom-left | bottom-right
552
+ layer.masksToBounds = true;
326
553
  }
327
554
  // combine to order by zIndex and add to transition context
328
555
  const snapshotData = transition.sharedElements.presenting.concat(transition.sharedElements.independent);
329
- snapshotData.sort((a, b) => (a.zIndex > b.zIndex ? 1 : -1));
556
+ snapshotData.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
330
557
  if (SharedTransition.DEBUG) {
331
558
  console.log(`zIndex settings:`, snapshotData.map((s) => {
332
559
  return {
@@ -365,13 +592,43 @@ export class SharedTransitionHelper {
365
592
  // add snapshot to animate
366
593
  transitionContext.containerView.addSubview(data.snapshot);
367
594
  }
595
+ // Hide every source-side shared element now that snapshots have been
596
+ // captured. The animating snapshot covers them; without this, the user
597
+ // sees a "double image" — the real source element AND the snapshot
598
+ // animating over it back to its position. Use the full list (not just
599
+ // the deduped `presenting`) so duplicates from sister lists are hidden
600
+ // too. Restored in cleanupDismiss below.
601
+ for (const sourceView of transition.sharedElements._allPresentingViews || []) {
602
+ if (sourceView?.ios) {
603
+ sourceView.ios.alpha = 0;
604
+ }
605
+ }
368
606
  const cleanupDismiss = () => {
607
+ // Restore alpha on every source view we hid — including duplicate
608
+ // sources with the same sharedTransitionTag — using NS opacity as
609
+ // the source of truth.
610
+ for (const sourceView of transition.sharedElements._allPresentingViews || []) {
611
+ if (sourceView?.ios) {
612
+ sourceView.ios.alpha = sourceView.opacity;
613
+ }
614
+ }
369
615
  for (const presenting of transition.sharedElements.presenting) {
370
- presenting.view.opacity = presenting.startOpacity;
371
616
  presenting.snapshot.removeFromSuperview();
372
617
  }
618
+ for (const presented of transition.sharedElements.presented) {
619
+ // Detach the imageSourceChange listener and restore alpha. Even though
620
+ // the destination page is being torn down, NS Views can outlive their
621
+ // nativeView and we shouldn't leak observers.
622
+ if (presented.imageSourceChangeListener) {
623
+ presented.view.off('imageSourceChange', presented.imageSourceChangeListener);
624
+ presented.imageSourceChangeListener = null;
625
+ }
626
+ if (presented.view.ios) {
627
+ presented.view.ios.alpha = presented.view.opacity;
628
+ }
629
+ }
373
630
  for (const independent of transition.sharedElements.independent) {
374
- independent.view.opacity = independent.startOpacity;
631
+ independent.view.ios.alpha = independent.view.opacity;
375
632
  independent.snapshot.removeFromSuperview();
376
633
  }
377
634
  SharedTransition.finishState(transition.id);
@@ -390,6 +647,11 @@ export class SharedTransitionHelper {
390
647
  transition.presented.view.alpha = isNumber(pageReturn?.opacity) ? pageReturn?.opacity : 0;
391
648
  const endFrame = getRectFromProps(pageReturn, getPageStartDefaultsForType(type));
392
649
  transition.presented.view.frame = CGRectMake(endFrame.x, endFrame.y, endFrame.width, endFrame.height);
650
+ // Animate cornerRadius back toward the start value (e.g. a sheet
651
+ // re-rounds as it slides offscreen).
652
+ if (isNumber(pageReturn?.cornerRadius)) {
653
+ transition.presented.view.layer.cornerRadius = pageReturn.cornerRadius;
654
+ }
393
655
  if (pageOut) {
394
656
  // always return to defaults if pageOut had been used
395
657
  transition.presenting.view.alpha = 1;
@@ -450,20 +712,78 @@ export class SharedTransitionHelper {
450
712
  switch (type) {
451
713
  case 'page':
452
714
  interactiveState.transitionContext.containerView.insertSubviewBelowSubview(state.instance.presenting.view, state.instance.presented.view);
715
+ // Pin the source view to its fullscreen frame so it doesn't slide
716
+ // in from off-left as the user drags. The default UIKit pop expects
717
+ // to animate the source from off-left; for shared-element / morph
718
+ // transitions we want the source to stay put underneath the
719
+ // animating destination so the visuals feel connected.
720
+ {
721
+ const sourceDefaults = getRectFromProps(null);
722
+ state.instance.presenting.view.frame = CGRectMake(0, 0, sourceDefaults.width, sourceDefaults.height);
723
+ }
453
724
  break;
454
725
  }
726
+ // Apply the dismiss shadow here, not from the gesture handler. UIKit
727
+ // reparents the presented view into the transition containerView as
728
+ // part of starting the interactive transition; applying earlier would
729
+ // attach the shadow to the now-orphaned old superlayer.
730
+ if (state.interactive?.dismiss?.shadow && state.instance?.presented?.view) {
731
+ applyInteractiveDismissShadow(state.instance.presented.view, state.interactive.dismiss.shadow);
732
+ }
455
733
  }
456
734
  static interactiveUpdate(state, interactiveState, type, percent) {
457
735
  if (interactiveState) {
736
+ if (state.interactive?.dismiss?.morph) {
737
+ // Morph mode: the gesture handler drives the destination view's
738
+ // transform directly. We only forward the event so observers can
739
+ // react to the gesture's percentage.
740
+ SharedTransition.notifyEvent(SharedTransition.interactiveUpdateEvent, {
741
+ id: state?.instance?.id,
742
+ type,
743
+ percent,
744
+ });
745
+ return;
746
+ }
458
747
  if (!interactiveState.added) {
748
+ // Defer setup until the gesture has clearly committed to a horizontal
749
+ // dismiss motion. A vertical scroll on a descendant scroll view also
750
+ // triggers the page-level pan gesture (NS pan recognizers don't yield
751
+ // to scroll-view pans by default), producing near-zero percent values.
752
+ // Without this guard we'd mount the dismiss snapshots and hide the
753
+ // destination during a scroll, making the image look "stuck" until the
754
+ // cancel animation runs.
755
+ if (Math.abs(percent) < 0.05) {
756
+ return;
757
+ }
459
758
  interactiveState.added = true;
460
759
  for (const p of state.instance.sharedElements.presented) {
461
- p.view.opacity = 0;
760
+ // Native alpha (not NS opacity) for the same reason as the dismiss path:
761
+ // if the user lets go without crossing the threshold, cancel uses NS
762
+ // opacity as source of truth to restore.
763
+ p.view.ios.alpha = 0;
462
764
  }
463
765
  for (const p of state.instance.sharedElements.presenting) {
464
766
  p.snapshot.alpha = p.endOpacity;
465
767
  interactiveState.transitionContext.containerView.addSubview(p.snapshot);
466
768
  }
769
+ // Re-mount independent (orphan) snapshots so they're available to
770
+ // animate alongside the gesture. They were removed in present cleanup
771
+ // but the records (including their snapshots) live on transition.sharedElements.
772
+ // Source-only orphans were left at alpha 0 since present; their snapshots
773
+ // pick up where the present animation left off (endOpacity).
774
+ for (const ind of state.instance.sharedElements.independent) {
775
+ ind.snapshot.alpha = ind.endOpacity;
776
+ interactiveState.transitionContext.containerView.addSubview(ind.snapshot);
777
+ }
778
+ // Hide every source-side shared element (including duplicates that share
779
+ // a sharedTransitionTag in sister lists) so the user only sees the
780
+ // animating snapshot — not the snapshot AND the real source element
781
+ // simultaneously ("double image" during interactive dismissal).
782
+ for (const sourceView of state.instance.sharedElements._allPresentingViews || []) {
783
+ if (sourceView?.ios) {
784
+ sourceView.ios.alpha = 0;
785
+ }
786
+ }
467
787
  const pageStart = state.pageStart;
468
788
  const startFrame = getRectFromProps(pageStart, getPageStartDefaultsForType(type));
469
789
  interactiveState.propertyAnimator = UIViewPropertyAnimator.alloc().initWithDurationDampingRatioAnimations(1, 1, () => {
@@ -472,8 +792,25 @@ export class SharedTransitionHelper {
472
792
  iOSUtils.copyLayerProperties(p.snapshot, p.view.ios, p.propertiesToMatch);
473
793
  p.snapshot.alpha = 1;
474
794
  }
795
+ // Animate orphan snapshots back toward their start state alongside
796
+ // the rest of the dismiss. For source-only orphans this is the
797
+ // fade-back-in that mirrors the fade-out of present.
798
+ for (const ind of state.instance.sharedElements.independent) {
799
+ ind.snapshot.alpha = ind.startOpacity;
800
+ if (ind.scale) {
801
+ ind.snapshot.transform = ind.startTransform;
802
+ }
803
+ else {
804
+ ind.snapshot.frame = ind.startFrame;
805
+ }
806
+ }
475
807
  state.instance.presented.view.alpha = isNumber(state.pageReturn?.opacity) ? state.pageReturn?.opacity : 0;
476
808
  state.instance.presented.view.frame = CGRectMake(startFrame.x, startFrame.y, state.instance.presented.view.bounds.size.width, state.instance.presented.view.bounds.size.height);
809
+ // Drive cornerRadius via the same percent the user controls — round
810
+ // the page back up as they pan it offscreen.
811
+ if (isNumber(state.pageReturn?.cornerRadius)) {
812
+ state.instance.presented.view.layer.cornerRadius = state.pageReturn.cornerRadius;
813
+ }
477
814
  });
478
815
  }
479
816
  interactiveState.propertyAnimator.fractionComplete = percent;
@@ -485,17 +822,108 @@ export class SharedTransitionHelper {
485
822
  }
486
823
  }
487
824
  static interactiveCancel(state, interactiveState, type) {
825
+ if (state?.instance && interactiveState && state.interactive?.dismiss?.morph) {
826
+ // Morph mode cancel — spring the destination view back to identity.
827
+ const view = state.instance.presented?.view;
828
+ let didFinalize = false;
829
+ const finalize = () => {
830
+ if (didFinalize)
831
+ return;
832
+ didFinalize = true;
833
+ if (view)
834
+ view.transform = CGAffineTransformIdentity;
835
+ // Restore the source-side element alphas that the gesture hid on
836
+ // engagement so the source page is correct when shown again.
837
+ for (const v of state.instance.sharedElements?._allPresentingViews || []) {
838
+ if (v?.ios)
839
+ v.ios.alpha = v.opacity;
840
+ }
841
+ if (interactiveState.transitionContext) {
842
+ interactiveState.transitionContext.cancelInteractiveTransition();
843
+ interactiveState.transitionContext.completeTransition(false);
844
+ }
845
+ SharedTransition.updateState(state?.instance?.id, {
846
+ interactiveBegan: false,
847
+ interactiveCancelled: true,
848
+ });
849
+ SharedTransition.notifyEvent(SharedTransition.interactiveCancelledEvent, {
850
+ id: state?.instance?.id,
851
+ type,
852
+ });
853
+ };
854
+ if (view) {
855
+ iOSUtils.animateWithSpring({
856
+ animations: () => {
857
+ view.transform = CGAffineTransformIdentity;
858
+ // Cross-fade sources back IN PARALLEL with the spring so
859
+ // they're visible from frame one of the snap-back. This
860
+ // also makes the source restoration robust to a new
861
+ // gesture interrupting our completion callback.
862
+ for (const v of state.instance.sharedElements?._allPresentingViews || []) {
863
+ if (v?.ios)
864
+ v.ios.alpha = v.opacity;
865
+ }
866
+ },
867
+ completion: () => finalize(),
868
+ });
869
+ // Safety: ensure finalize runs even if the animation completion
870
+ // is dropped (e.g. interrupted by another gesture).
871
+ setTimeout(finalize, 800);
872
+ }
873
+ else {
874
+ finalize();
875
+ }
876
+ return;
877
+ }
878
+ if (state?.instance && interactiveState && !interactiveState.added) {
879
+ // The gesture engaged the interactive transition but never crossed the
880
+ // motion threshold in interactiveUpdate (e.g., the user was scrolling
881
+ // vertically). Nothing was visually set up — just tell UIKit we're
882
+ // cancelling and clear the flags.
883
+ if (interactiveState.transitionContext) {
884
+ interactiveState.transitionContext.cancelInteractiveTransition();
885
+ interactiveState.transitionContext.completeTransition(false);
886
+ }
887
+ SharedTransition.updateState(state?.instance?.id, {
888
+ interactiveBegan: false,
889
+ interactiveCancelled: true,
890
+ });
891
+ SharedTransition.notifyEvent(SharedTransition.interactiveCancelledEvent, {
892
+ id: state?.instance?.id,
893
+ type,
894
+ });
895
+ return;
896
+ }
488
897
  if (state?.instance && interactiveState?.added && interactiveState?.propertyAnimator) {
489
898
  interactiveState.propertyAnimator.reversed = true;
490
899
  const duration = isNumber(state.pageEnd?.duration) ? state.pageEnd?.duration / 1000 : CORE_ANIMATION_DEFAULTS.duration;
491
900
  interactiveState.propertyAnimator.continueAnimationWithTimingParametersDurationFactor(null, duration);
492
901
  setTimeout(() => {
493
902
  for (const p of state.instance.sharedElements.presented) {
494
- p.view.opacity = 1;
903
+ // Restore native alpha from NS opacity (the user-intended value).
904
+ p.view.ios.alpha = p.view.opacity;
905
+ }
906
+ // Restore alpha on every source view we hid during interactiveUpdate
907
+ // (including duplicates from sister lists).
908
+ for (const sourceView of state.instance.sharedElements._allPresentingViews || []) {
909
+ if (sourceView?.ios) {
910
+ sourceView.ios.alpha = sourceView.opacity;
911
+ }
495
912
  }
496
913
  for (const p of state.instance.sharedElements.presenting) {
497
914
  p.snapshot.removeFromSuperview();
498
915
  }
916
+ // Tear down orphan snapshots that interactiveUpdate re-mounted. The
917
+ // source-only orphans' view.alpha stays at 0 — we're returning to the
918
+ // presented (modal-on-top) state, so they should remain hidden until
919
+ // a real dismiss completes. Cancel/finish for the next dismiss owns
920
+ // the eventual restore.
921
+ for (const ind of state.instance.sharedElements.independent) {
922
+ ind.snapshot.removeFromSuperview();
923
+ // Reset snapshot alpha to its post-present end state so a subsequent
924
+ // interactiveUpdate picks up from the right place.
925
+ ind.snapshot.alpha = ind.endOpacity;
926
+ }
499
927
  state.instance.presented.view.alpha = 1;
500
928
  interactiveState.propertyAnimator = null;
501
929
  interactiveState.added = false;
@@ -513,15 +941,263 @@ export class SharedTransitionHelper {
513
941
  }
514
942
  }
515
943
  static interactiveFinish(state, interactiveState, type) {
944
+ if (state?.instance && interactiveState && state.interactive?.dismiss?.morph) {
945
+ // Morph mode finish — animate the destination view to the matching
946
+ // source element's frame and fade it out, then complete.
947
+ const view = state.instance.presented?.view;
948
+ const destPresented = state.instance.sharedElements?.presented?.[0];
949
+ const destTag = destPresented?.view?.sharedTransitionTag;
950
+ const matchingSource = state.instance.sharedElements?.presenting?.find?.((p) => p.view.sharedTransitionTag === destTag);
951
+ const srcIos = matchingSource?.view?.ios;
952
+ const destSharedIos = destPresented?.view?.ios;
953
+ const containerView = interactiveState.transitionContext?.containerView;
954
+ let targetTransform = null;
955
+ if (view && srcIos && containerView) {
956
+ const sourceFrame = srcIos.convertRectToView(srcIos.bounds, containerView);
957
+ const bounds = view.bounds;
958
+ if (bounds.size.width > 0 && bounds.size.height > 0 && sourceFrame.size.width > 0 && sourceFrame.size.height > 0) {
959
+ // Anchor the modal's transform on the *internal* matched
960
+ // element (e.g. the album art inside the modal) so that the
961
+ // modal and the matched element converge along the same
962
+ // trajectory to the source thumbnail. Without this, the modal
963
+ // scales around its own center while the matched-element
964
+ // snapshot flies separately to source — making the two visibly
965
+ // drift apart mid-spring.
966
+ //
967
+ // Pick a uniform scale that takes the modal's matched element
968
+ // to the source's size. For square album art (both sides) this
969
+ // is exact; for slightly off-square sources (e.g. 360×380) we
970
+ // match width and let the small remainder be hidden by the
971
+ // fade-out.
972
+ const destSharedFrameInModal = destSharedIos ? destSharedIos.convertRectToView(destSharedIos.bounds, view) : null;
973
+ const sx = sourceFrame.size.width / bounds.size.width;
974
+ const sy = sourceFrame.size.height / bounds.size.height;
975
+ const targetCx = sourceFrame.origin.x + sourceFrame.size.width / 2;
976
+ const targetCy = sourceFrame.origin.y + sourceFrame.size.height / 2;
977
+ const modalCx = bounds.size.width / 2;
978
+ const modalCy = bounds.size.height / 2;
979
+ let tx;
980
+ let ty;
981
+ if (destSharedFrameInModal && destSharedFrameInModal.size.width > 0 && destSharedFrameInModal.size.height > 0) {
982
+ // Place the internal album art's center on the source's
983
+ // center. After scaling around modal center, internal art's
984
+ // visual center = modalCenter + (internalArtCenter - modalCenter) * scale + translation.
985
+ const internalCx = destSharedFrameInModal.origin.x + destSharedFrameInModal.size.width / 2;
986
+ const internalCy = destSharedFrameInModal.origin.y + destSharedFrameInModal.size.height / 2;
987
+ tx = targetCx - modalCx - (internalCx - modalCx) * sx;
988
+ ty = targetCy - modalCy - (internalCy - modalCy) * sy;
989
+ }
990
+ else {
991
+ // Fallback: align modal center on source center.
992
+ tx = targetCx - modalCx;
993
+ ty = targetCy - modalCy;
994
+ }
995
+ targetTransform = CGAffineTransformConcat(CGAffineTransformMakeTranslation(tx, ty), CGAffineTransformMakeScale(sx, sy));
996
+ }
997
+ }
998
+ // Build a snapshot fly-back for each shared element so the album
999
+ // art (etc.) does a real shared-element transition to its source
1000
+ // frame in parallel with the destination view's shrink+fade.
1001
+ // Without this, the image rides along with the morphing view and
1002
+ // lands at a visually offset position rather than precisely back
1003
+ // at the source thumbnail.
1004
+ const sharedSnapshots = [];
1005
+ if (containerView) {
1006
+ for (const presented of state.instance.sharedElements?.presented || []) {
1007
+ const destSharedView = presented.view?.ios;
1008
+ const tag = presented.view?.sharedTransitionTag;
1009
+ if (!destSharedView || !tag)
1010
+ continue;
1011
+ const sourceShared = state.instance.sharedElements?.presenting?.find?.((p) => p.view.sharedTransitionTag === tag);
1012
+ const srcSharedView = sourceShared?.view?.ios;
1013
+ if (!srcSharedView)
1014
+ continue;
1015
+ const startFrame = destSharedView.convertRectToView(destSharedView.bounds, containerView);
1016
+ const endFrame = srcSharedView.convertRectToView(srcSharedView.bounds, containerView);
1017
+ if (startFrame.size.width <= 0 || endFrame.size.width <= 0)
1018
+ continue;
1019
+ const snap = UIImageView.alloc().init();
1020
+ if (destSharedView instanceof UIImageView && destSharedView.image) {
1021
+ snap.image = destSharedView.image;
1022
+ snap.contentMode = destSharedView.contentMode;
1023
+ snap.tintColor = destSharedView.tintColor;
1024
+ }
1025
+ else {
1026
+ try {
1027
+ snap.image = iOSUtils.snapshotView(presented.view.ios, Screen.mainScreen.scale);
1028
+ }
1029
+ catch (e) {
1030
+ continue;
1031
+ }
1032
+ }
1033
+ snap.clipsToBounds = true;
1034
+ snap.layer.cornerRadius = destSharedView.layer?.cornerRadius || 0;
1035
+ snap.frame = startFrame;
1036
+ containerView.addSubview(snap);
1037
+ // Hide the destination shared element so we don't see it
1038
+ // shrinking inside the morphing view at the same time the
1039
+ // snapshot is flying back to source — that would double up.
1040
+ destSharedView.alpha = 0;
1041
+ sharedSnapshots.push({
1042
+ snap,
1043
+ endFrame,
1044
+ endCornerRadius: srcSharedView.layer?.cornerRadius || 0,
1045
+ srcSharedView,
1046
+ });
1047
+ }
1048
+ }
1049
+ // Identify which source NS views have a matching fly-back snapshot.
1050
+ // Those sources stay at alpha 0 through the spring and are restored
1051
+ // inside finalize(), so the snapshot doesn't overlay an already-
1052
+ // visible source mid-flight (which looks like two images for a beat).
1053
+ const snapshottedSources = new Set();
1054
+ for (const entry of sharedSnapshots) {
1055
+ if (entry.srcSharedView)
1056
+ snapshottedSources.add(entry.srcSharedView);
1057
+ }
1058
+ // Source-only orphan views (album thumbnails, badges, etc.) were
1059
+ // already restored to their opacity by the gesture handler at morph
1060
+ // engagement, so we don't need fade-in snapshots here — they're
1061
+ // already visible behind the morphing modal.
1062
+ let didFinalize = false;
1063
+ const finalize = () => {
1064
+ if (didFinalize)
1065
+ return;
1066
+ didFinalize = true;
1067
+ // Detach the imageSourceChange listeners that were attached during
1068
+ // PRESENT — the destination View is about to be torn down.
1069
+ for (const presented of state.instance.sharedElements?.presented || []) {
1070
+ if (presented.imageSourceChangeListener) {
1071
+ presented.view.off('imageSourceChange', presented.imageSourceChangeListener);
1072
+ presented.imageSourceChangeListener = null;
1073
+ }
1074
+ }
1075
+ // Restore the snapshotted sources (held at alpha 0 through the
1076
+ // spring) immediately before removing the snapshot, so the
1077
+ // snapshot's last frame and the source's first visible frame
1078
+ // coincide exactly — no double-image, no gap.
1079
+ for (const entry of sharedSnapshots) {
1080
+ const srcSharedView = entry.srcSharedView;
1081
+ if (srcSharedView) {
1082
+ const nsView = state.instance.sharedElements?.presenting?.find?.((p) => p.view?.ios === srcSharedView)?.view;
1083
+ srcSharedView.alpha = nsView ? nsView.opacity : 1;
1084
+ }
1085
+ }
1086
+ for (const { snap } of sharedSnapshots) {
1087
+ snap.removeFromSuperview();
1088
+ }
1089
+ // Independent (orphan) views were restored at gesture engagement,
1090
+ // so they're already visible. Just tear down any leftover present-
1091
+ // phase snapshots that weren't removed in present cleanup, and
1092
+ // ensure alpha is set from NS opacity for safety in case the
1093
+ // engagement code path was bypassed.
1094
+ for (const independent of state.instance?.sharedElements?.independent || []) {
1095
+ if (independent.view?.ios) {
1096
+ independent.view.ios.alpha = independent.view.opacity;
1097
+ }
1098
+ independent.snapshot?.removeFromSuperview();
1099
+ }
1100
+ if (view) {
1101
+ view.transform = CGAffineTransformIdentity;
1102
+ view.alpha = 1;
1103
+ }
1104
+ // Remove the sibling shadow view if interactive engagement added one.
1105
+ removeInteractiveDismissShadow(view);
1106
+ SharedTransition.finishState(state.instance.id);
1107
+ if (interactiveState.transitionContext) {
1108
+ interactiveState.transitionContext.finishInteractiveTransition();
1109
+ interactiveState.transitionContext.completeTransition(true);
1110
+ }
1111
+ SharedTransition.notifyEvent(SharedTransition.finishedEvent, {
1112
+ id: state?.instance?.id,
1113
+ type,
1114
+ action: 'interactiveFinish',
1115
+ });
1116
+ };
1117
+ if (view && targetTransform) {
1118
+ const shadowViewToAnimate = view.__sharedTransitionShadowView;
1119
+ iOSUtils.animateWithSpring({
1120
+ animations: () => {
1121
+ view.transform = targetTransform;
1122
+ view.alpha = 0;
1123
+ // Drive the sibling shadow view through the same spring so it
1124
+ // shrinks + translates with the modal as it morphs to the
1125
+ // source. Also fade it out so it doesn't linger behind the
1126
+ // finalizing snapshot.
1127
+ if (shadowViewToAnimate) {
1128
+ shadowViewToAnimate.transform = targetTransform;
1129
+ shadowViewToAnimate.alpha = 0;
1130
+ }
1131
+ // Restore source-side element alphas IN PARALLEL with the
1132
+ // destination's morph + fade — but skip sources that have
1133
+ // a flying snapshot. Those are held at alpha 0 and
1134
+ // revealed by finalize() right before the snapshot is
1135
+ // removed, so the user never sees both the snapshot AND
1136
+ // the source visible at the same time.
1137
+ for (const v of state.instance.sharedElements?._allPresentingViews || []) {
1138
+ if (v?.ios && !snapshottedSources.has(v.ios)) {
1139
+ v.ios.alpha = v.opacity;
1140
+ }
1141
+ }
1142
+ // Source-only orphan views were already restored at
1143
+ // engagement; nothing more to do for them here.
1144
+ // Fly each shared-element snapshot to its source frame.
1145
+ // Rides the same spring so the image lands precisely back
1146
+ // at the source position as the destination view fades.
1147
+ for (const { snap, endFrame, endCornerRadius } of sharedSnapshots) {
1148
+ snap.frame = endFrame;
1149
+ snap.layer.cornerRadius = endCornerRadius;
1150
+ }
1151
+ },
1152
+ completion: () => finalize(),
1153
+ });
1154
+ // Safety: ensure finalize runs even if the animation completion
1155
+ // is dropped (e.g. interrupted by another gesture).
1156
+ setTimeout(finalize, 800);
1157
+ }
1158
+ else {
1159
+ // No target transform; ensure sources are still restored.
1160
+ for (const v of state.instance.sharedElements?._allPresentingViews || []) {
1161
+ if (v?.ios)
1162
+ v.ios.alpha = v.opacity;
1163
+ }
1164
+ finalize();
1165
+ }
1166
+ return;
1167
+ }
516
1168
  if (state?.instance && interactiveState?.added && interactiveState?.propertyAnimator) {
517
1169
  interactiveState.propertyAnimator.reversed = false;
518
1170
  const duration = isNumber(state.pageReturn?.duration) ? state.pageReturn?.duration / 1000 : CORE_ANIMATION_DEFAULTS.duration;
519
1171
  interactiveState.propertyAnimator.continueAnimationWithTimingParametersDurationFactor(null, duration);
520
1172
  setTimeout(() => {
1173
+ // Restore alpha on every source view we hid (including duplicates) so
1174
+ // the source page is visible if shown again.
1175
+ for (const sourceView of state.instance.sharedElements._allPresentingViews || []) {
1176
+ if (sourceView?.ios) {
1177
+ sourceView.ios.alpha = sourceView.opacity;
1178
+ }
1179
+ }
521
1180
  for (const presenting of state.instance.sharedElements.presenting) {
522
- presenting.view.opacity = presenting.startOpacity;
523
1181
  presenting.snapshot.removeFromSuperview();
524
1182
  }
1183
+ for (const presented of state.instance.sharedElements.presented) {
1184
+ if (presented.imageSourceChangeListener) {
1185
+ presented.view.off('imageSourceChange', presented.imageSourceChangeListener);
1186
+ presented.imageSourceChangeListener = null;
1187
+ }
1188
+ }
1189
+ // Restore alpha on independent (orphan) source views and tear down
1190
+ // their snapshots. Without this, source-only orphans (any non-matched
1191
+ // sharedTransitionTag on the source page) stay at alpha 0 forever,
1192
+ // leaving the page visibly blank where they used to be. The non-morph
1193
+ // interactive path drives a propertyAnimator instead of running the
1194
+ // dismiss snapshot/animation rig, so the standard cleanup doesn't run.
1195
+ for (const ind of state.instance.sharedElements.independent) {
1196
+ if (ind.view?.ios) {
1197
+ ind.view.ios.alpha = ind.view.opacity;
1198
+ }
1199
+ ind.snapshot?.removeFromSuperview();
1200
+ }
525
1201
  SharedTransition.finishState(state.instance.id);
526
1202
  interactiveState.propertyAnimator = null;
527
1203
  interactiveState.added = false;