mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-09 16:16:41 +08:00
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:
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
53
core/src/components/refresher/test/scroll-target/e2e.ts
Normal file
53
core/src/components/refresher/test/scroll-target/e2e.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
93
core/src/components/refresher/test/scroll-target/index.html
Normal file
93
core/src/components/refresher/test/scroll-target/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user