@nativescript/core 9.1.0-alpha.5 → 9.1.0-alpha.7

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 (106) hide show
  1. package/application/application.android.js +2 -0
  2. package/application/application.android.js.map +1 -1
  3. package/file-system/file-system-access.ios.js +12 -25
  4. package/file-system/file-system-access.ios.js.map +1 -1
  5. package/package.json +1 -1
  6. package/ui/action-bar/index.ios.js +0 -1
  7. package/ui/action-bar/index.ios.js.map +1 -1
  8. package/ui/button/index.android.d.ts +2 -9
  9. package/ui/button/index.android.js +7 -24
  10. package/ui/button/index.android.js.map +1 -1
  11. package/ui/button/index.ios.d.ts +2 -9
  12. package/ui/button/index.ios.js +17 -72
  13. package/ui/button/index.ios.js.map +1 -1
  14. package/ui/core/view/index.android.d.ts +1 -0
  15. package/ui/core/view/index.android.js +17 -8
  16. package/ui/core/view/index.android.js.map +1 -1
  17. package/ui/core/view/index.ios.d.ts +1 -0
  18. package/ui/core/view/index.ios.js +18 -20
  19. package/ui/core/view/index.ios.js.map +1 -1
  20. package/ui/core/view/view-common.d.ts +0 -2
  21. package/ui/core/view/view-common.js +1 -6
  22. package/ui/core/view/view-common.js.map +1 -1
  23. package/ui/core/view/view-helper/index.d.ts +1 -0
  24. package/ui/core/view/view-helper/index.ios.d.ts +24 -0
  25. package/ui/core/view/view-helper/index.ios.js +54 -5
  26. package/ui/core/view/view-helper/index.ios.js.map +1 -1
  27. package/ui/core/view-base/index.d.ts +19 -9
  28. package/ui/core/view-base/index.js +42 -29
  29. package/ui/core/view-base/index.js.map +1 -1
  30. package/ui/frame/fragment.transitions.android.d.ts +13 -13
  31. package/ui/frame/fragment.transitions.android.js.map +1 -1
  32. package/ui/frame/frame-common.d.ts +2 -1
  33. package/ui/frame/frame-common.js +10 -0
  34. package/ui/frame/frame-common.js.map +1 -1
  35. package/ui/frame/frame-helper-for-android.d.ts +4 -4
  36. package/ui/frame/frame-helper-for-android.js +17 -8
  37. package/ui/frame/frame-helper-for-android.js.map +1 -1
  38. package/ui/frame/index.android.d.ts +6 -11
  39. package/ui/frame/index.android.js +18 -59
  40. package/ui/frame/index.android.js.map +1 -1
  41. package/ui/frame/index.d.ts +12 -21
  42. package/ui/frame/index.ios.d.ts +2 -2
  43. package/ui/frame/index.ios.js.map +1 -1
  44. package/ui/label/index.ios.d.ts +2 -5
  45. package/ui/label/index.ios.js +12 -44
  46. package/ui/label/index.ios.js.map +1 -1
  47. package/ui/layouts/absolute-layout/index.ios.js +5 -1
  48. package/ui/layouts/absolute-layout/index.ios.js.map +1 -1
  49. package/ui/layouts/dock-layout/index.ios.js +5 -1
  50. package/ui/layouts/dock-layout/index.ios.js.map +1 -1
  51. package/ui/layouts/flexbox-layout/index.ios.js +5 -1
  52. package/ui/layouts/flexbox-layout/index.ios.js.map +1 -1
  53. package/ui/layouts/grid-layout/index.ios.js +5 -1
  54. package/ui/layouts/grid-layout/index.ios.js.map +1 -1
  55. package/ui/layouts/layout-base.android.d.ts +3 -10
  56. package/ui/layouts/layout-base.android.js +7 -24
  57. package/ui/layouts/layout-base.android.js.map +1 -1
  58. package/ui/layouts/layout-base.ios.js.map +1 -1
  59. package/ui/layouts/stack-layout/index.ios.js +5 -1
  60. package/ui/layouts/stack-layout/index.ios.js.map +1 -1
  61. package/ui/layouts/wrap-layout/index.ios.js +5 -1
  62. package/ui/layouts/wrap-layout/index.ios.js.map +1 -1
  63. package/ui/list-view/index.d.ts +13 -1
  64. package/ui/list-view/index.ios.d.ts +3 -1
  65. package/ui/list-view/index.ios.js +48 -10
  66. package/ui/list-view/index.ios.js.map +1 -1
  67. package/ui/list-view/list-view-common.d.ts +14 -0
  68. package/ui/list-view/list-view-common.js +4 -0
  69. package/ui/list-view/list-view-common.js.map +1 -1
  70. package/ui/page/index.ios.js +17 -4
  71. package/ui/page/index.ios.js.map +1 -1
  72. package/ui/scroll-view/index.d.ts +13 -0
  73. package/ui/scroll-view/index.ios.js +21 -7
  74. package/ui/scroll-view/index.ios.js.map +1 -1
  75. package/ui/scroll-view/scroll-view-common.d.ts +2 -0
  76. package/ui/scroll-view/scroll-view-common.js +8 -0
  77. package/ui/scroll-view/scroll-view-common.js.map +1 -1
  78. package/ui/styling/style/index.d.ts +1 -0
  79. package/ui/styling/style/index.js.map +1 -1
  80. package/ui/styling/style-properties.d.ts +1 -0
  81. package/ui/styling/style-properties.js +13 -4
  82. package/ui/styling/style-properties.js.map +1 -1
  83. package/ui/tab-view/index.ios.d.ts +10 -1
  84. package/ui/tab-view/index.ios.js +12 -4
  85. package/ui/tab-view/index.ios.js.map +1 -1
  86. package/ui/text-base/index.android.d.ts +2 -9
  87. package/ui/text-base/index.android.js +7 -24
  88. package/ui/text-base/index.android.js.map +1 -1
  89. package/ui/text-field/index.ios.d.ts +2 -9
  90. package/ui/text-field/index.ios.js +2 -23
  91. package/ui/text-field/index.ios.js.map +1 -1
  92. package/ui/text-view/index.ios.d.ts +2 -9
  93. package/ui/text-view/index.ios.js +20 -72
  94. package/ui/text-view/index.ios.js.map +1 -1
  95. package/ui/transition/index.d.ts +2 -1
  96. package/ui/transition/modal-transition.ios.js +18 -4
  97. package/ui/transition/modal-transition.ios.js.map +1 -1
  98. package/ui/transition/page-transition.ios.d.ts +5 -0
  99. package/ui/transition/page-transition.ios.js +227 -31
  100. package/ui/transition/page-transition.ios.js.map +1 -1
  101. package/ui/transition/shared-transition-helper.ios.js +456 -20
  102. package/ui/transition/shared-transition-helper.ios.js.map +1 -1
  103. package/ui/transition/shared-transition.d.ts +23 -0
  104. package/ui/transition/shared-transition.js.map +1 -1
  105. package/utils/native-helper.ios.js +15 -4
  106. package/utils/native-helper.ios.js.map +1 -1
