diff --git a/core/api.txt b/core/api.txt index b183a34d9c..61396d23c7 100644 --- a/core/api.txt +++ b/core/api.txt @@ -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 diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 72f1d518f1..0109f7c693 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -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. */ diff --git a/core/src/components/infinite-scroll/infinite-scroll.tsx b/core/src/components/infinite-scroll/infinite-scroll.tsx index 291cdebb02..e2ec9d4277 100644 --- a/core/src/components/infinite-scroll/infinite-scroll.tsx +++ b/core/src/components/infinite-scroll/infinite-scroll.tsx @@ -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; - this.ionInfinite.emit(); + + 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 { + 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 { diff --git a/core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/index.html b/core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/index.html new file mode 100644 index 0000000000..7e2768a571 --- /dev/null +++ b/core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/index.html @@ -0,0 +1,129 @@ + + + + + Infinite Scroll - Item Replacement + + + + + + + + + + + + + Infinite Scroll - Item Replacement + + + + + + + Title + + + +
Scroll the list to see the title collapse.
+ + + + + + + +
+
+ + + + diff --git a/core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/infinite-scroll.e2e.ts b/core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/infinite-scroll.e2e.ts new file mode 100644 index 0000000000..8605b580e0 --- /dev/null +++ b/core/src/components/infinite-scroll/test/preserve-rerender-scroll-position/infinite-scroll.e2e.ts @@ -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; + } + }); + }); +}); diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index ae0bdafac3..907390819e 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -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: '', // 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; diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index 590555cdc8..56b308e25b 100644 --- a/packages/angular/standalone/src/directives/proxies.ts +++ b/packages/angular/standalone/src/directives/proxies.ts @@ -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: '', // 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 { diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 6e9dace0e2..0dd1a1f706 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -455,6 +455,7 @@ export const IonInfiniteScroll: StencilVueComponent = /*@ 'threshold', 'disabled', 'position', + 'preserveRerenderScrollPosition', 'ionInfinite' ], [ 'ionInfinite'