Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion core/src/components/nav/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getTimeGivenProgression } from '@utils/animation/cubic-bezier';
import { assert } from '@utils/helpers';
import { printIonWarning } from '@utils/logging';
import type { TransitionOptions } from '@utils/transition';
import { lifecycle, setPageHidden, transition } from '@utils/transition';
import { blockSwipeClicks, lifecycle, setPageHidden, transition } from '@utils/transition';

import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global';
Expand All @@ -30,6 +30,7 @@ import { VIEW_STATE_ATTACHED, VIEW_STATE_DESTROYED, VIEW_STATE_NEW, convertToVie
export class Nav implements ComponentInterface {
private transInstr: TransitionInstruction[] = [];
private sbAni?: Animation;
private unblockSwipeClicks?: () => void;
private gestureOrAnimationInProgress = false;
private isTransitioning = false;
private destroyed = false;
Expand Down Expand Up @@ -145,6 +146,14 @@ export class Nav implements ComponentInterface {
this.gesture.destroy();
this.gesture = undefined;
}

// Destroying the gesture skips onEnd, so end a deferred swipe here.
if (this.unblockSwipeClicks) {
this.unblockSwipeClicks();
this.unblockSwipeClicks = undefined;
this.gestureOrAnimationInProgress = false;
}

this.transInstr.length = 0;
this.views.length = 0;
this.destroyed = true;
Expand Down Expand Up @@ -919,6 +928,16 @@ export class Nav implements ComponentInterface {

private onStart() {
this.gestureOrAnimationInProgress = true;

/**
* Without animations there's nothing for the swipe to drive, so
* wait until the gesture ends to decide whether to go back.
*/
if (!(this.animated && config.getBoolean('animated', true))) {
this.unblockSwipeClicks = blockSwipeClicks(this.el);
return;
}

this.pop({ direction: 'back', progressAnimation: true });
}

Expand Down Expand Up @@ -958,6 +977,17 @@ export class Nav implements ComponentInterface {
this.sbAni.progressEnd(shouldComplete ? 1 : 0, newStepValue, dur);
} else {
this.gestureOrAnimationInProgress = false;

const { unblockSwipeClicks } = this;
if (unblockSwipeClicks) {
this.unblockSwipeClicks = undefined;

if (shouldComplete) {
this.pop({ direction: 'back' });
}

unblockSwipeClicks();
}
}
}

Expand Down
153 changes: 153 additions & 0 deletions core/src/components/nav/test/swipe-back/nav.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { expect } from '@playwright/test';
import type { E2EPage, E2EPageOptions } from '@utils/test/playwright';
import { configs, dragElementBy, test } from '@utils/test/playwright';

/**
* Renders an `ion-nav` and pushes a second page. The `extraScript`
* runs before Ionic loads, so it can change the global config.
*/
const pushSecondPage = async (page: E2EPage, config: E2EPageOptions, navAttrs = '', extraScript = '') => {
await page.setContent(
`
<ion-app>
<ion-nav ${navAttrs}></ion-nav>
</ion-app>
<script>
${extraScript}
window.createPage = (id) => {
const el = document.createElement('div');
el.id = id;
el.className = 'ion-page';
el.innerHTML = '<ion-content>' + id + '</ion-content>';
return el;
};
document.querySelector('ion-nav').root = createPage('page-one');
</script>
`,
config
);

const nav = page.locator('ion-nav');
await nav.evaluate((el: HTMLIonNavElement) => el.push((window as any).createPage('page-two')));
await expect(page.locator('#page-two')).toBeVisible();

return nav;
};

/**
* Starts a swipe to go back and lets go partway across, so the user
* should stay on the current page.
*/
const abandonSwipeBack = async (page: E2EPage) => {
const nav = page.locator('ion-nav');
const box = (await nav.boundingBox())!;
const startX = box.x + 5;
const y = box.y + box.height / 2;

await dragElementBy(nav, page, 60, 0, startX, y, false);

/**
* A quick drag is released with enough velocity to complete
* the swipe, so slow down before letting go.
*/
for (let i = 1; i <= 3; i++) {
await page.mouse.move(startX + 60 + i, y);
await page.waitForTimeout(50);
}

await page.mouse.up();
await page.waitForChanges();
};

/**
* Swipe to go back is only enabled by default in iOS mode,
* and this behavior does not vary across directions.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('nav: swipe back'), () => {
test('should not pop the page when an abandoned swipe starts on a non-animated nav', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/29793',
});

const nav = await pushSecondPage(page, config, 'animated="false"');

await abandonSwipeBack(page);

await expect(page.locator('#page-two')).toBeVisible();
expect(await nav.evaluate((el: HTMLIonNavElement) => el.getLength())).toBe(2);
});

test('should not pop the page when an abandoned swipe starts with animations disabled globally', async ({
page,
}, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/29793',
});

const nav = await pushSecondPage(page, config, '', 'window.Ionic.config.animated = false;');

await abandonSwipeBack(page);

await expect(page.locator('#page-two')).toBeVisible();
expect(await nav.evaluate((el: HTMLIonNavElement) => el.getLength())).toBe(2);
});

test('should not click the page when an abandoned swipe is released on a non-animated nav', async ({
page,
}, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/29793',
});

await pushSecondPage(page, config, 'animated="false"');

await page.locator('#page-two').evaluate((pageTwo) => {
const button = document.createElement('button');
button.style.cssText = 'position: absolute; inset: 0; width: 100%; height: 100%;';
button.addEventListener('click', () => ((window as any).pageClicked = true));
pageTwo.appendChild(button);
});

await abandonSwipeBack(page);

expect(await page.evaluate(() => (window as any).pageClicked)).toBeUndefined();
});

test('should not block clicks on a non-animated nav that is moved during a swipe', async ({ page }) => {
const nav = await pushSecondPage(page, config, 'animated="false"');
const box = (await nav.boundingBox())!;

await dragElementBy(nav, page, 60, 0, box.x + 5, box.y + box.height / 2, false);
await expect(nav).toHaveCSS('pointer-events', 'none');

await nav.evaluate((el) => document.body.appendChild(el));

await expect(nav).not.toHaveCSS('pointer-events', 'none');
await page.mouse.up();
});

test('should pop the page when a swipe completes on a non-animated nav', async ({ page }) => {
const nav = await pushSecondPage(page, config, 'animated="false"');
const box = (await nav.boundingBox())!;

await dragElementBy(nav, page, box.width * 0.75, 0, box.x + 5);

await expect.poll(() => nav.evaluate((el: HTMLIonNavElement) => el.getLength())).toBe(1);
await expect(page.locator('#page-one')).toBeVisible();
});

test('should pop the page when a swipe completes on an animated nav', async ({ page }) => {
const nav = await pushSecondPage(page, config);
const box = (await nav.boundingBox())!;

await dragElementBy(nav, page, box.width * 0.75, 0, box.x + 5);

await expect.poll(() => nav.evaluate((el: HTMLIonNavElement) => el.getLength())).toBe(1);
await expect(page.locator('#page-one')).toBeVisible();
});
});
});
36 changes: 34 additions & 2 deletions core/src/components/router-outlet/router-outlet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { attachComponent, detachComponent } from '@utils/framework-delegate';
import { shallowEqualStringMap, hasLazyBuild } from '@utils/helpers';
import { createLockController } from '@utils/lock-controller';
import { printIonError } from '@utils/logging';
import { transition } from '@utils/transition';
import { blockSwipeClicks, transition } from '@utils/transition';

import { config } from '../../global/config';
import { getIonMode } from '../../global/ionic-global';
Expand Down Expand Up @@ -33,6 +33,7 @@ export class RouterOutlet implements ComponentInterface, NavOutlet {
private activeParams: any;
private gesture?: Gesture;
private ani?: Animation;
private unblockSwipeClicks?: () => void;
private gestureOrAnimationInProgress = false;

@Element() el!: HTMLElement;
Expand Down Expand Up @@ -82,6 +83,16 @@ export class RouterOutlet implements ComponentInterface, NavOutlet {
async connectedCallback() {
const onStart = () => {
this.gestureOrAnimationInProgress = true;

/**
* Without animations there's nothing for the swipe to drive, so
* wait until the gesture ends to decide whether to go back.
*/
if (!(this.animated && config.getBoolean('animated', true))) {
this.unblockSwipeClicks = blockSwipeClicks(this.el);
return;
}

if (this.swipeHandler) {
this.swipeHandler.onStart();
}
Expand Down Expand Up @@ -125,7 +136,21 @@ export class RouterOutlet implements ComponentInterface, NavOutlet {

this.ani.progressEnd(shouldComplete ? 1 : 0, newStepValue, dur);
} else {
this.gestureOrAnimationInProgress = false;
const { swipeHandler, unblockSwipeClicks: unblock } = this;
this.unblockSwipeClicks = undefined;

// The stack can change during the swipe, so check that going back is still possible.
if (unblock && shouldComplete && swipeHandler?.canStart()) {
new Promise<void>((resolve) => resolve(swipeHandler.onStart()))
.then(() => swipeHandler.onEnd(true))
.finally(() => {
this.gestureOrAnimationInProgress = false;
unblock();
});
} else {
this.gestureOrAnimationInProgress = false;
unblock?.();
}
}
}
);
Expand All @@ -152,6 +177,13 @@ export class RouterOutlet implements ComponentInterface, NavOutlet {
this.gesture.destroy();
this.gesture = undefined;
}