@@ -34,6 +34,10 @@ export class SharedTransitionHelper {
34
34
  independent: [],
35
35
  };
36
36
  }
37
+ // Track every matched source view so cleanup can restore alpha,
38
+ // including duplicates (same album surfacing in multiple lists)
39
+ // that we hide but don't create a snapshot for.
40
+ transition.sharedElements._allPresentingViews = sharedElements.slice();
37
41
  if (SharedTransition.DEBUG) {
38
42
  console.log(` ${type}: Present`);
39
43
  console.log(`1. Found sharedTransitionTags to animate:`, sharedElementTags);
@@ -46,12 +50,22 @@ export class SharedTransitionHelper {
46
50
  const pageEndTags = pageEnd?.sharedTransitionTags || {};
47
51
  // console.log('pageEndIndependentTags:', pageEndIndependentTags);
48
52
  const positionSharedTags = async () => {
53
+ const processedTags = new Set();
49
54
  for (const presentingView of sharedElements) {
50
55
  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)
56
+ const tag = presentingView.sharedTransitionTag;
57
+ if (processedTags.has(tag)) {
58
+ // The same item can appear in multiple lists on the source page
59
+ // (e.g., a featured album also surfacing in "Recently Played").
60
+ // Each instance has the same sharedTransitionTag. Only the first
61
+ // one becomes the snapshot origin; the rest must still be hidden
62
+ // so they don't show through behind the animating snapshot.
63
+ if (presentingSharedElement) {
64
+ presentingSharedElement.alpha = 0;
65
+ }
66
+ continue;
67
+ }
68
+ processedTags.add(tag);
55
69
  const presentedView = presented.find((v) => v.sharedTransitionTag === presentingView.sharedTransitionTag);
56
70
  const presentedSharedElement = presentedView.ios;
57
71
  const pageEndProps = pageEndTags[presentingView.sharedTransitionTag];
@@ -60,15 +74,25 @@ export class SharedTransitionHelper {
60
74
  await pageEndProps?.callback(presentedView, 'present');
61
75
  }
62
76
  // treat images differently...
77
+ let imageSourceChangeListener;
63
78
  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', () => {
79
+ // In case the image is loaded async, keep the snapshot's image in sync with
80
+ // the destination view's image. We hold a ref so we can detach the listener
81
+ // on cleanup (the View would otherwise hold a strong ref via the observers
82
+ // map and the snapshot closure would leak).
83
+ imageSourceChangeListener = () => {
67
84
  snapshot.image = iOSUtils.snapshotView(presentedSharedElement, Screen.mainScreen.scale);
68
85
  snapshot.tintColor = presentedSharedElement.tintColor;
69
- });
86
+ };
87
+ presentedView.on('imageSourceChange', imageSourceChangeListener);
70
88
  snapshot.tintColor = presentedSharedElement.tintColor;
71
89
  snapshot.contentMode = presentedSharedElement.contentMode;
90
+ // Seed the snapshot with the source's already-loaded image so the very
91
+ // first frame of the animation isn't blank if the destination image is
92
+ // still loading.
93
+ if (presentingSharedElement instanceof UIImageView) {
94
+ snapshot.image = presentingSharedElement.image;
95
+ }
72
96
  }
