@nativescript/core
Version:
A JavaScript library providing an easy to use api for interacting with iOS and Android platform APIs.
456 lines • 25.4 kB
JavaScript
import { Transition } from '.';
import { isNumber } from '../../utils/types';
import { CORE_ANIMATION_DEFAULTS, getDurationWithDampingFromSpring } from '../../utils/animation-helpers';
import { GestureStateTypes } from '../gestures';
import { ios as iOSUtils } from '../../utils/native-helper';
import { SharedTransition, SharedTransitionAnimationType } from './shared-transition';
import { SharedTransitionHelper, removeInteractiveDismissShadow, syncInteractiveDismissShadow } from './shared-transition-helper';
function _findInnerScroll(view) {
// Walk the destination's view subtree breadth-first looking for the first
// UIScrollView. Returned so the dismiss handler can both read its current
// contentOffset (to tell "was scrolled" from "was at top") and pin it in
// place while an interactive dismiss is engaged.
if (!view)
return null;
const queue = [view];
while (queue.length) {
const v = queue.shift();
if (!v)
continue;
if (v instanceof UIScrollView) {
return v;
}
const subviews = v.subviews;
if (!subviews)
continue;
for (let i = 0; i < subviews.count; i++) {
queue.push(subviews.objectAtIndex(i));
}
}
return null;
}
export class PageTransition extends Transition {
constructor() {
super(...arguments);
// Tracks the gesture's lifecycle:
// - 'idle' : no active touch.
// - 'pending' : touched and might be panning, but we haven't seen enough
// motion to know whether it's a dismiss or a scroll.
// - 'active' : confirmed dismiss intent — UIKit interactive transition
// is engaged and the morph transform tracks the finger.
// - 'ignored' : determined the gesture is a scroll (or upward at the top
// of a non-scrolling area); we let the gesture run out
// without engaging UIKit.
this._gesturePhase = 'idle';
this._gestureScrollY = 0;
// The inner UIScrollView (if any) discovered at gesture-began. Held so we
// can freeze it (scrollEnabled = false) while the dismiss is active and
// restore the prior value when the gesture ends.
this._innerScroll = null;
// Captured at engagement: the matching source element's frame expressed in
// the destination view's coordinate system. Morph progress interpolates
// from identity toward this frame so the destination shrinks into place.
this._morphTarget = null;
}
iosNavigatedController(navigationController, operation, fromVC, toVC) {
this.navigationController = navigationController;
if (!this.transitionController) {
this.presented = toVC;
this.presenting = fromVC;
}
this.transitionController = PageTransitionController.initWithOwner(new WeakRef(this));
// console.log('iosNavigatedController presenting:', this.presenting);
this.operation = operation;
return this.transitionController;
}
iosInteractionDismiss(animator) {
// console.log('-- iosInteractionDismiss --');
this.interactiveController = PercentInteractiveController.initWithOwner(new WeakRef(this));
return this.interactiveController;
}
setupInteractiveGesture(startCallback, view) {
// console.log(' -- setupInteractiveGesture --');
this._interactiveStartCallback = startCallback;
if (!this._interactiveDismissGesture) {
// console.log('setup but tearing down first!');
view.off('pan', this._interactiveDismissGesture);
this._interactiveDismissGesture = this._interactiveDismissGestureHandler.bind(this);
}
view.on('pan', this._interactiveDismissGesture);
this._interactiveGestureTeardown = () => {
// console.log(`-- TEARDOWN setupInteractiveGesture --`);
if (view) {
view.off('pan', this._interactiveDismissGesture);
}
this._interactiveDismissGesture = null;
};
return this._interactiveGestureTeardown;
}
_interactiveDismissGestureHandler(args) {
if (!args?.ios?.view)
return;
const state = SharedTransition.getState(this.id);
if (!state) {
this._teardownGesture();
return;
}
const morph = !!state.interactive?.dismiss?.morph;
const morphOptions = typeof state.interactive?.dismiss?.morph === 'object' ? state.interactive.dismiss.morph : null;
const viewW = args.ios.view.bounds.size.width;
const viewH = args.ios.view.bounds.size.height;
const dx = args.deltaX;
const dy = args.deltaY;
const dist = Math.hypot(dx, dy);
const morphPercent = dist / viewH;
const percent = state.interactive?.dismiss?.percentFormula ? state.interactive.dismiss.percentFormula(args) : morph ? morphPercent : dx / viewW;
if (SharedTransition.DEBUG) {
console.log('Interactive dismissal percentage:', percent, 'phase:', this._gesturePhase);
}
switch (args.state) {
case GestureStateTypes.began: {
// Defensive reset: a previous interactive dismiss may have been
// interrupted before its cleanup ran, leaving a lingering
// transform on the presented view or alpha=0 on source views.
// Recover from that here so each gesture starts from a clean
// slate regardless of prior animation state.
const presentedView = this.presented?.view;
if (presentedView) {
presentedView.transform = CGAffineTransformIdentity;
presentedView.alpha = 1;
}
const sources = state.instance?.sharedElements?._allPresentingViews || [];
for (const v of sources) {
if (v?.ios)
v.ios.alpha = v.opacity ?? 1;
}
// Don't engage UIKit yet — we need a few points of motion to know
// whether this is a dismiss gesture or a scroll. Cache the inner
// scrollview and its contentOffset at gesture start so the decision
// can tell "was scrolled" from "was at top".
this._gesturePhase = 'pending';
this._innerScroll = _findInnerScroll(this.presented?.view);
this._gestureScrollY = this._innerScroll?.contentOffset.y ?? 0;
break;
}
case GestureStateTypes.changed:
if (this._gesturePhase === 'ignored')
return;
if (this._gesturePhase === 'pending') {
// Wait for enough motion to make a confident direction call.
const motionThreshold = 8;
const absX = Math.abs(dx);
const absY = Math.abs(dy);
if (absX < motionThreshold && absY < motionThreshold)
return;
const horizontalDominant = absX > absY;
const downward = dy > 0;
const scrollWasAtTop = this._gestureScrollY <= 0;
// Engagement rule:
// - Primarily horizontal motion → always allow (swipe-back).
// - Vertical-downward motion → only when the inner scrollview
// was at its top at gesture start (sheet-style dismiss).
// - Anything else → ignore so the scrollview can take over.
const shouldEngage = horizontalDominant || (downward && scrollWasAtTop);
if (!shouldEngage) {
this._gesturePhase = 'ignored';
return;
}
this._gesturePhase = 'active';
// Freeze the inner scrollview at its current offset for the
// duration of the dismiss so the page contents don't scroll
// along with the dismiss drag (which would shift the morph
// source mid-flight and make the snap-back look off).
if (this._innerScroll) {
this._scrollWasEnabled = this._innerScroll.scrollEnabled;
this._innerScroll.scrollEnabled = false;
}
SharedTransition.updateState(this.id, {
interactiveBegan: true,
interactiveCancelled: false,
});
if (this._interactiveStartCallback) {
this._interactiveStartCallback();
}
// Compute morph target: the matching source element's frame in
// the destination view's coordinate system. We capture it once
// at engagement so subsequent moves interpolate toward a stable
// anchor, even if the source page is scrolled during the drag.
this._morphTarget = null;
const stateNow = SharedTransition.getState(this.id);
if (morph && stateNow) {
const presented = stateNow.instance?.sharedElements?.presented?.[0];
const destTag = presented?.view?.sharedTransitionTag;
const sources = stateNow.instance?.sharedElements?._allPresentingViews || [];
const matchingSource = sources.find?.((v) => v?.sharedTransitionTag === destTag) || sources[0];
const srcIos = matchingSource?.ios;
const destView = this.presented?.view;
const destSharedIos = presented?.view?.ios;
if (srcIos && destView) {
const f = srcIos.convertRectToView(srcIos.bounds, destView);
const bounds = destView.bounds;
if (bounds.size.width > 0 && bounds.size.height > 0 && f.size.width > 0) {
// Uniform scale during drag — feels grabbed/proportional
// rather than squished. The release spring switches to
// non-uniform scale + anchored translation in interactiveFinish
// so the modal's matched element lands exactly on the source.
const scaleT = Math.min(f.size.width / bounds.size.width, f.size.height / bounds.size.height);
// Anchor on the modal's internal matched element so the
// modal and the matched element snapshot converge on the
// source thumbnail along the same trajectory, instead of
// the modal drifting toward a center-of-modal target while
// the matched snapshot heads to the source.
let txT;
let tyT;
const sharedFrameInModal = destSharedIos ? destSharedIos.convertRectToView(destSharedIos.bounds, destView) : null;
if (sharedFrameInModal && sharedFrameInModal.size.width > 0 && sharedFrameInModal.size.height > 0) {
const internalCx = sharedFrameInModal.origin.x + sharedFrameInModal.size.width / 2;
const internalCy = sharedFrameInModal.origin.y + sharedFrameInModal.size.height / 2;
txT = f.origin.x + f.size.width / 2 - bounds.size.width / 2 - (internalCx - bounds.size.width / 2) * scaleT;
tyT = f.origin.y + f.size.height / 2 - bounds.size.height / 2 - (internalCy - bounds.size.height / 2) * scaleT;
}
else {
txT = f.origin.x + f.size.width / 2 - bounds.size.width / 2;
tyT = f.origin.y + f.size.height / 2 - bounds.size.height / 2;
}
this._morphTarget = { scale: scaleT, tx: txT, ty: tyT };
}
}
// Hide every source-side element so it doesn't show through
// the morphing destination. Restored on cancel/finish.
for (const v of sources) {
if (v?.ios)
v.ios.alpha = 0;
}
// Reveal source-only orphan views (e.g. other album thumbnails,
// section headers' info) so the source page looks intact behind
// the morphing modal. They were hidden during present; without
// restoring them now, the user sees blank spots through the
// shrinking modal where artwork/text should be.
//
// We also re-apply each view's NS background to nudge UIKit into
// re-displaying the layer with its corner-radius mask. Without
// this, some plugin-backed image views (e.g. SDAnimatedImageView)
// render their cached content with square corners during the
// transition's compositor pass even though the layer's
// cornerRadius/masksToBounds are still set correctly.
for (const ind of stateNow.instance?.sharedElements?.independent || []) {
if (!ind.isPresented && ind.view?.ios) {
ind.view.ios.alpha = ind.view.opacity;
const v = ind.view;
if (typeof v._redrawNativeBackground === 'function') {
try {
v._redrawNativeBackground(v.style?.backgroundInternal);
}
catch (_) { }
}
ind.view.ios.layer?.setNeedsDisplay?.();
}
}
// Dismiss shadow is applied from SharedTransitionHelper.interactiveStart
// (it runs after UIKit has reparented the presented view into the
// transition's containerView).
}
}
if (this._gesturePhase !== 'active')
return;
if (morph) {
const presentedView = this.presented?.view;
if (presentedView) {
const minScale = morphOptions?.minScale ?? 0.2;
// Interpolate from identity (t=0) toward the source frame
// (t=1) as the drag progresses. `t` accelerates the morph so
// the destination noticeably shrinks within typical drag
// distances rather than staying near full size.
const t = Math.min(dist / (viewH * 0.5), 1);
const target = this._morphTarget;
let scale;
let tx;
let ty;
if (target) {
const targetScale = Math.max(target.scale, minScale);
scale = 1 + (targetScale - 1) * t;
// Blend finger-follow (1-t weight) with target-attract
// (t weight) so the view feels grabbed but gravitates
// toward the source as the user commits.
tx = dx * (1 - t) + target.tx * t;
ty = dy * (1 - t) + target.ty * t;
}
else {
scale = Math.max(minScale, 1 - t * (1 - minScale));
tx = dx;
ty = dy;
}
presentedView.transform = CGAffineTransformConcat(CGAffineTransformMakeTranslation(tx, ty), CGAffineTransformMakeScale(scale, scale));
}
}
if (percent < 1 && this.interactiveController) {
this.interactiveController.updateInteractiveTransition(percent);
}
// Mirror the destination's current visual state onto the sibling
// shadow layer. Done for both morph (direct transform) and non-morph
// (UIViewPropertyAnimator-driven) — sync reads the presentation
// layer so it works for both.
syncInteractiveDismissShadow(this.presented?.view);
break;
case GestureStateTypes.cancelled:
case GestureStateTypes.ended: {
const phase = this._gesturePhase;
this._gesturePhase = 'idle';
// Restore the inner scrollview's scrollEnabled regardless of
// whether the gesture engaged or was ignored.
if (this._innerScroll && this._scrollWasEnabled !== undefined) {
this._innerScroll.scrollEnabled = this._scrollWasEnabled;
this._scrollWasEnabled = undefined;
}
this._innerScroll = null;
if (phase !== 'active') {
// Either the gesture never crossed the engagement threshold or
// we explicitly ignored it (a scroll). Nothing to wind down.
return;
}
const finishThreshold = isNumber(state.interactive?.dismiss?.finishThreshold) ? state.interactive.dismiss.finishThreshold : 0.5;
const shouldFinish = percent > finishThreshold;
if (this.interactiveController) {
if (shouldFinish) {
this._teardownGesture();
this.interactiveController.finishInteractiveTransition();
}
else {
SharedTransition.updateState(this.id, {
interactiveCancelled: true,
});
this.interactiveController.cancelInteractiveTransition();
}
// Always restore the modal's layer state we touched to render
// the dismiss shadow — on cancel (view is being kept) AND on
// finish (the same NS view instance may be reused on the next
// presentation, so leaked layer state stacks across dismissals).
removeInteractiveDismissShadow(this.presented?.view);
}
else if (morph) {
// Fallback: UIKit didn't engage. Reset the morph transform so the
// destination doesn't stay stuck.
const presentedView = this.presented?.view;
if (presentedView) {
iOSUtils.animateWithSpring({
animations: () => {
presentedView.transform = CGAffineTransformIdentity;
},
});
}
}
break;
}
}
}
_teardownGesture() {
if (this._interactiveGestureTeardown) {
this._interactiveGestureTeardown();
this._interactiveGestureTeardown = null;
}
}
}
var PercentInteractiveController = (function (_super) {
__extends(PercentInteractiveController, _super);
function PercentInteractiveController() {
return _super !== null && _super.apply(this, arguments) || this;
}
PercentInteractiveController.initWithOwner = function (owner) {
var ctrl = PercentInteractiveController.new();
ctrl.owner = owner;
return ctrl;
};
PercentInteractiveController.prototype.startInteractiveTransition = function (transitionContext) {
var _a;
if (!this.interactiveState) {
this.interactiveState = {
transitionContext: transitionContext,
};
var owner = (_a = this.owner) === null || _a === void 0 ? void 0 : _a.deref();
if (owner) {
var state = SharedTransition.getState(owner.id);
SharedTransitionHelper.interactiveStart(state, this.interactiveState, "page");
}
}
};
PercentInteractiveController.prototype.updateInteractiveTransition = function (percentComplete) {
var _a;
var owner = (_a = this.owner) === null || _a === void 0 ? void 0 : _a.deref();
if (owner) {
var state = SharedTransition.getState(owner.id);
SharedTransitionHelper.interactiveUpdate(state, this.interactiveState, "page", percentComplete);
}
};
PercentInteractiveController.prototype.cancelInteractiveTransition = function () {
var _a;
var owner = (_a = this.owner) === null || _a === void 0 ? void 0 : _a.deref();
if (owner) {
var state = SharedTransition.getState(owner.id);
SharedTransitionHelper.interactiveCancel(state, this.interactiveState, "page");
}
};
PercentInteractiveController.prototype.finishInteractiveTransition = function () {
var _a;
var owner = (_a = this.owner) === null || _a === void 0 ? void 0 : _a.deref( // - 'idle' : no active touch.
// - 'pending' : touched and might be panning, but we haven't seen enough
// motion to know whether it's a dismiss or a scroll.
// - 'active' : confirmed dismiss intent — UIKit interactive transition
// is engaged and the morph transform tracks the finger.
// - 'ignored' : determined the gesture is a scroll (or upward at the top
// of a non-scrolling area); we let the gesture run out
// without engaging UIKit.
);
if (owner) {
var state = SharedTransition.getState(owner.id);
SharedTransitionHelper.interactiveFinish(state, this.interactiveState, "page");
}
};
PercentInteractiveController.ObjCProtocols = [UIViewControllerInteractiveTransitioning];
return PercentInteractiveController;
}(UIPercentDrivenInteractiveTransition));
var PageTransitionController = (function (_super) {
__extends(PageTransitionController, _super);
function PageTransitionController() {
return _super !== null && _super.apply(this, arguments) || this;
}
PageTransitionController.initWithOwner = function (owner) {
var ctrl = PageTransitionController.new();
ctrl.owner = owner;
return ctrl;
};
PageTransitionController.prototype.transitionDuration = function (transitionContext) {
var _a, _b, _c, _d, _e, _f;
var owner = this.owner.deref();
if (owner) {
var state = SharedTransition.getState(owner.id);
switch (state === null || state === void 0 ? void 0 : state.activeType) {
case SharedTransitionAnimationType.present: if (isNumber((_a = state === null || state === void 0 ? void 0 : state.pageEnd) === null || _a === void 0 ? void 0 : _a.duration)) {
return ((_b = state.pageEnd) === null || _b === void 0 ? void 0 : _b.duration) / 1000;
}
else {
return getDurationWithDampingFromSpring((_c = state.pageEnd) === null || _c === void 0 ? void 0 : _c.spring).duration;
}
case SharedTransitionAnimationType.dismiss: if (isNumber((_d = state === null || state === void 0 ? void 0 : state.pageReturn) === null || _d === void 0 ? void 0 : _d.duration)) {
return ((_e = state.pageReturn) === null || _e === void 0 ? void 0 : _e.duration) / 1000;
}
else {
return getDurationWithDampingFromSpring((_f = state.pageReturn) === null || _f === void 0 ? void 0 : _f.spring).duration;
}
}
}
return CORE_ANIMATION_DEFAULTS.duration;
};
PageTransitionController.prototype.animateTransition = function (transitionContext) {
var owner = this.owner.deref();
if (owner) {
var state = SharedTransition.getState(owner.id);
if (!state) {
return;
}
SharedTransitionHelper.animate(state, transitionContext, "page");
}
};
PageTransitionController.ObjCProtocols = [UIViewControllerAnimatedTransitioning];
return PageTransitionController;
}(NSObject));
//# sourceMappingURL=page-transition.ios.js.map