feat: interactive dismissal for shared element transitions

This commit is contained in:
Nathan Walker
2023-03-15 20:59:55 -07:00
parent 69f920995c
commit 393c3f0d97
8 changed files with 224 additions and 21 deletions

View File

@@ -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,

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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);
};
}

View File

@@ -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];
}
});

View File

@@ -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];

View File

@@ -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.
*/

View File

@@ -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;
}