@nativescript/core 9.1.0-alpha.6 → 9.1.0-alpha.8

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 (73) hide show
  1. package/application/application.android.js +2 -0
  2. package/application/application.android.js.map +1 -1
  3. package/package.json +1 -1
  4. package/ui/button/index.android.d.ts +2 -9
  5. package/ui/button/index.android.js +7 -24
  6. package/ui/button/index.android.js.map +1 -1
  7. package/ui/button/index.ios.d.ts +2 -9
  8. package/ui/button/index.ios.js +17 -72
  9. package/ui/button/index.ios.js.map +1 -1
  10. package/ui/core/view/index.android.d.ts +1 -0
  11. package/ui/core/view/index.android.js +17 -8
  12. package/ui/core/view/index.android.js.map +1 -1
  13. package/ui/core/view/index.ios.d.ts +1 -0
  14. package/ui/core/view/index.ios.js +17 -4
  15. package/ui/core/view/index.ios.js.map +1 -1
  16. package/ui/core/view/view-common.d.ts +0 -2
  17. package/ui/core/view/view-common.js +1 -6
  18. package/ui/core/view/view-common.js.map +1 -1
  19. package/ui/core/view-base/index.d.ts +19 -9
  20. package/ui/core/view-base/index.js +41 -28
  21. package/ui/core/view-base/index.js.map +1 -1
  22. package/ui/frame/fragment.transitions.android.d.ts +13 -13
  23. package/ui/frame/fragment.transitions.android.js.map +1 -1
  24. package/ui/frame/frame-common.d.ts +2 -1
  25. package/ui/frame/frame-helper-for-android.d.ts +4 -4
  26. package/ui/frame/frame-helper-for-android.js +17 -8
  27. package/ui/frame/frame-helper-for-android.js.map +1 -1
  28. package/ui/frame/index.android.d.ts +6 -11
  29. package/ui/frame/index.android.js +18 -59
  30. package/ui/frame/index.android.js.map +1 -1
  31. package/ui/frame/index.d.ts +12 -21
  32. package/ui/frame/index.ios.d.ts +2 -2
  33. package/ui/frame/index.ios.js.map +1 -1
  34. package/ui/label/index.ios.d.ts +2 -5
  35. package/ui/label/index.ios.js +12 -44
  36. package/ui/label/index.ios.js.map +1 -1
  37. package/ui/layouts/layout-base.android.d.ts +3 -10
  38. package/ui/layouts/layout-base.android.js +7 -24
  39. package/ui/layouts/layout-base.android.js.map +1 -1
  40. package/ui/layouts/layout-base.ios.js.map +1 -1
  41. package/ui/page/index.ios.js +13 -2
  42. package/ui/page/index.ios.js.map +1 -1
  43. package/ui/styling/style/index.d.ts +1 -0
  44. package/ui/styling/style/index.js.map +1 -1
  45. package/ui/styling/style-properties.d.ts +1 -0
  46. package/ui/styling/style-properties.js +13 -4
  47. package/ui/styling/style-properties.js.map +1 -1
  48. package/ui/tab-view/index.ios.d.ts +10 -8
  49. package/ui/tab-view/index.ios.js +23 -17
  50. package/ui/tab-view/index.ios.js.map +1 -1
  51. package/ui/text-base/index.android.d.ts +2 -9
  52. package/ui/text-base/index.android.js +7 -24
  53. package/ui/text-base/index.android.js.map +1 -1
  54. package/ui/text-field/index.ios.d.ts +2 -9
  55. package/ui/text-field/index.ios.js +2 -23
  56. package/ui/text-field/index.ios.js.map +1 -1
  57. package/ui/text-view/index.ios.d.ts +2 -9
  58. package/ui/text-view/index.ios.js +20 -72
  59. package/ui/text-view/index.ios.js.map +1 -1
  60. package/ui/transition/modal-transition.ios.js +15 -1
  61. package/ui/transition/modal-transition.ios.js.map +1 -1
  62. package/ui/transition/page-transition.ios.js +72 -5
  63. package/ui/transition/page-transition.ios.js.map +1 -1
  64. package/ui/transition/shared-transition-helper.android.d.ts +3 -0
  65. package/ui/transition/shared-transition-helper.android.js +9 -0
  66. package/ui/transition/shared-transition-helper.android.js.map +1 -1
  67. package/ui/transition/shared-transition-helper.d.ts +7 -0
  68. package/ui/transition/shared-transition-helper.ios.d.ts +36 -0
  69. package/ui/transition/shared-transition-helper.ios.js +336 -96
  70. package/ui/transition/shared-transition-helper.ios.js.map +1 -1
  71. package/ui/transition/shared-transition.d.ts +43 -0
  72. package/ui/transition/shared-transition.js +57 -2
  73. package/ui/transition/shared-transition.js.map +1 -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;
