Skip to content

Commit c3fcca2

Browse files
committed
Fix delete & add scrollToBottomForce method & add new addMethod
1 parent b797db7 commit c3fcca2

8 files changed

Lines changed: 288 additions & 81 deletions

File tree

README.md

Lines changed: 103 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,6 @@ Try the live demo here: [StackBlitz Demo](https://stackblitz.com/edit/stackblitz
4747
- Simpler integration, less boilerplate
4848
- Designed for chat, feeds, and any list with variable content
4949

50-
## 🌐 Browser Support
51-
52-
- Chrome (latest 2 versions)
53-
- Firefox (latest 2 versions)
54-
- Edge (latest 2 versions)
55-
- Safari (latest 2 versions)
56-
- Opera (latest 2 versions)
57-
- Mobile browsers (iOS Safari, Chrome for Android)
58-
5950
## Quick Start
6051

6152
### 1) Template
@@ -157,53 +148,113 @@ export class AppComponent {
157148
- `get(id: ItemId, count: number): Observable<T[]>` — load items relative to a specific id.
158149
- `settings.bufferSize` — page size.
159150
- `settings.heightToLoadMore` — distance (in px) from the edge to trigger loading.
160-
- `datasource.adapter` — get the Adapter instance for programmatic list control.
151+
- `settings.addMethod` — defines how items are added to the view. Defaults to `fallback`.
152+
- `datasource.adapter` — provides methods for programmatic list control:
153+
- `addItem(item: T): boolean` — Adds an item to the list. If the scroll is at the bottom, the item is immediately visible. Otherwise, it will appear when the user scrolls to the bottom.
154+
- `updateItem(id: string, changes: Partial<T>): boolean` — Updates an item by its ID with the provided changes.
155+
- `deleteItem(id: string): boolean` — Deletes an item by its ID.
156+
- `scrollToId(id: string): boolean` — Scrolls to the item with the specified ID.
157+
- `scrollToBottomForce(): boolean` — Forces the scroll to the bottom of the list.
161158

162-
### Adapter API
159+
## 📖 Adapter API
163160

164161
You can access the adapter via `datasource.adapter`. The adapter provides convenient methods to manage the list items programmatically. Each method returns a boolean indicating whether the operation was successful.
165162

166-
#### Methods
167-
168-
- `addItem(item: T): boolean`
169-
Adds an item to the end of the list. If the scroll is at the very bottom, the new item will appear immediately in the view. If the scroll is not at the bottom, the item will be shown only when the user scrolls to the bottom.
170-
- **Parameters:**
171-
- `item: T` — the item to add (must have a unique `id`)
172-
- **Returns:** `boolean``true` if the item was added, `false` otherwise
173-
174-
- `addFirstItem(item: T): boolean`
175-
Adds an item to the beginning of the list.
176-
- **Parameters:**
177-
- `item: T` — the item to add (must have a unique `id`)
178-
- **Returns:** `boolean``true` if the item was added, `false` otherwise
179-
180-
- `update(id: ItemId, data: T): boolean`
181-
Updates an existing item by its `id`.
182-
- **Parameters:**
183-
- `id: ItemId` — the id of the item to update
184-
- `data: T` — the new data for the item
185-
- **Returns:** `boolean``true` if the item was found and updated, `false` otherwise
186-
187-
- `findAndUpdate(findOptions: { find: (item: T) => boolean; data: T }): boolean`
188-
Finds an item by a custom predicate and updates it.
189-
- **Parameters:**
190-
- `find: (item: T) => boolean` — function to find the item
191-
- `data: T` — the new data for the item
192-
- **Returns:** `boolean``true` if an item was found and updated, `false` otherwise
193-
194-
- `delete(id: ItemId): boolean`
195-
Deletes an item by its `id`.
196-
- **Parameters:**
197-
- `id: ItemId` — the id of the item to delete
198-
- **Returns:** `boolean``true` if the item was found and deleted, `false` otherwise
199-
200-
- `scrollToId(id: ItemId): boolean`
201-
Scrolls the container to the item with the given `id`.
202-
- **Parameters:**
203-
- `id: ItemId` — the id of the item to scroll to
204-
- **Returns:** `boolean``true` if the item was found and scrolled to, `false` otherwise
205-
206-
> **Note:** All adapter methods return `true` if the operation was successful, or `false` if the item was not found or could not be added/updated/deleted.
163+
### Methods
164+
165+
- **addItem(item: T): boolean**
166+
- Adds an item to the end of the list. If the scroll is at the very bottom, the new item will appear immediately in the view. If the scroll is not at the bottom, the item will be shown only when the user scrolls to the bottom.
167+
168+
**Parameters:**
169+
- `item: T` — the item to add (must have a unique id)
170+
171+
**Returns:**
172+
- `boolean` — true if the item was added, false otherwise
173+
174+
- **addFirstItem(item: T): boolean**
175+
- Adds an item to the beginning of the list.
176+
177+
**Parameters:**
178+
- `item: T` — the item to add (must have a unique id)
179+
180+
**Returns:**
181+
- `boolean` — true if the item was added, false otherwise
182+
183+
- **update(id: ItemId, data: T): boolean**
184+
- Updates an existing item by its id.
185+
186+
**Parameters:**
187+
- `id: ItemId` — the id of the item to update
188+
- `data: T` — the new data for the item
189+
190+
**Returns:**
191+
- `boolean` — true if the item was found and updated, false otherwise
192+
193+
- **findAndUpdate(findOptions: { find: (item: T) => boolean; data: T }): boolean**
194+
- Finds an item by a custom predicate and updates it.
195+
196+
**Parameters:**
197+
- `find: (item: T) => boolean` — function to find the item
198+
- `data: T` — the new data for the item
199+
200+
**Returns:**
201+
- `boolean` — true if an item was found and updated, false otherwise
202+
203+
- **delete(id: ItemId): boolean**
204+
- Deletes an item by its id.
205+
206+
**Parameters:**
207+
- `id: ItemId` — the id of the item to delete
208+
209+
**Returns:**
210+
- `boolean` — true if the item was found and deleted, false otherwise
211+
212+
- **scrollToId(id: ItemId): boolean**
213+
- Scrolls the container to the item with the given id.
214+
215+
**Parameters:**
216+
- `id: ItemId` — the id of the item to scroll to
217+
218+
**Returns:**
219+
- `boolean` — true if the item was found and scrolled to, false otherwise
220+
221+
- **scrollToBottomForce(): boolean**
222+
- Forces the scroll to the bottom of the list.
223+
224+
**Returns:**
225+
- `boolean` — true if the operation was successful, false otherwise
226+
227+
- **scroll$: Observable<ScrollInfo>**
228+
- Emits scroll events with detailed information about the current scroll state.
229+
230+
**Returns:**
231+
- `Observable<ScrollInfo>` — an observable stream of scroll events.
232+
233+
## ⚙️ New Property: `addMethod`
234+
235+
The `addMethod` property controls how items are added to the view. It has two modes:
236+
237+
- **`onlyVisible` (legacy behavior):**
238+
- Items are added only if they are immediately visible to the user after rendering.
239+
- If the scroll is not at the bottom, the item will not appear until the user scrolls to the bottom.
240+
241+
- **`fallback` (default):**
242+
- Items are added regardless of visibility if there is no space to load more items below.
243+
- If there is space, the `onlyVisible` logic is applied.
244+
245+
### Default Values
246+
247+
If not explicitly set, the following default values are used:
248+
249+
- `bufferSize`: 50
250+
- `heightToLoadMore`: 300
251+
- `addMethod`: `fallback`
252+
253+
These defaults ensure smooth scrolling and efficient rendering.
254+
255+
> **Important:**
256+
> - Avoid animations or dynamic resizing of elements, as they can disrupt virtual scroll calculations.
257+
> - Ensure item heights remain stable after initial rendering.
207258
208259
## Recommendations
209260

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "ar-virtual-scroll",
33
"description": "Virtual scroll for Angular with auto size detection",
44
"author": "Artem Osipenko",
5-
"version": "0.0.7",
5+
"version": "0.0.8",
66
"private": false,
77
"license": "MIT",
88
"repository": {

src/lib/adapter.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ export class Adapter<T extends ObjectId> {
5252
return false;
5353
}
5454

55-
this.datasource.storage.items.splice(itemIndex, 1);
56-
this.datasource.virtualScrollAdapter.deleteItem({ id });
55+
const deletedItem = this.datasource.storage.items.splice(itemIndex, 1);
56+
this.datasource.virtualScrollAdapter.deleteItem({ ...deletedItem[0], deletedIndex: itemIndex });
5757
return true;
5858
}
5959

@@ -64,4 +64,12 @@ export class Adapter<T extends ObjectId> {
6464
this.datasource.virtualScrollAdapter.scrollToId(id);
6565
return true;
6666
}
67+
68+
scrollToBottomForce() {
69+
this.datasource.virtualScrollAdapter.scrollToBottomForce();
70+
}
71+
72+
get scroll$() {
73+
return this.datasource.virtualScrollAdapter.currentScroll$;
74+
}
6775
}

src/lib/datasource.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface DatasourceSettings<T extends ObjectId> {
1111
settings?: {
1212
bufferSize?: number;
1313
heightToLoadMore?: number;
14+
addMethod?: 'fallback' | 'onlyVisible';
1415
}
1516
}
1617

@@ -30,6 +31,10 @@ export class Datasource<T extends ObjectId> {
3031
settings.settings = { ...settings.settings, heightToLoadMore: 300 };
3132
}
3233

34+
if (settings.settings?.addMethod === undefined) {
35+
settings.settings = { ...settings.settings, addMethod: 'fallback' };
36+
}
37+
3338
this.#settings = settings;
3439
this.#virtualViewAdapter.initial();
3540
}

