Skip to content

Commit 0e4473f

Browse files
committed
feat(infinite-scroll): adding preserveRerenderScrollPosition property that allows infinite scroll to keep the scroll position during a full re-render of its contents
1 parent 7f904d0 commit 0e4473f

File tree

7 files changed

+195
-7
lines changed

7 files changed

+195
-7
lines changed

core/api.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,7 @@ ion-infinite-scroll,none
919919
ion-infinite-scroll,prop,disabled,boolean,false,false,false
920920
ion-infinite-scroll,prop,mode,"ios" | "md",undefined,false,false
921921
ion-infinite-scroll,prop,position,"bottom" | "top",'bottom',false,false
922+
ion-infinite-scroll,prop,preserveRerenderScrollPosition,boolean,false,false,false
922923
ion-infinite-scroll,prop,theme,"ios" | "md" | "ionic",undefined,false,false
923924
ion-infinite-scroll,prop,threshold,string,'15%',false,false
924925
ion-infinite-scroll,method,complete,complete() => Promise<void>

core/src/components.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1505,6 +1505,11 @@ export namespace Components {
15051505
* @default 'bottom'
15061506
*/
15071507
"position": 'top' | 'bottom';
1508+
/**
1509+
* 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.
1510+
* @default false
1511+
*/
1512+
"preserveRerenderScrollPosition": boolean;
15081513
/**
15091514
* The theme determines the visual appearance of the component.
15101515
*/
@@ -7436,6 +7441,11 @@ declare namespace LocalJSX {
74367441
* @default 'bottom'
74377442
*/
74387443
"position"?: 'top' | 'bottom';
7444+
/**
7445+
* 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.
7446+
* @default false
7447+
*/
7448+
"preserveRerenderScrollPosition"?: boolean;
74397449
/**
74407450
* The theme determines the visual appearance of the component.
74417451
*/

core/src/components/infinite-scroll/infinite-scroll.tsx

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export class InfiniteScroll implements ComponentInterface {
1616
private thrPx = 0;
1717
private thrPc = 0;
1818
private scrollEl?: HTMLElement;
19+
private contentEl: HTMLElement | null = null;
1920

2021
/**
2122
* didFire exists so that ionInfinite
@@ -80,6 +81,13 @@ export class InfiniteScroll implements ComponentInterface {
8081
*/
8182
@Prop() position: 'top' | 'bottom' = 'bottom';
8283

84+
/**
85+
* If `true`, the infinite scroll will preserve the scroll position
86+
* when the content is re-rendered. This is useful when the content is
87+
* re-rendered with new keys, and the scroll position should be preserved.
88+
*/
89+
@Prop() preserveRerenderScrollPosition: boolean = false;
90+
8391
/**
8492
* Emitted when the scroll reaches
8593
* the threshold distance. From within your infinite handler,
@@ -89,12 +97,12 @@ export class InfiniteScroll implements ComponentInterface {
8997
@Event() ionInfinite!: EventEmitter<void>;
9098

9199
async connectedCallback() {
92-
const contentEl = findClosestIonContent(this.el);
93-
if (!contentEl) {
100+
this.contentEl = findClosestIonContent(this.el);
101+
if (!this.contentEl) {
94102
printIonContentErrorMsg(this.el);
95103
return;
96104
}
97-
this.scrollEl = await getScrollElement(contentEl);
105+
this.scrollEl = await getScrollElement(this.contentEl);
98106
this.thresholdChanged();
99107
this.disabledChanged();
100108
if (this.position === 'top') {
@@ -136,6 +144,11 @@ export class InfiniteScroll implements ComponentInterface {
136144
if (!this.didFire) {
137145
this.isLoading = true;
138146
this.didFire = true;
147+
148+
// Lock the min height of the siblings of the infinite scroll
149+
// if we are preserving the rerender scroll position
150+
this.lockSiblingMinHeight(true);
151+
139152
this.ionInfinite.emit();
140153
return 3;
141154
}
@@ -144,6 +157,33 @@ export class InfiniteScroll implements ComponentInterface {
144157
return 4;
145158
};
146159

160+
private lockSiblingMinHeight(lock: boolean) {
161+
if (!this.preserveRerenderScrollPosition) {
162+
return;
163+
}
164+
165+
// Loop through all the siblings of the infinite scroll, but ignore the infinite scroll itself
166+
const siblings = this.el.parentElement?.children;
167+
if (siblings) {
168+
for (const sibling of siblings) {
169+
if (sibling !== this.el && sibling instanceof HTMLElement) {
170+
if (lock) {
171+
const elementHeight = sibling.getBoundingClientRect().height;
172+
const previousMinHeight = sibling.style.minHeight;
173+
if (previousMinHeight) {
174+
sibling.style.setProperty('--ion-previous-min-height', previousMinHeight);
175+
}
176+
sibling.style.minHeight = `${elementHeight}px`;
177+
} else {
178+
const previousMinHeight = sibling.style.getPropertyValue('--ion-previous-min-height');
179+
sibling.style.minHeight = previousMinHeight || 'auto';
180+
sibling.style.removeProperty('--ion-previous-min-height');
181+
}
182+
}
183+
}
184+
}
185+
}
186+
147187
/**
148188
* Call `complete()` within the `ionInfinite` output event handler when
149189
* your async operation has completed. For example, the `loading`
@@ -208,6 +248,12 @@ export class InfiniteScroll implements ComponentInterface {
208248
} else {
209249
this.didFire = false;
210250
}
251+
252+
// Unlock the min height of the siblings of the infinite scroll
253+
// if we are preserving the rerender scroll position
254+
setTimeout(() => {
255+
this.lockSiblingMinHeight(false);
256+
}, 100);
211257
}
212258

213259
private canStart(): boolean {
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Infinite Scroll - Item Replacement</title>
6+
<meta
7+
name="viewport"
8+
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
9+
/>
10+
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
11+
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
12+
<script src="../../../../../scripts/testing/scripts.js"></script>
13+
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
14+
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
15+
</head>
16+
17+
<body>
18+
<ion-app>
19+
<ion-header>
20+
<ion-toolbar>
21+
<ion-title>Infinite Scroll - Item Replacement</ion-title>
22+
</ion-toolbar>
23+
</ion-header>
24+
25+
<ion-content class="ion-padding" id="content">
26+
<IonHeader collapse="condense">
27+
<IonToolbar>
28+
<IonTitle size="large">Title</IonTitle>
29+
</IonToolbar>
30+
</IonHeader>
31+
32+
<div className="ion-padding">Scroll the list to see the title collapse.</div>
33+
34+
<ion-list id="list"></ion-list>
35+
36+
<ion-infinite-scroll threshold="100px" id="infinite-scroll" preserve-rerender-scroll-position>
37+
<ion-infinite-scroll-content loading-spinner="crescent" loading-text="Loading more data...">
38+
</ion-infinite-scroll-content>
39+
</ion-infinite-scroll>
40+
</ion-content>
41+
</ion-app>
42+
43+
<script>
44+
const list = document.getElementById('list');
45+
const infiniteScroll = document.getElementById('infinite-scroll');
46+
const content = document.getElementById('content');
47+
const scrollPositionDiv = document.getElementById('scroll-position');
48+
const modeDiv = document.getElementById('mode');
49+
let loading = false;
50+
let itemCount = 0;
51+
let generationCount = 0;
52+
53+
// Track scroll position for debugging
54+
content.addEventListener('ionScroll', () => {
55+
const scrollTop = content.scrollTop;
56+
scrollPositionDiv.textContent = `Scroll Position: ${scrollTop}`;
57+
});
58+
59+
infiniteScroll.addEventListener('ionInfinite', async function () {
60+
// Save current scroll position before replacement
61+
const currentScrollTop = content.scrollTop;
62+
window.currentScrollBeforeReplace = currentScrollTop;
63+
console.log('loading', loading);
64+
if (loading) {
65+
infiniteScroll.complete();
66+
return;
67+
}
68+
loading = true;
69+
70+
await wait(300);
71+
replaceAllItems();
72+
infiniteScroll.complete();
73+
74+
window.dispatchEvent(
75+
new CustomEvent('ionInfiniteComplete', {
76+
detail: {
77+
scrollTopBefore: currentScrollTop,
78+
scrollTopAfter: content.scrollTop,
79+
generation: generationCount,
80+
mode: 'normal',
81+
},
82+
})
83+
);
84+
85+
setTimeout(() => {
86+
console.log('setting loading to false');
87+
loading = false;
88+
});
89+
});
90+
91+
function replaceAllItems() {
92+
console.log('replaceAllItems');
93+
// This simulates what happens in React when all items get new keys
94+
// Clear all existing items
95+
list.innerHTML = '';
96+
97+
generationCount++;
98+
const generation = generationCount;
99+
100+
// Add new items with new "keys" (different content/identifiers)
101+
// Start with more items to ensure scrollable content
102+
const totalItems = generation === 1 ? 30 : 30 + generation * 20;
103+
itemCount = 0;
104+
105+
for (let i = 0; i < totalItems; i++) {
106+
const el = document.createElement('ion-item');
107+
el.setAttribute('data-key', `gen-${generation}-item-${i}`);
108+
el.setAttribute('data-generation', generation);
109+
el.textContent = `Gen ${generation} - Item ${
110+
i + 1
111+
} - Additional content to make this item taller and ensure scrolling`;
112+
el.id = `item-gen-${generation}-${i}`;
113+
list.appendChild(el);
114+
itemCount++;
115+
}
116+
}
117+
118+
function wait(time) {
119+
return new Promise((resolve) => {
120+
setTimeout(() => {
121+
resolve();
122+
}, time);
123+
});
124+
}
125+
126+
// Initial load
127+
replaceAllItems();
128+
</script>
129+
</body>
130+
</html>

packages/angular/src/directives/proxies.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -927,15 +927,15 @@ export declare interface IonImg extends Components.IonImg {
927927

928928

929929
@ProxyCmp({
930-
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
930+
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
931931
methods: ['complete']
932932
})
933933
@Component({
934934
selector: 'ion-infinite-scroll',
935935
changeDetection: ChangeDetectionStrategy.OnPush,
936936
template: '<ng-content></ng-content>',
937937
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
938-
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
938+
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
939939
})
940940
export class IonInfiniteScroll {
941941
protected el: HTMLIonInfiniteScrollElement;

packages/angular/standalone/src/directives/proxies.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -952,15 +952,15 @@ export declare interface IonImg extends Components.IonImg {
952952

953953
@ProxyCmp({
954954
defineCustomElementFn: defineIonInfiniteScroll,
955-
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
955+
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
956956
methods: ['complete']
957957
})
958958
@Component({
959959
selector: 'ion-infinite-scroll',
960960
changeDetection: ChangeDetectionStrategy.OnPush,
961961
template: '<ng-content></ng-content>',
962962
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
963-
inputs: ['disabled', 'mode', 'position', 'theme', 'threshold'],
963+
inputs: ['disabled', 'mode', 'position', 'preserveRerenderScrollPosition', 'theme', 'threshold'],
964964
standalone: true
965965
})
966966
export class IonInfiniteScroll {

packages/vue/src/proxies.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,7 @@ export const IonInfiniteScroll: StencilVueComponent<JSX.IonInfiniteScroll> = /*@
455455
'threshold',
456456
'disabled',
457457
'position',
458+
'preserveRerenderScrollPosition',
458459
'ionInfinite'
459460
], [
460461
'ionInfinite'

0 commit comments

Comments
 (0)