diff --git a/apps/toolbox/src/pages/transitions.ts b/apps/toolbox/src/pages/transitions.ts
index 826d3711f..6859885a2 100644
--- a/apps/toolbox/src/pages/transitions.ts
+++ b/apps/toolbox/src/pages/transitions.ts
@@ -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,
diff --git a/apps/toolbox/src/pages/transitions/transitions-modal.xml b/apps/toolbox/src/pages/transitions/transitions-modal.xml
index 25062599a..538b7e051 100644
--- a/apps/toolbox/src/pages/transitions/transitions-modal.xml
+++ b/apps/toolbox/src/pages/transitions/transitions-modal.xml
@@ -8,7 +8,7 @@
-
+
diff --git a/packages/core/ui/core/view/index.ios.ts b/packages/core/ui/core/view/index.ios.ts
index 7454b1c98..136ab4807 100644
--- a/packages/core/ui/core/view/index.ios.ts
+++ b/packages/core/ui/core/view/index.ios.ts
@@ -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;
+ ownerView: WeakRef;
static ObjCProtocols = [UIViewControllerTransitioningDelegate];
- static initWithOwner(owner: WeakRef) {
+ static initWithOwner(owner: WeakRef, ownerView: WeakRef) {
const delegate = 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;
}
diff --git a/packages/core/ui/core/view/view-common.ts b/packages/core/ui/core/view/view-common.ts
index e1d1a96cb..a8648792a 100644
--- a/packages/core/ui/core/view/view-common.ts
+++ b/packages/core/ui/core/view/view-common.ts
@@ -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);
};
}
diff --git a/packages/core/ui/transition/index.ios.ts b/packages/core/ui/transition/index.ios.ts
index 6d04db109..4aee7e43b 100644
--- a/packages/core/ui/transition/index.ios.ts
+++ b/packages/core/ui/transition/index.ios.ts
@@ -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];
}
});
diff --git a/packages/core/ui/transition/modal-transition.ios.ts b/packages/core/ui/transition/modal-transition.ios.ts
index b50b1dfd4..7821db7aa 100644
--- a/packages/core/ui/transition/modal-transition.ios.ts
+++ b/packages/core/ui/transition/modal-transition.ios.ts
@@ -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;
+ started = false;
+ transitionContext: UIViewControllerContextTransitioning;
+ backgroundAnimation: UIViewPropertyAnimator;
+
+ static initWithOwner(owner: WeakRef) {
+ const ctrl = 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];
diff --git a/packages/core/ui/transition/shared-transition.ts b/packages/core/ui/transition/shared-transition.ts
index ff48464ba..4d9c0dc9c 100644
--- a/packages/core/ui/transition/shared-transition.ts
+++ b/packages/core/ui/transition/shared-transition.ts
@@ -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.
*/
diff --git a/packages/core/utils/number-utils.ts b/packages/core/utils/number-utils.ts
index 3805977d5..5deeb085f 100644
--- a/packages/core/utils/number-utils.ts
+++ b/packages/core/utils/number-utils.ts
@@ -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;
+}