mirror of
https://github.com/NativeScript/NativeScript.git
synced 2025-11-05 13:26:48 +08:00
feat: interactive dismissal for shared element transitions
This commit is contained in:
@@ -27,6 +27,7 @@ export class TransitionsModel extends Observable {
|
||||
openModal() {
|
||||
page.showModal('pages/transitions/transitions-modal', {
|
||||
transition: SharedTransition.custom(new ModalTransition(), {
|
||||
interactiveDismissal: true,
|
||||
toPageStart: {
|
||||
y: 200,
|
||||
duration: 1000,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<Label text="Opened Modal" verticalAlignment="top" marginTop="20" class="text-center" fontSize="28" color="black" />
|
||||
|
||||
<ContentView row="1" sharedTransitionTag="open-modal-box2" marginTop="20" width="75" height="75" borderRadius="37" backgroundColor="orange" />
|
||||
<ContentView row="2" sharedTransitionTag="open-modal-box" marginTop="20" width="200" height="200" borderRadius="100" backgroundColor="purple" />
|
||||
<ContentView row="2" sharedTransitionTag="open-modal-box1" marginTop="20" width="200" height="200" borderRadius="100" backgroundColor="purple" />
|
||||
<ContentView row="2" sharedTransitionTag="open-modal-box3" marginTop="20" width="30" height="30" borderRadius="15" backgroundColor="red" horizontalAlignment="right" marginRight="50" />
|
||||
<ContentView row="1" sharedTransitionTag="open-modal-box4" marginTop="20" width="30" height="30" borderRadius="15" backgroundColor="pink" horizontalAlignment="left" marginLeft="50" />
|
||||
</GridLayout>
|
||||
|
||||
@@ -14,6 +14,8 @@ import { accessibilityEnabledProperty, accessibilityHiddenProperty, accessibilit
|
||||
import { IOSPostAccessibilityNotificationType, isAccessibilityServiceEnabled, updateAccessibilityProperties, AccessibilityEventOptions, AccessibilityRole, AccessibilityState } from '../../../accessibility';
|
||||
import { CoreTypes } from '../../../core-types';
|
||||
import type { ModalTransition } from '../../transition/modal-transition';
|
||||
import { SharedTransition } from '../../transition/shared-transition';
|
||||
import { GestureStateTypes, PanGestureEventData } from '../../gestures';
|
||||
|
||||
export * from './view-common';
|
||||
// helpers (these are okay re-exported here)
|
||||
@@ -30,9 +32,11 @@ const majorVersion = iOSNativeHelper.MajorVersion;
|
||||
export class View extends ViewCommon implements ViewDefinition {
|
||||
nativeViewProtected: UIView;
|
||||
viewController: UIViewController;
|
||||
transitionIteractiveCtrl: UIPercentDrivenInteractiveTransition;
|
||||
private _popoverPresentationDelegate: IOSHelper.UIPopoverPresentationControllerDelegateImp;
|
||||
private _adaptivePresentationDelegate: IOSHelper.UIAdaptivePresentationControllerDelegateImp;
|
||||
private _transitioningDelegate: UIViewControllerTransitioningDelegateImpl;
|
||||
private _interactiveDismissGesture: (args: PanGestureEventData) => void;
|
||||
|
||||
/**
|
||||
* Track modal open animated options to use same option upon close
|
||||
@@ -466,8 +470,16 @@ export class View extends ViewCommon implements ViewDefinition {
|
||||
if (options.transition) {
|
||||
controller.modalPresentationStyle = UIModalPresentationStyle.Custom;
|
||||
if (options.transition.instance) {
|
||||
this._transitioningDelegate = UIViewControllerTransitioningDelegateImpl.initWithOwner(new WeakRef(options.transition.instance));
|
||||
this._transitioningDelegate = UIViewControllerTransitioningDelegateImpl.initWithOwner(new WeakRef(options.transition.instance), new WeakRef(this));
|
||||
controller.transitioningDelegate = this._transitioningDelegate;
|
||||
const transitionState = SharedTransition.getState(options.transition.instance.id);
|
||||
if (transitionState?.interactiveDismissal) {
|
||||
// interactive transitions via gestures
|
||||
// TODO - these could be typed as: boolean | (view: View) => void
|
||||
// to allow users to define their own custom gesture dismissals
|
||||
this._interactiveDismissGesture = this._interactiveDismissGestureHandler.bind(this);
|
||||
this.on('pan', this._interactiveDismissGesture);
|
||||
}
|
||||
}
|
||||
} else if (options.fullscreen) {
|
||||
controller.modalPresentationStyle = UIModalPresentationStyle.FullScreen;
|
||||
@@ -549,6 +561,41 @@ export class View extends ViewCommon implements ViewDefinition {
|
||||
controller = null;
|
||||
}
|
||||
|
||||
private _interactiveDismissGestureHandler(args: PanGestureEventData) {
|
||||
if (args?.ios?.view) {
|
||||
this.interactiveDismissGestureBegan = true;
|
||||
this.interactiveDismissGestureCancelled = false;
|
||||
const percent = args.deltaY / (args.ios.view.bounds.size.height / 2);
|
||||
console.log('pan:', percent);
|
||||
switch (args.state) {
|
||||
case GestureStateTypes.began:
|
||||
if (this._closeModalCallback) {
|
||||
this._closeModalCallback();
|
||||
}
|
||||
break;
|
||||
case GestureStateTypes.changed:
|
||||
// TODO: allow customization of threshold
|
||||
if (percent < 1) {
|
||||
if (this.transitionIteractiveCtrl) {
|
||||
this.transitionIteractiveCtrl.updateInteractiveTransition(percent);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case GestureStateTypes.cancelled:
|
||||
case GestureStateTypes.ended:
|
||||
if (this.transitionIteractiveCtrl) {
|
||||
if (percent > 0.5) {
|
||||
this.transitionIteractiveCtrl.finishInteractiveTransition();
|
||||
} else {
|
||||
this.interactiveDismissGestureCancelled = true;
|
||||
this.transitionIteractiveCtrl.cancelInteractiveTransition();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected _hideNativeModalView(parent: View, whenClosedCallback: () => void) {
|
||||
if (!parent || !parent.viewController) {
|
||||
Trace.error('Trying to hide modal view but no parent with viewController specified.');
|
||||
@@ -564,10 +611,20 @@ export class View extends ViewCommon implements ViewDefinition {
|
||||
}
|
||||
|
||||
const parentController = parent.viewController;
|
||||
const animated = this._modalAnimatedOptions ? !!this._modalAnimatedOptions.pop() : true;
|
||||
let animated = true;
|
||||
if (this._modalAnimatedOptions?.length) {
|
||||
animated = this._modalAnimatedOptions.slice(-1)[0];
|
||||
}
|
||||
|
||||
parentController.dismissViewControllerAnimatedCompletion(animated, () => {
|
||||
this._transitioningDelegate = null;
|
||||
if (!this.interactiveDismissGestureCancelled) {
|
||||
this._transitioningDelegate = null;
|
||||
this.transitionIteractiveCtrl = null;
|
||||
this.off('pan', this._interactiveDismissGesture);
|
||||
if (this._modalAnimatedOptions) {
|
||||
this._modalAnimatedOptions.pop();
|
||||
}
|
||||
}
|
||||
whenClosedCallback();
|
||||
});
|
||||
}
|
||||
@@ -923,11 +980,13 @@ View.prototype._nativeBackgroundState = 'unset';
|
||||
@NativeClass
|
||||
class UIViewControllerTransitioningDelegateImpl extends NSObject implements UIViewControllerTransitioningDelegate {
|
||||
owner: WeakRef<ModalTransition>;
|
||||
ownerView: WeakRef<View>;
|
||||
static ObjCProtocols = [UIViewControllerTransitioningDelegate];
|
||||
|
||||
static initWithOwner(owner: WeakRef<ModalTransition>) {
|
||||
static initWithOwner(owner: WeakRef<ModalTransition>, ownerView: WeakRef<View>) {
|
||||
const delegate = <UIViewControllerTransitioningDelegateImpl>UIViewControllerTransitioningDelegateImpl.new();
|
||||
delegate.owner = owner;
|
||||
delegate.ownerView = ownerView;
|
||||
return delegate;
|
||||
}
|
||||
|
||||
@@ -950,7 +1009,14 @@ class UIViewControllerTransitioningDelegateImpl extends NSObject implements UIVi
|
||||
interactionControllerForDismissal?(animator: UIViewControllerAnimatedTransitioning): UIViewControllerInteractiveTransitioning {
|
||||
const owner = this.owner?.deref();
|
||||
if (owner?.iosInteractionDismiss) {
|
||||
return owner.iosInteractionDismiss(animator);
|
||||
const ownerView = this.ownerView?.deref();
|
||||
if (ownerView) {
|
||||
if (ownerView.interactiveDismissGestureBegan) {
|
||||
console.log('interactionControllerForDismissal!');
|
||||
ownerView.transitionIteractiveCtrl = owner.iosInteractionDismiss(animator);
|
||||
return ownerView.transitionIteractiveCtrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,8 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition {
|
||||
public _modalParent: ViewCommon;
|
||||
private _modalContext: any;
|
||||
private _modal: ViewCommon;
|
||||
interactiveDismissGestureBegan = false;
|
||||
interactiveDismissGestureCancelled = false;
|
||||
|
||||
private _measuredWidth: number;
|
||||
private _measuredHeight: number;
|
||||
@@ -401,27 +403,39 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition {
|
||||
this.style._fontScale = getCurrentFontScale();
|
||||
this._modalParent = parent;
|
||||
this._modalContext = options.context;
|
||||
const that = this;
|
||||
this._closeModalCallback = function (...originalArgs) {
|
||||
if (that._closeModalCallback) {
|
||||
const modalIndex = _rootModalViews.indexOf(that);
|
||||
this._closeModalCallback = (...originalArgs) => {
|
||||
const cleanupModalViews = () => {
|
||||
const modalIndex = _rootModalViews.indexOf(this);
|
||||
_rootModalViews.splice(modalIndex);
|
||||
that._modalParent = null;
|
||||
that._modalContext = null;
|
||||
that._closeModalCallback = null;
|
||||
that._dialogClosed();
|
||||
this._modalParent = null;
|
||||
this._modalContext = null;
|
||||
this._closeModalCallback = null;
|
||||
this._dialogClosed();
|
||||
parent._modal = null;
|
||||
};
|
||||
|
||||
const whenClosedCallback = () => {
|
||||
const whenClosedCallback = () => {
|
||||
if (this.interactiveDismissGestureBegan) {
|
||||
this.interactiveDismissGestureBegan = false;
|
||||
if (!this.interactiveDismissGestureCancelled) {
|
||||
cleanupModalViews();
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.interactiveDismissGestureCancelled) {
|
||||
if (typeof options.closeCallback === 'function') {
|
||||
options.closeCallback.apply(undefined, originalArgs);
|
||||
}
|
||||
|
||||
that._tearDownUI(true);
|
||||
};
|
||||
this._tearDownUI(true);
|
||||
}
|
||||
};
|
||||
|
||||
that._hideNativeModalView(parent, whenClosedCallback);
|
||||
if (!this.interactiveDismissGestureBegan) {
|
||||
cleanupModalViews();
|
||||
}
|
||||
|
||||
this._hideNativeModalView(parent, whenClosedCallback);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -56,14 +56,14 @@ export function iosMatchLayerProperties(view: UIView, toView: UIView) {
|
||||
|
||||
viewPropertiesToMatch.forEach((property) => {
|
||||
if (view[property] !== toView[property]) {
|
||||
console.log('| -- matching view property:', property);
|
||||
// console.log('| -- matching view property:', property);
|
||||
view[property as any] = toView[property];
|
||||
}
|
||||
});
|
||||
|
||||
layerPropertiesToMatch.forEach((property) => {
|
||||
if (view.layer[property] !== toView.layer[property]) {
|
||||
console.log('| -- matching layer property:', property);
|
||||
// console.log('| -- matching layer property:', property);
|
||||
view.layer[property as any] = toView.layer[property];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { querySelectorAll } from '../core/view-base';
|
||||
import type { View } from '../core/view';
|
||||
import { Screen } from '../../platform';
|
||||
import { valueMap } from '../../utils/number-utils';
|
||||
import { Transition, iosMatchLayerProperties, iosPrintRect, iosSnapshotView } from '.';
|
||||
import { SharedTransition, SharedTransitionAnimationType, DEFAULT_DURATION } from './shared-transition';
|
||||
|
||||
export class ModalTransition extends Transition {
|
||||
transitionController: ModalTransitionController;
|
||||
interactiveController: UIPercentDrivenInteractiveTransition;
|
||||
presented: UIViewController;
|
||||
presenting: UIViewController;
|
||||
sharedElements: {
|
||||
@@ -28,7 +30,8 @@ export class ModalTransition extends Transition {
|
||||
|
||||
iosInteractionDismiss(animator: UIViewControllerAnimatedTransitioning): UIViewControllerInteractiveTransitioning {
|
||||
console.log('-- iosInteractionDismiss --');
|
||||
return null;
|
||||
this.interactiveController = PercentInteractiveController.initWithOwner(new WeakRef(this));
|
||||
return this.interactiveController;
|
||||
}
|
||||
|
||||
iosInteractionPresented(animator: UIViewControllerAnimatedTransitioning): UIViewControllerInteractiveTransitioning {
|
||||
@@ -42,6 +45,108 @@ export class ModalTransition extends Transition {
|
||||
}
|
||||
}
|
||||
|
||||
@NativeClass()
|
||||
class PercentInteractiveController extends UIPercentDrivenInteractiveTransition implements UIViewControllerInteractiveTransitioning {
|
||||
static ObjCProtocols = [UIViewControllerInteractiveTransitioning];
|
||||
owner: WeakRef<ModalTransition>;
|
||||
started = false;
|
||||
transitionContext: UIViewControllerContextTransitioning;
|
||||
backgroundAnimation: UIViewPropertyAnimator;
|
||||
|
||||
static initWithOwner(owner: WeakRef<ModalTransition>) {
|
||||
const ctrl = <PercentInteractiveController>PercentInteractiveController.new();
|
||||
ctrl.owner = owner;
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
startInteractiveTransition(transitionContext: UIViewControllerContextTransitioning) {
|
||||
console.log('startInteractiveTransition');
|
||||
this.transitionContext = transitionContext;
|
||||
}
|
||||
|
||||
updateInteractiveTransition(percentComplete: number) {
|
||||
// console.log('percentComplete:', percentComplete);
|
||||
const owner = this.owner?.deref();
|
||||
if (owner) {
|
||||
if (!this.started) {
|
||||
this.started = true;
|
||||
for (const p of owner.sharedElements.presented) {
|
||||
p.view.opacity = 0;
|
||||
}
|
||||
for (const p of owner.sharedElements.presenting) {
|
||||
p.snapshot.alpha = p.endOpacity;
|
||||
this.transitionContext.containerView.addSubview(p.snapshot);
|
||||
}
|
||||
this.backgroundAnimation = UIViewPropertyAnimator.alloc().initWithDurationDampingRatioAnimations(1, 1, () => {
|
||||
for (const p of owner.sharedElements.presenting) {
|
||||
p.snapshot.frame = p.startFrame;
|
||||
iosMatchLayerProperties(p.snapshot, p.view.ios);
|
||||
|
||||
p.snapshot.alpha = 1;
|
||||
}
|
||||
owner.presented.view.alpha = 0;
|
||||
owner.presented.view.frame = CGRectMake(0, 200, owner.presented.view.bounds.size.width, owner.presented.view.bounds.size.height);
|
||||
});
|
||||
}
|
||||
|
||||
this.backgroundAnimation.fractionComplete = percentComplete;
|
||||
}
|
||||
}
|
||||
|
||||
cancelInteractiveTransition() {
|
||||
console.log('cancelInteractiveTransition');
|
||||
const owner = this.owner?.deref();
|
||||
if (owner && this.started) {
|
||||
const state = SharedTransition.getState(owner.id);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
if (this.backgroundAnimation) {
|
||||
this.backgroundAnimation.reversed = true;
|
||||
const duration = typeof state.toPageStart?.duration === 'number' ? state.toPageStart?.duration / 1000 : DEFAULT_DURATION;
|
||||
this.backgroundAnimation.continueAnimationWithTimingParametersDurationFactor(null, duration);
|
||||
setTimeout(() => {
|
||||
for (const p of owner.sharedElements.presented) {
|
||||
p.view.opacity = 1;
|
||||
}
|
||||
for (const p of owner.sharedElements.presenting) {
|
||||
p.snapshot.removeFromSuperview();
|
||||
}
|
||||
owner.presented.view.alpha = 1;
|
||||
this.backgroundAnimation = null;
|
||||
this.started = false;
|
||||
this.transitionContext.completeTransition(false);
|
||||
}, duration * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finishInteractiveTransition() {
|
||||
console.log('finishInteractiveTransition');
|
||||
const owner = this.owner?.deref();
|
||||
if (owner && this.started) {
|
||||
if (this.backgroundAnimation) {
|
||||
const state = SharedTransition.getState(owner.id);
|
||||
if (!state) {
|
||||
SharedTransition.finishState(owner.id);
|
||||
this.transitionContext.completeTransition(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = typeof state.fromPageEnd?.duration === 'number' ? state.fromPageEnd?.duration / 1000 : DEFAULT_DURATION;
|
||||
this.backgroundAnimation.continueAnimationWithTimingParametersDurationFactor(null, duration);
|
||||
setTimeout(() => {
|
||||
for (const presenting of owner.sharedElements.presenting) {
|
||||
presenting.view.opacity = presenting.startOpacity;
|
||||
}
|
||||
SharedTransition.finishState(owner.id);
|
||||
this.transitionContext.completeTransition(true);
|
||||
}, duration * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@NativeClass()
|
||||
class ModalTransitionController extends NSObject implements UIViewControllerAnimatedTransitioning {
|
||||
static ObjCProtocols = [UIViewControllerAnimatedTransitioning];
|
||||
|
||||
@@ -17,6 +17,10 @@ export interface SharedTransitionConfig {
|
||||
* Preconfigured transition or your own custom configured one
|
||||
*/
|
||||
instance?: Transition;
|
||||
/**
|
||||
* Whether you want to allow interactive dismissal
|
||||
*/
|
||||
interactiveDismissal?: boolean;
|
||||
/**
|
||||
* View settings to start your transition.
|
||||
*/
|
||||
|
||||
@@ -31,3 +31,16 @@ export function notNegative(value: Object): boolean {
|
||||
export const radiansToDegrees = (a: number) => a * (180 / Math.PI);
|
||||
|
||||
export const degreesToRadians = (a: number) => a * (Math.PI / 180);
|
||||
|
||||
/**
|
||||
* Map value changes across a set of criteria
|
||||
* @param val value to map
|
||||
* @param in_min minimum
|
||||
* @param in_max maximum
|
||||
* @param out_min starting value
|
||||
* @param out_max ending value
|
||||
* @returns
|
||||
*/
|
||||
export function valueMap(val: number, in_min: number, in_max: number, out_min: number, out_max: number) {
|
||||
return ((val - in_min) * (out_max - out_min)) / (in_max - in_min) + out_min;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user