feat(content, reorder-group, header, footer, infinite-scroll, refresher): add custom scroll target to improve compatibility with virtual scroll (#24883)

Resolves #23437
This commit is contained in:
Sean Perkins
2022-03-15 11:47:46 -04:00
committed by GitHub
parent 171020e9d2
commit 2a438da010
38 changed files with 1305 additions and 178 deletions

View File

@ -24,6 +24,21 @@ The iOS native `ion-refresher` relies on rubber band scrolling in order to work
Using the MD native `ion-refresher` requires setting the `pullingIcon` property on `ion-refresher-content` to the value of one of the available spinners. See the [ion-spinner Documentation](../spinner#properties) for accepted values. `pullingIcon` defaults to the `circular` spinner on MD.
### Virtual Scroll Usage
Refresher requires a scroll container to function. When using a virtual scrolling solution, you will need to disable scrolling on the `ion-content` and indicate which element container is responsible for the scroll container with the `.ion-content-scroll-host` class target.
```html
<ion-content scroll-y="false">
<ion-refresher slot="fixed">
<ion-refresher-content></ion-refresher-content>
</ion-refresher>
<virtual-scroll-element class="ion-content-scroll-host">
<!-- Your virtual scroll content -->
</virtual-scroll-element>
</ion-content>
```
## Interfaces
### RefresherEventDetail

View File

@ -1,9 +1,10 @@
import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h, readTask, writeTask } from '@stencil/core';
import { findClosestIonContent, getScrollElement, printIonContentErrorMsg } from '@utils/content';
import { getIonMode } from '../../global/ionic-global';
import { Animation, Gesture, GestureDetail, RefresherEventDetail } from '../../interface';
import { getTimeGivenProgression } from '../../utils/animation/cubic-bezier';
import { clamp, componentOnReady, getElementRoot, raf, transitionEndAsync } from '../../utils/helpers';
import { clamp, getElementRoot, raf, transitionEndAsync } from '../../utils/helpers';
import { hapticImpact } from '../../utils/native/haptic';
import {
@ -253,50 +254,50 @@ export class Refresher implements ComponentInterface {
this.scrollEl!.addEventListener('scroll', this.scrollListenerCallback);
this.gesture = (await import('../../utils/gesture')).createGesture({
el: this.scrollEl!,
gestureName: 'refresher',
gesturePriority: 31,
direction: 'y',
threshold: 5,
onStart: () => {
this.pointerDown = true;
if (!this.didRefresh) {
translateElement(this.elementToTransform, '0px');
}
el: this.scrollEl!,
gestureName: 'refresher',
gesturePriority: 31,
direction: 'y',
threshold: 5,
onStart: () => {
this.pointerDown = true;
/**
* If the content had `display: none` when
* the refresher was initialized, its clientHeight
* will be 0. When the gesture starts, the content
* will be visible, so try to get the correct
* client height again. This is most common when
* using the refresher in an ion-menu.
*/
if (MAX_PULL === 0) {
MAX_PULL = this.scrollEl!.clientHeight * 0.16;
}
},
onMove: ev => {
this.lastVelocityY = ev.velocityY;
},
onEnd: () => {
this.pointerDown = false;
this.didStart = false;
if (this.needsCompletion) {
this.resetNativeRefresher(this.elementToTransform, RefresherState.Completing);
this.needsCompletion = false;
} else if (this.didRefresh) {
readTask(() => translateElement(this.elementToTransform, `${this.el.clientHeight}px`));
}
},
});
if (!this.didRefresh) {
translateElement(this.elementToTransform, '0px');
}
/**
* If the content had `display: none` when
* the refresher was initialized, its clientHeight
* will be 0. When the gesture starts, the content
* will be visible, so try to get the correct
* client height again. This is most common when
* using the refresher in an ion-menu.
*/
if (MAX_PULL === 0) {
MAX_PULL = this.scrollEl!.clientHeight * 0.16;
}
},
onMove: ev => {
this.lastVelocityY = ev.velocityY;
},
onEnd: () => {
this.pointerDown = false;
this.didStart = false;
if (this.needsCompletion) {
this.resetNativeRefresher(this.elementToTransform, RefresherState.Completing);
this.needsCompletion = false;
} else if (this.didRefresh) {
readTask(() => translateElement(this.elementToTransform, `${this.el.clientHeight}px`));
}
},
});
this.disabledChanged();
}
private async setupMDNativeRefresher(contentEl: HTMLIonContentElement, pullingSpinner: HTMLIonSpinnerElement, refreshingSpinner: HTMLIonSpinnerElement) {
private async setupMDNativeRefresher(contentEl: HTMLElement, pullingSpinner: HTMLIonSpinnerElement, refreshingSpinner: HTMLIonSpinnerElement) {
const circle = getElementRoot(pullingSpinner).querySelector('circle');
const pullingRefresherIcon = this.el.querySelector('ion-refresher-content .refresher-pulling-icon') as HTMLElement;
const refreshingCircle = getElementRoot(refreshingSpinner).querySelector('circle');
@ -383,7 +384,7 @@ export class Refresher implements ComponentInterface {
this.disabledChanged();
}
private async setupNativeRefresher(contentEl: HTMLIonContentElement | null) {
private async setupNativeRefresher(contentEl: HTMLElement | null) {
if (this.scrollListenerCallback || !contentEl || this.nativeRefresher || !this.scrollEl) {
return;
}
@ -419,16 +420,25 @@ export class Refresher implements ComponentInterface {
return;
}
const contentEl = this.el.closest('ion-content');
const contentEl = findClosestIonContent(this.el);
if (!contentEl) {
console.error('<ion-refresher> must be used inside an <ion-content>');
printIonContentErrorMsg(this.el);
return;
}
await new Promise(resolve => componentOnReady(contentEl, resolve));
this.scrollEl = await getScrollElement(contentEl);
/**
* Query the host `ion-content` directly (if it is available), to use its
* inner #background-content has the target. Otherwise fallback to the
* custom scroll target host.
*
* This makes it so that implementers do not need to re-create the background content
* element and styles.
*/
const backgroundContentHost = this.el.closest('ion-content') ?? contentEl;
this.scrollEl = await contentEl.getScrollElement();
this.backgroundContentEl = getElementRoot(contentEl).querySelector('#background-content') as HTMLElement;
this.backgroundContentEl = getElementRoot(backgroundContentHost).querySelector('#background-content') as HTMLElement;
if (await shouldUseNativeRefresher(this.el, getIonMode(this))) {
this.setupNativeRefresher(contentEl);

View File

@ -8,7 +8,7 @@ import { isPlatform } from '../../utils/platform';
// -----------------------------
type RefresherAnimationType = 'scale' | 'translate';
export const getRefresherAnimationType = (contentEl: HTMLIonContentElement): RefresherAnimationType => {
export const getRefresherAnimationType = (contentEl: HTMLElement): RefresherAnimationType => {
const previousSibling = contentEl.previousElementSibling;
const hasHeader = previousSibling !== null && previousSibling.tagName === 'ION-HEADER';

View File

@ -0,0 +1,53 @@
import type { E2EPage } from '@stencil/core/testing';
import { newE2EPage } from '@stencil/core/testing';
import { pullToRefresh } from '../test.utils';
describe('refresher: custom scroll target', () => {
let page: E2EPage;
beforeEach(async () => {
page = await newE2EPage({
url: '/src/components/refresher/test/scroll-target?ionic:_testing=true'
});
});
describe('legacy refresher', () => {
it('should load more items when performing a pull-to-refresh', async () => {
const initialItems = await page.findAll('ion-item');
expect(initialItems.length).toBe(30);
await pullToRefresh(page);
const items = await page.findAll('ion-item');
expect(items.length).toBe(60);
});
});
describe('native refresher', () => {
it('should load more items when performing a pull-to-refresh', async () => {
const refresherContent = await page.$('ion-refresher-content');
refresherContent.evaluate((el: any) => {
// Resets the pullingIcon to enable the native refresher
el.pullingIcon = undefined;
});
await page.waitForChanges();
const initialItems = await page.findAll('ion-item');
expect(initialItems.length).toBe(30);
await pullToRefresh(page);
const items = await page.findAll('ion-item');
expect(items.length).toBe(60);
});
});
});

View File

@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<title>Refresher - Custom Scroll Target</title>
<meta name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet">
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet">
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
#content {
position: relative;
display: block;
flex: 1;
height: 100%;
overflow: hidden;
contain: size style;
}
#inner-scroll {
height: 100%;
overflow-y: auto;
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Pull To Refresh</ion-title>
</ion-toolbar>
</ion-header>
<ion-content scroll-y="false">
<ion-refresher id="refresher" slot="fixed">
<ion-refresher-content></ion-refresher-content>
</ion-refresher>
<div id="content" class="ion-content-scroll-host">
<div id="inner-scroll">
<ion-list id="list"></ion-list>
</div>
</div>
</ion-content>
</ion-app>
<script>
let items = createItems();
const list = document.getElementById('list');
const refresher = document.getElementById('refresher');
refresher.addEventListener('ionRefresh', async function () {
const data = await getAsyncData();
items = items.concat(data);
refresher.complete();
render();
// Custom event consumed by e2e tests
document.dispatchEvent(new CustomEvent('ionRefreshComplete'));
});
function render() {
let html = '';
for (let item of items) {
html += `<ion-item button>${item}</ion-item>`;
}
list.innerHTML = html;
}
function getAsyncData() {
// async return mock data
return new Promise(resolve => {
setTimeout(() => resolve(createItems(items.length)), 500);
});
}
function createItems(start = 0) {
return new Array(30).fill().map((_, i) => start + i + 1);
}
render();
</script>
</body>
</html>