73
97
  iOSUtils.copyLayerProperties(snapshot, presentingSharedElement, pageEndProps?.propertiesToMatch);
74
98
  snapshot.clipsToBounds = true;
@@ -96,12 +120,19 @@ export class SharedTransitionHelper {
96
120
  startOpacity: presentedView.opacity,
97
121
  endOpacity: presentingView.opacity,
98
122
  propertiesToMatch: pageEndProps?.propertiesToMatch,
123
+ imageSourceChangeListener,
99
124
  });
100
125
  // set initial opacity to match the source view opacity
101
126
  snapshot.alpha = presentingView.opacity;
102
- // hide both while animating within the transition context
103
- presentingView.opacity = 0;
104
- presentedView.opacity = 0;
127
+ // Hide both while animating in the transition context.
128
+ //
129
+ // We mutate native alpha directly rather than NS `opacity` so the NS-side
130
+ // style remains the user's intended value (typically 1). If we instead set
131
+ // `view.opacity = 0` and then a transition was interrupted before cleanup
132
+ // (or the page was reused), `startOpacity` here could capture 0 from a prior
133
+ // run and the cleanup below would leave the destination invisible.
134
+ presentingSharedElement.alpha = 0;
135
+ presentedSharedElement.alpha = 0;
105
136
  }
106
137
  };
107
138
  const positionIndependentTags = async () => {
@@ -174,7 +205,8 @@ export class SharedTransitionHelper {
174
205
  propertiesToMatch: pageEndProps?.propertiesToMatch,
175
206
  zIndex: isNumber(pageEndProps?.zIndex) ? pageEndProps.zIndex : 0,
176
207
  });
177
- independentView.opacity = 0;
208
+ // Native alpha; see comment in positionSharedTags.
209
+ independentSharedElement.alpha = 0;
178
210
  }
179
211
  }
180
212
  };