@@ -136,78 +245,99 @@ export class SharedTransitionHelper {
136
245
  }
137
246
  };
138
247
  const positionIndependentTags = async () => {
139
- // independent tags
140
- for (const tag in pageEndTags) {
141
- // only handle if independent (otherwise it's shared between both pages and handled above)
142
- if (!sharedElementTags.includes(tag)) {
143
- // only consider start when there's a matching end
144
- const pageStartIndependentProps = pageStart?.sharedTransitionTags ? pageStart?.sharedTransitionTags[tag] : null;
145
- // console.log('start:', tag, pageStartIndependentProps);
146
- const pageEndProps = pageEndTags[tag];
147
- let independentView = presenting.find((v) => v.sharedTransitionTag === tag);
148
- 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);
149
284
  if (!independentView) {
150
- independentView = presented.find((v) => v.sharedTransitionTag === tag);
151
- if (!independentView) {
152
- break;
153
- }
154
- isPresented = true;
155
- }
156
- const independentSharedElement = independentView.ios;
157
- if (pageEndProps?.callback) {
158
- await pageEndProps?.callback(independentView, 'present');
159
- }
160
- // let snapshot: UIImageView;
161
- // if (isPresented) {
162
- // snapshot = UIImageView.alloc().init();
163
- // } else {
164
- const snapshot = UIImageView.alloc().initWithImage(iOSUtils.snapshotView(independentSharedElement, Screen.mainScreen.scale));
165
- // }
166
- if (independentSharedElement instanceof UIImageView) {
167
- // in case the image is loaded async, we need to update the snapshot when it changes
168
- // todo: remove listener on transition end
169
- // if (isPresented) {
170
- // independentView.on('imageSourceChange', () => {
171
- // snapshot.image = iOSNativeHelper.snapshotView(independentSharedElement, Screen.mainScreen.scale);
172
- // snapshot.tintColor = independentSharedElement.tintColor;
173
- // });
174
- // }
175
- snapshot.tintColor = independentSharedElement.tintColor;
176
- snapshot.contentMode = independentSharedElement.contentMode;
285
+ // Tag declared in config but no matching view; skip (don't
286
+ // break — later tags might still resolve).
287
+ continue;
177
288
  }
178
- snapshot.clipsToBounds = true;
179
- const startFrame = independentSharedElement.convertRectToView(independentSharedElement.bounds, transitionContext.containerView);
180
- const startFrameRect = getRectFromProps(pageStartIndependentProps);
181
- // adjust for any specified start positions
182
- const startFrameAdjusted = CGRectMake(startFrame.origin.x + startFrameRect.x, startFrame.origin.y + startFrameRect.y, startFrame.size.width, startFrame.size.height);
183
- // console.log('startFrameAdjusted:', tag, iOSNativeHelper.printCGRect(startFrameAdjusted));
184
- // if (pageStartIndependentProps?.scale) {
185
- // snapshot.transform = CGAffineTransformConcat(CGAffineTransformMakeTranslation(startFrameAdjusted.origin.x, startFrameAdjusted.origin.y), CGAffineTransformMakeScale(pageStartIndependentProps.scale.x, pageStartIndependentProps.scale.y))
186
- // } else {
187
- snapshot.frame = startFrame; //startFrameAdjusted;
188
- // }
189
- if (SharedTransition.DEBUG) {
190
- console.log('---> ', independentView.sharedTransitionTag, ' frame:', iOSUtils.printCGRect(snapshot.frame));
191
- }
192
- const endFrameRect = getRectFromProps(pageEndProps);
193
- const endFrame = CGRectMake(startFrame.origin.x + endFrameRect.x, startFrame.origin.y + endFrameRect.y, startFrame.size.width, startFrame.size.height);
194
- // console.log('endFrame:', tag, iOSNativeHelper.printCGRect(endFrame));
195
- transition.sharedElements.independent.push({
196
- view: independentView,
197
- isPresented,
198
- startFrame,
199
- snapshot,
200
- endFrame,
201
- startTransform: independentSharedElement.transform,
202
- scale: pageEndProps.scale,
203
- startOpacity: independentView.opacity,
204
- endOpacity: isNumber(pageEndProps.opacity) ? pageEndProps.opacity : 0,
205
- propertiesToMatch: pageEndProps?.propertiesToMatch,
206
- zIndex: isNumber(pageEndProps?.zIndex) ? pageEndProps.zIndex : 0,
207
- });
208
- // Native alpha; see comment in positionSharedTags.
209
- independentSharedElement.alpha = 0;
289
+ isPresented = true;
210
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));
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;
211
341
  }
