fix(react): swipe to go back gesture works on ios (#25563)

resolves #22342

Co-authored-by: masonicboom <masonicboom@users.noreply.github.com>
This commit is contained in:
Liam DeBeasi
2022-07-19 09:28:10 -04:00
committed by GitHub
parent b7afcb0f0c
commit 7ec3683e94
10 changed files with 363 additions and 52 deletions

View File

@ -107,9 +107,10 @@ export class ReactRouterViewStack extends ViewStacks {
return children; return children;
} }
findViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string) { findViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string, updateMatch?: boolean) {
const { viewItem, match } = this.findViewItemByPath(routeInfo.pathname, outletId); const { viewItem, match } = this.findViewItemByPath(routeInfo.pathname, outletId);
if (viewItem && match) { const shouldUpdateMatch = updateMatch === undefined || updateMatch === true;
if (shouldUpdateMatch && viewItem && match) {
viewItem.routeData.match = match; viewItem.routeData.match = match;
} }
return viewItem; return viewItem;

View File

@ -25,6 +25,8 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
context!: React.ContextType<typeof RouteManagerContext>; context!: React.ContextType<typeof RouteManagerContext>;
ionRouterOutlet?: React.ReactElement; ionRouterOutlet?: React.ReactElement;
routerOutletElement: HTMLIonRouterOutletElement | undefined; routerOutletElement: HTMLIonRouterOutletElement | undefined;
prevProps?: StackManagerProps;
skipTransition: boolean;
stackContextValue: StackContextState = { stackContextValue: StackContextState = {
registerIonPage: this.registerIonPage.bind(this), registerIonPage: this.registerIonPage.bind(this),
@ -39,6 +41,8 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
this.transitionPage = this.transitionPage.bind(this); this.transitionPage = this.transitionPage.bind(this);
this.handlePageTransition = this.handlePageTransition.bind(this); this.handlePageTransition = this.handlePageTransition.bind(this);
this.id = generateId('routerOutlet'); this.id = generateId('routerOutlet');
this.prevProps = undefined;
this.skipTransition = false;
} }
componentDidMount() { componentDidMount() {
@ -50,7 +54,13 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
} }
componentDidUpdate(prevProps: StackManagerProps) { componentDidUpdate(prevProps: StackManagerProps) {
if (this.props.routeInfo.pathname !== prevProps.routeInfo.pathname || this.pendingPageTransition) { const { pathname } = this.props.routeInfo;
const { pathname: prevPathname } = prevProps.routeInfo;
if (pathname !== prevPathname) {
this.prevProps = prevProps;
this.handlePageTransition(this.props.routeInfo);
} else if (this.pendingPageTransition) {
this.handlePageTransition(this.props.routeInfo); this.handlePageTransition(this.props.routeInfo);
this.pendingPageTransition = false; this.pendingPageTransition = false;
} }
@ -187,34 +197,151 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
const canStart = () => { const canStart = () => {
const config = getConfig(); const config = getConfig();
const swipeEnabled = config && config.get('swipeBackEnabled', routerOutlet.mode === 'ios'); const swipeEnabled = config && config.get('swipeBackEnabled', routerOutlet.mode === 'ios');
if (swipeEnabled) { if (!swipeEnabled) { return false; }
return this.context.canGoBack();
} else { const { routeInfo } = this.props;
return false;
} const propsToUse = (this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute) ? this.prevProps.routeInfo : { pathname: routeInfo.pushedByRoute || '' } as any;
const enteringViewItem = this.context.findViewItemByRouteInfo(propsToUse, this.id, false);
return (
!!enteringViewItem &&
/**
* The root url '/' is treated as
* the first view item (but is never mounted),
* so we do not want to swipe back to the
* root url.
*/
enteringViewItem.mount &&
/**
* When on the first page (whatever view
* you land on after the root url) it
* is possible for findViewItemByRouteInfo to
* return the exact same view you are currently on.
* Make sure that we are not swiping back to the same
* instances of a view.
*/
enteringViewItem.routeData.match.path !== routeInfo.pathname
);
}; };
const onStart = () => { const onStart = async () => {
this.context.goBack(); const { routeInfo } = this.props;
const propsToUse = (this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute) ? this.prevProps.routeInfo : { pathname: routeInfo.pushedByRoute || '' } as any;
const enteringViewItem = this.context.findViewItemByRouteInfo(propsToUse, this.id, false);
const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
/**
* When the gesture starts, kick off
* a transition that is controlled
* via a swipe gesture.
*/
if (enteringViewItem && leavingViewItem) {
await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true);
}
return Promise.resolve();
}; };
const onEnd = (shouldContinue: boolean) => {
if (shouldContinue) {
this.skipTransition = true;
this.context.goBack();
} else {
/**
* In the event that the swipe
* gesture was aborted, we should
* re-hide the page that was going to enter.
*/
const { routeInfo } = this.props;
const propsToUse = (this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute) ? this.prevProps.routeInfo : { pathname: routeInfo.pushedByRoute || '' } as any;
const enteringViewItem = this.context.findViewItemByRouteInfo(propsToUse, this.id, false);
const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
/**
* Ionic React has a design defect where it
* a) Unmounts the leaving view item when using parameterized routes
* b) Considers the current view to be the entering view when using
* parameterized routes
*
* As a result, we should not hide the view item here
* as it will cause the current view to be hidden.
*/
if (
enteringViewItem !== leavingViewItem &&
enteringViewItem?.ionPageElement !== undefined
) {
const { ionPageElement } = enteringViewItem;
ionPageElement.setAttribute('aria-hidden', 'true');
ionPageElement.classList.add('ion-page-hidden');
}
}
}
routerOutlet.swipeHandler = { routerOutlet.swipeHandler = {
canStart, canStart,
onStart, onStart,
onEnd: (_shouldContinue) => true, onEnd
}; };
} }
async transitionPage( async transitionPage(
routeInfo: RouteInfo, routeInfo: RouteInfo,
enteringViewItem: ViewItem, enteringViewItem: ViewItem,
leavingViewItem?: ViewItem leavingViewItem?: ViewItem,
direction?: 'forward' | 'back',
progressAnimation = false
) { ) {
const runCommit = async (enteringEl: HTMLElement, leavingEl?: HTMLElement) => {
const skipTransition = this.skipTransition;
/**
* If the transition was handled
* via the swipe to go back gesture,
* then we do not want to perform
* another transition.
*
* We skip adding ion-page or ion-page-invisible
* because the entering view already exists in the DOM.
* If we added the classes, there would be a flicker where
* the view would be briefly hidden.
*/
if (skipTransition) {
/**
* We need to reset skipTransition before
* we call routerOutlet.commit otherwise
* the transition triggered by the swipe
* to go back gesture would reset it. In
* that case you would see a duplicate
* transition triggered by handlePageTransition
* in componentDidUpdate.
*/
this.skipTransition = false;
} else {
enteringEl.classList.add('ion-page');
enteringEl.classList.add('ion-page-invisible');
}
await routerOutlet.commit(enteringEl, leavingEl, {
deepWait: true,
duration: skipTransition || directionToUse === undefined ? 0 : undefined,
direction: directionToUse,
showGoBack: !!routeInfo.pushedByRoute,
progressAnimation,
animationBuilder: routeInfo.routeAnimation,
});
}
const routerOutlet = this.routerOutletElement!; const routerOutlet = this.routerOutletElement!;
const direction = const routeInfoFallbackDirection =
routeInfo.routeDirection === 'none' || routeInfo.routeDirection === 'root' routeInfo.routeDirection === 'none' || routeInfo.routeDirection === 'root'
? undefined ? undefined
: routeInfo.routeDirection; : routeInfo.routeDirection;
const directionToUse = direction ?? routeInfoFallbackDirection;
if (enteringViewItem && enteringViewItem.ionPageElement && this.routerOutletElement) { if (enteringViewItem && enteringViewItem.ionPageElement && this.routerOutletElement) {
if ( if (
@ -238,26 +365,12 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
} }
} else { } else {
await runCommit(enteringViewItem.ionPageElement, leavingViewItem?.ionPageElement); await runCommit(enteringViewItem.ionPageElement, leavingViewItem?.ionPageElement);
if (leavingViewItem && leavingViewItem.ionPageElement) { if (leavingViewItem && leavingViewItem.ionPageElement && !progressAnimation) {
leavingViewItem.ionPageElement.classList.add('ion-page-hidden'); leavingViewItem.ionPageElement.classList.add('ion-page-hidden');
leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true'); leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true');
} }
} }
} }
async function runCommit(enteringEl: HTMLElement, leavingEl?: HTMLElement) {
enteringEl.classList.add('ion-page');
enteringEl.classList.add('ion-page-invisible');
await routerOutlet.commit(enteringEl, leavingEl, {
deepWait: true,
duration: direction === undefined ? 0 : undefined,
direction: direction as any,
showGoBack: !!routeInfo.pushedByRoute,
progressAnimation: false,
animationBuilder: routeInfo.routeAnimation,
});
}
} }
render() { render() {

View File

@ -5,13 +5,139 @@ describe('Swipe To Go Back', () => {
This spec tests that swipe to go back works This spec tests that swipe to go back works
*/ */
it('/swipe-to-go-back, ', () => { it('should swipe and abort', () => {
cy.visit(`http://localhost:${port}/swipe-to-go-back`); cy.visit(`http://localhost:${port}/swipe-to-go-back`);
cy.ionPageVisible('main'); cy.ionPageVisible('main');
cy.ionNav('ion-item', 'Details'); cy.ionNav('ion-item', 'Details');
cy.ionPageVisible('details'); cy.ionPageVisible('details');
cy.ionPageHidden('main'); cy.ionPageHidden('main');
cy.ionSwipeToGoBack(true);
cy.ionSwipeToGoBack(false, 'ion-router-outlet#swipe-to-go-back');
cy.ionPageVisible('details');
cy.ionPageHidden('main');
});
it('should swipe and go back', () => {
cy.visit(`http://localhost:${port}/swipe-to-go-back`);
cy.ionPageVisible('main');
cy.ionNav('ion-item', 'Details');
cy.ionPageVisible('details');
cy.ionPageHidden('main');
cy.ionSwipeToGoBack(true, 'ion-router-outlet#swipe-to-go-back');
cy.ionPageVisible('main'); cy.ionPageVisible('main');
}); });
it('should swipe and abort within a tab', () => {
cy.visit(`http://localhost:${port}/tabs/tab1`);
cy.ionPageVisible('tab1');
cy.get('#child-one').click();
cy.ionPageHidden('tab1');
cy.ionPageVisible('tab1child1');
cy.ionSwipeToGoBack(false, 'ion-tabs ion-router-outlet');
cy.ionPageHidden('tab1');
cy.ionPageVisible('tab1child1')
});
it('should swipe and go back within a tab', () => {
cy.visit(`http://localhost:${port}/tabs/tab1`);
cy.ionPageVisible('tab1');
cy.get('#child-one').click();
cy.ionPageHidden('tab1');
cy.ionPageVisible('tab1child1');
cy.ionSwipeToGoBack(true, 'ion-tabs ion-router-outlet');
cy.ionPageVisible('tab1');
cy.ionPageDoesNotExist('tab1child1')
});
it('should swipe and go back to correct tab after switching tabs', () => {
cy.visit(`http://localhost:${port}`);
cy.ionPageVisible('home');
cy.get('#go-to-tabs').click();
cy.ionPageHidden('home');
cy.ionPageVisible('tab1');
cy.ionPageVisible('tabs');
cy.get('#child-one').click();
cy.ionPageHidden('tab1');
cy.ionPageVisible('tab1child1');
cy.get('ion-tab-button#tab-button-tab2').click();
cy.ionPageVisible('tab2');
cy.ionPageHidden('tab1child1');
cy.get('ion-tab-button#tab-button-tab1').click();
cy.ionPageVisible('tab1child1');
cy.ionPageHidden('tab2');
cy.ionSwipeToGoBack(true, 'ion-tabs ion-router-outlet');
cy.ionPageVisible('tab1');
cy.ionPageDoesNotExist('tab1child1');
cy.ionSwipeToGoBack(true, 'ion-tabs ion-router-outlet');
cy.ionPageVisible('home');
cy.ionPageDoesNotExist('tabs');
});
it('should be able to swipe back from child tab page after visiting', () => {
cy.visit(`http://localhost:${port}/tabs/tab1`);
cy.ionPageVisible('tab1');
cy.get('#child-one').click();
cy.ionPageHidden('tab1');
cy.ionPageVisible('tab1child1');
cy.get('#child-two').click();
cy.ionPageHidden('tab1child1');
cy.ionPageVisible('tab1child2');
cy.ionSwipeToGoBack(true, 'ion-tabs ion-router-outlet');
cy.ionPageDoesNotExist('tab1child2');
cy.ionPageVisible('tab1child1');
cy.ionSwipeToGoBack(true, 'ion-tabs ion-router-outlet');
cy.ionPageDoesNotExist('tab1child1');
cy.ionPageVisible('tab1');
cy.get('#child-one').click();
cy.ionPageHidden('tab1');
cy.ionPageVisible('tab1child1');
cy.ionSwipeToGoBack(true, 'ion-tabs ion-router-outlet');
cy.ionPageDoesNotExist('tab1child1');
cy.ionPageVisible('tab1');
})
it('should not swipe to go back to the same view you are on', () => {
cy.visit(`http://localhost:${port}`);
cy.ionPageVisible('home');
cy.ionSwipeToGoBack(false);
cy.ionPageVisible('home');
})
it('should not hide a parameterized page when swiping and aborting', () => {
cy.visit(`http://localhost:${port}/params/0`);
cy.ionPageVisible('params-0');
cy.get('#next-page').click();
cy.ionPageVisible('params-1');
cy.ionSwipeToGoBack(false);
cy.ionPageVisible('params-1');
})
}); });

