From 9aecdda4856d485cbbcba47348d2e61728fe1770 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Tue, 21 Mar 2023 19:22:10 -0700 Subject: [PATCH] chore: cleanup --- apps/toolbox/src/app-root.xml | 2 +- packages/core/ui/index.ts | 1 + packages/core/ui/transition/index.d.ts | 21 +- .../ui/transition/modal-transition.ios.ts | 491 +---------------- .../core/ui/transition/page-transition.ios.ts | 401 ++------------ .../shared-transition-helper.android.ts | 7 + .../transition/shared-transition-helper.d.ts | 10 + .../shared-transition-helper.ios.ts | 502 ++++++++++++++++++ .../core/ui/transition/shared-transition.ts | 67 ++- 9 files changed, 646 insertions(+), 856 deletions(-) create mode 100644 packages/core/ui/transition/shared-transition-helper.android.ts create mode 100644 packages/core/ui/transition/shared-transition-helper.d.ts create mode 100644 packages/core/ui/transition/shared-transition-helper.ios.ts diff --git a/apps/toolbox/src/app-root.xml b/apps/toolbox/src/app-root.xml index aec46ed6f..54e70d976 100644 --- a/apps/toolbox/src/app-root.xml +++ b/apps/toolbox/src/app-root.xml @@ -1,2 +1,2 @@ - + diff --git a/packages/core/ui/index.ts b/packages/core/ui/index.ts index 21f6de033..6b724547a 100644 --- a/packages/core/ui/index.ts +++ b/packages/core/ui/index.ts @@ -80,5 +80,6 @@ export { PageTransition } from './transition/page-transition'; export { FadeTransition } from './transition/fade-transition'; export { SlideTransition } from './transition/slide-transition'; export { SharedTransition, SharedTransitionAnimationType } from './transition/shared-transition'; +export type { SharedTransitionConfig } from './transition/shared-transition'; export { WebView } from './web-view'; export type { LoadEventData, WebViewNavigationType } from './web-view'; diff --git a/packages/core/ui/transition/index.d.ts b/packages/core/ui/transition/index.d.ts index 85a01707d..ca0979e4d 100644 --- a/packages/core/ui/transition/index.d.ts +++ b/packages/core/ui/transition/index.d.ts @@ -1,7 +1,26 @@ -import type { BackstackEntry } from '../frame'; +import type { View } from '../core/view'; +import type { BackstackEntry } from '../frame'; +export type SharedElementSettings = { view: View; startFrame: any; endFrame?: any; startOpacity?: number; endOpacity?: number; scale?: { x?: number; y?: number }; startTransform?: any; snapshot?: any }; +export type SharedTransitionType = 'page' | 'modal'; +export interface SharedInteractiveState { + started?: false; + added?: boolean; + transitionContext?: any; + propertyAnimator?: any; +} export declare class Transition { id: number; + transitionController?: any; + interactiveController?: any; + presented?: any; + presenting?: any; + sharedElements?: { + presented?: Array; + presenting?: Array; + // independent sharedTransitionTags which are part of the shared transition but only on one page + independent?: Array; + }; static AndroidTransitionType?: { enter?: string; exit?: string; popEnter?: string; popExit?: string }; constructor(duration?: number, nativeCurve?: any /* UIViewAnimationCurve | string | CubicBezierAnimationCurve | android.view.animation.Interpolator | android.view.animation.LinearInterpolator */); getDuration(): number; diff --git a/packages/core/ui/transition/modal-transition.ios.ts b/packages/core/ui/transition/modal-transition.ios.ts index af8a790dc..5f0b444ca 100644 --- a/packages/core/ui/transition/modal-transition.ios.ts +++ b/packages/core/ui/transition/modal-transition.ios.ts @@ -2,10 +2,10 @@ import type { View } from '../core/view'; import { Screen } from '../../platform'; import { iOSNativeHelper } from '../../utils/native-helper'; import { isNumber } from '../../utils/types'; -import { Transition } from '.'; -import { SharedTransition, SharedTransitionAnimationType, DEFAULT_DURATION, DEFAULT_SPRING, getRectFromProps } from './shared-transition'; +import { Transition, SharedElementSettings, SharedInteractiveState } from '.'; +import { SharedTransition, SharedTransitionAnimationType, DEFAULT_DURATION, DEFAULT_SPRING, getRectFromProps, getPageStartDefaultsForType } from './shared-transition'; +import { SharedTransitionHelper } from './shared-transition-helper'; import { PanGestureEventData, GestureStateTypes } from '../gestures'; -type SharedElementSettings = { view: View; startFrame: CGRect; endFrame?: CGRect; startOpacity?: number; endOpacity?: number; scale?: { x?: number; y?: number }; startTransform?: CGAffineTransform; snapshot?: UIImageView }; export class ModalTransition extends Transition { transitionController: ModalTransitionController; @@ -59,10 +59,6 @@ export class ModalTransition extends Transition { private _interactiveDismissGestureHandler(args: PanGestureEventData) { if (args?.ios?.view) { const state = SharedTransition.getState(this.id); - // SharedTransition.updateState(this.id, { - // interactiveBegan: true, - // interactiveCancelled: false - // }); const percent = state.interactive?.dismiss?.percentFormula ? state.interactive.dismiss.percentFormula(args) : args.deltaY / (args.ios.view.bounds.size.height / 2); if (SharedTransition.DEBUG) { console.log('Interactive dismissal percentage:', percent); @@ -107,9 +103,7 @@ export class ModalTransition extends Transition { class PercentInteractiveController extends UIPercentDrivenInteractiveTransition implements UIViewControllerInteractiveTransitioning { static ObjCProtocols = [UIViewControllerInteractiveTransitioning]; owner: WeakRef; - started = false; - transitionContext: UIViewControllerContextTransitioning; - backgroundAnimation: UIViewPropertyAnimator; + interactiveState: SharedInteractiveState; static initWithOwner(owner: WeakRef) { const ctrl = PercentInteractiveController.new(); @@ -119,93 +113,41 @@ class PercentInteractiveController extends UIPercentDrivenInteractiveTransition startInteractiveTransition(transitionContext: UIViewControllerContextTransitioning) { // console.log('startInteractiveTransition'); - this.transitionContext = transitionContext; + if (!this.interactiveState) { + this.interactiveState = { + transitionContext, + }; + const owner = this.owner?.deref(); + if (owner) { + const state = SharedTransition.getState(owner.id); + SharedTransitionHelper.interactiveStart(state, this.interactiveState, 'modal'); + } + } } updateInteractiveTransition(percentComplete: number) { 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); - } - const state = SharedTransition.getState(owner.id); - const toProps = state.pageStart; - const startY = isNumber(toProps?.y) ? toProps?.y : Screen.mainScreen.heightDIPs; - this.backgroundAnimation = UIViewPropertyAnimator.alloc().initWithDurationDampingRatioAnimations(1, 1, () => { - for (const p of owner.sharedElements.presenting) { - p.snapshot.frame = p.startFrame; - iOSNativeHelper.copyLayerProperties(p.snapshot, p.view.ios); - - p.snapshot.alpha = 1; - } - owner.presented.view.alpha = 0; - owner.presented.view.frame = CGRectMake(0, startY, owner.presented.view.bounds.size.width, owner.presented.view.bounds.size.height); - }); - } - - this.backgroundAnimation.fractionComplete = percentComplete; + const state = SharedTransition.getState(owner.id); + SharedTransitionHelper.interactiveUpdate(state, this.interactiveState, 'modal', percentComplete); } } cancelInteractiveTransition() { // console.log('cancelInteractiveTransition'); const owner = this.owner?.deref(); - if (owner && this.started) { + if (owner) { const state = SharedTransition.getState(owner.id); - if (!state) { - return; - } - if (this.backgroundAnimation) { - this.backgroundAnimation.reversed = true; - const duration = isNumber(state.pageStart?.duration) ? state.pageStart?.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.cancelInteractiveTransition(); - this.transitionContext.completeTransition(false); - }, duration * 1000); - } + SharedTransitionHelper.interactiveCancel(state, this.interactiveState, 'modal'); } } finishInteractiveTransition() { // console.log('finishInteractiveTransition'); const owner = this.owner?.deref(); - if (owner && this.started) { - if (this.backgroundAnimation) { - this.backgroundAnimation.reversed = false; - const state = SharedTransition.getState(owner.id); - if (!state) { - SharedTransition.finishState(owner.id); - this.transitionContext.completeTransition(true); - return; - } - - const duration = isNumber(state.pageReturn?.duration) ? state.pageReturn?.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.finishInteractiveTransition(); - this.transitionContext.completeTransition(true); - }, duration * 1000); - } + if (owner) { + const state = SharedTransition.getState(owner.id); + SharedTransitionHelper.interactiveFinish(state, this.interactiveState, 'modal'); } } } @@ -234,396 +176,7 @@ class ModalTransitionController extends NSObject implements UIViewControllerAnim if (!state) { return; } - // console.log('state.activeType:', state.activeType) - switch (state.activeType) { - case SharedTransitionAnimationType.present: { - // console.log('-- Transition present --'); - - transitionContext.containerView.addSubview(owner.presented.view); - owner.presented.view.layoutIfNeeded(); - - const { sharedElements, presented, presenting } = SharedTransition.getSharedElements(state.page, state.toPage); - - if (SharedTransition.DEBUG) { - console.log(' ModalTransition: Present'); - console.log( - `1. Found sharedTransitionTags to animate:`, - sharedElements.map((v) => v.sharedTransitionTag) - ); - - console.log(`2. Take snapshots of shared elements and position them based on presenting view:`); - } - - const pageStart = state.pageStart; - owner.presented.view.alpha = isNumber(pageStart?.opacity) ? pageStart?.opacity : 0; - - const startFrame = getRectFromProps(pageStart, { - x: 0, - y: Screen.mainScreen.heightDIPs, - width: Screen.mainScreen.widthDIPs, - height: Screen.mainScreen.heightDIPs, - }); - - const pageEnd = state.pageEnd; - const pageEndIndependentTags = Object.keys(pageEnd?.sharedTransitionTags || {}); - console.log('pageEndIndependentTags:', pageEndIndependentTags); - - for (const presentingView of sharedElements) { - if (!owner.sharedElements) { - owner.sharedElements = { - presented: [], - presenting: [], - independent: [], - }; - } - const presentingSharedElement = presentingView.ios; - // console.log('fromTarget instanceof UIImageView:', fromTarget instanceof UIImageView) - - // TODO: discuss whether we should check if UIImage/UIImageView type to always snapshot images or if other view types could be duped/added vs. snapshotted - // Note: snapshot may be most efficient/simple - // console.log('---> ', presentingView.sharedTransitionTag, ': ', presentingSharedElement) - - const presentedView = presented.find((v) => v.sharedTransitionTag === presentingView.sharedTransitionTag); - const presentedSharedElement = presentedView.ios; - - const snapshot = UIImageView.alloc().init(); - - // treat images differently... - if (presentedSharedElement instanceof UIImageView) { - // in case the image is loaded async, we need to update the snapshot when it changes - // todo: remove listener on transition end - presentedView.on('imageSourceChange', () => { - snapshot.image = iOSNativeHelper.snapshotView(presentedSharedElement, Screen.mainScreen.scale); - snapshot.tintColor = presentedSharedElement.tintColor; - }); - - snapshot.tintColor = presentedSharedElement.tintColor; - snapshot.contentMode = presentedSharedElement.contentMode; - } - - iOSNativeHelper.copyLayerProperties(snapshot, presentingSharedElement); - 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:', iOSNativeHelper.printCGRect(snapshot.frame)); - } - - owner.sharedElements.presenting.push({ - view: presentingView, - startFrame, - endFrame, - snapshot, - startOpacity: presentingView.opacity, - endOpacity: presentedView.opacity, - }); - owner.sharedElements.presented.push({ - view: presentedView, - startFrame: endFrame, - endFrame: startFrame, - startOpacity: presentedView.opacity, - endOpacity: presentingView.opacity, - }); - - // set initial opacity to match the source view opacity - snapshot.alpha = presentingView.opacity; - // hide both while animating within the transition context - presentingView.opacity = 0; - presentedView.opacity = 0; - // add snapshot to animate - transitionContext.containerView.addSubview(snapshot); - } - - for (const tag of pageEndIndependentTags) { - // only consider start when there's a matching end - const pageStartIndependentProps = pageStart?.sharedTransitionTags[tag]; - console.log('start:', tag, pageStartIndependentProps); - const pageEndIndependentProps = pageEnd?.sharedTransitionTags[tag]; - let independentView = presenting.find((v) => v.sharedTransitionTag === tag); - let isPresented = false; - if (!independentView) { - independentView = presented.find((v) => v.sharedTransitionTag === tag); - if (!independentView) { - break; - } - isPresented = true; - } - const independentSharedElement: UIView = independentView.ios; - - let snapshot: UIImageView; - if (isPresented) { - snapshot = UIImageView.alloc().init(); - } else { - snapshot = UIImageView.alloc().initWithImage(iOSNativeHelper.snapshotView(independentSharedElement, Screen.mainScreen.scale)); - } - - if (independentSharedElement instanceof UIImageView) { - // in case the image is loaded async, we need to update the snapshot when it changes - // todo: remove listener on transition end - if (isPresented) { - independentView.on('imageSourceChange', () => { - snapshot.image = iOSNativeHelper.snapshotView(independentSharedElement, Screen.mainScreen.scale); - snapshot.tintColor = independentSharedElement.tintColor; - }); - } - - snapshot.tintColor = independentSharedElement.tintColor; - snapshot.contentMode = independentSharedElement.contentMode; - } - snapshot.clipsToBounds = true; - - const startFrame = independentSharedElement.convertRectToView(independentSharedElement.bounds, transitionContext.containerView); - const startFrameRect = getRectFromProps(pageStartIndependentProps); - // adjust for any specified start positions - const startFrameAdjusted = CGRectMake(startFrame.origin.x + startFrameRect.x, startFrame.origin.y + startFrameRect.y, startFrame.size.width, startFrame.size.height); - console.log('startFrameAdjusted:', tag, iOSNativeHelper.printCGRect(startFrameAdjusted)); - // if (pageStartIndependentProps?.scale) { - // snapshot.transform = CGAffineTransformConcat(CGAffineTransformMakeTranslation(startFrameAdjusted.origin.x, startFrameAdjusted.origin.y), CGAffineTransformMakeScale(pageStartIndependentProps.scale.x, pageStartIndependentProps.scale.y)) - // } else { - snapshot.frame = startFrameAdjusted; - // } - if (SharedTransition.DEBUG) { - console.log('---> ', independentView.sharedTransitionTag, ' frame:', iOSNativeHelper.printCGRect(snapshot.frame)); - } - - const endFrameRect = getRectFromProps(pageEndIndependentProps); - - const endFrame = CGRectMake(startFrame.origin.x + endFrameRect.x, startFrame.origin.y + endFrameRect.y, startFrame.size.width, startFrame.size.height); - console.log('endFrame:', tag, iOSNativeHelper.printCGRect(endFrame)); - owner.sharedElements.independent.push({ - view: independentView, - isPresented, - startFrame, - snapshot, - endFrame, - startTransform: independentSharedElement.transform, - scale: pageEndIndependentProps.scale, - startOpacity: independentView.opacity, - endOpacity: isNumber(pageEndIndependentProps.opacity) ? pageEndIndependentProps.opacity : 0, - }); - - independentView.opacity = 0; - // add snapshot to animate - transitionContext.containerView.addSubview(snapshot); - } - - // Important: always set after above shared element positions have had their start positions set - owner.presented.view.frame = CGRectMake(startFrame.x, startFrame.y, startFrame.width, startFrame.height); - - const cleanupPresent = () => { - for (const presented of owner.sharedElements.presented) { - presented.view.opacity = presented.startOpacity; - } - for (const presenting of owner.sharedElements.presenting) { - presenting.snapshot.removeFromSuperview(); - } - for (const independent of owner.sharedElements.independent) { - independent.snapshot.removeFromSuperview(); - if (independent.isPresented) { - independent.view.opacity = independent.startOpacity; - } - } - SharedTransition.updateState(owner.id, { - activeType: SharedTransitionAnimationType.dismiss, - }); - transitionContext.completeTransition(true); - }; - - const animateProperties = () => { - if (SharedTransition.DEBUG) { - console.log('3. Animating shared elements:'); - } - owner.presented.view.alpha = isNumber(pageEnd?.opacity) ? pageEnd?.opacity : 1; - - const endFrame = getRectFromProps(pageEnd); - owner.presented.view.frame = CGRectMake(endFrame.x, endFrame.y, endFrame.width, endFrame.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 owner.sharedElements.presented) { - const presentingMatch = owner.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 - iOSNativeHelper.copyLayerProperties(presentingMatch.snapshot, presented.view.ios); - // create a snapshot of the presented view - presentingMatch.snapshot.image = iOSNativeHelper.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: `, iOSNativeHelper.printCGRect(correctedEndFrame)); - } - } - for (const independent of owner.sharedElements.independent) { - let endFrame: CGRect = 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: `, iOSNativeHelper.printCGRect(independent.endFrame)); - } - } - }; - - if (isNumber(pageEnd?.duration)) { - // override spring and use only linear animation - UIView.animateWithDurationAnimationsCompletion( - pageEnd?.duration / 1000, - () => { - animateProperties(); - }, - () => { - cleanupPresent(); - } - ); - } else { - const spring = pageEnd?.spring; - iOSNativeHelper.animateWithSpring({ - tension: isNumber(spring?.tension) ? spring?.tension : DEFAULT_SPRING.tension, - friction: isNumber(spring?.friction) ? spring?.friction : DEFAULT_SPRING.friction, - mass: isNumber(spring?.mass) ? spring?.mass : DEFAULT_SPRING.mass, - velocity: isNumber(spring?.velocity) ? spring?.velocity : DEFAULT_SPRING.velocity, - delay: isNumber(spring?.delay) ? spring?.delay : DEFAULT_SPRING.delay, - animations: () => { - animateProperties(); - }, - completion: () => { - cleanupPresent(); - }, - }); - } - - break; - } - case SharedTransitionAnimationType.dismiss: { - // console.log('-- Transition dismiss --'); - - // console.log('transitionContext.containerView.subviews.count:', transitionContext.containerView.subviews.count); - - if (SharedTransition.DEBUG) { - console.log(' ModalTransition: Dismiss'); - console.log( - `1. Dismiss sharedTransitionTags to animate:`, - owner.sharedElements.presented.map((p) => p.view.sharedTransitionTag) - ); - - console.log(`2. Add back previously stored sharedElements to dismiss:`); - } - - for (const p of owner.sharedElements.presented) { - p.view.opacity = 0; - } - for (const p of owner.sharedElements.presenting) { - p.snapshot.alpha = p.endOpacity; - transitionContext.containerView.addSubview(p.snapshot); - } - for (const independent of owner.sharedElements.independent) { - independent.snapshot.alpha = independent.endOpacity; - transitionContext.containerView.addSubview(independent.snapshot); - } - - const pageReturn = state.pageReturn; - - const cleanupDismiss = () => { - for (const presenting of owner.sharedElements.presenting) { - presenting.view.opacity = presenting.startOpacity; - } - for (const independent of owner.sharedElements.independent) { - independent.view.opacity = independent.startOpacity; - } - SharedTransition.finishState(owner.id); - owner.sharedElements = null; - transitionContext.completeTransition(true); - }; - - const animateProperties = () => { - if (SharedTransition.DEBUG) { - console.log('3. Dismissing shared elements:'); - } - - owner.presented.view.alpha = isNumber(pageReturn?.opacity) ? pageReturn?.opacity : 0; - - const endFrame = getRectFromProps(pageReturn, { - x: 0, - y: Screen.mainScreen.heightDIPs, - width: Screen.mainScreen.widthDIPs, - height: Screen.mainScreen.heightDIPs, - }); - owner.presented.view.frame = CGRectMake(endFrame.x, endFrame.y, endFrame.width, endFrame.height); - - for (const presenting of owner.sharedElements.presenting) { - iOSNativeHelper.copyLayerProperties(presenting.snapshot, presenting.view.ios); - presenting.snapshot.frame = presenting.startFrame; - presenting.snapshot.alpha = presenting.startOpacity; - - if (SharedTransition.DEBUG) { - console.log(`---> ${presenting.view.sharedTransitionTag} animate to: `, iOSNativeHelper.printCGRect(presenting.startFrame)); - } - } - - for (const independent of owner.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: `, iOSNativeHelper.printCGRect(independent.startFrame)); - } - } - }; - - if (isNumber(pageReturn?.duration)) { - // override spring and use only linear animation - UIView.animateWithDurationAnimationsCompletion( - pageReturn?.duration / 1000, - () => { - animateProperties(); - }, - () => { - cleanupDismiss(); - } - ); - } else { - const spring = pageReturn?.spring; - iOSNativeHelper.animateWithSpring({ - tension: isNumber(spring?.tension) ? spring?.tension : DEFAULT_SPRING.tension, - friction: isNumber(spring?.friction) ? spring?.friction : DEFAULT_SPRING.friction, - animations: () => { - animateProperties(); - }, - completion: () => { - cleanupDismiss(); - }, - }); - } - - break; - } - } + SharedTransitionHelper.animate(state, transitionContext, 'modal'); } } } diff --git a/packages/core/ui/transition/page-transition.ios.ts b/packages/core/ui/transition/page-transition.ios.ts index 786c9180d..0597227c0 100644 --- a/packages/core/ui/transition/page-transition.ios.ts +++ b/packages/core/ui/transition/page-transition.ios.ts @@ -1,10 +1,11 @@ import type { View } from '../core/view'; -import { Transition } from '.'; +import { SharedElementSettings, SharedInteractiveState, Transition } from '.'; import { Screen } from '../../platform'; import { iOSNativeHelper } from '../../utils/native-helper'; import { isNumber } from '../../utils/types'; import { PanGestureEventData, GestureStateTypes } from '../gestures'; -import { SharedTransition, SharedTransitionAnimationType, DEFAULT_DURATION, DEFAULT_SPRING } from './shared-transition'; +import { SharedTransition, SharedTransitionAnimationType, DEFAULT_DURATION, DEFAULT_SPRING, getRectFromProps, getPageStartDefaultsForType } from './shared-transition'; +import { SharedTransitionHelper } from './shared-transition-helper'; export class PageTransition extends Transition { transitionController: PageTransitionController; @@ -14,8 +15,10 @@ export class PageTransition extends Transition { navigationController: UINavigationController; operation: number; sharedElements: { - presented?: Array<{ view: View; startFrame?: CGRect; endFrame?: CGRect; snapshot?: UIImageView; startOpacity?: number; endOpacity?: number }>; - presenting?: Array<{ view: View; startFrame: CGRect; endFrame?: CGRect; snapshot?: UIImageView; startOpacity?: number; endOpacity?: number }>; + presented?: Array; + presenting?: Array; + // independent sharedTransitionTags which are part of the shared transition but only on one page + independent?: Array; }; private _interactiveStartCallback: () => void; private _interactiveDismissGesture: (args: any /*PanGestureEventData*/) => void; @@ -52,7 +55,9 @@ export class PageTransition extends Transition { this._interactiveGestureTeardown = () => { // console.log(`-- TEARDOWN setupInteractiveGesture --`); - view.off('pan', this._interactiveDismissGesture); + if (view) { + view.off('pan', this._interactiveDismissGesture); + } this._interactiveDismissGesture = null; }; return this._interactiveGestureTeardown; @@ -62,6 +67,14 @@ export class PageTransition extends Transition { if (args?.ios?.view) { // console.log('this.id:', this.id); const state = SharedTransition.getState(this.id); + if (!state) { + // cleanup and exit, already shutdown + if (this._interactiveGestureTeardown) { + this._interactiveGestureTeardown(); + this._interactiveGestureTeardown = null; + } + return; + } const percent = state.interactive?.dismiss?.percentFormula ? state.interactive.dismiss.percentFormula(args) : args.deltaX / (args.ios.view.bounds.size.width / 2); if (SharedTransition.DEBUG) { @@ -111,9 +124,7 @@ export class PageTransition extends Transition { class PercentInteractiveController extends UIPercentDrivenInteractiveTransition implements UIViewControllerInteractiveTransitioning { static ObjCProtocols = [UIViewControllerInteractiveTransitioning]; owner: WeakRef; - started = false; - transitionContext: UIViewControllerContextTransitioning; - backgroundAnimation: UIViewPropertyAnimator; + interactiveState: SharedInteractiveState; static initWithOwner(owner: WeakRef) { const ctrl = PercentInteractiveController.new(); @@ -121,16 +132,16 @@ class PercentInteractiveController extends UIPercentDrivenInteractiveTransition return ctrl; } - added = false; startInteractiveTransition(transitionContext: UIViewControllerContextTransitioning) { // console.log('startInteractiveTransition'); - this.transitionContext = transitionContext; - - if (!this.started) { - this.started = true; - const owner: any = this.owner?.deref(); + if (!this.interactiveState) { + this.interactiveState = { + transitionContext, + }; + const owner = this.owner?.deref(); if (owner) { - this.transitionContext.containerView.insertSubviewBelowSubview(owner.presenting.view, owner.presented.view); + const state = SharedTransition.getState(owner.id); + SharedTransitionHelper.interactiveStart(state, this.interactiveState, 'page'); } } } @@ -138,94 +149,26 @@ class PercentInteractiveController extends UIPercentDrivenInteractiveTransition updateInteractiveTransition(percentComplete: number) { const owner = this.owner?.deref(); if (owner) { - if (!this.added) { - this.added = 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); - } - const state = SharedTransition.getState(owner.id); - const props = state.pageReturn; - this.backgroundAnimation = UIViewPropertyAnimator.alloc().initWithDurationDampingRatioAnimations(1, 1, () => { - for (const p of owner.sharedElements.presenting) { - p.snapshot.frame = p.startFrame; - iOSNativeHelper.copyLayerProperties(p.snapshot, p.view.ios); - - p.snapshot.alpha = 1; - } - owner.presented.view.alpha = isNumber(props?.opacity) ? props?.opacity : 0; - const endX = isNumber(props?.x) ? props?.x : Screen.mainScreen.widthDIPs; - const endY = isNumber(props?.y) ? props?.y : 0; - const endWidth = isNumber(props?.width) ? props?.width : Screen.mainScreen.widthDIPs; - const endHeight = isNumber(props?.height) ? props?.height : Screen.mainScreen.heightDIPs; - owner.presented.view.frame = CGRectMake(endX, endY, endWidth, endHeight); - }); - } - - this.backgroundAnimation.fractionComplete = percentComplete; + const state = SharedTransition.getState(owner.id); + SharedTransitionHelper.interactiveUpdate(state, this.interactiveState, 'page', percentComplete); } } cancelInteractiveTransition() { // console.log('cancelInteractiveTransition'); const owner = this.owner?.deref(); - if (owner && this.added) { + if (owner) { const state = SharedTransition.getState(owner.id); - if (!state) { - return; - } - if (this.backgroundAnimation) { - this.backgroundAnimation.reversed = true; - const duration = isNumber(state.pageStart?.duration) ? state.pageStart?.duration / 1000 : 0.35; - 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.added = false; - this.transitionContext.cancelInteractiveTransition(); - this.transitionContext.completeTransition(false); - }, duration * 1000); - } + SharedTransitionHelper.interactiveCancel(state, this.interactiveState, 'page'); } } finishInteractiveTransition() { // console.log('finishInteractiveTransition'); const owner = this.owner?.deref(); - if (owner && this.added) { - if (this.backgroundAnimation) { - this.backgroundAnimation.reversed = false; - const state = SharedTransition.getState(owner.id); - if (!state) { - SharedTransition.finishState(owner.id); - this.transitionContext.completeTransition(true); - return; - } - - const duration = isNumber(state.pageReturn?.duration) ? state.pageReturn?.duration / 1000 : 0.35; - this.backgroundAnimation.continueAnimationWithTimingParametersDurationFactor(null, duration); - setTimeout(() => { - for (const presenting of owner.sharedElements.presenting) { - presenting.view.opacity = presenting.startOpacity; - presenting.snapshot.removeFromSuperview(); - } - - SharedTransition.finishState(owner.id); - this.backgroundAnimation = null; - this.added = false; - this.transitionContext.finishInteractiveTransition(); - this.transitionContext.completeTransition(true); - }, duration * 1000); - } + if (owner) { + const state = SharedTransition.getState(owner.id); + SharedTransitionHelper.interactiveFinish(state, this.interactiveState, 'page'); } } } @@ -253,287 +196,11 @@ class PageTransitionController extends NSObject implements UIViewControllerAnima const owner = this.owner.deref(); if (owner) { // console.log('--- PageTransitionController animateTransition'); - // console.log('owner.presented:', owner.presented); - // console.log('owner.presenting:', owner.presenting); - - // console.log('owner.id:', owner.id); const state = SharedTransition.getState(owner.id); if (!state) { return; } - // console.log('state.activeType:', state.activeType); - // console.log('operation:', owner.operation); - // switch (state.activeType) { - switch (owner.operation) { - case UINavigationControllerOperation.Push: { - // console.log('pushing!!'); - // this.presented = toViewCtrl; - // this.presenting = fromViewCtrl; - - transitionContext.containerView.insertSubviewAboveSubview(owner.presented.view, owner.presenting.view); - owner.presented.view.layoutIfNeeded(); - - const { sharedElements, presented } = SharedTransition.getSharedElements(state.page, state.toPage); - - if (SharedTransition.DEBUG) { - console.log(' PageTransition: Push'); - console.log( - `1. Found sharedTransitionTags to animate:`, - sharedElements.map((v) => v.sharedTransitionTag) - ); - - console.log(`2. Take snapshots of shared elements and position them based on presenting view:`); - } - - for (const presentingView of sharedElements) { - if (!owner.sharedElements) { - owner.sharedElements = { - presented: [], - presenting: [], - }; - } - const presentingSharedElement = presentingView.ios; - - // TODO: discuss whether we should check if UIImage/UIImageView type to always snapshot images or if other view types could be duped/added vs. snapshotted - // Note: snapshot may be most efficient/simple - // console.log('---> ', presentingView.sharedTransitionTag, ': ', presentingSharedElement) - - const presentedView = presented.find((v) => v.sharedTransitionTag === presentingView.sharedTransitionTag); - const presentedSharedElement = presentedView.ios; - - const sharedElementSnapshot = UIImageView.alloc().init(); // WithImage(snapshotView(presentedSharedElement)); - - // treat images differently... - if (presentedSharedElement instanceof UIImageView) { - // in case the image is loaded async, we need to update the snapshot when it changes - // todo: remove listener on transition end - presentedView.on('imageSourceChange', () => { - sharedElementSnapshot.image = iOSNativeHelper.snapshotView(presentedSharedElement, Screen.mainScreen.scale); - sharedElementSnapshot.tintColor = presentedSharedElement.tintColor; - }); - - sharedElementSnapshot.tintColor = presentedSharedElement.tintColor; - sharedElementSnapshot.contentMode = presentedSharedElement.contentMode; - } - - iOSNativeHelper.copyLayerProperties(sharedElementSnapshot, presentingSharedElement); - sharedElementSnapshot.clipsToBounds = true; - // console.log('---> snapshot: ', sharedElementSnapshot); - - const startFrame = presentingSharedElement.convertRectToView(presentingSharedElement.bounds, transitionContext.containerView); - const endFrame = presentedSharedElement.convertRectToView(presentedSharedElement.bounds, transitionContext.containerView); - sharedElementSnapshot.frame = startFrame; - if (SharedTransition.DEBUG) { - console.log('---> ', presentingView.sharedTransitionTag, ' frame:', iOSNativeHelper.printCGRect(sharedElementSnapshot.frame)); - } - - owner.sharedElements.presenting.push({ - view: presentingView, - startFrame, - endFrame, - snapshot: sharedElementSnapshot, - startOpacity: presentingView.opacity, - endOpacity: presentedView.opacity, - }); - owner.sharedElements.presented.push({ - view: presentedView, - startFrame: endFrame, - endFrame: startFrame, - startOpacity: presentedView.opacity, - endOpacity: presentingView.opacity, - }); - - // set initial opacity to match the source view opacity - sharedElementSnapshot.alpha = presentingView.opacity; - // hide both while animating within the transition context - presentingView.opacity = 0; - presentedView.opacity = 0; - // add snapshot to animate - transitionContext.containerView.addSubview(sharedElementSnapshot); - } - - const cleanupPresent = () => { - for (const presented of owner.sharedElements.presented) { - presented.view.opacity = presented.startOpacity; - } - for (const presenting of owner.sharedElements.presenting) { - presenting.snapshot.removeFromSuperview(); - } - SharedTransition.updateState(owner.id, { - activeType: SharedTransitionAnimationType.dismiss, - }); - owner.presenting.view.removeFromSuperview(); - transitionContext.completeTransition(true); - }; - const updateFramePresent = () => { - // 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(); - if (SharedTransition.DEBUG) { - console.log('3. Animating shared elements:'); - } - for (const presented of owner.sharedElements.presented) { - const presentingMatch = owner.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 - iOSNativeHelper.copyLayerProperties(presentingMatch.snapshot, presented.view.ios); - // create a snapshot of the presented view - presentingMatch.snapshot.image = iOSNativeHelper.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: `, iOSNativeHelper.printCGRect(correctedEndFrame)); - } - } - }; - - // starting page properties - const toProps = state.pageStart; - owner.presented.view.alpha = isNumber(toProps?.opacity) ? toProps?.opacity : 0; - - const startX = isNumber(toProps?.x) ? toProps?.x : Screen.mainScreen.widthDIPs; - const startY = isNumber(toProps?.y) ? toProps?.y : 0; - const startWidth = isNumber(toProps?.width) ? toProps?.width : Screen.mainScreen.widthDIPs; - const startHeight = isNumber(toProps?.height) ? toProps?.height : Screen.mainScreen.heightDIPs; - owner.presented.view.frame = CGRectMake(startX, startY, startWidth, startHeight); - - const animateProperties = () => { - const props = state.pageEnd; - // animate page properties to the following: - owner.presented.view.alpha = isNumber(props?.opacity) ? props?.opacity : 1; - - const endX = isNumber(props?.x) ? props?.x : 0; - const endY = isNumber(props?.y) ? props?.y : 0; - const endWidth = isNumber(props?.width) ? props?.width : Screen.mainScreen.widthDIPs; - const endHeight = isNumber(props?.height) ? props?.height : Screen.mainScreen.heightDIPs; - owner.presented.view.frame = CGRectMake(endX, endY, endWidth, endHeight); - }; - if (isNumber(toProps?.duration)) { - // override spring and use only linear animation - UIView.animateWithDurationAnimationsCompletion( - toProps?.duration / 1000, - () => { - animateProperties(); - updateFramePresent(); - }, - () => { - cleanupPresent(); - } - ); - } else { - const spring = toProps?.spring; - iOSNativeHelper.animateWithSpring({ - tension: isNumber(spring?.tension) ? spring?.tension : DEFAULT_SPRING.tension, - friction: isNumber(spring?.friction) ? spring?.friction : DEFAULT_SPRING.friction, - animations: () => { - animateProperties(); - - updateFramePresent(); - }, - completion: () => { - cleanupPresent(); - }, - }); - } - break; - } - case UINavigationControllerOperation.Pop: { - // console.log('popping!'); - transitionContext.containerView.insertSubviewBelowSubview(owner.presenting.view, owner.presented.view); - - // console.log('transitionContext.containerView.subviews.count:', transitionContext.containerView.subviews.count); - - if (SharedTransition.DEBUG) { - console.log(' PageTransition: Pop'); - console.log( - `1. Dismiss sharedTransitionTags to animate:`, - owner.sharedElements.presented.map((p) => p.view.sharedTransitionTag) - ); - - console.log(`2. Add back previously stored sharedElements to dismiss:`); - } - - for (const p of owner.sharedElements.presented) { - p.view.opacity = 0; - } - for (const p of owner.sharedElements.presenting) { - p.snapshot.alpha = p.endOpacity; - transitionContext.containerView.addSubview(p.snapshot); - } - - const cleanupDismiss = () => { - for (const presenting of owner.sharedElements.presenting) { - presenting.view.opacity = presenting.startOpacity; - presenting.snapshot.removeFromSuperview(); - } - SharedTransition.finishState(owner.id); - transitionContext.completeTransition(true); - }; - const updateFrameDismiss = () => { - if (SharedTransition.DEBUG) { - console.log('3. Dismissing shared elements:'); - } - for (const presenting of owner.sharedElements.presenting) { - iOSNativeHelper.copyLayerProperties(presenting.snapshot, presenting.view.ios); - presenting.snapshot.frame = presenting.startFrame; - presenting.snapshot.alpha = presenting.startOpacity; - - if (SharedTransition.DEBUG) { - console.log(`---> ${presenting.view.sharedTransitionTag} animate to: `, iOSNativeHelper.printCGRect(presenting.startFrame)); - } - } - }; - - const props = state.pageReturn; - - const animateProperties = () => { - owner.presented.view.alpha = isNumber(props?.opacity) ? props?.opacity : 0; - - const endX = isNumber(props?.x) ? props?.x : Screen.mainScreen.widthDIPs; - const endY = isNumber(props?.y) ? props?.y : 0; - const endWidth = isNumber(props?.width) ? props?.width : Screen.mainScreen.widthDIPs; - const endHeight = isNumber(props?.height) ? props?.height : Screen.mainScreen.heightDIPs; - owner.presented.view.frame = CGRectMake(endX, endY, endWidth, endHeight); - }; - - if (isNumber(props?.duration)) { - // override spring and use only linear animation - UIView.animateWithDurationAnimationsCompletion( - props?.duration / 1000, - () => { - animateProperties(); - updateFrameDismiss(); - }, - () => { - cleanupDismiss(); - } - ); - } else { - const spring = props?.spring; - iOSNativeHelper.animateWithSpring({ - tension: isNumber(spring?.tension) ? spring?.tension : DEFAULT_SPRING.tension, - friction: isNumber(spring?.friction) ? spring?.friction : DEFAULT_SPRING.friction, - animations: () => { - animateProperties(); - - updateFrameDismiss(); - }, - completion: () => { - cleanupDismiss(); - }, - }); - } - break; - } - } + SharedTransitionHelper.animate(state, transitionContext, 'page'); } } } diff --git a/packages/core/ui/transition/shared-transition-helper.android.ts b/packages/core/ui/transition/shared-transition-helper.android.ts new file mode 100644 index 000000000..60f9183a8 --- /dev/null +++ b/packages/core/ui/transition/shared-transition-helper.android.ts @@ -0,0 +1,7 @@ +import { SharedTransitionState } from './shared-transition'; + +export class SharedTransitionHelper { + static animate(state: SharedTransitionState, transitionContext: any, type: 'page' | 'modal') { + // may be able to consolidate android handling here in future + } +} diff --git a/packages/core/ui/transition/shared-transition-helper.d.ts b/packages/core/ui/transition/shared-transition-helper.d.ts new file mode 100644 index 000000000..c45122376 --- /dev/null +++ b/packages/core/ui/transition/shared-transition-helper.d.ts @@ -0,0 +1,10 @@ +import type { SharedTransitionType, SharedInteractiveState } from '.'; +import type { SharedTransitionState } from './shared-transition'; + +export declare class SharedTransitionHelper { + static animate(state: SharedTransitionState, transitionContext: any /* iOS: UIViewControllerContextTransitioning */, type: SharedTransitionType): void; + static interactiveStart(state: SharedTransitionState, interactiveState: SharedInteractiveState, type: SharedTransitionType): void; + static interactiveUpdate(state: SharedTransitionState, interactiveState: SharedInteractiveState, type: SharedTransitionType, percent: number): void; + static interactiveCancel(state: SharedTransitionState, interactiveState: SharedInteractiveState, type: SharedTransitionType): void; + static interactiveFinish(state: SharedTransitionState, interactiveState: SharedInteractiveState, type: SharedTransitionType): void; +} diff --git a/packages/core/ui/transition/shared-transition-helper.ios.ts b/packages/core/ui/transition/shared-transition-helper.ios.ts new file mode 100644 index 000000000..e257a82f1 --- /dev/null +++ b/packages/core/ui/transition/shared-transition-helper.ios.ts @@ -0,0 +1,502 @@ +import type { SharedInteractiveState, SharedTransitionType } from '.'; +import { getPageStartDefaultsForType, getRectFromProps, getSpringFromProps, SharedTransition, SharedTransitionAnimationType, SharedTransitionState } from './shared-transition'; +import { isNumber } from '../../utils/types'; +import { Screen } from '../../platform'; +import { iOSNativeHelper } from '../../utils/native-helper'; +interface PlatformSharedInteractiveState extends SharedInteractiveState { + transitionContext?: UIViewControllerContextTransitioning; + propertyAnimator?: UIViewPropertyAnimator; +} +export class SharedTransitionHelper { + static animate(state: SharedTransitionState, transitionContext: UIViewControllerContextTransitioning, type: SharedTransitionType) { + const transition = state.instance; + setTimeout(() => { + // 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.events().notify({ + eventName: SharedTransition.startedEvent, + }); + + 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); + + if (SharedTransition.DEBUG) { + console.log(' ModalTransition: Present'); + console.log( + `1. Found sharedTransitionTags to animate:`, + sharedElements.map((v) => v.sharedTransitionTag) + ); + + console.log(`2. Take snapshots of shared elements and position them based on presenting view:`); + } + + const pageStart = state.pageStart; + + const startFrame = getRectFromProps(pageStart, getPageStartDefaultsForType(type)); + + const pageEnd = state.pageEnd; + const pageEndIndependentTags = Object.keys(pageEnd?.sharedTransitionTags || {}); + // console.log('pageEndIndependentTags:', pageEndIndependentTags); + + for (const presentingView of sharedElements) { + if (!transition.sharedElements) { + transition.sharedElements = { + presented: [], + presenting: [], + independent: [], + }; + } + const presentingSharedElement = presentingView.ios; + // console.log('fromTarget instanceof UIImageView:', fromTarget instanceof UIImageView) + + // TODO: discuss whether we should check if UIImage/UIImageView type to always snapshot images or if other view types could be duped/added vs. snapshotted + // Note: snapshot may be most efficient/simple + // console.log('---> ', presentingView.sharedTransitionTag, ': ', presentingSharedElement) + + const presentedView = presented.find((v) => v.sharedTransitionTag === presentingView.sharedTransitionTag); + const presentedSharedElement = presentedView.ios; + + const snapshot = UIImageView.alloc().init(); + + // treat images differently... + if (presentedSharedElement instanceof UIImageView) { + // in case the image is loaded async, we need to update the snapshot when it changes + // todo: remove listener on transition end + presentedView.on('imageSourceChange', () => { + snapshot.image = iOSNativeHelper.snapshotView(presentedSharedElement, Screen.mainScreen.scale); + snapshot.tintColor = presentedSharedElement.tintColor; + }); + + snapshot.tintColor = presentedSharedElement.tintColor; + snapshot.contentMode = presentedSharedElement.contentMode; + } + + iOSNativeHelper.copyLayerProperties(snapshot, presentingSharedElement); + 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:', iOSNativeHelper.printCGRect(snapshot.frame)); + } + + transition.sharedElements.presenting.push({ + view: presentingView, + startFrame, + endFrame, + snapshot, + startOpacity: presentingView.opacity, + endOpacity: presentedView.opacity, + }); + transition.sharedElements.presented.push({ + view: presentedView, + startFrame: endFrame, + endFrame: startFrame, + startOpacity: presentedView.opacity, + endOpacity: presentingView.opacity, + }); + + // set initial opacity to match the source view opacity + snapshot.alpha = presentingView.opacity; + // hide both while animating within the transition context + presentingView.opacity = 0; + presentedView.opacity = 0; + // add snapshot to animate + transitionContext.containerView.addSubview(snapshot); + } + + for (const tag of pageEndIndependentTags) { + // only consider start when there's a matching end + const pageStartIndependentProps = pageStart?.sharedTransitionTags[tag]; + console.log('start:', tag, pageStartIndependentProps); + const pageEndIndependentProps = pageEnd?.sharedTransitionTags[tag]; + let independentView = presenting.find((v) => v.sharedTransitionTag === tag); + let isPresented = false; + if (!independentView) { + independentView = presented.find((v) => v.sharedTransitionTag === tag); + if (!independentView) { + break; + } + isPresented = true; + } + const independentSharedElement: UIView = independentView.ios; + + let snapshot: UIImageView; + // if (isPresented) { + // snapshot = UIImageView.alloc().init(); + // } else { + snapshot = UIImageView.alloc().initWithImage(iOSNativeHelper.snapshotView(independentSharedElement, Screen.mainScreen.scale)); + // } + + if (independentSharedElement instanceof UIImageView) { + // in case the image is loaded async, we need to update the snapshot when it changes + // todo: remove listener on transition end + // if (isPresented) { + // independentView.on('imageSourceChange', () => { + // snapshot.image = iOSNativeHelper.snapshotView(independentSharedElement, Screen.mainScreen.scale); + // snapshot.tintColor = independentSharedElement.tintColor; + // }); + // } + + snapshot.tintColor = independentSharedElement.tintColor; + snapshot.contentMode = independentSharedElement.contentMode; + } + snapshot.clipsToBounds = true; + + const startFrame = independentSharedElement.convertRectToView(independentSharedElement.bounds, transitionContext.containerView); + const startFrameRect = getRectFromProps(pageStartIndependentProps); + // adjust for any specified start positions + const startFrameAdjusted = CGRectMake(startFrame.origin.x + startFrameRect.x, startFrame.origin.y + startFrameRect.y, startFrame.size.width, startFrame.size.height); + console.log('startFrameAdjusted:', tag, iOSNativeHelper.printCGRect(startFrameAdjusted)); + // if (pageStartIndependentProps?.scale) { + // snapshot.transform = CGAffineTransformConcat(CGAffineTransformMakeTranslation(startFrameAdjusted.origin.x, startFrameAdjusted.origin.y), CGAffineTransformMakeScale(pageStartIndependentProps.scale.x, pageStartIndependentProps.scale.y)) + // } else { + snapshot.frame = startFrame; //startFrameAdjusted; + // } + if (SharedTransition.DEBUG) { + console.log('---> ', independentView.sharedTransitionTag, ' frame:', iOSNativeHelper.printCGRect(snapshot.frame)); + } + + const endFrameRect = getRectFromProps(pageEndIndependentProps); + + const endFrame = CGRectMake(startFrame.origin.x + endFrameRect.x, startFrame.origin.y + endFrameRect.y, startFrame.size.width, startFrame.size.height); + console.log('endFrame:', tag, iOSNativeHelper.printCGRect(endFrame)); + transition.sharedElements.independent.push({ + view: independentView, + isPresented, + startFrame, + snapshot, + endFrame, + startTransform: independentSharedElement.transform, + scale: pageEndIndependentProps.scale, + startOpacity: independentView.opacity, + endOpacity: isNumber(pageEndIndependentProps.opacity) ? pageEndIndependentProps.opacity : 0, + }); + + independentView.opacity = 0; + // add snapshot to animate + transitionContext.containerView.addSubview(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); + + const cleanupPresent = () => { + for (const presented of transition.sharedElements.presented) { + presented.view.opacity = presented.startOpacity; + } + 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.opacity = independent.startOpacity; + } + } + SharedTransition.updateState(transition.id, { + activeType: SharedTransitionAnimationType.dismiss, + }); + if (type === 'page') { + transition.presenting.view.removeFromSuperview(); + } + transitionContext.completeTransition(true); + SharedTransition.events().notify({ + eventName: SharedTransition.finishedEvent, + }); + }; + + 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 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 + iOSNativeHelper.copyLayerProperties(presentingMatch.snapshot, presented.view.ios); + // create a snapshot of the presented view + presentingMatch.snapshot.image = iOSNativeHelper.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: `, iOSNativeHelper.printCGRect(correctedEndFrame)); + } + } + for (const independent of transition.sharedElements.independent) { + let endFrame: CGRect = 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: `, iOSNativeHelper.printCGRect(independent.endFrame)); + } + } + }; + + if (isNumber(pageEnd?.duration)) { + // override spring and use only linear animation + UIView.animateWithDurationAnimationsCompletion( + pageEnd?.duration / 1000, + () => { + animateProperties(); + }, + () => { + cleanupPresent(); + } + ); + } else { + iOSNativeHelper.animateWithSpring({ + ...getSpringFromProps(pageEnd?.spring), + animations: () => { + animateProperties(); + }, + completion: () => { + cleanupPresent(); + }, + }); + } + + break; + } + case SharedTransitionAnimationType.dismiss: { + // console.log('-- Transition dismiss --'); + SharedTransition.events().notify({ + eventName: SharedTransition.startedEvent, + }); + if (type === 'page') { + transitionContext.containerView.insertSubviewBelowSubview(transition.presenting.view, transition.presented.view); + } + + // console.log('transitionContext.containerView.subviews.count:', transitionContext.containerView.subviews.count); + + if (SharedTransition.DEBUG) { + console.log(' ModalTransition: 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:`); + } + + for (const p of transition.sharedElements.presented) { + p.view.opacity = 0; + } + for (const p of transition.sharedElements.presenting) { + p.snapshot.alpha = p.endOpacity; + transitionContext.containerView.addSubview(p.snapshot); + } + for (const independent of transition.sharedElements.independent) { + independent.snapshot.alpha = independent.endOpacity; + transitionContext.containerView.addSubview(independent.snapshot); + } + + const pageReturn = state.pageReturn; + + const cleanupDismiss = () => { + for (const presenting of transition.sharedElements.presenting) { + presenting.view.opacity = presenting.startOpacity; + presenting.snapshot.removeFromSuperview(); + } + for (const independent of transition.sharedElements.independent) { + independent.view.opacity = independent.startOpacity; + independent.snapshot.removeFromSuperview(); + } + SharedTransition.finishState(transition.id); + transition.sharedElements = null; + transitionContext.completeTransition(true); + SharedTransition.events().notify({ + eventName: SharedTransition.finishedEvent, + }); + }; + + 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); + + for (const presenting of transition.sharedElements.presenting) { + iOSNativeHelper.copyLayerProperties(presenting.snapshot, presenting.view.ios); + presenting.snapshot.frame = presenting.startFrame; + presenting.snapshot.alpha = presenting.startOpacity; + + if (SharedTransition.DEBUG) { + console.log(`---> ${presenting.view.sharedTransitionTag} animate to: `, iOSNativeHelper.printCGRect(presenting.startFrame)); + } + } + + 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: `, iOSNativeHelper.printCGRect(independent.startFrame)); + } + } + }; + + if (isNumber(pageReturn?.duration)) { + // override spring and use only linear animation + UIView.animateWithDurationAnimationsCompletion( + pageReturn?.duration / 1000, + () => { + animateProperties(); + }, + () => { + cleanupDismiss(); + } + ); + } else { + iOSNativeHelper.animateWithSpring({ + ...getSpringFromProps(pageReturn?.spring), + animations: () => { + animateProperties(); + }, + completion: () => { + cleanupDismiss(); + }, + }); + } + + break; + } + } + }); + } + + static interactiveStart(state: SharedTransitionState, interactiveState: SharedInteractiveState, type: SharedTransitionType) { + SharedTransition.events().notify({ + eventName: SharedTransition.startedEvent, + }); + switch (type) { + case 'page': + interactiveState.transitionContext.containerView.insertSubviewBelowSubview(state.instance.presenting.view, state.instance.presented.view); + break; + } + } + + static interactiveUpdate(state: SharedTransitionState, interactiveState: SharedInteractiveState, type: SharedTransitionType, percent: number) { + if (!interactiveState.added) { + interactiveState.added = true; + for (const p of state.instance.sharedElements.presented) { + p.view.opacity = 0; + } + for (const p of state.instance.sharedElements.presenting) { + p.snapshot.alpha = p.endOpacity; + interactiveState.transitionContext.containerView.addSubview(p.snapshot); + } + + 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; + iOSNativeHelper.copyLayerProperties(p.snapshot, p.view.ios); + + p.snapshot.alpha = 1; + } + 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); + }); + } + interactiveState.propertyAnimator.fractionComplete = percent; + } + + static interactiveCancel(state: SharedTransitionState, interactiveState: SharedInteractiveState, type: SharedTransitionType) { + if (state.instance && interactiveState.added && interactiveState.propertyAnimator) { + interactiveState.propertyAnimator.reversed = true; + const duration = isNumber(state.pageStart?.duration) ? state.pageStart?.duration / 1000 : 0.35; + interactiveState.propertyAnimator.continueAnimationWithTimingParametersDurationFactor(null, duration); + setTimeout(() => { + for (const p of state.instance.sharedElements.presented) { + p.view.opacity = 1; + } + for (const p of state.instance.sharedElements.presenting) { + p.snapshot.removeFromSuperview(); + } + state.instance.presented.view.alpha = 1; + interactiveState.propertyAnimator = null; + interactiveState.added = false; + interactiveState.transitionContext.cancelInteractiveTransition(); + interactiveState.transitionContext.completeTransition(false); + SharedTransition.events().notify({ + eventName: SharedTransition.cancelledEvent, + }); + }, duration * 1000); + } + } + + static interactiveFinish(state: SharedTransitionState, interactiveState: SharedInteractiveState, type: SharedTransitionType) { + if (state.instance && interactiveState.added && interactiveState.propertyAnimator) { + interactiveState.propertyAnimator.reversed = false; + + const duration = isNumber(state.pageReturn?.duration) ? state.pageReturn?.duration / 1000 : 0.35; + interactiveState.propertyAnimator.continueAnimationWithTimingParametersDurationFactor(null, duration); + setTimeout(() => { + for (const presenting of state.instance.sharedElements.presenting) { + presenting.view.opacity = presenting.startOpacity; + presenting.snapshot.removeFromSuperview(); + } + + SharedTransition.finishState(state.instance.id); + interactiveState.propertyAnimator = null; + interactiveState.added = false; + interactiveState.transitionContext.finishInteractiveTransition(); + interactiveState.transitionContext.completeTransition(true); + SharedTransition.events().notify({ + eventName: SharedTransition.finishedEvent, + }); + }, duration * 1000); + } + } +} diff --git a/packages/core/ui/transition/shared-transition.ts b/packages/core/ui/transition/shared-transition.ts index 2ab4605bc..b827bbf99 100644 --- a/packages/core/ui/transition/shared-transition.ts +++ b/packages/core/ui/transition/shared-transition.ts @@ -1,4 +1,5 @@ import type { Transition } from '.'; +import { Observable } from '../../data/observable'; import { Screen } from '../../platform'; import { isNumber } from '../../utils/types'; import { querySelectorAll, ViewBase } from '../core/view-base'; @@ -34,10 +35,6 @@ export interface SharedTransitionInteractiveOptions { percentFormula?: (eventData: PanGestureEventData) => number; } export interface SharedTransitionConfig { - /** - * Page which will start the transition. - */ - page?: ViewBase; /** * Preconfigured transition or your own custom configured one. */ @@ -66,6 +63,10 @@ export interface SharedTransitionConfig { pageReturn?: SharedTransitionPageProperties; } export interface SharedTransitionState extends SharedTransitionConfig { + /** + * Page which will start the transition. + */ + page?: ViewBase; activeType?: SharedTransitionAnimationType; toPage?: ViewBase; // used internally for determining interactive gesture state of the transition @@ -74,6 +75,14 @@ export interface SharedTransitionState extends SharedTransitionConfig { } export type SharedRect = { x?: number; y?: number; width?: number; height?: number }; export type SharedProperties = SharedRect & { opacity?: number; scale?: { x?: number; y?: number } }; +export type SharedSpringProperties = { + tension?: number; + friction?: number; + mass?: number; + delay?: number; + velocity?: number; + animateOptions?: any /* ios only: UIViewAnimationOptions */; +}; type SharedTransitionPageProperties = SharedProperties & { /** * Linear duration in milliseconds @@ -85,15 +94,9 @@ type SharedTransitionPageProperties = SharedProperties & { * Spring animation settings. * Defaults to 140 tension with 10 friction. */ - spring?: { - tension?: number; - friction?: number; - mass?: number; - delay?: number; - velocity?: number; - animateOptions?: any /* ios only: UIViewAnimationOptions */; - }; + spring?: SharedSpringProperties; }; +let sharedTransitionEvents: Observable; /** * Shared Element Transitions (preview) * Allows you to auto animate between shared elements on two different screesn to create smooth navigational experiences. @@ -114,6 +117,15 @@ export class SharedTransition { }); return { instance: transition }; } + static events() { + if (!sharedTransitionEvents) { + sharedTransitionEvents = new Observable(); + } + return sharedTransitionEvents; + } + static startedEvent = 'SharedTransitionStartedEvent'; + static finishedEvent = 'SharedTransitionFinishedEvent'; + static cancelledEvent = 'SharedTransitionCancelledEvent'; /** * Enable to see various console logging output of Shared Element Transition behavior. @@ -145,13 +157,13 @@ export class SharedTransition { * @private */ static getState(id: number) { - return SharedTransition.currentStack.find((t) => t.instance.id === id); + return SharedTransition.currentStack?.find((t) => t.instance.id === id); } /** * @private */ static finishState(id: number) { - const index = SharedTransition.currentStack.findIndex((t) => t.instance.id === id); + const index = SharedTransition.currentStack?.findIndex((t) => t.instance.id === id); if (index > -1) { SharedTransition.currentStack.splice(index, 1); } @@ -176,10 +188,10 @@ export class SharedTransition { // 2. Presenting view: gather all sharedTransitionTag views const presentingSharedElements = >querySelectorAll(fromPage, 'sharedTransitionTag'); - console.log( - 'presenting sharedTransitionTags:', - presentingSharedElements.map((v) => v.sharedTransitionTag) - ); + // console.log( + // 'presenting sharedTransitionTags:', + // presentingSharedElements.map((v) => v.sharedTransitionTag) + // ); // 3. only handle sharedTransitionTag on presenting which match presented const presentedTags = presentedSharedElements.map((v) => v.sharedTransitionTag); @@ -206,3 +218,22 @@ export function getRectFromProps(props: SharedTransitionPageProperties, defaults height: isNumber(props?.height) ? props?.height : defaults.height, }; } + +export function getSpringFromProps(props: SharedSpringProperties) { + return { + tension: isNumber(props?.tension) ? props?.tension : DEFAULT_SPRING.tension, + friction: isNumber(props?.friction) ? props?.friction : DEFAULT_SPRING.friction, + mass: isNumber(props?.mass) ? props?.mass : DEFAULT_SPRING.mass, + velocity: isNumber(props?.velocity) ? props?.velocity : DEFAULT_SPRING.velocity, + delay: isNumber(props?.delay) ? props?.delay : DEFAULT_SPRING.delay, + }; +} + +export function getPageStartDefaultsForType(type: 'page' | 'modal') { + return { + x: type === 'page' ? Screen.mainScreen.widthDIPs : 0, + y: type === 'page' ? 0 : Screen.mainScreen.heightDIPs, + width: Screen.mainScreen.widthDIPs, + height: Screen.mainScreen.heightDIPs, + }; +}