212
342
  };
213
343
  // position all sharedTransitionTag elements
@@ -215,7 +345,7 @@ export class SharedTransitionHelper {
215
345
  await positionIndependentTags();
216
346
  // combine to order by zIndex and add to transition context
217
347
  const snapshotData = transition.sharedElements.presenting.concat(transition.sharedElements.independent);
218
- snapshotData.sort((a, b) => (a.zIndex > b.zIndex ? 1 : -1));
348
+ snapshotData.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
219
349
  if (SharedTransition.DEBUG) {
220
350
  console.log(`zIndex settings:`, snapshotData.map((s) => {
221
351
  return {
@@ -238,8 +368,8 @@ export class SharedTransitionHelper {
238
368
  // cornerRadius change.
239
369
  if (isNumber(pageStart?.cornerRadius) || isNumber(pageEnd?.cornerRadius)) {
240
370
  const layer = transition.presented.view.layer;
241
- // top-left | top-right (CACornerMask bits)
242
- layer.maskedCorners = 3;
371
+ // top-left | top-right | bottom-left | bottom-right (CACornerMask bits)
372
+ layer.maskedCorners = 15;
243
373
  layer.masksToBounds = true;
244
374
  if (isNumber(pageStart?.cornerRadius)) {
245
375
  layer.cornerRadius = pageStart.cornerRadius;
@@ -262,7 +392,7 @@ export class SharedTransitionHelper {
262
392
  // duplicates that share a sharedTransitionTag and were hidden without
263
393
  // a snapshot. Even though the source page is being removed, the View
264
394
  // instances may be reused on subsequent navigations.
265
- for (const sourceView of (transition.sharedElements._allPresentingViews || [])) {
395
+ for (const sourceView of transition.sharedElements._allPresentingViews || []) {
266
396
  if (sourceView?.ios) {
267
397
  sourceView.ios.alpha = sourceView.opacity;
268
398
  }
@@ -418,12 +548,12 @@ export class SharedTransitionHelper {
418
548
  // set these, this is a harmless no-op.
419
549
  if (isNumber(pageReturn?.cornerRadius)) {
420
550
  const layer = transition.presented.view.layer;
421
- layer.maskedCorners = 3;
551
+ layer.maskedCorners = 15; // top-left | top-right | bottom-left | bottom-right
422
552
  layer.masksToBounds = true;
423
553
  }
424
554
  // combine to order by zIndex and add to transition context
425
555
  const snapshotData = transition.sharedElements.presenting.concat(transition.sharedElements.independent);
426
- snapshotData.sort((a, b) => (a.zIndex > b.zIndex ? 1 : -1));
556
+ snapshotData.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
427
557
  if (SharedTransition.DEBUG) {
428
558
  console.log(`zIndex settings:`, snapshotData.map((s) => {
429
559
  return {
@@ -468,7 +598,7 @@ export class SharedTransitionHelper {
468
598
  // animating over it back to its position. Use the full list (not just
469
599
  // the deduped `presenting`) so duplicates from sister lists are hidden
470
600
  // too. Restored in cleanupDismiss below.
471
- for (const sourceView of (transition.sharedElements._allPresentingViews || [])) {
601
+ for (const sourceView of transition.sharedElements._allPresentingViews || []) {
472
602
  if (sourceView?.ios) {
473
603
  sourceView.ios.alpha = 0;
474
604
  }
@@ -477,7 +607,7 @@ export class SharedTransitionHelper {
477
607
  // Restore alpha on every source view we hid — including duplicate
478
608
  // sources with the same sharedTransitionTag — using NS opacity as
479
609
  // the source of truth.
480
- for (const sourceView of (transition.sharedElements._allPresentingViews || [])) {
610
+ for (const sourceView of transition.sharedElements._allPresentingViews || []) {
481
611
  if (sourceView?.ios) {
482
612
  sourceView.ios.alpha = sourceView.opacity;
483
613
  }
@@ -593,6 +723,13 @@ export class SharedTransitionHelper {
593
723
  }
594
724
  break;
595
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
+ }
596
733
  }
597
734
  static interactiveUpdate(state, interactiveState, type, percent) {
598
735
  if (interactiveState) {
@@ -629,11 +766,20 @@ export class SharedTransitionHelper {
629
766
  p.snapshot.alpha = p.endOpacity;
630
767
  interactiveState.transitionContext.containerView.addSubview(p.snapshot);
631
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
+ }
632
778
  // Hide every source-side shared element (including duplicates that share
633
779
  // a sharedTransitionTag in sister lists) so the user only sees the
634
780
  // animating snapshot — not the snapshot AND the real source element
635
781
  // simultaneously ("double image" during interactive dismissal).
636
- for (const sourceView of (state.instance.sharedElements._allPresentingViews || [])) {
782
+ for (const sourceView of state.instance.sharedElements._allPresentingViews || []) {
637
783
  if (sourceView?.ios) {
638
784
  sourceView.ios.alpha = 0;
639
785
  }
@@ -646,6 +792,18 @@ export class SharedTransitionHelper {
646
792
  iOSUtils.copyLayerProperties(p.snapshot, p.view.ios, p.propertiesToMatch);
647
793
  p.snapshot.alpha = 1;
648
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
+ }
649
807
  state.instance.presented.view.alpha = isNumber(state.pageReturn?.opacity) ? state.pageReturn?.opacity : 0;
650
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);
651
809
  // Drive cornerRadius via the same percent the user controls — round
@@ -676,7 +834,7 @@ export class SharedTransitionHelper {
676
834
  view.transform = CGAffineTransformIdentity;
677
835
  // Restore the source-side element alphas that the gesture hid on
678
836
  // engagement so the source page is correct when shown again.
679
- for (const v of ((state.instance.sharedElements?._allPresentingViews) || [])) {
837
+ for (const v of state.instance.sharedElements?._allPresentingViews || []) {
680
838
  if (v?.ios)
681
839
  v.ios.alpha = v.opacity;
682
840
  }
@@ -701,7 +859,7 @@ export class SharedTransitionHelper {
701
859
  // they're visible from frame one of the snap-back. This
702
860
  // also makes the source restoration robust to a new
703
861
  // gesture interrupting our completion callback.
704
- for (const v of ((state.instance.sharedElements?._allPresentingViews) || [])) {
862
+ for (const v of state.instance.sharedElements?._allPresentingViews || []) {
705
863
  if (v?.ios)
706
864
  v.ios.alpha = v.opacity;
707
865
  }
@@ -747,7 +905,7 @@ export class SharedTransitionHelper {
747
905
  }
748
906
  // Restore alpha on every source view we hid during interactiveUpdate
749
907
  // (including duplicates from sister lists).
750
- for (const sourceView of (state.instance.sharedElements._allPresentingViews || [])) {
908
+ for (const sourceView of state.instance.sharedElements._allPresentingViews || []) {
751
909
  if (sourceView?.ios) {
752
910
  sourceView.ios.alpha = sourceView.opacity;
753
911
  }
@@ -755,6 +913,17 @@ export class SharedTransitionHelper {
755
913
  for (const p of state.instance.sharedElements.presenting) {
756
914
  p.snapshot.removeFromSuperview();
757
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
+ }
758
927
  state.instance.presented.view.alpha = 1;
759
928
  interactiveState.propertyAnimator = null;
760
929
  interactiveState.added = false;
@@ -776,23 +945,54 @@ export class SharedTransitionHelper {
776
945
  // Morph mode finish — animate the destination view to the matching
777
946
  // source element's frame and fade it out, then complete.
778
947
  const view = state.instance.presented?.view;
779
- const destTag = state.instance.sharedElements?.presented?.[0]?.view?.sharedTransitionTag;
948
+ const destPresented = state.instance.sharedElements?.presented?.[0];
949
+ const destTag = destPresented?.view?.sharedTransitionTag;
780
950
  const matchingSource = state.instance.sharedElements?.presenting?.find?.((p) => p.view.sharedTransitionTag === destTag);
781
951
  const srcIos = matchingSource?.view?.ios;
952
+ const destSharedIos = destPresented?.view?.ios;
782
953
  const containerView = interactiveState.transitionContext?.containerView;
783
954
  let targetTransform = null;
784
955
  if (view && srcIos && containerView) {
785
- const frameInContainer = srcIos.convertRectToView(srcIos.bounds, containerView);
956
+ const sourceFrame = srcIos.convertRectToView(srcIos.bounds, containerView);
786
957
  const bounds = view.bounds;
787
- if (bounds.size.width > 0 && bounds.size.height > 0) {
788
- const targetScale = frameInContainer.size.width / bounds.size.width;
789
- const targetCx = frameInContainer.origin.x + frameInContainer.size.width / 2;
790
- const targetCy = frameInContainer.origin.y + frameInContainer.size.height / 2;
791
- const currentCx = bounds.size.width / 2;
792
- const currentCy = bounds.size.height / 2;
793
- const tx = targetCx - currentCx;
794
- const ty = targetCy - currentCy;
795
- targetTransform = CGAffineTransformConcat(CGAffineTransformMakeTranslation(tx, ty), CGAffineTransformMakeScale(targetScale, targetScale));
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));
796
996
  }
797
997
  }
798
998
  // Build a snapshot fly-back for each shared element so the album
@@ -855,6 +1055,10 @@ export class SharedTransitionHelper {
855
1055
  if (entry.srcSharedView)
856
1056
  snapshottedSources.add(entry.srcSharedView);
857
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.
858
1062
  let didFinalize = false;
859
1063
  const finalize = () => {
860
1064
  if (didFinalize)
@@ -862,7 +1066,7 @@ export class SharedTransitionHelper {
862
1066
  didFinalize = true;
863
1067
  // Detach the imageSourceChange listeners that were attached during
864
1068
  // PRESENT — the destination View is about to be torn down.
865
- for (const presented of (state.instance.sharedElements?.presented || [])) {
1069
+ for (const presented of state.instance.sharedElements?.presented || []) {
866
1070
  if (presented.imageSourceChangeListener) {
867
1071
  presented.view.off('imageSourceChange', presented.imageSourceChangeListener);
868
1072
  presented.imageSourceChangeListener = null;
@@ -882,10 +1086,23 @@ export class SharedTransitionHelper {
882
1086
  for (const { snap } of sharedSnapshots) {
883
1087
  snap.removeFromSuperview();
884
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
+ }
885
1100
  if (view) {
886
1101
  view.transform = CGAffineTransformIdentity;
887
1102
  view.alpha = 1;
888
1103
  }
1104
+ // Remove the sibling shadow view if interactive engagement added one.
1105
+ removeInteractiveDismissShadow(view);
889
1106
  SharedTransition.finishState(state.instance.id);
890
1107
  if (interactiveState.transitionContext) {
891
1108
  interactiveState.transitionContext.finishInteractiveTransition();
@@ -898,21 +1115,32 @@ export class SharedTransitionHelper {
898
1115
  });
899
1116
  };
900
1117
  if (view && targetTransform) {
1118
+ const shadowViewToAnimate = view.__sharedTransitionShadowView;
901
1119
  iOSUtils.animateWithSpring({
902
1120
  animations: () => {
903
1121
  view.transform = targetTransform;
904
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
+ }
905
1131
  // Restore source-side element alphas IN PARALLEL with the
906
1132
  // destination's morph + fade — but skip sources that have
907
1133
  // a flying snapshot. Those are held at alpha 0 and
908
1134
  // revealed by finalize() right before the snapshot is
909
1135
  // removed, so the user never sees both the snapshot AND
910
1136
  // the source visible at the same time.
911
- for (const v of ((state.instance.sharedElements?._allPresentingViews) || [])) {
1137
+ for (const v of state.instance.sharedElements?._allPresentingViews || []) {
912
1138
  if (v?.ios && !snapshottedSources.has(v.ios)) {
913
1139
  v.ios.alpha = v.opacity;
914
1140
  }
915
1141
  }
1142
+ // Source-only orphan views were already restored at
1143
+ // engagement; nothing more to do for them here.
916
1144
  // Fly each shared-element snapshot to its source frame.
917
1145
  // Rides the same spring so the image lands precisely back
918
1146
  // at the source position as the destination view fades.
@@ -929,7 +1157,7 @@ export class SharedTransitionHelper {
929
1157
  }
930
1158
  else {
931
1159
  // No target transform; ensure sources are still restored.
932
- for (const v of ((state.instance.sharedElements?._allPresentingViews) || [])) {
1160
+ for (const v of state.instance.sharedElements?._allPresentingViews || []) {
933
1161
  if (v?.ios)
934
1162
  v.ios.alpha = v.opacity;
935
1163
  }
@@ -944,7 +1172,7 @@ export class SharedTransitionHelper {
944
1172
  setTimeout(() => {
945
1173
  // Restore alpha on every source view we hid (including duplicates) so
946
1174
  // the source page is visible if shown again.
947
- for (const sourceView of (state.instance.sharedElements._allPresentingViews || [])) {
1175
+ for (const sourceView of state.instance.sharedElements._allPresentingViews || []) {
948
1176
  if (sourceView?.ios) {
949
1177
  sourceView.ios.alpha = sourceView.opacity;
950
1178
  }
@@ -958,6 +1186,18 @@ export class SharedTransitionHelper {
958
1186
  presented.imageSourceChangeListener = null;
959
1187
  }
960
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
+ }
961
1201
  SharedTransition.finishState(state.instance.id);
962
1202
  interactiveState.propertyAnimator = null;
963
1203
  interactiveState.added = false;