@@ -199,9 +231,41 @@ export class SharedTransitionHelper {
199
231
  // Important: always set after above shared element positions have had their start positions set
200
232
  transition.presented.view.alpha = isNumber(pageStart?.opacity) ? pageStart?.opacity : 0;
201
233
  transition.presented.view.frame = CGRectMake(startFrame.x, startFrame.y, startFrame.width, startFrame.height);
234
+ // Optional top-corner rounding for sheet-style transitions (e.g.
235
+ // a modal that's rounded at the bottom of the screen and flattens
236
+ // as it slides up). Setting maskedCorners and masksToBounds here
237
+ // (before the animation) so they're stable during the animated
238
+ // cornerRadius change.
239
+ if (isNumber(pageStart?.cornerRadius) || isNumber(pageEnd?.cornerRadius)) {
240
+ const layer = transition.presented.view.layer;
241
+ // top-left | top-right | bottom-left | bottom-right (CACornerMask bits)
242
+ layer.maskedCorners = 15;
243
+ layer.masksToBounds = true;
244
+ if (isNumber(pageStart?.cornerRadius)) {
245
+ layer.cornerRadius = pageStart.cornerRadius;
246
+ }
247
+ }
202
248
  const cleanupPresent = () => {
203
249
  for (const presented of transition.sharedElements.presented) {
204
- presented.view.opacity = presented.startOpacity;
250
+ // Detach the per-transition imageSourceChange listener registered
251
+ // during positionSharedTags so it doesn't leak (the snapshot is gone).
252
+ if (presented.imageSourceChangeListener) {
253
+ presented.view.off('imageSourceChange', presented.imageSourceChangeListener);
254
+ presented.imageSourceChangeListener = null;
255
+ }
256
+ // Restore the destination native alpha from the NS opacity (the source
257
+ // of truth). NS opacity wasn't modified during the transition, so this
258
+ // reflects the user's intended visibility (typically 1).
259
+ presented.view.ios.alpha = presented.view.opacity;
260
+ }
261
+ // Restore native alpha on every source view we touched — including
262
+ // duplicates that share a sharedTransitionTag and were hidden without
263
+ // a snapshot. Even though the source page is being removed, the View
264
+ // instances may be reused on subsequent navigations.
265
+ for (const sourceView of transition.sharedElements._allPresentingViews || []) {
266
+ if (sourceView?.ios) {
267
+ sourceView.ios.alpha = sourceView.opacity;
268
+ }
205
269
  }
206
270
  for (const presenting of transition.sharedElements.presenting) {
207
271
  presenting.snapshot.removeFromSuperview();
@@ -209,7 +273,7 @@ export class SharedTransitionHelper {
209
273
  for (const independent of transition.sharedElements.independent) {
210
274
  independent.snapshot.removeFromSuperview();
211
275
  if (independent.isPresented) {
212
- independent.view.opacity = independent.startOpacity;
276
+ independent.view.ios.alpha = independent.view.opacity;
213
277
  }
214
278
  }
215
279
  SharedTransition.updateState(transition.id, {
@@ -232,6 +296,12 @@ export class SharedTransitionHelper {
232
296
  transition.presented.view.alpha = isNumber(pageEnd?.opacity) ? pageEnd?.opacity : 1;
233
297
  const endFrame = getRectFromProps(pageEnd);
234
298
  transition.presented.view.frame = CGRectMake(endFrame.x, endFrame.y, endFrame.width, endFrame.height);
299
+ // Animate cornerRadius alongside the page frame. Implicit animation
300
+ // of cornerRadius is supported on iOS 11+, both inside
301
+ // UIView.animateWith… and UIViewPropertyAnimator's animations block.
302
+ if (isNumber(pageEnd?.cornerRadius)) {
303
+ transition.presented.view.layer.cornerRadius = pageEnd.cornerRadius;
304
+ }
235
305
  if (pageOut) {
236
306
  if (isNumber(pageOut.opacity)) {
237
307
  transition.presenting.view.alpha = pageOut?.opacity;
@@ -310,6 +380,23 @@ export class SharedTransitionHelper {
310
380
  });
311
381
  if (type === 'page') {
312
382
  transitionContext.containerView.insertSubviewBelowSubview(transition.presenting.view, transition.presented.view);
383
+ // UIKit's standard pop places the incoming source view off to the
384
+ // left so it can slide into position; for custom shared transitions
385
+ // (where the destination either slides down or morphs to source) we
386
+ // don't want that lateral slide. Pin the source to its fullscreen
387
+ // frame so it stays put underneath the animating destination.
388
+ // Users can still opt into a custom source animation via `pageOut`.
389
+ const sourceDefaults = getRectFromProps(null);
390
+ transition.presenting.view.frame = CGRectMake(0, 0, sourceDefaults.width, sourceDefaults.height);
391
+ }
392
+ // Morph + interactive dismiss: the gesture handler drives the
393
+ // animation via transforms and finalizes by calling cancel/finish
394
+ // on the transitionContext. Running the standard snapshot + spring
395
+ // rig here would race that and frequently call completeTransition(true)
396
+ // *before* the user's gesture ended — causing the dismiss to commit
397
+ // even on a cancel. Skip the rest of the dismiss setup entirely.
398
+ if (state.interactive?.dismiss?.morph && state.interactiveBegan) {
399
+ return;
313
400
  }
314
401
  // console.log('transitionContext.containerView.subviews.count:', transitionContext.containerView.subviews.count);
315
402
  if (SharedTransition.DEBUG) {
@@ -322,7 +409,17 @@ export class SharedTransitionHelper {
322
409
  const pageEndTags = pageEnd?.sharedTransitionTags || {};
323
410
  const pageReturn = state.pageReturn;
324
411
  for (const p of transition.sharedElements.presented) {
325
- p.view.opacity = 0;
412
+ // Use native alpha (not NS opacity) so we don't pollute the NS-side
413
+ // value if the dismiss is interrupted; cleanup restores from NS opacity.
414
+ p.view.ios.alpha = 0;
415
+ }
416
+ // Ensure top-corner masking + clipping is in place before we animate
417
+ // cornerRadius back toward the start value. If the present pass already
418
+ // set these, this is a harmless no-op.
419
+ if (isNumber(pageReturn?.cornerRadius)) {
420
+ const layer = transition.presented.view.layer;
421
+ layer.maskedCorners = 15; // top-left | top-right | bottom-left | bottom-right
422
+ layer.masksToBounds = true;
326
423
  }
327
424
  // combine to order by zIndex and add to transition context
328
425
  const snapshotData = transition.sharedElements.presenting.concat(transition.sharedElements.independent);
@@ -365,13 +462,43 @@ export class SharedTransitionHelper {
365
462
  // add snapshot to animate
366
463
  transitionContext.containerView.addSubview(data.snapshot);
367
464
  }
465
+ // Hide every source-side shared element now that snapshots have been
466
+ // captured. The animating snapshot covers them; without this, the user
467
+ // sees a "double image" — the real source element AND the snapshot
468
+ // animating over it back to its position. Use the full list (not just
469
+ // the deduped `presenting`) so duplicates from sister lists are hidden
470
+ // too. Restored in cleanupDismiss below.
471
+ for (const sourceView of transition.sharedElements._allPresentingViews || []) {
472
+ if (sourceView?.ios) {
473
+ sourceView.ios.alpha = 0;
474
+ }
475
+ }
368
476
  const cleanupDismiss = () => {
477
+ // Restore alpha on every source view we hid — including duplicate
478
+ // sources with the same sharedTransitionTag — using NS opacity as
479
+ // the source of truth.
480
+ for (const sourceView of transition.sharedElements._allPresentingViews || []) {
481
+ if (sourceView?.ios) {
482
+ sourceView.ios.alpha = sourceView.opacity;
483
+ }
484
+ }
369
485
  for (const presenting of transition.sharedElements.presenting) {
370
- presenting.view.opacity = presenting.startOpacity;
371
486
  presenting.snapshot.removeFromSuperview();
372
487
  }
488
+ for (const presented of transition.sharedElements.presented) {
489
+ // Detach the imageSourceChange listener and restore alpha. Even though
490
+ // the destination page is being torn down, NS Views can outlive their
491
+ // nativeView and we shouldn't leak observers.
492
+ if (presented.imageSourceChangeListener) {
493
+ presented.view.off('imageSourceChange', presented.imageSourceChangeListener);
494
+ presented.imageSourceChangeListener = null;
495
+ }
496
+ if (presented.view.ios) {
497
+ presented.view.ios.alpha = presented.view.opacity;
498
+ }
499
+ }
373
500
  for (const independent of transition.sharedElements.independent) {
374
- independent.view.opacity = independent.startOpacity;
501
+ independent.view.ios.alpha = independent.view.opacity;
375
502
  independent.snapshot.removeFromSuperview();
376
503
  }
377
504
  SharedTransition.finishState(transition.id);
@@ -390,6 +517,11 @@ export class SharedTransitionHelper {
390
517
  transition.presented.view.alpha = isNumber(pageReturn?.opacity) ? pageReturn?.opacity : 0;
391
518
  const endFrame = getRectFromProps(pageReturn, getPageStartDefaultsForType(type));
392
519
  transition.presented.view.frame = CGRectMake(endFrame.x, endFrame.y, endFrame.width, endFrame.height);
520
+ // Animate cornerRadius back toward the start value (e.g. a sheet
521
+ // re-rounds as it slides offscreen).
522
+ if (isNumber(pageReturn?.cornerRadius)) {
523
+ transition.presented.view.layer.cornerRadius = pageReturn.cornerRadius;
524
+ }
393
525
  if (pageOut) {
394
526
  // always return to defaults if pageOut had been used
395
527
  transition.presenting.view.alpha = 1;
@@ -450,20 +582,62 @@ export class SharedTransitionHelper {
450
582
  switch (type) {
451
583
  case 'page':
452
584
  interactiveState.transitionContext.containerView.insertSubviewBelowSubview(state.instance.presenting.view, state.instance.presented.view);
585
+ // Pin the source view to its fullscreen frame so it doesn't slide
586
+ // in from off-left as the user drags. The default UIKit pop expects
587
+ // to animate the source from off-left; for shared-element / morph
588
+ // transitions we want the source to stay put underneath the
589
+ // animating destination so the visuals feel connected.
590
+ {
591
+ const sourceDefaults = getRectFromProps(null);
592
+ state.instance.presenting.view.frame = CGRectMake(0, 0, sourceDefaults.width, sourceDefaults.height);
593
+ }
453
594
  break;
454
595
  }
455
596
  }
456
597
  static interactiveUpdate(state, interactiveState, type, percent) {
457
598
  if (interactiveState) {
599
+ if (state.interactive?.dismiss?.morph) {
600
+ // Morph mode: the gesture handler drives the destination view's
601
+ // transform directly. We only forward the event so observers can
602
+ // react to the gesture's percentage.
603
+ SharedTransition.notifyEvent(SharedTransition.interactiveUpdateEvent, {
604
+ id: state?.instance?.id,
605
+ type,
606
+ percent,
607
+ });
608
+ return;
609
+ }
458
610
  if (!interactiveState.added) {
611
+ // Defer setup until the gesture has clearly committed to a horizontal
612
+ // dismiss motion. A vertical scroll on a descendant scroll view also
613
+ // triggers the page-level pan gesture (NS pan recognizers don't yield
614
+ // to scroll-view pans by default), producing near-zero percent values.
615
+ // Without this guard we'd mount the dismiss snapshots and hide the
616
+ // destination during a scroll, making the image look "stuck" until the
617
+ // cancel animation runs.
618
+ if (Math.abs(percent) < 0.05) {
619
+ return;
620
+ }
459
621
  interactiveState.added = true;
460
622
  for (const p of state.instance.sharedElements.presented) {
461
- p.view.opacity = 0;
623
+ // Native alpha (not NS opacity) for the same reason as the dismiss path:
624
+ // if the user lets go without crossing the threshold, cancel uses NS
625
+ // opacity as source of truth to restore.
626
+ p.view.ios.alpha = 0;
462
627
  }
463
628
  for (const p of state.instance.sharedElements.presenting) {
464
629
  p.snapshot.alpha = p.endOpacity;
465
630
  interactiveState.transitionContext.containerView.addSubview(p.snapshot);
466
631
  }
632
+ // Hide every source-side shared element (including duplicates that share
633
+ // a sharedTransitionTag in sister lists) so the user only sees the
634
+ // animating snapshot — not the snapshot AND the real source element
635
+ // simultaneously ("double image" during interactive dismissal).
636
+ for (const sourceView of state.instance.sharedElements._allPresentingViews || []) {
637
+ if (sourceView?.ios) {
638
+ sourceView.ios.alpha = 0;
639
+ }
640
+ }
467
641
  const pageStart = state.pageStart;
468
642
  const startFrame = getRectFromProps(pageStart, getPageStartDefaultsForType(type));
469
643
  interactiveState.propertyAnimator = UIViewPropertyAnimator.alloc().initWithDurationDampingRatioAnimations(1, 1, () => {
@@ -474,6 +648,11 @@ export class SharedTransitionHelper {
474
648
  }
475
649
  state.instance.presented.view.alpha = isNumber(state.pageReturn?.opacity) ? state.pageReturn?.opacity : 0;
476
650
  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
+ // Drive cornerRadius via the same percent the user controls — round
652
+ // the page back up as they pan it offscreen.
653
+ if (isNumber(state.pageReturn?.cornerRadius)) {
654
+ state.instance.presented.view.layer.cornerRadius = state.pageReturn.cornerRadius;
655
+ }
477
656
  });
478
657
  }
479
658
  interactiveState.propertyAnimator.fractionComplete = percent;
@@ -485,13 +664,93 @@ export class SharedTransitionHelper {
485
664
  }
486
665
  }
487
666
  static interactiveCancel(state, interactiveState, type) {
667
+ if (state?.instance && interactiveState && state.interactive?.dismiss?.morph) {
668
+ // Morph mode cancel — spring the destination view back to identity.
669
+ const view = state.instance.presented?.view;
670
+ let didFinalize = false;
671
+ const finalize = () => {
672
+ if (didFinalize)
673
+ return;
674
+ didFinalize = true;
675
+ if (view)
676
+ view.transform = CGAffineTransformIdentity;
677
+ // Restore the source-side element alphas that the gesture hid on
678
+ // engagement so the source page is correct when shown again.
679
+ for (const v of state.instance.sharedElements?._allPresentingViews || []) {
680
+ if (v?.ios)
681
+ v.ios.alpha = v.opacity;
682
+ }
683
+ if (interactiveState.transitionContext) {
684
+ interactiveState.transitionContext.cancelInteractiveTransition();
685
+ interactiveState.transitionContext.completeTransition(false);
686
+ }
687
+ SharedTransition.updateState(state?.instance?.id, {
688
+ interactiveBegan: false,
689
+ interactiveCancelled: true,
690
+ });
691
+ SharedTransition.notifyEvent(SharedTransition.interactiveCancelledEvent, {
692
+ id: state?.instance?.id,
693
+ type,
694
+ });
695
+ };
696
+ if (view) {
697
+ iOSUtils.animateWithSpring({
698
+ animations: () => {
699
+ view.transform = CGAffineTransformIdentity;
700
+ // Cross-fade sources back IN PARALLEL with the spring so
701
+ // they're visible from frame one of the snap-back. This
702
+ // also makes the source restoration robust to a new
703
+ // gesture interrupting our completion callback.
704
+ for (const v of state.instance.sharedElements?._allPresentingViews || []) {
705
+ if (v?.ios)
706
+ v.ios.alpha = v.opacity;
707
+ }
708
+ },
709
+ completion: () => finalize(),
710
+ });
711
+ // Safety: ensure finalize runs even if the animation completion
712
+ // is dropped (e.g. interrupted by another gesture).
713
+ setTimeout(finalize, 800);
714
+ }
715
+ else {
716
+ finalize();
717
+ }
718
+ return;
719
+ }
720
+ if (state?.instance && interactiveState && !interactiveState.added) {
721
+ // The gesture engaged the interactive transition but never crossed the
722
+ // motion threshold in interactiveUpdate (e.g., the user was scrolling
723
+ // vertically). Nothing was visually set up — just tell UIKit we're
724
+ // cancelling and clear the flags.
725
+ if (interactiveState.transitionContext) {
726
+ interactiveState.transitionContext.cancelInteractiveTransition();
727
+ interactiveState.transitionContext.completeTransition(false);
728
+ }
729
+ SharedTransition.updateState(state?.instance?.id, {
730
+ interactiveBegan: false,
731
+ interactiveCancelled: true,
732
+ });
733
+ SharedTransition.notifyEvent(SharedTransition.interactiveCancelledEvent, {
734
+ id: state?.instance?.id,
735
+ type,
736
+ });
737
+ return;
738
+ }
488
739
  if (state?.instance && interactiveState?.added && interactiveState?.propertyAnimator) {
489
740
  interactiveState.propertyAnimator.reversed = true;
490
741
  const duration = isNumber(state.pageEnd?.duration) ? state.pageEnd?.duration / 1000 : CORE_ANIMATION_DEFAULTS.duration;
491
742
  interactiveState.propertyAnimator.continueAnimationWithTimingParametersDurationFactor(null, duration);
492
743
  setTimeout(() => {
493
744
  for (const p of state.instance.sharedElements.presented) {
494
- p.view.opacity = 1;
745
+ // Restore native alpha from NS opacity (the user-intended value).
746
+ p.view.ios.alpha = p.view.opacity;
747
+ }
748
+ // Restore alpha on every source view we hid during interactiveUpdate
749
+ // (including duplicates from sister lists).
750
+ for (const sourceView of state.instance.sharedElements._allPresentingViews || []) {
751
+ if (sourceView?.ios) {
752
+ sourceView.ios.alpha = sourceView.opacity;
753
+ }
495
754
  }
496
755
  for (const p of state.instance.sharedElements.presenting) {
497
756
  p.snapshot.removeFromSuperview();
@@ -513,15 +772,192 @@ export class SharedTransitionHelper {
513
772
  }
514
773
  }
515
774
  static interactiveFinish(state, interactiveState, type) {
775
+ if (state?.instance && interactiveState && state.interactive?.dismiss?.morph) {
776
+ // Morph mode finish — animate the destination view to the matching
777
+ // source element's frame and fade it out, then complete.
778
+ const view = state.instance.presented?.view;
779
+ const destTag = state.instance.sharedElements?.presented?.[0]?.view?.sharedTransitionTag;
780
+ const matchingSource = state.instance.sharedElements?.presenting?.find?.((p) => p.view.sharedTransitionTag === destTag);
781
+ const srcIos = matchingSource?.view?.ios;
782
+ const containerView = interactiveState.transitionContext?.containerView;
783
+ let targetTransform = null;
784
+ if (view && srcIos && containerView) {
785
+ const frameInContainer = srcIos.convertRectToView(srcIos.bounds, containerView);
786
+ 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));
796
+ }
797
+ }
798
+ // Build a snapshot fly-back for each shared element so the album
799
+ // art (etc.) does a real shared-element transition to its source
800
+ // frame in parallel with the destination view's shrink+fade.
801
+ // Without this, the image rides along with the morphing view and
802
+ // lands at a visually offset position rather than precisely back
803
+ // at the source thumbnail.
804
+ const sharedSnapshots = [];
805
+ if (containerView) {
806
+ for (const presented of state.instance.sharedElements?.presented || []) {
807
+ const destSharedView = presented.view?.ios;
808
+ const tag = presented.view?.sharedTransitionTag;
809
+ if (!destSharedView || !tag)
810
+ continue;
811
+ const sourceShared = state.instance.sharedElements?.presenting?.find?.((p) => p.view.sharedTransitionTag === tag);
812
+ const srcSharedView = sourceShared?.view?.ios;
813
+ if (!srcSharedView)
814
+ continue;
815
+ const startFrame = destSharedView.convertRectToView(destSharedView.bounds, containerView);
816
+ const endFrame = srcSharedView.convertRectToView(srcSharedView.bounds, containerView);
817
+ if (startFrame.size.width <= 0 || endFrame.size.width <= 0)
818
+ continue;
819
+ const snap = UIImageView.alloc().init();
820
+ if (destSharedView instanceof UIImageView && destSharedView.image) {
821
+ snap.image = destSharedView.image;
822
+ snap.contentMode = destSharedView.contentMode;
823
+ snap.tintColor = destSharedView.tintColor;
824
+ }
825
+ else {
826
+ try {
827
+ snap.image = iOSUtils.snapshotView(presented.view.ios, Screen.mainScreen.scale);
828
+ }
829
+ catch (e) {
830
+ continue;
831
+ }
832
+ }
833
+ snap.clipsToBounds = true;
834
+ snap.layer.cornerRadius = destSharedView.layer?.cornerRadius || 0;
835
+ snap.frame = startFrame;
836
+ containerView.addSubview(snap);
837
+ // Hide the destination shared element so we don't see it
838
+ // shrinking inside the morphing view at the same time the
839
+ // snapshot is flying back to source — that would double up.
840
+ destSharedView.alpha = 0;
841
+ sharedSnapshots.push({
842
+ snap,
843
+ endFrame,
844
+ endCornerRadius: srcSharedView.layer?.cornerRadius || 0,
845
+ srcSharedView,
846
+ });
847
+ }
848
+ }
849
+ // Identify which source NS views have a matching fly-back snapshot.
850
+ // Those sources stay at alpha 0 through the spring and are restored
851
+ // inside finalize(), so the snapshot doesn't overlay an already-
852
+ // visible source mid-flight (which looks like two images for a beat).
853
+ const snapshottedSources = new Set();
854
+ for (const entry of sharedSnapshots) {
855
+ if (entry.srcSharedView)
856
+ snapshottedSources.add(entry.srcSharedView);
857
+ }
858
+ let didFinalize = false;
859
+ const finalize = () => {
860
+ if (didFinalize)
861
+ return;
862
+ didFinalize = true;
863
+ // Detach the imageSourceChange listeners that were attached during
864
+ // PRESENT — the destination View is about to be torn down.
865
+ for (const presented of state.instance.sharedElements?.presented || []) {
866
+ if (presented.imageSourceChangeListener) {
867
+ presented.view.off('imageSourceChange', presented.imageSourceChangeListener);
868
+ presented.imageSourceChangeListener = null;
869
+ }
870
+ }
871
+ // Restore the snapshotted sources (held at alpha 0 through the
872
+ // spring) immediately before removing the snapshot, so the
873
+ // snapshot's last frame and the source's first visible frame
874
+ // coincide exactly — no double-image, no gap.
875
+ for (const entry of sharedSnapshots) {
876
+ const srcSharedView = entry.srcSharedView;
877
+ if (srcSharedView) {
878
+ const nsView = state.instance.sharedElements?.presenting?.find?.((p) => p.view?.ios === srcSharedView)?.view;
879
+ srcSharedView.alpha = nsView ? nsView.opacity : 1;
880
+ }
881
+ }
882
+ for (const { snap } of sharedSnapshots) {
883
+ snap.removeFromSuperview();
884
+ }
885
+ if (view) {
886
+ view.transform = CGAffineTransformIdentity;
887
+ view.alpha = 1;
888
+ }
889
+ SharedTransition.finishState(state.instance.id);
890
+ if (interactiveState.transitionContext) {
891
+ interactiveState.transitionContext.finishInteractiveTransition();
892
+ interactiveState.transitionContext.completeTransition(true);
893
+ }
894
+ SharedTransition.notifyEvent(SharedTransition.finishedEvent, {
895
+ id: state?.instance?.id,
896
+ type,
897
+ action: 'interactiveFinish',
898
+ });
899
+ };
900
+ if (view && targetTransform) {
901
+ iOSUtils.animateWithSpring({
902
+ animations: () => {
903
+ view.transform = targetTransform;
904
+ view.alpha = 0;
905
+ // Restore source-side element alphas IN PARALLEL with the
906
+ // destination's morph + fade — but skip sources that have
907
+ // a flying snapshot. Those are held at alpha 0 and
908
+ // revealed by finalize() right before the snapshot is
909
+ // removed, so the user never sees both the snapshot AND
910
+ // the source visible at the same time.
911
+ for (const v of state.instance.sharedElements?._allPresentingViews || []) {
912
+ if (v?.ios && !snapshottedSources.has(v.ios)) {
913
+ v.ios.alpha = v.opacity;
914
+ }
915
+ }
916
+ // Fly each shared-element snapshot to its source frame.
917
+ // Rides the same spring so the image lands precisely back
918
+ // at the source position as the destination view fades.
919
+ for (const { snap, endFrame, endCornerRadius } of sharedSnapshots) {
920
+ snap.frame = endFrame;
921
+ snap.layer.cornerRadius = endCornerRadius;
922
+ }
923
+ },
924
+ completion: () => finalize(),
925
+ });
926
+ // Safety: ensure finalize runs even if the animation completion
927
+ // is dropped (e.g. interrupted by another gesture).
928
+ setTimeout(finalize, 800);
929
+ }
930
+ else {
931
+ // No target transform; ensure sources are still restored.
932
+ for (const v of state.instance.sharedElements?._allPresentingViews || []) {
933
+ if (v?.ios)
934
+ v.ios.alpha = v.opacity;
935
+ }
936
+ finalize();
937
+ }
938
+ return;
939
+ }
516
940
  if (state?.instance && interactiveState?.added && interactiveState?.propertyAnimator) {
517
941
  interactiveState.propertyAnimator.reversed = false;
518
942
  const duration = isNumber(state.pageReturn?.duration) ? state.pageReturn?.duration / 1000 : CORE_ANIMATION_DEFAULTS.duration;
519
943
  interactiveState.propertyAnimator.continueAnimationWithTimingParametersDurationFactor(null, duration);
520
944
  setTimeout(() => {
945
+ // Restore alpha on every source view we hid (including duplicates) so
946
+ // the source page is visible if shown again.
947
+ for (const sourceView of state.instance.sharedElements._allPresentingViews || []) {
948
+ if (sourceView?.ios) {
949
+ sourceView.ios.alpha = sourceView.opacity;
950
+ }
951
+ }
521
952
  for (const presenting of state.instance.sharedElements.presenting) {
522
- presenting.view.opacity = presenting.startOpacity;
523
953
  presenting.snapshot.removeFromSuperview();
524
954
  }
955
+ for (const presented of state.instance.sharedElements.presented) {
956
+ if (presented.imageSourceChangeListener) {
957
+ presented.view.off('imageSourceChange', presented.imageSourceChangeListener);
958
+ presented.imageSourceChangeListener = null;
959
+ }
960
+ }
525
961
  SharedTransition.finishState(state.instance.id);
526
962
  interactiveState.propertyAnimator = null;
527
963
  interactiveState.added = false;