src/lib/scroll-storage.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,8 @@ export class ScrollStorage<T extends ObjectId> {
9999
if (startIndex === -1 || endIndex === -1 || startIndex > endIndex) return [];
100100
return this.items.slice(startIndex, endIndex + 1);
101101
}
102+
103+
getLastItems(count: number) {
104+
return this.items.slice(0, count);
105+
}
102106
}

src/lib/virtual-scroll-adapter.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import { Datasource } from './datasource';
22
import { ScrollStorage } from './scroll-storage';
33
import { Adapter } from './adapter';
44
import { Observable, Subject } from 'rxjs';
5-
import { ItemId, ItemWithHeight, ObjectId, VirtualScrollItem } from './types';
5+
import { ItemId, ItemWithHeight, ObjectId, ScrollInfo, VirtualScrollItem } from './types';
66

77
export type AddItemEvent<T> = { item: T; isFirst?: boolean };
88
export type UpdateEvent<T> = { id: ItemId; data: T };
9-
export type DeleteEvent<T> = { id: ItemId };
9+
export type DeleteEvent<T> = ItemWithHeight<T> & { deletedIndex: number };
1010

1111
export class VirtualScrollAdapter<T extends ObjectId> {
1212
constructor(
@@ -15,10 +15,12 @@ export class VirtualScrollAdapter<T extends ObjectId> {
1515
private storage: ScrollStorage<T>
1616
) {}
1717

18-
#addItem = new Subject<AddItemEvent<T>>();
19-
#updateItem = new Subject<UpdateEvent<T>>();
20-
#deleteItem = new Subject<DeleteEvent<T>>();
21-
#scrollToId = new Subject<ItemId>();
18+
#addItem$ = new Subject<AddItemEvent<T>>();
19+
#updateItem$ = new Subject<UpdateEvent<T>>();
20+
#deleteItem$ = new Subject<DeleteEvent<T>>();
21+
#scrollToId$ = new Subject<ItemId>();
22+
#scrollToBottomForce$ = new Subject<void>();
23+
#currentScroll$ = new Subject<ScrollInfo>();
2224

2325
getData(
2426
id: ItemId,
@@ -56,35 +58,64 @@ export class VirtualScrollAdapter<T extends ObjectId> {
5658
});
5759
}
5860

61+
getLastItems(count: number) {
62+
return this.storage.getLastItems(count);
63+
}
64+
5965
updateItem(event: UpdateEvent<T>) {
60-
this.#updateItem.next(event);
66+
this.#updateItem$.next(event);
6167
}
6268

6369
addItem(event: AddItemEvent<T>) {
64-
this.#addItem.next(event);
70+
this.#addItem$.next(event);
6571
}
6672

6773
deleteItem(event: DeleteEvent<T>) {
68-
this.#deleteItem.next(event);
74+
this.#deleteItem$.next(event);
6975
}
7076

7177
scrollToId(id: ItemId) {
72-
this.#scrollToId.next(id);
78+
this.#scrollToId$.next(id);
79+
}
80+
81+
scrollToBottomForce() {
82+
this.#scrollToBottomForce$.next();
83+
}
84+
85+
sendScrollInfo(info: ScrollInfo) {
86+
this.#currentScroll$.next(info);
7387
}
7488

7589
get addItem$() {
76-
return this.#addItem.asObservable();
90+
return this.#addItem$.asObservable();
7791
}
7892

7993
get updateItem$() {
80-
return this.#updateItem.asObservable();
94+
return this.#updateItem$.asObservable();
8195
}
8296

8397
get deleteItem$() {
84-
return this.#deleteItem.asObservable();
98+
return this.#deleteItem$.asObservable();
8599
}
86100

87101
get scrollToId$() {
88-
return this.#scrollToId.asObservable();
102+
return this.#scrollToId$.asObservable();
103+
}
104+
105+
get scrollToBottomForce$() {
106+
return this.#scrollToBottomForce$.asObservable();
107+
}
108+
109+
get currentScroll$() {
110+
return this.#currentScroll$.asObservable();
111+
}
112+
113+
destroy() {
114+
this.#addItem$.complete();
115+
this.#updateItem$.complete();
116+
this.#deleteItem$.complete();
117+
this.#scrollToId$.complete();
118+
this.#scrollToBottomForce$.complete();
119+
this.#currentScroll$.complete();
89120
}
90121
}

