Skip to content

fix(react-router): enable swipe-back animation for React iOS #25469

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 11 commits into from
116 changes: 113 additions & 3 deletions packages/react-router/src/ReactRouter/StackManager.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {
AnimationBuilder,
RouteInfo,
RouteManagerContext,
StackContext,
StackContextState,
ViewItem,
generateId,
getConfig,
iosTransitionAnimation,
} from '@ionic/react';
import React from 'react';
import { matchPath } from 'react-router-dom';
Expand Down Expand Up @@ -33,6 +35,9 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa

private pendingPageTransition = false;

// HACK: this flag prevents a duplicate transition after swiping back.
private skipTransition = false;

constructor(props: StackManagerProps) {
super(props);
this.registerIonPage = this.registerIonPage.bind(this);
Expand Down Expand Up @@ -194,13 +199,103 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
}
};

const onStart = () => {
this.context.goBack();
const onStart = async () => {
if (routerOutlet.mode !== 'ios') {
this.context.goBack();
return;
}

const routeInfo = this.props.routeInfo;
const routerAnimation = routeInfo.routeAnimation;

const outletId = this.id;
const enteringViewItem = this.context.findViewItemByPathname(routeInfo.pushedByRoute || '', outletId);
const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, outletId);

if (leavingViewItem) {
const defaultAnimationBuilder = routerOutlet.mode === 'ios' ? iosTransitionAnimation : undefined;
const animationBuilder = routerAnimation ?? defaultAnimationBuilder;
const enteringEl = enteringViewItem?.ionPageElement;
const leavingEl = leavingViewItem.ionPageElement;

if (enteringEl && leavingEl) {
await transition(
enteringEl,
leavingEl,
'back',
this.context.canGoBack(),
true,
animationBuilder
);
}
}

return Promise.resolve();
};

const transition = (
enteringEl: HTMLElement,
leavingEl: HTMLElement,
direction: any, // TODO types
showGoBack: boolean,
progressAnimation: boolean,
animationBuilder?: AnimationBuilder
) => {
return new Promise(resolve => {
// NOTE: When transitioning between two instances of the same component
// (with different props) enteringEl and leavingEl may be identical.
// To handle that, clone the leaving element and transition from that.
let clone: HTMLElement | undefined;
if (enteringEl === leavingEl) {
clone = clonePageElement(leavingEl.outerHTML);

if (!clone) {
// TODO: should probably use this return value to trigger a goBack, even though the transition didn't happen.
return resolve(false);
}
}

requestAnimationFrame(() => {
requestAnimationFrame(async () => {
// enteringEl.classList.add('ion-page-invisible');

const leavingOrClonedEl = clone ?? leavingEl;
if (leavingOrClonedEl === clone) {
this.routerOutletElement?.appendChild(clone);
}

const result = await this.routerOutletElement?.commit(enteringEl, leavingOrClonedEl, {
deepWait: true,
duration: direction === undefined || direction === 'root' || direction === 'none' ? 0 : undefined,
direction,
showGoBack,
progressAnimation,
animationBuilder
});

if (leavingOrClonedEl === clone) {
this.routerOutletElement?.removeChild(clone);
}

return resolve(result);
});
});
});
}

routerOutlet.swipeHandler = {
canStart,
onStart,
onEnd: (_shouldContinue) => true,
onEnd: (_shouldContinue) => {
if (_shouldContinue) {
// HACK: this flag prevents a redundant transition upon going back.
this.skipTransition = true;

this.context.goBack();
}

return true;
},
};
}

Expand All @@ -211,6 +306,11 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
) {
const routerOutlet = this.routerOutletElement!;

// HACK: when swiping back, the transition has already happened.
// This flag prevents a redundant transition after the swipe completes.
const { skipTransition } = this;
const resetSkipTransition = () => this.skipTransition = false;

const direction =
routeInfo.routeDirection === 'none' || routeInfo.routeDirection === 'root'
? undefined
Expand Down Expand Up @@ -249,13 +349,23 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
enteringEl.classList.add('ion-page');
enteringEl.classList.add('ion-page-invisible');

// HACK: when swiping back, the transition has already happened.
// This flag prevents a redundant transition after the swipe completes.
const animated = !skipTransition;
if (skipTransition) {
// Reset the flag so that only the transition that gets skipped is
// the one immediately following the swipe.
resetSkipTransition();
}

await routerOutlet.commit(enteringEl, leavingEl, {
deepWait: true,
duration: direction === undefined ? 0 : undefined,
direction: direction as any,
showGoBack: !!routeInfo.pushedByRoute,
progressAnimation: false,
animationBuilder: routeInfo.routeAnimation,
animated,
});
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/test-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import DynamicIonpageClassnames from './pages/dynamic-ionpage-classnames/Dynamic
import Tabs from './pages/tabs/Tabs';
import TabsSecondary from './pages/tabs/TabsSecondary';

setupIonicReact();
setupIonicReact({ mode: "ios" });

const App: React.FC = () => {
return (
Expand Down