@nativescript/core
Version:
A JavaScript library providing an easy to use api for interacting with iOS and Android platform APIs.
1,215 lines • 76.1 kB
JavaScript
import { getPageStartDefaultsForType, getRectFromProps, getSpringFromProps, SharedTransition, SharedTransitionAnimationType } from './shared-transition';
import { isNumber } from '../../utils/types';
import { Screen } from '../../platform';
import { CORE_ANIMATION_DEFAULTS } from '../../utils/animation-helpers';
import { ios as iOSUtils } from '../../utils/native-helper';
import { Color } from '../../color';
/**
* Apply a drop shadow behind the destination view during an interactive
* dismiss so it looks elevated above the source (Apple Music–style).
*
* Shadow + rounded corners can't coexist on the same CALayer on iOS — both
* `masksToBounds = true` and `layer.mask` clip the shadow as part of the
* compositing pipeline. So the shadow lives on a SIBLING CALayer inserted
* below the modal's layer in its superlayer (the same pattern NS uses for
* box-shadow via `outerShadowContainerLayer`). The modal keeps its own
* `cornerRadius` + `masksToBounds` so subviews still clip to the rounded
* shape.
*
* Because the shadow layer is a sibling (not a child), it doesn't inherit
* the modal's transform automatically — `syncInteractiveDismissShadow`
* mirrors transform/position/bounds during the drag.
*
* Falsy `shadowConfig` is a no-op.
*/
export function applyInteractiveDismissShadow(presentedView, shadowConfig) {
if (!presentedView?.layer || !shadowConfig)
return;
const layer = presentedView.layer;
const superlayer = layer.superlayer;
if (!superlayer)
return;
const bounds = layer.bounds;
if (bounds.size.width <= 0 || bounds.size.height <= 0)
return;
// Idempotent.
if (presentedView.__sharedTransitionShadowLayer)
return;
const cfg = shadowConfig === true ? {} : shadowConfig;
const colorInput = cfg.color ?? '#000';
let cgColor;
try {
const nsColor = typeof colorInput === 'string' ? new Color(colorInput) : colorInput;
cgColor = nsColor.ios?.CGColor || nsColor.CGColor || UIColor.blackColor.CGColor;
}
catch (_) {
cgColor = UIColor.blackColor.CGColor;
}
const opacity = isNumber(cfg.opacity) ? cfg.opacity : 0.3;
const radius = isNumber(cfg.radius) ? cfg.radius : 30;
const offsetX = isNumber(cfg.offset?.x) ? cfg.offset.x : 0;
const offsetY = isNumber(cfg.offset?.y) ? cfg.offset.y : 8;
const cornerRadius = layer.cornerRadius || 0;
const roundedRectPath = UIBezierPath.bezierPathWithRoundedRectCornerRadius(CGRectMake(0, 0, bounds.size.width, bounds.size.height), cornerRadius).CGPath;
const shadowLayer = CALayer.layer();
// Match the modal's geometry so shadow tracks 1:1.
shadowLayer.anchorPoint = layer.anchorPoint;
shadowLayer.bounds = bounds;
shadowLayer.position = layer.position;
shadowLayer.transform = layer.transform;
shadowLayer.shadowColor = cgColor;
shadowLayer.shadowOpacity = opacity;
shadowLayer.shadowRadius = radius;
shadowLayer.shadowOffset = CGSizeMake(offsetX, offsetY);
// Explicit shadowPath = shadow renders from this shape outward;
// the layer itself stays fully transparent.
shadowLayer.shadowPath = roundedRectPath;
CATransaction.begin();
CATransaction.setDisableActions(true);
superlayer.insertSublayerBelow(shadowLayer, layer);
CATransaction.commit();
presentedView.__sharedTransitionShadowLayer = shadowLayer;
}
/**
* Mirror the modal layer's geometry onto the sibling shadow layer so the
* shadow tracks the interactive drag. Called from the gesture handlers
* after the modal's state has been updated (either via direct transform
* in morph mode, or via `interactiveController.updateInteractiveTransition`
* for the non-morph modal/page case driven by UIViewPropertyAnimator).
*
* Reads the layer's **presentation** values when an animation is in flight
* — model values would point at the animation's end state and snap the
* shadow there. Falls back to model values when no animation is running.
*/
export function syncInteractiveDismissShadow(presentedView) {
const shadowLayer = presentedView?.__sharedTransitionShadowLayer;
if (!shadowLayer || !presentedView?.layer)
return;
const layer = presentedView.layer;
const pres = typeof layer.presentationLayer === 'function' ? layer.presentationLayer() : null;
const src = pres || layer;
const b = src.bounds;
CATransaction.begin();
CATransaction.setDisableActions(true);
shadowLayer.bounds = b;
shadowLayer.position = src.position;
shadowLayer.anchorPoint = src.anchorPoint;
shadowLayer.transform = src.transform;
shadowLayer.shadowPath = UIBezierPath.bezierPathWithRoundedRectCornerRadius(CGRectMake(0, 0, b.size.width, b.size.height), src.cornerRadius || 0).CGPath;
CATransaction.commit();
}
/**
* Remove the sibling shadow layer. Safe to call repeatedly / when no
* shadow was applied.
*/
export function removeInteractiveDismissShadow(presentedView) {
const shadowLayer = presentedView?.__sharedTransitionShadowLayer;
if (!shadowLayer)
return;
CATransaction.begin();
CATransaction.setDisableActions(true);
shadowLayer.removeFromSuperlayer();
CATransaction.commit();
presentedView.__sharedTransitionShadowLayer = null;
}
export class SharedTransitionHelper {
static animate(state, transitionContext, type) {
const transition = state.instance;
setTimeout(async () => {
// Run on next tick
// ensures that existing UI state finishes before snapshotting
// (eg, button touch up state)
switch (state.activeType) {
case SharedTransitionAnimationType.present: {
// console.log('-- Transition present --');
SharedTransition.notifyEvent(SharedTransition.startedEvent, {
id: transition.id,
type,
action: 'present',
});
if (type === 'modal') {
transitionContext.containerView.addSubview(transition.presented.view);
}
else if (type === 'page') {
transitionContext.containerView.insertSubviewAboveSubview(transition.presented.view, transition.presenting.view);
}
transition.presented.view.layoutIfNeeded();
const { sharedElements, presented, presenting } = SharedTransition.getSharedElements(state.page, state.toPage);
const sharedElementTags = sharedElements.map((v) => v.sharedTransitionTag);
if (!transition.sharedElements) {
transition.sharedElements = {
presented: [],
presenting: [],
independent: [],
};
}
// Track every matched source view so cleanup can restore alpha,
// including duplicates (same album surfacing in multiple lists)
// that we hide but don't create a snapshot for.
transition.sharedElements._allPresentingViews = sharedElements.slice();
if (SharedTransition.DEBUG) {
console.log(` ${type}: Present`);
console.log(`1. Found sharedTransitionTags to animate:`, sharedElementTags);
console.log(`2. Take snapshots of shared elements and position them based on presenting view:`);
}
const pageOut = state.pageOut;
const pageStart = state.pageStart;
const startFrame = getRectFromProps(pageStart, getPageStartDefaultsForType(type));
const pageEnd = state.pageEnd;
const pageEndTags = pageEnd?.sharedTransitionTags || {};
// console.log('pageEndIndependentTags:', pageEndIndependentTags);
const positionSharedTags = async () => {
const processedTags = new Set();
for (const presentingView of sharedElements) {
const presentingSharedElement = presentingView.ios;
const tag = presentingView.sharedTransitionTag;
if (processedTags.has(tag)) {
// The same item can appear in multiple lists on the source page
// (e.g., a featured album also surfacing in "Recently Played").
// Each instance has the same sharedTransitionTag. Only the first
// one becomes the snapshot origin; the rest must still be hidden
// so they don't show through behind the animating snapshot.
if (presentingSharedElement) {
presentingSharedElement.alpha = 0;
}
continue;
}
processedTags.add(tag);
const presentedView = presented.find((v) => v.sharedTransitionTag === presentingView.sharedTransitionTag);
const presentedSharedElement = presentedView.ios;
const pageEndProps = pageEndTags[presentingView.sharedTransitionTag];
const snapshot = UIImageView.alloc().init();
if (pageEndProps?.callback) {
await pageEndProps?.callback(presentedView, 'present');
}
// treat images differently...
let imageSourceChangeListener;
if (presentedSharedElement instanceof UIImageView) {
// In case the image is loaded async, keep the snapshot's image in sync with
// the destination view's image. We hold a ref so we can detach the listener
// on cleanup (the View would otherwise hold a strong ref via the observers
// map and the snapshot closure would leak).
imageSourceChangeListener = () => {
snapshot.image = iOSUtils.snapshotView(presentedSharedElement, Screen.mainScreen.scale);
snapshot.tintColor = presentedSharedElement.tintColor;
};
presentedView.on('imageSourceChange', imageSourceChangeListener);
snapshot.tintColor = presentedSharedElement.tintColor;
snapshot.contentMode = presentedSharedElement.contentMode;
// Seed the snapshot with the source's already-loaded image so the very
// first frame of the animation isn't blank if the destination image is
// still loading.
if (presentingSharedElement instanceof UIImageView) {
snapshot.image = presentingSharedElement.image;
}
}
iOSUtils.copyLayerProperties(snapshot, presentingSharedElement, pageEndProps?.propertiesToMatch);
snapshot.clipsToBounds = true;
// console.log('---> snapshot: ', snapshot);
const startFrame = presentingSharedElement.convertRectToView(presentingSharedElement.bounds, transitionContext.containerView);
const endFrame = presentedSharedElement.convertRectToView(presentedSharedElement.bounds, transitionContext.containerView);
snapshot.frame = startFrame;
if (SharedTransition.DEBUG) {
console.log('---> ', presentingView.sharedTransitionTag, ' frame:', iOSUtils.printCGRect(snapshot.frame));
}
transition.sharedElements.presenting.push({
view: presentingView,
startFrame,
endFrame,
snapshot,
startOpacity: presentingView.opacity,
endOpacity: isNumber(pageEndProps?.opacity) ? pageEndProps.opacity : presentedView.opacity,
propertiesToMatch: pageEndProps?.propertiesToMatch,
zIndex: isNumber(pageEndProps?.zIndex) ? pageEndProps.zIndex : 0,
});
transition.sharedElements.presented.push({
view: presentedView,
startFrame: endFrame,
endFrame: startFrame,
startOpacity: presentedView.opacity,
endOpacity: presentingView.opacity,
propertiesToMatch: pageEndProps?.propertiesToMatch,
imageSourceChangeListener,
});
// set initial opacity to match the source view opacity
snapshot.alpha = presentingView.opacity;
// Hide both while animating in the transition context.
//
// We mutate native alpha directly rather than NS `opacity` so the NS-side
// style remains the user's intended value (typically 1). If we instead set
// `view.opacity = 0` and then a transition was interrupted before cleanup
// (or the page was reused), `startOpacity` here could capture 0 from a prior
// run and the cleanup below would leave the destination invisible.
presentingSharedElement.alpha = 0;
presentedSharedElement.alpha = 0;
}
};
const positionIndependentTags = async () => {
// "Independent" tags appear on only one of the two pages (or were
// explicitly enumerated in pageEnd.sharedTransitionTags). We auto-discover
// them so authors don't have to enumerate every tag manually:
// - source-only (orphan on presenting): fade *out* on present, fade
// back in on dismiss. e.g., a "FEATURED ALBUM" label on a hero card
// that has no counterpart on the destination page.
// - destination-only (orphan on presented): fade *in* on present, fade
// back out on dismiss. e.g., metadata that only exists on the detail
// page.
// Authors can override per-tag via pageStart / pageEnd `sharedTransitionTags`
// (e.g. add `y: -20` to slide while fading), or opt a view out entirely
// with `sharedTransitionIgnore`.
const orphanTags = [];
const seenOrphan = new Set();
const addOrphan = (tag) => {
if (!tag || seenOrphan.has(tag) || sharedElementTags.includes(tag))
return;
seenOrphan.add(tag);
orphanTags.push(tag);
};
for (const v of presenting)
addOrphan(v.sharedTransitionTag);
for (const v of presented)
addOrphan(v.sharedTransitionTag);
// Tags the user listed in pageEnd.sharedTransitionTags but that don't
// match a real view are silently skipped further down — keep them in
// the iteration so per-tag config still applies to existing orphans.
for (const tag in pageEndTags)
addOrphan(tag);
for (const tag of orphanTags) {
const pageStartIndependentProps = pageStart?.sharedTransitionTags ? pageStart?.sharedTransitionTags[tag] : null;
const pageEndProps = pageEndTags[tag];
let independentView = presenting.find((v) => v.sharedTransitionTag === tag);
let isPresented = false;
if (!independentView) {
independentView = presented.find((v) => v.sharedTransitionTag === tag);
if (!independentView) {
// Tag declared in config but no matching view; skip (don't
// break — later tags might still resolve).
continue;
}
isPresented = true;
}
const independentSharedElement = independentView.ios;
if (pageEndProps?.callback) {
await pageEndProps?.callback(independentView, 'present');
}
const snapshot = UIImageView.alloc().initWithImage(iOSUtils.snapshotView(independentSharedElement, Screen.mainScreen.scale));
if (independentSharedElement instanceof UIImageView) {
snapshot.tintColor = independentSharedElement.tintColor;
snapshot.contentMode = independentSharedElement.contentMode;
}
snapshot.clipsToBounds = true;
// Copy layer properties (cornerRadius, borderWidth/Color)
// from the source view so the snapshot renders with the
// same rounded corners. Without this, source-side
// thumbnails/buttons appear with sharp square corners
// during a non-morph interactive dismiss — the snapshot
// is what the user sees through the dismissing modal,
// and its layer is freshly created with cornerRadius 0.
iOSUtils.copyLayerProperties(snapshot, independentSharedElement, pageEndProps?.propertiesToMatch);
const startFrame = independentSharedElement.convertRectToView(independentSharedElement.bounds, transitionContext.containerView);
snapshot.frame = startFrame;
if (SharedTransition.DEBUG) {
console.log('---> ', independentView.sharedTransitionTag, ' frame:', iOSUtils.printCGRect(snapshot.frame));
}
const endFrameRect = getRectFromProps(pageEndProps);
const endFrame = CGRectMake(startFrame.origin.x + (endFrameRect.x || 0), startFrame.origin.y + (endFrameRect.y || 0), startFrame.size.width, startFrame.size.height);
// Opacity defaults are side-aware: source-only orphans fade out (1 → 0),
// destination-only orphans fade in (0 → 1). The values are read back
// during dismiss as `endOpacity → startOpacity` so the return phase is
// automatically symmetric. Author-supplied opacity always wins.
const startOpacity = isNumber(pageStartIndependentProps?.opacity) ? pageStartIndependentProps.opacity : isPresented ? 0 : independentView.opacity;
const endOpacity = isNumber(pageEndProps?.opacity) ? pageEndProps.opacity : isPresented ? independentView.opacity : 0;
// Snapshot's initial visible alpha. Default UIImageView alpha is 1;
// destination-only orphans need to start at 0 so the fade-in is
// visible, hence we always set it explicitly.
snapshot.alpha = startOpacity;
transition.sharedElements.independent.push({
view: independentView,
isPresented,
startFrame,
snapshot,
endFrame,
startTransform: independentSharedElement.transform,
scale: pageEndProps?.scale,
startOpacity,
endOpacity,
propertiesToMatch: pageEndProps?.propertiesToMatch,
zIndex: isNumber(pageEndProps?.zIndex) ? pageEndProps.zIndex : 0,
});
// Native alpha; see comment in positionSharedTags.
independentSharedElement.alpha = 0;
}
};
// position all sharedTransitionTag elements
await positionSharedTags();
await positionIndependentTags();
// combine to order by zIndex and add to transition context
const snapshotData = transition.sharedElements.presenting.concat(transition.sharedElements.independent);
snapshotData.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
if (SharedTransition.DEBUG) {
console.log(`zIndex settings:`, snapshotData.map((s) => {
return {
sharedTransitionTag: s.view.sharedTransitionTag,
zIndex: s.zIndex,
};
}));
}
for (const data of snapshotData) {
// add snapshot to animate
transitionContext.containerView.addSubview(data.snapshot);
}
// Important: always set after above shared element positions have had their start positions set
transition.presented.view.alpha = isNumber(pageStart?.opacity) ? pageStart?.opacity : 0;
transition.presented.view.frame = CGRectMake(startFrame.x, startFrame.y, startFrame.width, startFrame.height);
// Optional top-corner rounding for sheet-style transitions (e.g.
// a modal that's rounded at the bottom of the screen and flattens
// as it slides up). Setting maskedCorners and masksToBounds here
// (before the animation) so they're stable during the animated
// cornerRadius change.
if (isNumber(pageStart?.cornerRadius) || isNumber(pageEnd?.cornerRadius)) {
const layer = transition.presented.view.layer;
// top-left | top-right | bottom-left | bottom-right (CACornerMask bits)
layer.maskedCorners = 15;
layer.masksToBounds = true;
if (isNumber(pageStart?.cornerRadius)) {
layer.cornerRadius = pageStart.cornerRadius;
}
}
const cleanupPresent = () => {
for (const presented of transition.sharedElements.presented) {
// Detach the per-transition imageSourceChange listener registered
// during positionSharedTags so it doesn't leak (the snapshot is gone).
if (presented.imageSourceChangeListener) {
presented.view.off('imageSourceChange', presented.imageSourceChangeListener);
presented.imageSourceChangeListener = null;
}
// Restore the destination native alpha from the NS opacity (the source
// of truth). NS opacity wasn't modified during the transition, so this
// reflects the user's intended visibility (typically 1).
presented.view.ios.alpha = presented.view.opacity;
}
// Restore native alpha on every source view we touched — including
// duplicates that share a sharedTransitionTag and were hidden without
// a snapshot. Even though the source page is being removed, the View
// instances may be reused on subsequent navigations.
for (const sourceView of transition.sharedElements._allPresentingViews || []) {
if (sourceView?.ios) {
sourceView.ios.alpha = sourceView.opacity;
}
}
for (const presenting of transition.sharedElements.presenting) {
presenting.snapshot.removeFromSuperview();
}
for (const independent of transition.sharedElements.independent) {
independent.snapshot.removeFromSuperview();
if (independent.isPresented) {
independent.view.ios.alpha = independent.view.opacity;
}
}
SharedTransition.updateState(transition.id, {
activeType: SharedTransitionAnimationType.dismiss,
});
if (type === 'page') {
transition.presenting.view.removeFromSuperview();
}
transitionContext.completeTransition(true);
SharedTransition.notifyEvent(SharedTransition.finishedEvent, {
id: transition.id,
type,
action: 'present',
});
};
const animateProperties = () => {
if (SharedTransition.DEBUG) {
console.log('3. Animating shared elements:');
}
transition.presented.view.alpha = isNumber(pageEnd?.opacity) ? pageEnd?.opacity : 1;
const endFrame = getRectFromProps(pageEnd);
transition.presented.view.frame = CGRectMake(endFrame.x, endFrame.y, endFrame.width, endFrame.height);
// Animate cornerRadius alongside the page frame. Implicit animation
// of cornerRadius is supported on iOS 11+, both inside
// UIView.animateWith… and UIViewPropertyAnimator's animations block.
if (isNumber(pageEnd?.cornerRadius)) {
transition.presented.view.layer.cornerRadius = pageEnd.cornerRadius;
}
if (pageOut) {
if (isNumber(pageOut.opacity)) {
transition.presenting.view.alpha = pageOut?.opacity;
}
const outFrame = getRectFromProps(pageOut);
transition.presenting.view.frame = CGRectMake(outFrame.x, outFrame.y, outFrame.width, outFrame.height);
}
// animate page properties to the following:
// https://stackoverflow.com/a/27997678/1418981
// In order to have proper layout. Seems mostly needed when presenting.
// For instance during presentation, destination view doesn't account navigation bar height.
// Not sure if best to leave all the time?
// owner.presented.view.setNeedsLayout();
// owner.presented.view.layoutIfNeeded();
for (const presented of transition.sharedElements.presented) {
const presentingMatch = transition.sharedElements.presenting.find((v) => v.view.sharedTransitionTag === presented.view.sharedTransitionTag);
// Workaround wrong origin due ongoing layout process.
const updatedEndFrame = presented.view.ios.convertRectToView(presented.view.ios.bounds, transitionContext.containerView);
const correctedEndFrame = CGRectMake(updatedEndFrame.origin.x, updatedEndFrame.origin.y, presentingMatch.endFrame.size.width, presentingMatch.endFrame.size.height);
presentingMatch.snapshot.frame = correctedEndFrame;
// apply view and layer properties to the snapshot view to match the source/presented view
iOSUtils.copyLayerProperties(presentingMatch.snapshot, presented.view.ios, presented.propertiesToMatch);
// create a snapshot of the presented view
presentingMatch.snapshot.image = iOSUtils.snapshotView(presented.view.ios, Screen.mainScreen.scale);
// apply correct alpha
presentingMatch.snapshot.alpha = presentingMatch.endOpacity;
if (SharedTransition.DEBUG) {
console.log(`---> ${presentingMatch.view.sharedTransitionTag} animate to: `, iOSUtils.printCGRect(correctedEndFrame));
}
}
for (const independent of transition.sharedElements.independent) {
const endFrame = independent.endFrame;
// if (independent.isPresented) {
// const updatedEndFrame = independent.view.ios.convertRectToView(independent.view.ios.bounds, transitionContext.containerView);
// endFrame = CGRectMake(updatedEndFrame.origin.x, updatedEndFrame.origin.y, independent.endFrame.size.width, independent.endFrame.size.height);
// }
if (independent.scale) {
independent.snapshot.transform = CGAffineTransformConcat(CGAffineTransformMakeTranslation(endFrame.origin.x, endFrame.origin.y), CGAffineTransformMakeScale(independent.scale.x, independent.scale.y));
}
else {
independent.snapshot.frame = endFrame;
}
independent.snapshot.alpha = independent.endOpacity;
if (SharedTransition.DEBUG) {
console.log(`---> ${independent.view.sharedTransitionTag} animate to: `, iOSUtils.printCGRect(independent.endFrame));
}
}
};
if (isNumber(pageEnd?.duration)) {
// override spring and use only linear animation
UIView.animateWithDurationDelayOptionsAnimationsCompletion(pageEnd?.duration / 1000, 0, 0 /* UIViewAnimationOptions.CurveEaseInOut */, () => {
animateProperties();
}, () => {
cleanupPresent();
});
}
else {
iOSUtils.animateWithSpring({
...getSpringFromProps(pageEnd?.spring),
animations: () => {
animateProperties();
},
completion: () => {
cleanupPresent();
},
});
}
break;
}
case SharedTransitionAnimationType.dismiss: {
// console.log('-- Transition dismiss --');
SharedTransition.notifyEvent(SharedTransition.startedEvent, {
id: transition.id,
type,
action: 'dismiss',
});
if (type === 'page') {
transitionContext.containerView.insertSubviewBelowSubview(transition.presenting.view, transition.presented.view);
// UIKit's standard pop places the incoming source view off to the
// left so it can slide into position; for custom shared transitions
// (where the destination either slides down or morphs to source) we
// don't want that lateral slide. Pin the source to its fullscreen
// frame so it stays put underneath the animating destination.
// Users can still opt into a custom source animation via `pageOut`.
const sourceDefaults = getRectFromProps(null);
transition.presenting.view.frame = CGRectMake(0, 0, sourceDefaults.width, sourceDefaults.height);
}
// Morph + interactive dismiss: the gesture handler drives the
// animation via transforms and finalizes by calling cancel/finish
// on the transitionContext. Running the standard snapshot + spring
// rig here would race that and frequently call completeTransition(true)
// *before* the user's gesture ended — causing the dismiss to commit
// even on a cancel. Skip the rest of the dismiss setup entirely.
if (state.interactive?.dismiss?.morph && state.interactiveBegan) {
return;
}
// console.log('transitionContext.containerView.subviews.count:', transitionContext.containerView.subviews.count);
if (SharedTransition.DEBUG) {
console.log(` ${type}: Dismiss`);
console.log(`1. Dismiss sharedTransitionTags to animate:`, transition.sharedElements.presented.map((p) => p.view.sharedTransitionTag));
console.log(`2. Add back previously stored sharedElements to dismiss:`);
}
const pageOut = state.pageOut;
const pageEnd = state.pageEnd;
const pageEndTags = pageEnd?.sharedTransitionTags || {};
const pageReturn = state.pageReturn;
for (const p of transition.sharedElements.presented) {
// Use native alpha (not NS opacity) so we don't pollute the NS-side
// value if the dismiss is interrupted; cleanup restores from NS opacity.
p.view.ios.alpha = 0;
}
// Ensure top-corner masking + clipping is in place before we animate
// cornerRadius back toward the start value. If the present pass already
// set these, this is a harmless no-op.
if (isNumber(pageReturn?.cornerRadius)) {
const layer = transition.presented.view.layer;
layer.maskedCorners = 15; // top-left | top-right | bottom-left | bottom-right
layer.masksToBounds = true;
}
// combine to order by zIndex and add to transition context
const snapshotData = transition.sharedElements.presenting.concat(transition.sharedElements.independent);
snapshotData.sort((a, b) => (a.zIndex || 0) - (b.zIndex || 0));
if (SharedTransition.DEBUG) {
console.log(`zIndex settings:`, snapshotData.map((s) => {
return {
sharedTransitionTag: s.view.sharedTransitionTag,
zIndex: s.zIndex,
};
}));
}
// first loop through all the shared elements and fire the callback
for (const data of snapshotData) {
const pageEndProps = pageEndTags[data.view.sharedTransitionTag];
if (pageEndProps?.callback) {
await pageEndProps?.callback(data.view, 'dismiss');
}
}
// now that all the callbacks had their chance to run, we can take the snapshots
for (const data of snapshotData) {
const view = data.view.ios;
const currentAlpha = view.alpha;
if (pageReturn?.useStartOpacity) {
// when desired, reset the alpha to the start value so the view is visible in the snapshot
view.alpha = data.startOpacity;
}
// take a new snapshot
data.snapshot.image = iOSUtils.snapshotView(view, Screen.mainScreen.scale);
// find the currently visible view with the same sharedTransitionTag
const fromView = transition.sharedElements.presented.find((p) => p.view.sharedTransitionTag === data.view.sharedTransitionTag)?.view;
if (fromView) {
// match the snapshot frame to the current frame of the fromView
data.snapshot.frame = fromView.ios.convertRectToView(fromView.ios.bounds, transitionContext.containerView);
}
// snapshot has been taken, we can restore the alpha
view.alpha = currentAlpha;
// we recalculate the startFrame because the view might have changed its position in the background
data.startFrame = view.convertRectToView(view.bounds, transitionContext.containerView);
// add snapshot to animate
transitionContext.containerView.addSubview(data.snapshot);
}
// Hide every source-side shared element now that snapshots have been
// captured. The animating snapshot covers them; without this, the user
// sees a "double image" — the real source element AND the snapshot
// animating over it back to its position. Use the full list (not just
// the deduped `presenting`) so duplicates from sister lists are hidden
// too. Restored in cleanupDismiss below.
for (const sourceView of transition.sharedElements._allPresentingViews || []) {
if (sourceView?.ios) {
sourceView.ios.alpha = 0;
}
}
const cleanupDismiss = () => {
// Restore alpha on every source view we hid — including duplicate
// sources with the same sharedTransitionTag — using NS opacity as
// the source of truth.
for (const sourceView of transition.sharedElements._allPresentingViews || []) {
if (sourceView?.ios) {
sourceView.ios.alpha = sourceView.opacity;
}
}
for (const presenting of transition.sharedElements.presenting) {
presenting.snapshot.removeFromSuperview();
}
for (const presented of transition.sharedElements.presented) {
// Detach the imageSourceChange listener and restore alpha. Even though
// the destination page is being torn down, NS Views can outlive their
// nativeView and we shouldn't leak observers.
if (presented.imageSourceChangeListener) {
presented.view.off('imageSourceChange', presented.imageSourceChangeListener);
presented.imageSourceChangeListener = null;
}
if (presented.view.ios) {
presented.view.ios.alpha = presented.view.opacity;
}
}
for (const independent of transition.sharedElements.independent) {
independent.view.ios.alpha = independent.view.opacity;
independent.snapshot.removeFromSuperview();
}
SharedTransition.finishState(transition.id);
transition.sharedElements = null;
transitionContext.completeTransition(true);
SharedTransition.notifyEvent(SharedTransition.finishedEvent, {
id: transition.id,
type,
action: 'dismiss',
});
};
const animateProperties = () => {
if (SharedTransition.DEBUG) {
console.log('3. Dismissing shared elements:');
}
transition.presented.view.alpha = isNumber(pageReturn?.opacity) ? pageReturn?.opacity : 0;
const endFrame = getRectFromProps(pageReturn, getPageStartDefaultsForType(type));
transition.presented.view.frame = CGRectMake(endFrame.x, endFrame.y, endFrame.width, endFrame.height);
// Animate cornerRadius back toward the start value (e.g. a sheet
// re-rounds as it slides offscreen).
if (isNumber(pageReturn?.cornerRadius)) {
transition.presented.view.layer.cornerRadius = pageReturn.cornerRadius;
}
if (pageOut) {
// always return to defaults if pageOut had been used
transition.presenting.view.alpha = 1;
const outFrame = getRectFromProps(null);
transition.presenting.view.frame = CGRectMake(0, 0, outFrame.width, outFrame.height);
}
for (const presenting of transition.sharedElements.presenting) {
iOSUtils.copyLayerProperties(presenting.snapshot, presenting.view.ios, presenting.propertiesToMatch);
presenting.snapshot.frame = presenting.startFrame;
presenting.snapshot.alpha = presenting.startOpacity;
if (SharedTransition.DEBUG) {
console.log(`---> ${presenting.view.sharedTransitionTag} animate to: `, iOSUtils.printCGRect(presenting.snapshot.frame));
}
}
for (const independent of transition.sharedElements.independent) {
independent.snapshot.alpha = independent.startOpacity;
if (independent.scale) {
independent.snapshot.transform = independent.startTransform;
}
else {
independent.snapshot.frame = independent.startFrame;
}
if (SharedTransition.DEBUG) {
console.log(`---> ${independent.view.sharedTransitionTag} animate to: `, iOSUtils.printCGRect(independent.snapshot.frame));
}
}
};
if (isNumber(pageReturn?.duration)) {
// override spring and use only linear animation
UIView.animateWithDurationDelayOptionsAnimationsCompletion(pageReturn?.duration / 1000, 0, 0 /* UIViewAnimationOptions.CurveEaseInOut */, () => {
animateProperties();
}, () => {
cleanupDismiss();
});
}
else {
iOSUtils.animateWithSpring({
...getSpringFromProps(pageReturn?.spring),
animations: () => {
animateProperties();
},
completion: () => {
cleanupDismiss();
},
});
}
break;
}
}
});
}
static interactiveStart(state, interactiveState, type) {
SharedTransition.notifyEvent(SharedTransition.startedEvent, {
id: state.instance.id,
type,
action: 'interactiveStart',
});
switch (type) {
case 'page':
interactiveState.transitionContext.containerView.insertSubviewBelowSubview(state.instance.presenting.view, state.instance.presented.view);
// Pin the source view to its fullscreen frame so it doesn't slide
// in from off-left as the user drags. The default UIKit pop expects
// to animate the source from off-left; for shared-element / morph
// transitions we want the source to stay put underneath the
// animating destination so the visuals feel connected.
{
const sourceDefaults = getRectFromProps(null);
state.instance.presenting.view.frame = CGRectMake(0, 0, sourceDefaults.width, sourceDefaults.height);
}
break;
}
// Apply the dismiss shadow here, not from the gesture handler. UIKit
// reparents the presented view into the transition containerView as
// part of starting the interactive transition; applying earlier would
// attach the shadow to the now-orphaned old superlayer.
if (state.interactive?.dismiss?.shadow && state.instance?.presented?.view) {
applyInteractiveDismissShadow(state.instance.presented.view, state.interactive.dismiss.shadow);
}
}
static interactiveUpdate(state, interactiveState, type, percent) {
if (interactiveState) {
if (state.interactive?.dismiss?.morph) {
// Morph mode: the gesture handler drives the destination view's
// transform directly. We only forward the event so observers can
// react to the gesture's percentage.
SharedTransition.notifyEvent(SharedTransition.interactiveUpdateEvent, {
id: state?.instance?.id,
type,
percent,
});
return;
}
if (!interactiveState.added) {
// Defer setup until the gesture has clearly committed to a horizontal
// dismiss motion. A vertical scroll on a descendant scroll view also
// triggers the page-level pan gesture (NS pan recognizers don't yield
// to scroll-view pans by default), producing near-zero percent values.
// Without this guard we'd mount the dismiss snapshots and hide the
// destination during a scroll, making the image look "stuck" until the
// cancel animation runs.
if (Math.abs(percent) < 0.05) {
return;
}
interactiveState.added = true;
for (const p of state.instance.sharedElements.presented) {
// Native alpha (not NS opacity) for the same reason as the dismiss path:
// if the user lets go without crossing the threshold, cancel uses NS
// opacity as source of truth to restore.
p.view.ios.alpha = 0;
}
for (const p of state.instance.sharedElements.presenting) {
p.snapshot.alpha = p.endOpacity;
interactiveState.transitionContext.containerView.addSubview(p.snapshot);
}
// Re-mount independent (orphan) snapshots so they're available to
// animate alongside the gesture. They were removed in present cleanup
// but the records (including their snapshots) live on transition.sharedElements.
// Source-only orphans were left at alpha 0 since present; their snapshots
// pick up where the present animation left off (endOpacity).
for (const ind of state.instance.sharedElements.independent) {
ind.snapshot.alpha = ind.endOpacity;
interactiveState.transitionContext.containerView.addSubview(ind.snapshot);
}
// Hide every source-side shared element (including duplicates that share
// a sharedTransitionTag in sister lists) so the user only sees the
// animating snapshot — not the snapshot AND the real source element
// simultaneously ("double image" during interactive dismissal).
for (const sourceView of state.instance.sharedElements._allPresentingViews || []) {
if (sourceView?.ios) {
sourceView.ios.alpha = 0;
}
}
const pageStart = state.pageStart;
const startFrame = getRectFromProps(pageStart, getPageStartDefaultsForType(type));
interactiveState.propertyAnimator = UIViewPropertyAnimator.alloc().initWithDurationDampingRatioAnimations(1, 1, () => {
for (const p of state.instance.sharedElements.presenting) {
p.snapshot.frame = p.startFrame;
iOSUtils.copyLayerProperties(p.snapshot, p.view.ios, p.propertiesToMatch);
p.snapshot.alpha = 1;
}
// Animate orphan snapshots back toward their start state alongside
// the rest of the dismiss. For source-only orphans this is the
// fade-back-in that mirrors the fade-out of present.
for (const ind of state.instance.sharedElements.independent) {
ind.snapshot.alpha = ind.startOpacity;
if (ind.scale) {
ind.snapshot.transform = ind.startTransform;
}
else {
ind.snapshot.frame = ind.startFrame;
}
}
state.instance.presented.view.alpha = isNumber(state.pageReturn?.opacity) ? state.pageReturn?.opacity : 0;
state.instance.presented.view.frame = CGRectMake(startFrame.x, startFrame.y, state.instance.presented.view.bounds.size.width, state.instance.presented.view.bounds.size.height);
// Drive cornerRadius via the same percent the user controls — round
// the page back up as they pan it offscreen.
if (isNumber(state.pageReturn?.cornerRadius)) {
state.instance.presented.view.layer.cornerRadius = state.pageReturn.cornerRadius;
}
});
}
interactiveState.propertyAnimator.fractionComplete = percent;
SharedTransition.notifyEvent(SharedTransition.interactiveUpdateEvent, {
id: state?.instance?.id,
type,
percent,
});
}
}
static interactiveCancel(state, interactiveState, type) {
if (state?.instance && interactiveState && state.interactive?.dismiss?.morph) {
// Morph mode cancel — spring the destination view back to identity.
const view = state.instance.presented?.view;
let didFinalize = false;
const finalize = () => {
if (didFinalize)
return;
didFinalize = true;
if (view)
view.transform = CGAffineTransformIdentity;
// Restore the source-side element alphas that the gesture hid on
// engagement so the source page is correct when shown again.
for (const v of state.instance.sharedElements?._allPresentingViews || []) {
if (v?.ios)
v.ios.alpha = v.opacity;
}
if (interactiveState.transitionContext) {
interactiveState.transitionContext.cancelInteractiveTransition();
interactiveState.transitionContext.completeTransition(false);
}
SharedTransition.updateState(state?.instance?.id, {
interactiveBegan: false,
interactiveCancelled: true,
});
SharedTransition.notifyEvent(SharedTransition.interactiveCancelledEvent, {
id: state?.instance?.id,
type,
});
};
if (view) {
iOSUtils.animateWithSpring({
animations: () => {
view.transform = CGAffineTransformIdentity;
// Cross-fade sources back IN PARALLEL with the spring so
// they're visible from frame one of the snap-back. This
// also makes the source restoration robust to a new
// gesture interrupting our completion callback.
for (const v of state.instance.sharedElements?._allPresentingViews || []) {
if (v?.ios)
v.ios.alpha = v.opacity;
}
},
completion: () => finalize(),
});
// Safety: ensure finalize runs even if the animation completion
// is dropped (e.g. interrupted by another gesture).
setTimeout(finalize, 800);
}
else {
finalize();
}
return;
}
if (state?.instance && interactiveState && !interactiveState.added) {
// The gesture engaged the interactive transition but never crossed the
// motion threshold in interactiveUpdate (e.g., the user was scrolling
// vertically). Nothing was visually set up — just tell UIKit we're
// cancelling and clear the flags.
if (interactiveState.transitionContext) {
interactiveState.transitionContext.cancelInteractiveTransition();
interactiveState.transitionContext.completeTransition(false);
}
SharedTransition.updateState(state?.instance?.id, {
interactiveBegan: false,
interactiveCancelled: true,
});
SharedTransition.notifyEvent(SharedTransition.interactiveCancelledEvent, {
id: state?.instance?.id,
type,
});
return;
}
if (state?.instance && interactiveState?.added && interactiveState?.propertyAnimator) {
interactiveState.propertyAnimator.reversed = true;
const duration = isNumber(state.pageEnd?.duration) ? state.pageEnd?.duration / 1000 : CORE_ANIMATION_DEFAULTS.duration;
interactiveState.propertyAnimator.continueAnimationWithTimingParametersDurationFactor(null, duration);
setTimeout(() => {
for (const p of state.instance.sharedElements.presented) {
// Restore native alpha from NS opacity (the user-intended value).
p.view.ios.alpha = p.view.opacity;
}
// Restore alpha on every source view we hid during interactiveUpdate
// (including duplicates from sister lists).
for (const sourceView of state.instance.sharedElements._allPresentingViews || []) {
if (sourceView?.ios) {
sourceView.ios.alpha = sourceView.opacity;
}
}
for (const p of state.instance.sharedElements.presenting) {
p.snapshot.removeFromSuperview();
}
// Tear down orphan snapshots that interactiveUpdate re-mounted. The
// source-only orphans' view.alpha stays at 0 — we're returning to the
// presented (modal-on-top) state, so they should remain hidden until
// a real dismiss completes. Cancel/finish for the next dismiss owns
// the eventual restore.
for (const ind of state.instance.sharedElements.independent) {
ind.snapshot.removeFromSuperview();
// Reset snapshot alpha to its post-present end state so a subsequent
// interactiveUpdate picks up from the right place.
ind.snapshot.alpha = ind.endOpacity;
}
state.instance.presented.view.alpha = 1;
interactiveState.propertyAnimator = null;
interactiveState.added = false;
interactiveState.transitionContext.cancelInteractiveTransition();
interactiveState.transitionContext.completeTransition(false);
SharedTransition.updateState(state?.instance?.id, {
interactiveBegan: false,
interactiveCancelled: true,
});
SharedTransition.notifyEvent(SharedTransition.interactiveCancelledEvent, {
id: state?.instance?.id,
type,
});
}, duration * 1000);
}
}
static interactiveFinish(state, interactiveState, type) {
if (state?.instance && interactiveState && state.interactive?.dismiss?.morph) {
// Morph mode finish — animate the destination view to the matching
// source element's frame and fade it out, then complete.
const view = state.instance.presented?.view;
const destPresented = state.instance.sharedElements?.presented?.[0];
const destTag = destPresented?.view?.sharedTransitionTag;
const matchingSource = state.instance.sharedElements?.presenting?.find?.((p) => p.view.sharedTransitionTag === destTag);
const srcIos = matchingSource?.view?.ios;
const destSharedIos = destPresented?.view?.ios;
const containerView = interactiveState.transitionContext?.containerView;
let targetTransform = null;
if (view && srcIos && containerView) {
const sourceFrame = srcIos.convertRectToView(srcIos.bounds, containerView);
const bounds = view.bounds;
if (bounds.size.width > 0 && bounds.size.height > 0 && sourceFrame.size.width > 0 && sourceFrame.size.height > 0) {
// Anchor the modal's transform on the *internal* matched
// element (e.g. the album art inside the modal) so that the
// modal and the matched element converge along the same
// trajectory to the source thumbnail. Without this, the modal
// scales around its own center while the matched-element
// snapshot flies separately to source — making the two visibly
// drift apart mid-spring.
//
// Pick a uniform scale that takes the modal's matched element
// to the source's size. For square album art (both sides) this
// is exact; for slightly off-square sources (e.g. 360×380) we
// match width and let the small remainder be hidden by the
// fade-out.
const destSharedFrameInModal = destSharedIos ? destSharedIos.convertRectToView(destSharedIos.bounds, view) : null;
const sx = sourceFrame.size.width / bounds.size.width;
const sy = sourceFrame.size.height / bounds.size.height;
const targetCx = sourceFrame.origin.x + sourceFrame.size.width / 2;
const targetCy = sourceFrame.origin.y + sourceFrame.size.height / 2;
const modalCx = bounds.size.width / 2;
const modalCy = bounds.size.height / 2;
let tx;
let ty;
if (destSharedFrameInModal && destSharedFrameInModal.size.width > 0 && destSharedFrameInModal.size.height > 0) {
// Place the internal album art's center on the source's
// center. After scaling around modal center, internal art's
// visual center = modalCenter + (internalArtCenter - modalCenter) * scale + translation.
const internalCx = destSharedFrameInModal.origin.x + destSharedFrameInModal.size.width / 2;
const internalCy = destSharedFrameInModal.origin.y + destSharedFrameInModal.size.height / 2;
tx = targetCx - modalCx - (internalCx - modalCx) * sx;
ty = targetCy - modalCy - (internalCy - modalCy) * sy;
}
else {
// Fallback: align modal center on source center.
tx = targetCx - modalCx;
ty = targetCy - modalCy;
}
targetTransform = CGAffineTransformConcat(CGAffineTransformMakeTranslation(tx, ty), CGAffineTransformMakeScale(sx, sy));
}
}
// Build a snapshot fly-back for each shared element so the album
// art (etc.) does a real shared-element transition to its source
// frame in parallel with the destination view's shrink+fade.
// Without this, the image rides along with the morphing view and
// lands at a visually offset position rather than precisely back
// at the source thumbnail.
const sharedSnapshots = [];
if (containerView) {
for (const presented of state.instance.sharedElements?.presented || []) {
const destSharedView = presented.view?.ios;
const tag = presented.view?.sharedTransitionTag;
if (!destSharedView || !tag)
continue;
const sourceShared = state.instance.sharedElements?.presenting?.find?.((p) => p.view.sharedTransitionTag === tag);
const srcSharedView = sourceShared?.view?.ios;
if (!srcSharedView)
continue;
const startFrame = destSharedView.convertRectToView(destSharedView.bounds, containerView);
const endFrame = srcSharedView.convertRectToView(srcSharedView.bounds, containerView);
if (startFrame.size.width <= 0 || endFrame.size.width <= 0)
continue;
const snap = UIImageView.alloc().init();
if (destSharedView instanceof UIImageView && destSharedView.image) {
snap.image = destSharedView.image;
snap.contentMode = destSharedView.contentMode;
snap.tintColor = destSharedView.tintColor;
}
else {
try {
snap.image = iOSUtils.snapshotView(presented.view.ios, Screen.mainScreen.scale);
}
catch (e) {
continue;
}
}
snap.clipsToBounds = true;
snap.layer.cornerRadius = destSharedView.layer?.cornerRadius || 0;
snap.frame = startFrame;
containerView.addSubview(snap);
// Hide the destination shared element so we don't see it
// shrinking inside the morphing view at the same time the
// snapshot is flying back to source — that would double up.
destSharedView.alpha = 0;
sharedSnapshots.push({
snap,
endFrame,
endCornerRadius: srcSharedView.layer?.cornerRadius || 0,
srcSharedView,
});
}
}
// Identify which source NS views have a matching fly-back snapshot.
// Those sources stay at alpha 0 through the spring and are restored
// inside finalize(), so the snapshot doesn't overlay an already-
// visible source mid-flight (which looks like two images for a beat).
const snapshottedSources = new Set();
for (const entry of sharedSnapshots) {
if (entry.srcSharedView)
snapshottedSources.add(entry.srcSharedView);
}
// Source-only orphan views (album thumbnails, badges, etc.) were
// already restored to their opacity by the gesture handler at morph
// engagement, so we don't need fade-in snapshots here — they're
// already visible behind the morphing modal.
let didFinalize = false;
const finalize = () => {
if (didFinalize)
return;
didFinalize = true;
// Detach the imageSourceChange listeners that were attached during
// PRESENT — the destination View is about to be torn down.
for (const presented of state.instance.sharedElements?.presented || []) {
if (presented.imageSourceChangeListener) {
presented.view.off('imageSourceChange', presented.imageSourceChangeListener);
presented.imageSourceChangeListener = null;
}
}
// Restore the snapshotted sources (held at alpha 0 through the
// spring) immediately before removing the snapshot, so the
// snapshot's last frame and the source's first visible frame
// coincide exactly — no double-image, no gap.
for (const entry of sharedSnapshots) {
const srcSharedView = entry.srcSharedView;
if (srcSharedView) {
const nsView = state.instance.sharedElements?.presenting?.find?.((p) => p.view?.ios === srcSharedView)?.view;
srcSharedView.alpha = nsView ? nsView.opacity : 1;
}
}
for (const { snap } of sharedSnapshots) {
snap.removeFromSuperview();
}
// Independent (orphan) views were restored at gesture engagement,
// so they're already visible. Just tear down any leftover present-
// phase snapshots that weren't removed in present cleanup, and
// ensure alpha is set from NS opacity for safety in case the
// engagement code path was bypassed.
for (const independent of state.instance?.sharedElements?.independent || []) {
if (independent.view?.ios) {
independent.view.ios.alpha = independent.view.opacity;
}
independent.snapshot?.removeFromSuperview();
}
if (view) {
view.transform = CGAffineTransformIdentity;
view.alpha = 1;
}
// Remove the sibling shadow view if interactive engagement added one.
removeInteractiveDismissShadow(view);
SharedTransition.finishState(state.instance.id);
if (interactiveState.transitionContext) {
interactiveState.transitionContext.finishInteractiveTransition();
interactiveState.transitionContext.completeTransition(true);
}
SharedTransition.notifyEvent(SharedTransition.finishedEvent, {
id: state?.instance?.id,
type,
action: 'interactiveFinish',
});
};
if (view && targetTransform) {
const shadowViewToAnimate = view.__sharedTransitionShadowView;
iOSUtils.animateWithSpring({
animations: () => {
view.transform = targetTransform;
view.alpha = 0;
// Drive the sibling shadow view through the same spring so it
// shrinks + translates with the modal as it morphs to the
// source. Also fade it out so it doesn't linger behind the
// finalizing snapshot.
if (shadowViewToAnimate) {
shadowViewToAnimate.transform = targetTransform;
shadowViewToAnimate.alpha = 0;
}
// Restore source-side element alphas IN PARALLEL with the
// destination's morph + fade — but skip sources that have
// a flying snapshot. Those are held at alpha 0 and
// revealed by finalize() right before the snapshot is
// removed, so the user never sees both the snapshot AND
// the source visible at the same time.
for (const v of state.instance.sharedElements?._allPresentingViews || []) {
if (v?.ios && !snapshottedSources.has(v.ios)) {
v.ios.alpha = v.opacity;
}
}
// Source-only orphan views were already restored at
// engagement; nothing more to do for them here.
// Fly each shared-element snapshot to its source frame.
// Rides the same spring so the image lands precisely back
// at the source position as the destination view fades.
for (const { snap, endFrame, endCornerRadius } of sharedSnapshots) {
snap.frame = endFrame;
snap.layer.cornerRadius = endCornerRadius;
}
},
completion: () => finalize(),
});
// Safety: ensure finalize runs even if the animation completion
// is dropped (e.g. interrupted by another gesture).
setTimeout(finalize, 800);
}
else {
// No target transform; ensure sources are still restored.
for (const v of state.instance.sharedElements?._allPresentingViews || []) {
if (v?.ios)
v.ios.alpha = v.opacity;
}
finalize();
}
return;
}
if (state?.instance && interactiveState?.added && interactiveState?.propertyAnimator) {
interactiveState.propertyAnimator.reversed = false;
const duration = isNumber(state.pageReturn?.duration) ? state.pageReturn?.duration / 1000 : CORE_ANIMATION_DEFAULTS.duration;
interactiveState.propertyAnimator.continueAnimationWithTimingParametersDurationFactor(null, duration);
setTimeout(() => {
// Restore alpha on every source view we hid (including duplicates) so
// the source page is visible if shown again.
for (const sourceView of state.instance.sharedElements._allPresentingViews || []) {
if (sourceView?.ios) {
sourceView.ios.alpha = sourceView.opacity;
}
}
for (const presenting of state.instance.sharedElements.presenting) {
presenting.snapshot.removeFromSuperview();
}
for (const presented of state.instance.sharedElements.presented) {
if (presented.imageSourceChangeListener) {
presented.view.off('imageSourceChange', presented.imageSourceChangeListener);
presented.imageSourceChangeListener = null;
}
}
// Restore alpha on independent (orphan) source views and tear down
// their snapshots. Without this, source-only orphans (any non-matched
// sharedTransitionTag on the source page) stay at alpha 0 forever,
// leaving the page visibly blank where they used to be. The non-morph
// interactive path drives a propertyAnimator instead of running the
// dismiss snapshot/animation rig, so the standard cleanup doesn't run.
for (const ind of state.instance.sharedElements.independent) {
if (ind.view?.ios) {
ind.view.ios.alpha = ind.view.opacity;
}
ind.snapshot?.removeFromSuperview();
}
SharedTransition.finishState(state.instance.id);
interactiveState.propertyAnimator = null;
interactiveState.added = false;
interactiveState.transitionContext.finishInteractiveTransition();
interactiveState.transitionContext.completeTransition(true);
SharedTransition.notifyEvent(SharedTransition.finishedEvent, {
id: state?.instance?.id,
type,
action: 'interactiveFinish',
});
}, duration * 1000);
}
}
}
//# sourceMappingURL=shared-transition-helper.ios.js.map