src/lib/virtual-scroll-container.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
Directive,
55
ElementRef,
66
HostListener,
7-
inject,
7+
inject, OnDestroy,
88
Renderer2,
99
} from '@angular/core';
1010
import { Subject } from 'rxjs';
@@ -13,8 +13,7 @@ import { ScrollInfo } from './types';
1313
@Directive({
1414
selector: '[arVirtualScrollContainer]'
1515
})
16-
export class VirtualScrollContainer implements AfterViewInit {
17-
16+
export class VirtualScrollContainer implements AfterViewInit, OnDestroy {
1817
#elementRef = inject(ElementRef<any>);
1918
#renderer2 = inject(Renderer2);
2019
#lastScroll = 0;
@@ -28,6 +27,8 @@ export class VirtualScrollContainer implements AfterViewInit {
2827
topHeight = 0;
2928
bottomHeight = 0;
3029

30+
#viewportHeight = 0;
31+
3132
constructor() { }
3233

3334
@HostListener('scroll', ['$event'] )
@@ -59,13 +60,22 @@ export class VirtualScrollContainer implements AfterViewInit {
5960
this.#lastScroll = element.scrollTop;
6061
}
6162

63+
#resizeObserver = new ResizeObserver(() => {
64+
const difference = this.#viewportHeight - this.getViewportHeight();
65+
this.setScrollPosition(this.#lastScroll + (difference > 0 ? difference : difference + 1));
66+
this.#viewportHeight = this.getViewportHeight();
67+
});
68+
6269
ngAfterViewInit() {
6370
const topScroller = this.#renderer2.createElement('div');
6471
this.#renderer2.insertBefore(this.#elementRef.nativeElement, topScroller, this.#elementRef.nativeElement.firstChild);
6572
const bottomScroller = this.#renderer2.createElement('div');
6673
this.#renderer2.appendChild(this.#elementRef.nativeElement, bottomScroller);
6774
this.topScroller = topScroller;
6875
this.bottomScroller = bottomScroller;
76+
77+
this.#viewportHeight = this.getViewportHeight();
78+
this.#resizeObserver.observe(this.#elementRef.nativeElement);
6979
}
7080

7181
get scroll$() {
@@ -135,4 +145,9 @@ export class VirtualScrollContainer implements AfterViewInit {
135145
const clamped = Math.max(0, Math.min(target, maxScroll));
136146
this.setScrollPosition(clamped);
137147
}
148+
149+
ngOnDestroy() {
150+
this.#resizeObserver.disconnect();
151+
this.#scroll$.complete();
152+
}
138153
}

0 commit comments

Comments
 (0)