mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-16 18:17:31 +08:00
feat(infinite-scroll): adding preserveRerenderScrollPosition property (#30566)
Issue number: resolves internal --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> Currently, if you use infinite scroll and fully change out elements in the DOM, you'll lose your scroll position. This can present as a race condition in some frameworks, like React, but will present pretty consistently in vanilla JavaScript. This happens because the browser is removing the old elements from the DOM and adding the new ones, and during that time the container holding the old elements will shrink and the browser will adjust the top position to be the maximum of the new container height. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> With this new property (`preserveRerenderScrollPosition`) set, we will loop through siblings of the infinite scroll and set their min-heights to be their current heights before triggering the `ionInfinite` event, then we clean up after complete is called by restoring their previous min-heights or setting them to auto if there were none. This prevents the container from resizing and the browser from losing the scroll position. ## Does this introduce a breaking change? - [ ] Yes - [X] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> **Current dev build**: ``` 8.6.6-dev.11753719591.13a5c65f ```
This commit is contained in:
@ -919,6 +919,7 @@ ion-infinite-scroll,none
|
||||
ion-infinite-scroll,prop,disabled,boolean,false,false,false
|
||||
ion-infinite-scroll,prop,mode,"ios" | "md",undefined,false,false
|
||||
ion-infinite-scroll,prop,position,"bottom" | "top",'bottom',false,false
|
||||
ion-infinite-scroll,prop,preserveRerenderScrollPosition,boolean,false,false,false
|
||||
ion-infinite-scroll,prop,theme,"ios" | "md" | "ionic",undefined,false,false
|
||||
ion-infinite-scroll,prop,threshold,string,'15%',false,false
|
||||
ion-infinite-scroll,method,complete,complete() => Promise<void>
|
||||
|
10
core/src/components.d.ts
vendored
10
core/src/components.d.ts
vendored
@ -1505,6 +1505,11 @@ export namespace Components {
|
||||
* @default 'bottom'
|
||||
*/
|
||||
"position": 'top' | 'bottom';
|
||||
/**
|
||||
* If `true`, the infinite scroll will preserve the scroll position when the content is re-rendered. This is useful when the content is re-rendered with new keys, and the scroll position should be preserved.
|
||||
* @default false
|
||||
*/
|
||||
"preserveRerenderScrollPosition": boolean;
|
||||
/**
|
||||
* The theme determines the visual appearance of the component.
|
||||
*/
|
||||
@ -7436,6 +7441,11 @@ declare namespace LocalJSX {
|
||||
* @default 'bottom'
|
||||
*/
|
||||
"position"?: 'top' | 'bottom';
|
||||
/**
|
||||
* If `true`, the infinite scroll will preserve the scroll position when the content is re-rendered. This is useful when the content is re-rendered with new keys, and the scroll position should be preserved.
|
||||
* @default false
|
||||
*/
|
||||
"preserveRerenderScrollPosition"?: boolean;
|
||||
/**
|
||||
* The theme determines the visual appearance of the component.
|
||||
*/
|
||||
|
@ -16,6 +16,7 @@ export class InfiniteScroll implements ComponentInterface {
|
||||
private thrPx = 0;
|
||||
private thrPc = 0;
|
||||
private scrollEl?: HTMLElement;
|
||||
private minHeightLocked = false;
|
||||
|
||||
/**
|
||||
* didFire exists so that ionInfinite
|
||||
@ -80,6 +81,13 @@ export class InfiniteScroll implements ComponentInterface {
|
||||
*/
|
||||
@Prop() position: 'top' | 'bottom' = 'bottom';
|
||||
|
||||
/**
|
||||
* If `true`, the infinite scroll will preserve the scroll position
|
||||
* when the content is re-rendered. This is useful when the content is
|
||||
* re-rendered with new keys, and the scroll position should be preserved.
|
||||
*/
|
||||
@Prop() preserveRerenderScrollPosition: boolean = false;
|
||||
|
||||
/**
|
||||
* Emitted when the scroll reaches
|
||||
* the threshold distance. From within your infinite handler,
|
||||
@ -136,7 +144,16 @@ export class InfiniteScroll implements ComponentInterface {
|
||||
if (!this.didFire) {
|
||||
this.isLoading = true;
|
||||
this.didFire = true;
|
||||
|
||||
if (this.preserveRerenderScrollPosition) {
|
||||
// Lock the min height of the siblings of the infinite scroll
|
||||
// if we are preserving the rerender scroll position
|
||||
this.lockSiblingMinHeight(true).then(() => {
|
||||
this.ionInfinite.emit();
|
||||
});
|
||||
} else {
|
||||
this.ionInfinite.emit();
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
@ -144,6 +161,55 @@ export class InfiniteScroll implements ComponentInterface {
|
||||
return 4;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loop through our sibling elements and lock or unlock their min height.
|
||||
* This keeps our siblings, for example `ion-list`, the same height as their
|
||||
* content currently is, so when it loads new data and the DOM removes the old
|
||||
* data, the height of the container doesn't change and we don't lose our scroll position.
|
||||
*
|
||||
* We preserve existing min-height values, if they're set, so we don't erase what
|
||||
* has been previously set by the user when we restore after complete is called.
|
||||
*/
|
||||
private lockSiblingMinHeight(lock: boolean): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const siblings = this.el.parentElement?.children || [];
|
||||
const writes: (() => void)[] = [];
|
||||
|
||||
for (const sibling of siblings) {
|
||||
// Loop through all the siblings of the infinite scroll, but ignore ourself
|
||||
if (sibling !== this.el && sibling instanceof HTMLElement) {
|
||||
if (lock) {
|
||||
const elementHeight = sibling.getBoundingClientRect().height;
|
||||
writes.push(() => {
|
||||
if (this.minHeightLocked) {
|
||||
// The previous min height is from us locking it before, so we can disregard it
|
||||
// We still need to lock the min height if we're already locked, though, because
|
||||
// the user could have triggered a new load before we've finished the previous one.
|
||||
const previousMinHeight = sibling.style.minHeight;
|
||||
if (previousMinHeight) {
|
||||
sibling.style.setProperty('--ion-previous-min-height', previousMinHeight);
|
||||
}
|
||||
}
|
||||
sibling.style.minHeight = `${elementHeight}px`;
|
||||
});
|
||||
} else {
|
||||
writes.push(() => {
|
||||
const previousMinHeight = sibling.style.getPropertyValue('--ion-previous-min-height');
|
||||
sibling.style.minHeight = previousMinHeight || 'auto';
|
||||
sibling.style.removeProperty('--ion-previous-min-height');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeTask(() => {
|
||||
writes.forEach((w) => w());
|
||||
this.minHeightLocked = lock;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Call `complete()` within the `ionInfinite` output event handler when
|
||||
* your async operation has completed. For example, the `loading`
|
||||
@ -208,6 +274,14 @@ export class InfiniteScroll implements ComponentInterface {
|
||||
} else {
|
||||
this.didFire = false;
|
||||
}
|
||||
|
||||
// Unlock the min height of the siblings of the infinite scroll
|
||||
// if we are preserving the rerender scroll position
|
||||
if (this.preserveRerenderScrollPosition) {
|
||||
setTimeout(async () => {
|
||||
await this.lockSiblingMinHeight(false);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
private canStart(): boolean {
|
||||
|
@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Infinite Scroll - Item Replacement</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>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<ion-app>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Infinite Scroll - Item Replacement</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding" id="content">
|
||||
<IonHeader collapse="condense">
|
||||
<IonToolbar>
|
||||
<IonTitle size="large">Title</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<div className="ion-padding">Scroll the list to see the title collapse.</div>
|
||||
|
||||
<ion-list id="list"></ion-list>
|
||||
|
||||
<ion-infinite-scroll threshold="100px" id="infinite-scroll" preserve-rerender-scroll-position>
|
||||
<ion-infinite-scroll-content loading-spinner="crescent" loading-text="Loading more data...">
|
||||
</ion-infinite-scroll-content>
|
||||
</ion-infinite-scroll>
|
||||
</ion-content>
|
||||
</ion-app>
|
||||
|
||||
<script>
|
||||
const list = document.getElementById('list');
|
||||
const infiniteScroll = document.getElementById('infinite-scroll');
|
||||
const content = document.getElementById('content');
|
||||
const scrollPositionDiv = document.getElementById('scroll-position');
|
||||
const modeDiv = document.getElementById('mode');
|
||||
let loading = false;
|
||||
let itemCount = 0;
|
||||
let generationCount = 0;
|
||||
|
||||
// Track scroll position for debugging
|
||||
content.addEventListener('ionScroll', () => {
|
||||
const scrollTop = content.scrollTop;
|
||||
scrollPositionDiv.textContent = `Scroll Position: ${scrollTop}`;
|
||||
});
|
||||
|
||||
infiniteScroll.addEventListener('ionInfinite', async function () {
|
||||
// Save current scroll position before replacement
|
||||
const currentScrollTop = content.scrollTop;
|
||||
window.currentScrollBeforeReplace = currentScrollTop;
|
||||
console.log('loading', loading);
|
||||
if (loading) {
|
||||
infiniteScroll.complete();
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
|
||||
replaceAllItems();
|
||||
infiniteScroll.complete();
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('ionInfiniteComplete', {
|
||||
detail: {
|
||||
scrollTopBefore: currentScrollTop,
|
||||
scrollTopAfter: content.scrollTop,
|
||||
generation: generationCount,
|
||||
mode: 'normal',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('setting loading to false');
|
||||
loading = false;
|
||||
});
|
||||
});
|
||||
|
||||
function replaceAllItems() {
|
||||
console.log('replaceAllItems');
|
||||
// This simulates what happens in React when all items get new keys
|
||||
// Clear all existing items
|
||||
list.innerHTML = '';
|
||||
|
||||
generationCount++;
|
||||
const generation = generationCount;
|
||||
|
||||
// Add new items with new "keys" (different content/identifiers)
|
||||
// Start with more items to ensure scrollable content
|
||||
const totalItems = generation === 1 ? 50 : 30 + generation * 20;
|
||||
itemCount = 0;
|
||||
|
||||
for (let i = 0; i < totalItems; i++) {
|
||||
const el = document.createElement('ion-item');
|
||||
el.setAttribute('data-key', `gen-${generation}-item-${i}`);
|
||||
el.setAttribute('data-generation', generation);
|
||||
el.textContent = `Gen ${generation} - Item ${
|
||||
i + 1
|
||||
} - Additional content to make this item taller and ensure scrolling`;
|
||||
el.id = `item-gen-${generation}-${i}`;
|
||||
list.appendChild(el);
|
||||
itemCount++;
|
||||
}
|
||||
}
|
||||
|
||||
function wait(time) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, time);
|
||||
});
|
||||
}
|
||||
|
||||
// Initial load
|
||||
replaceAllItems();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,32 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test } from '@utils/test/playwright';
|
||||
|
||||
test.setTimeout(100000);
|
||||
|
||||
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
|
||||
test.describe(title('infinite-scroll: preserve rerender scroll position'), () => {
|
||||
test('should load more items when scrolled to the bottom', async ({ page }) => {
|
||||
await page.goto('/src/components/infinite-scroll/test/preserve-rerender-scroll-position', config);
|
||||
|
||||
const ionInfiniteComplete = await page.spyOnEvent('ionInfiniteComplete');
|
||||
const content = page.locator('ion-content');
|
||||
const items = page.locator('ion-item');
|
||||
const innerScroll = page.locator('.inner-scroll');
|
||||
expect(await items.count()).toBe(50);
|
||||
|
||||
let previousScrollTop = 0;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0));
|
||||
const currentScrollTop = await innerScroll.evaluate((el: HTMLIonContentElement) => el.scrollTop);
|
||||
expect(currentScrollTop).toBeGreaterThan(previousScrollTop);
|
||||
await ionInfiniteComplete.next();
|
||||
const newScrollTop = await innerScroll.evaluate((el: HTMLIonContentElement) => el.scrollTop);
|
||||
console.log(`Scroll position should be preserved after ${i + 1} iterations`, newScrollTop, previousScrollTop);
|
||||
expect(newScrollTop, `Scroll position should be preserved after ${i + 1} iterations`).toBeGreaterThanOrEqual(
|
||||
previousScrollTop
|
||||
);
|
||||
previousScrollTop = currentScrollTop;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
@ -927,7 +927,7 @@ export declare interface IonImg extends Components.IonImg {
|
||||
|
||||
|
||||
@ProxyCmp({
|
||||
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
|
||||
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
|
||||
methods: ['complete']
|
||||
})
|
||||
@Component({
|
||||
@ -935,7 +935,7 @@ export declare interface IonImg extends Components.IonImg {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: '<ng-content></ng-content>',
|
||||
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
|
||||
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
|
||||
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
|
||||
})
|
||||
export class IonInfiniteScroll {
|
||||
protected el: HTMLIonInfiniteScrollElement;
|
||||
|
@ -952,7 +952,7 @@ export declare interface IonImg extends Components.IonImg {
|
||||
|
||||
@ProxyCmp({
|
||||
defineCustomElementFn: defineIonInfiniteScroll,
|
||||
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
|
||||
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
|
||||
methods: ['complete']
|
||||
})
|
||||
@Component({
|
||||
@ -960,7 +960,7 @@ export declare interface IonImg extends Components.IonImg {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: '<ng-content></ng-content>',
|
||||
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
|
||||
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
|
||||
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
|
||||
standalone: true
|
||||
})
|
||||
export class IonInfiniteScroll {
|
||||
|
@ -455,6 +455,7 @@ export const IonInfiniteScroll: StencilVueComponent<JSX.IonInfiniteScroll> = /*@
|
||||
'threshold',
|
||||
'disabled',
|
||||
'position',
|
||||
'preserveRerenderScrollPosition',
|
||||
'ionInfinite'
|
||||
], [
|
||||
'ionInfinite'
|
||||
|
Reference in New Issue
Block a user