View File

@ -102,7 +102,7 @@ Cypress.Commands.add('ionMenuNav', (contains) => {
Cypress.Commands.add('ionTabClick', (tabText) => { Cypress.Commands.add('ionTabClick', (tabText) => {
// TODO: figure out how to get rid of this wait. Switching tabs after a forward nav to a details page needs it // TODO: figure out how to get rid of this wait. Switching tabs after a forward nav to a details page needs it
cy.wait(250); cy.wait(500);
cy.contains('ion-tab-button', tabText).click({ force: true }); cy.contains('ion-tab-button', tabText).click({ force: true });
// cy.get('ion-tab-button.tab-selected').contains(tabText) // cy.get('ion-tab-button.tab-selected').contains(tabText)
}); });

View File

@ -1,4 +1,4 @@
import { IonApp, setupIonicReact } from '@ionic/react'; import { IonApp, setupIonicReact, IonRouterOutlet } from '@ionic/react';
import React from 'react'; import React from 'react';
import { Route } from 'react-router-dom'; import { Route } from 'react-router-dom';
@ -36,6 +36,7 @@ import Refs from './pages/refs/Refs';
import DynamicIonpageClassnames from './pages/dynamic-ionpage-classnames/DynamicIonpageClassnames'; import DynamicIonpageClassnames from './pages/dynamic-ionpage-classnames/DynamicIonpageClassnames';
import Tabs from './pages/tabs/Tabs'; import Tabs from './pages/tabs/Tabs';
import TabsSecondary from './pages/tabs/TabsSecondary'; import TabsSecondary from './pages/tabs/TabsSecondary';
import Params from './pages/params/Params';
setupIonicReact(); setupIonicReact();
@ -43,21 +44,24 @@ const App: React.FC = () => {
return ( return (
<IonApp> <IonApp>
<IonReactRouter> <IonReactRouter>
<Route path="/" component={Main} exact /> <IonRouterOutlet>
<Route path="/routing" component={Routing} /> <Route path="/" component={Main} exact />
<Route path="/dynamic-routes" component={DynamicRoutes} /> <Route path="/routing" component={Routing} />
<Route path="/multiple-tabs" component={MultipleTabs} /> <Route path="/dynamic-routes" component={DynamicRoutes} />
<Route path="/dynamic-tabs" component={DynamicTabs} /> <Route path="/multiple-tabs" component={MultipleTabs} />
<Route path="/nested-outlet" component={NestedOutlet} /> <Route path="/dynamic-tabs" component={DynamicTabs} />
<Route path="/nested-outlet2" component={NestedOutlet2} /> <Route path="/nested-outlet" component={NestedOutlet} />
<Route path="/replace-action" component={ReplaceAction} /> <Route path="/nested-outlet2" component={NestedOutlet2} />
<Route path="/tab-context" component={TabsContext} /> <Route path="/replace-action" component={ReplaceAction} />
<Route path="/outlet-ref" component={OutletRef} /> <Route path="/tab-context" component={TabsContext} />
<Route path="/swipe-to-go-back" component={SwipeToGoBack} /> <Route path="/outlet-ref" component={OutletRef} />
<Route path="/dynamic-ionpage-classnames" component={DynamicIonpageClassnames} /> <Route path="/swipe-to-go-back" component={SwipeToGoBack} />
<Route path="/tabs" component={Tabs} /> <Route path="/dynamic-ionpage-classnames" component={DynamicIonpageClassnames} />
<Route path="/tabs-secondary" component={TabsSecondary} /> <Route path="/tabs" component={Tabs} />
<Route path="/refs" component={Refs} /> <Route path="/tabs-secondary" component={TabsSecondary} />
<Route path="/refs" component={Refs} />
<Route path="/params/:id" component={Params} />
</IonRouterOutlet>
</IonReactRouter> </IonReactRouter>
</IonApp> </IonApp>
); );

View File

@ -14,7 +14,7 @@ interface MainProps {}
const Main: React.FC<MainProps> = () => { const Main: React.FC<MainProps> = () => {
return ( return (
<IonPage> <IonPage data-pageid="home">
<IonHeader> <IonHeader>
<IonToolbar> <IonToolbar>
<IonTitle>Main</IonTitle> <IonTitle>Main</IonTitle>
@ -58,6 +58,12 @@ const Main: React.FC<MainProps> = () => {
<IonItem routerLink="/Refs"> <IonItem routerLink="/Refs">
<IonLabel>Refs</IonLabel> <IonLabel>Refs</IonLabel>
</IonItem> </IonItem>
<IonItem routerLink="/tabs" id="go-to-tabs">
<IonLabel>Tabs</IonLabel>
</IonItem>
<IonItem routerLink="/params/0">
<IonLabel>Params</IonLabel>
</IonItem>
</IonList> </IonList>
</IonContent> </IonContent>
</IonPage> </IonPage>

View File

@ -0,0 +1,41 @@
import React from 'react';
import {
IonButtons,
IonBackButton,
IonButton,
IonContent,
IonHeader,
IonPage,
IonTitle,
IonToolbar,
} from '@ionic/react';
import { RouteComponentProps } from 'react-router';
interface PageProps
extends RouteComponentProps<{
id: string;
}> {}
const Page: React.FC<PageProps> = ({ match }) => {
const parseID = parseInt(match.params.id);
return (
<IonPage data-pageid={'params-' + match.params.id }>
<IonHeader>
<IonToolbar>
<IonTitle>Params { match.params.id }</IonTitle>
<IonButtons slot="start">
<IonBackButton />
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent>
<IonButton id="next-page" routerLink={'/params/' + (parseID + 1) } >Go to next param</IonButton>
<br />
Page ID: { match.params.id }
</IonContent>
</IonPage>
);
};
export default Page;

View File

@ -22,11 +22,12 @@ interface TabsProps {}
const Tabs: React.FC<TabsProps> = () => { const Tabs: React.FC<TabsProps> = () => {
return ( return (
<IonTabs> <IonTabs data-pageid="tabs">
<IonRouterOutlet id="tabs"> <IonRouterOutlet id="tabs">
<Route path="/tabs/tab1" component={Tab1} exact /> <Route path="/tabs/tab1" component={Tab1} exact />
<Route path="/tabs/tab2" component={Tab2} exact /> <Route path="/tabs/tab2" component={Tab2} exact />
<Route path="/tabs/tab1/child" component={Tab1Child1} exact /> <Route path="/tabs/tab1/child" component={Tab1Child1} exact />
<Route path="/tabs/tab1/child2" component={Tab1Child2} exact />
<Redirect from="/tabs" to="/tabs/tab1" exact /> <Redirect from="/tabs" to="/tabs/tab1" exact />
</IonRouterOutlet> </IonRouterOutlet>
<IonTabBar slot="bottom"> <IonTabBar slot="bottom">
@ -71,7 +72,26 @@ const Tab1Child1 = () => {
</IonToolbar> </IonToolbar>
</IonHeader> </IonHeader>
<IonContent> <IonContent>
Tab 1 Child 1
<IonButton routerLink="/tabs/tab1/child2" id="child-two">Go to Tab1Child2</IonButton>
</IonContent>
</IonPage>
);
};
const Tab1Child2 = () => {
return (
<IonPage data-pageid="tab1child2">
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonBackButton />
</IonButtons>
<IonTitle>Tab1</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
Tab 1 Child 2
</IonContent> </IonContent>
</IonPage> </IonPage>
); );

View File

@ -16,7 +16,7 @@ export interface RouteManagerContextState {
) => ViewItem; ) => ViewItem;
findViewItemByPathname(pathname: string, outletId?: string): ViewItem | undefined; findViewItemByPathname(pathname: string, outletId?: string): ViewItem | undefined;
findLeavingViewItemByRouteInfo: (routeInfo: RouteInfo, outletId?: string) => ViewItem | undefined; findLeavingViewItemByRouteInfo: (routeInfo: RouteInfo, outletId?: string) => ViewItem | undefined;
findViewItemByRouteInfo: (routeInfo: RouteInfo, outletId?: string) => ViewItem | undefined; findViewItemByRouteInfo: (routeInfo: RouteInfo, outletId?: string, updateMatch?: boolean) => ViewItem | undefined;
getChildrenToRender: ( getChildrenToRender: (
outletId: string, outletId: string,
ionRouterOutlet: React.ReactElement, ionRouterOutlet: React.ReactElement,

View File

@ -65,7 +65,7 @@ export abstract class ViewStacks {
page?: HTMLElement page?: HTMLElement
): ViewItem; ): ViewItem;
abstract findViewItemByPathname(pathname: string, outletId?: string): ViewItem | undefined; abstract findViewItemByPathname(pathname: string, outletId?: string): ViewItem | undefined;
abstract findViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string): ViewItem | undefined; abstract findViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string, updateMatch?: boolean): ViewItem | undefined;
abstract findLeavingViewItemByRouteInfo( abstract findLeavingViewItemByRouteInfo(
routeInfo: RouteInfo, routeInfo: RouteInfo,
outletId?: string outletId?: string