// Destroying the gesture skips onEnd, so end a deferred swipe here.
if (this.unblockSwipeClicks) {
this.unblockSwipeClicks();
this.unblockSwipeClicks = undefined;
this.gestureOrAnimationInProgress = false;
}
}

/** @internal */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { expect } from '@playwright/test';
import { configs, dragElementBy, test } from '@utils/test/playwright';

/**
* Swipe to go back is only enabled by default in iOS mode,
* and this behavior does not vary across directions.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('router-outlet: swipe back'), () => {
test('should not block clicks on a non-animated outlet that is moved during a swipe', async ({ page }) => {
await page.setContent(
`
<ion-app>
<ion-router-outlet animated="false"></ion-router-outlet>
</ion-app>
`,
config
);

const outlet = page.locator('ion-router-outlet');

// A framework normally provides the handler that lets the gesture start.
await outlet.evaluate((el: HTMLIonRouterOutletElement) => {
el.swipeHandler = {
canStart: () => true,
onStart: () => {},
onEnd: () => {},
};
});
await page.waitForChanges();

const box = (await outlet.boundingBox())!;

await dragElementBy(outlet, page, 60, 0, box.x + 5, box.y + box.height / 2, false);
await expect(outlet).toHaveCSS('pointer-events', 'none');

await outlet.evaluate((el) => document.body.appendChild(el));

await expect(outlet).not.toHaveCSS('pointer-events', 'none');
await page.mouse.up();
});
});
});
13 changes: 13 additions & 0 deletions core/src/utils/transition/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,19 @@ export const setPageHidden = (el: HTMLElement, hidden: boolean) => {
}
};

/**
* Blocks clicks on `el` during a swipe to go back without animations,
* so releasing the pointer doesn't click whatever is under it. Returns
* a function that unblocks it after the release click.
*/
export const blockSwipeClicks = (el: HTMLElement) => {
el.style.setProperty('pointer-events', 'none');

return () => {
setTimeout(() => el.style.removeProperty('pointer-events'));
};
};

const setZIndex = (
enteringEl: HTMLElement | undefined,
leavingEl: HTMLElement | undefined,
Expand Down
Loading
Loading