#region Apache License 2.0
/*
Nuclex Foundation libraries for .NET
Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
#endregion // Apache License 2.0
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
#if !NO_SPECIALIZED_COLLECTIONS
using System.Collections.Specialized;
#endif
using Nuclex.Support.Collections;
namespace Nuclex.Avalonia.Collections {
///
/// List which fires events when items are added or removed, whilst also
/// lazily fetching items as needed (for example from a socket or database)
///
/// Type of items the list manages
public abstract class AsyncVirtualObservableReadOnlyList :
IList,
IList,
ICollection,
#if !NO_SPECIALIZED_COLLECTIONS
INotifyCollectionChanged,
#endif
IObservableCollection {
#region class Enumerator
/// Enumerates over the items in a virtual list
private class Enumerator : IEnumerator, IEnumerator {
/// Initializes a new virtual list enumerator
/// List whose items will be enumerated
public Enumerator(AsyncVirtualObservableReadOnlyList virtualList) {
this.virtualList = virtualList;
this.currentItemIndex = -1;
this.lastMoveNextResult = false;
Reset();
}
/// Immediately releases all resources owned by the instance
public void Dispose() {
this.virtualList = null!; // Only to decouple and make the GC's work easier
}
/// The item at the enumerator's current position
public TItem Current {
get {
#if DEBUG
checkVersion();
#endif
// If the most recent call to MoveNext() returned false, it means that
// the enumerator has reached the end of the list, so in that
if(this.lastMoveNextResult == false) {
throw new InvalidOperationException("Enumerator is not on a valid position");
}
this.virtualList.requireCount();
this.virtualList.requirePage(this.currentItemIndex / this.virtualList.pageSize);
return this.virtualList.typedList[this.currentItemIndex];
}
}
/// Advances the enumerator to the next item
/// True if there was a next item
public bool MoveNext() {
#if DEBUG
checkVersion();
#endif
this.virtualList.requireCount();
// Go forward if there potentially are items remaining. The count may still be
// unreliable at this point (due to the uncertain count mechanism that truncates
// the list when fetching items finds an earlier end of the list)
if(this.currentItemIndex < this.virtualList.assumedCount.Value) {
// If the enumerator's 'Current' property is never used an the virtual list
// uses the dynamic truncation features (for unknown list sizes), then this
// enumerator could be moved way past the last element via 'MoveNext()'.
this.virtualList.requirePage(this.currentItemIndex / this.virtualList.pageSize);
++this.currentItemIndex; // Accept potentially advancing past the end here
}
// Are we on a valid item? If so, return true to indicate the list continued,
// otherwise we must have hit the end (or are already past it).
return this.lastMoveNextResult = (
(this.currentItemIndex < this.virtualList.assumedCount.Value)
);
}
/// Resets the enumerator to its initial position
public void Reset() {
this.currentItemIndex = -1;
#if DEBUG
this.expectedVersion = this.virtualList.version;
#endif
}
/// The item at the enumerator's current position
object? IEnumerator.Current {
get { return Current; }
}
#if DEBUG
/// Ensures that the virtual list has not changed
private void checkVersion() {
if(this.expectedVersion != this.virtualList.version)
throw new InvalidOperationException("Virtual list has been modified");
}
#endif
/// Virtual list the enumerator belongs to
private AsyncVirtualObservableReadOnlyList virtualList;
/// Index of the item the enumerator currently is in
private int currentItemIndex;
/// The most recent result returned from MoveNext()
private bool lastMoveNextResult;
#if DEBUG
/// Version the virtual list is expected to have
private int expectedVersion;
#endif
}
#endregion // class Enumerator
/// Raised when an item has been added to the collection
public event EventHandler>? ItemAdded { add {} remove{} }
/// Raised when an item is removed from the collection
public event EventHandler>? ItemRemoved { add {} remove{} }
/// Raised when an item is replaced in the collection
public event EventHandler>? ItemReplaced;
/// Raised when the collection is about to be cleared
///
/// This could be covered by calling ItemRemoved for each item currently
/// contained in the collection, but it is often simpler and more efficient
/// to process the clearing of the entire collection as a special operation.
///
public event EventHandler? Clearing { add {} remove{} }
/// Raised when the collection has been cleared
public event EventHandler? Cleared { add {} remove{} }
#if !NO_SPECIALIZED_COLLECTIONS
/// Called when the collection has changed
public event NotifyCollectionChangedEventHandler? CollectionChanged;
#endif
///
/// Initializes a new instance of the ObservableList class that is empty.
///
///
/// How many items to download in one batch
///
///
/// The can be set to one to request items
/// individually or to a larger value in order to improve efficiency when
/// the source of the items is a database or similar source that gains
/// performance from requesting multiple items at once.
///
public AsyncVirtualObservableReadOnlyList(int pageSize = 32) {
this.typedList = new List();
this.objectList = (IList)this.typedList;
this.pageSize = pageSize;
this.fetchedPages = new bool[0];
}
///
/// Marks all items as non-fetched, causing them to be requested again
///
///
/// Whether to also clear the items that may already be in memory but would
/// get overwritten on the next fetch. If items consume a lot of memory, this
/// will make them available for garbage collection.
///
public void InvalidateAll(bool purgeItems = false) {
int itemCount;
lock(this) {
if(!this.assumedCount.HasValue) {
return; // If not fetched before, no action is needed
}
itemCount = this.assumedCount.Value;
}
// Mark the pages as un-fetched
int pageCount = this.fetchedPages.Length;
if(purgeItems) {
var oldItemList = new List(capacity: this.pageSize);
for(int pageIndex = 0; pageIndex < pageCount; ++pageIndex) {
if(this.fetchedPages[pageIndex]) {
this.fetchedPages[pageIndex] = false;
// Figure out the page's item start index and how big the page is
int offset = pageIndex * this.pageSize;
int count = Math.Min(itemCount - offset, this.pageSize);
// Make a backup of the old item values so we can provide them
// in the ItemReplaced change notification we'll send out.
for(int index = 0; index < count; ++index) {
oldItemList[index] = this.typedList[offset + index];
}
// Replace the loaded items with placeholder items
CreatePlaceholderItems(this.typedList, pageIndex * this.pageSize, count);
// Send out change notifications for all items we just replace with placeholders
for(int index = 0; index < count; ++index) {
OnReplaced(oldItemList[index], this.typedList[offset + index], offset + index);
}
// Make sure the old items aren't lingering around
oldItemList.Clear();
}
}
} else {
for(int pageIndex = 0; pageIndex < pageCount; ++pageIndex) {
this.fetchedPages[pageIndex] = false;
}
}
}
///
/// Marks an items as non-fetched, causing it to be requested again on access
///
///
/// Index of the item that will be marked as non-fetched
///
///
/// Whether to also clear the items that may already be in memory but would
/// get overwritten on the next fetch. If items consume a lot of memory, this
/// will make them available for garbage collection.
///
///
/// Since the list works in pages, this will actually mark the whole page as
/// non-fetched, causing all items in the same page to be requested again
/// when any of them are next accessed.
///
public void Invalidate(int itemIndex, bool purgeItems = false) {
lock(this) {
if(!this.assumedCount.HasValue) {
return; // If not fetched before, no action is needed
}
}
// Mark the entie page as needing re-fetching (we've got no other choice)
int pageIndex = itemIndex / this.pageSize;
this.fetchedPages[pageIndex] = false;
// If we're asked to also purge the item, replace the item with
// a placeholder item and trigger the change notification
if(purgeItems) {
TItem oldItem = this.typedList[itemIndex];
CreatePlaceholderItems(this.typedList, itemIndex, 1);
OnReplaced(oldItem, this.typedList[itemIndex], itemIndex);
}
}
/// Determines the index of the specified item in the list
/// Item whose index will be determined
/// The index of the item in the list or -1 if not found
public int IndexOf(TItem item) {
return this.typedList.IndexOf(item);
}
/// Inserts an item into the list at the specified index
/// Index the item will be inserted at
/// Item that will be inserted into the list
public void Insert(int index, TItem item) {
throw new NotSupportedException("Cannot insert items into a read-only list");
}
/// Removes the item at the specified index from the list
/// Index at which the item will be removed
public void RemoveAt(int index) {
throw new NotSupportedException("Cannot remove items from a read-only list");
}
/// Accesses the item at the specified index in the list
/// Index of the item that will be accessed
/// The item at the specified index
public TItem this[int index] {
get {
requireCount();
requirePage(index / this.pageSize);
return this.typedList[index];
}
set {
// Make sure the page is fetched, otherwise, the item would only suddenly
// revert to its state in the source when the pages around it is fetchd later.
requireCount();
requirePage(index / this.pageSize);
#if DEBUG
++this.version;
#endif
TItem oldItem = this.typedList[index];
this.typedList[index] = value;
OnReplaced(oldItem, value, index);
}
}
/// Adds an item to the end of the list
/// Item that will be added to the list
public void Add(TItem item) {
throw new NotSupportedException("Cannot add items to a read-only list");
}
/// Removes all items from the list
public void Clear() {
throw new NotSupportedException("Cannot clear a read-only list");
}
/// Checks whether the list contains the specified item
/// Item the list will be checked for
/// True if the list contains the specified items
public bool Contains(TItem item) {
requireCount();
requireAllPages();
return this.typedList.Contains(item);
}
/// Copies the contents of the list into an array
/// Array the list will be copied into
///
/// Index in the target array where the first item will be copied to
///
public void CopyTo(TItem[] array, int arrayIndex) {
requireCount();
requireAllPages();
this.typedList.CopyTo(array, arrayIndex);
}
/// Total number of items in the list
public int Count {
get {
return requireCount();
}
}
/// Whether the list is a read-only list
public bool IsReadOnly {
get {
//return this.typedList.IsReadOnly;
return true;
}
}
/// Removes the specified item from the list
/// Item that will be removed from the list
///
/// True if the item was found and removed from the list, false otherwise
///
public bool Remove(TItem item) {
throw new NotSupportedException("Cannot remove items from a read-only list");
}
/// Returns an enumerator for the items in the list
/// An enumerator for the list's items
public IEnumerator GetEnumerator() {
return new Enumerator(this);
}
#region IEnumerable implementation
/// Returns an enumerator for the items in the list
/// An enumerator for the list's items
IEnumerator IEnumerable.GetEnumerator() {
return new Enumerator(this);
}
#endregion // IEnumerable implementation
#region ICollection implementation
/// Copies the contents of the list into an array
/// Array the list will be copied into
///
/// Index in the target array where the first item will be copied to
///
void ICollection.CopyTo(Array array, int arrayIndex) {
requireCount();
requireAllPages();
this.objectList.CopyTo(array, arrayIndex);
}
/// Whether this list performs thread synchronization
bool ICollection.IsSynchronized {
get { return this.objectList.IsSynchronized; }
}
/// Synchronization root used by the list to synchronize threads
object ICollection.SyncRoot {
get { return this.objectList.SyncRoot; }
}
#endregion // ICollection implementation
#region IList implementation
/// Adds an item to the list
/// Item that will be added to the list
///
/// The position at which the item has been inserted or -1 if the item was not inserted
///
int IList.Add(object? value) {
throw new NotSupportedException("Cannot add items into a read-only list");
}
/// Checks whether the list contains the specified item
/// Item the list will be checked for
/// True if the list contains the specified items
bool IList.Contains(object? item) {
requireCount();
requireAllPages();
return this.objectList.Contains(item);
}
/// Determines the index of the specified item in the list
/// Item whose index will be determined
/// The index of the item in the list or -1 if not found
int IList.IndexOf(object? item) {
requireCount();
requireAllPages();
return this.objectList.IndexOf(item);
}
/// Inserts an item into the list at the specified index
/// Index the item will be inserted at
/// Item that will be inserted into the list
void IList.Insert(int index, object? item) {
throw new NotSupportedException("Cannot insert items into a read-only list");
}
/// Whether the list is of a fixed size
bool IList.IsFixedSize {
get { return this.objectList.IsFixedSize; }
}
/// Removes the specified item from the list
/// Item that will be removed from the list
void IList.Remove(object? item) {
throw new NotSupportedException("Cannot remove items from a read-only list");
}
/// Accesses the item at the specified index in the list
/// Index of the item that will be accessed
/// The item at the specified index
object? IList.this[int index] {
get {
requireCount();
requirePage(index / this.pageSize);
return this.objectList[index];
}
set {
throw new NotSupportedException("Cannot assign items into a read-only list");
}
}
#endregion // IList implementation
/// Fires the 'ItemReplaced' event
/// Item that has been replaced
/// New item the original item was replaced with
/// Index of the replaced item
protected virtual void OnReplaced(TItem oldItem, TItem newItem, int index) {
if(ItemReplaced != null) {
ItemReplaced(this, new ItemReplaceEventArgs(oldItem, newItem));
}
#if !NO_SPECIALIZED_COLLECTIONS
if(CollectionChanged != null) {
CollectionChanged(
this,
new NotifyCollectionChangedEventArgs(
NotifyCollectionChangedAction.Replace, newItem, oldItem, index
)
);
}
#endif
}
/// Counts the total number of items in the virtual collection
/// The total number of items
protected abstract int CountItems();
/// Fetches a page required by the collection
/// List into which the items should be fetched
///
/// Index of the first item to fetch. This is both the start index in
/// the actual data and the element index at which to write into the list.
///
/// Number of items that should be fetched
/// The number of items that were actually fetched
///
/// If you fetch fewer than the requested number of items here, you will immediately
/// truncate the entire list (it will assume that the end was reached, a means
/// to support cases where the total number is not known). Fetching more than
/// the requested number of items will just put items in memory that the list will
/// continue to think are empty and fetch again if they are actually accessed.
///
protected abstract Task FetchItemsAsync(
IList target, int startIndex, int count
);
/// Requests placeholder items to be created
/// List into which the items should be created
///
/// Index of the first item to create a placeholder for. This is both the start index
/// in the actual data and the element index at which to write into the list.
///
/// Number of items that should be crated
///
/// When you requests items that have not been fetched yet, this method will be
/// called synchronously to create placeholder items (these could be empty items
/// or items with a 'StillLoading' flag set. This allows any UI controls showing
/// the collection's contents to immediately display the items while fetching
/// happens in the background and will replace the placeholder once complete.
///
protected abstract void CreatePlaceholderItems(
IList target, int startIndex, int count
);
/// Reports errors when fetching data
/// Exception that has occured
/// Describes the action during which the error happened
protected abstract void HandleFetchError(Exception error, string action);
/// Ensures that the total number of items is known
/// The item count
[MemberNotNull(nameof(assumedCount))]
private int requireCount() {
lock(this) {
if(this.assumedCount.HasValue) {
return this.assumedCount.Value;
}
}
int itemCount;
try {
itemCount = CountItems();
}
catch(Exception error) {
lock(this) {
this.assumedCount = 0; // Act as if the collection has zero items
}
// Do not let the error bubble up into what is likely an unsuspecting
// data grid control or virtualized list widget. Let the user decide what
// to do about the error - i.e. set an error flag in their view model.
HandleFetchError(
error, "Failed to determine the number of items in the collection"
);
return 0;
} // try CountItems() catch
// Items were counted successfully, so adopt the count and initialize our
// internal arrays to avoid having to resize the arrays later on (which
// saves us a few lock statements)
lock(this) {
this.assumedCount = itemCount;
int pageCount = (itemCount + this.pageSize - 1) / this.pageSize;
this.fetchedPages = new bool[pageCount];
// Resize the list (so we don't have to mutex-lock the list itself),
// but put default items in there. We'll create placeholders when they
// are accessed, not before (ideally, I'd want uninitialized instances
// here, but that would need very thorough verification
while(this.typedList.Count < itemCount) {
this.typedList.Add(default(TItem)!);
}
} // lock this
return itemCount;
}
/// Ensures that all items have fetched
///
/// Avoid if possible.
///
private void requireAllPages() {
lock(this) {
Debug.Assert(
this.assumedCount.HasValue,
"This method should only be called when item count is already known"
);
}
int pageCount = this.fetchedPages.Length;
for(int index = 0; index < pageCount; ++index) {
requirePage(index);
// CHECK: Should we throttle this by constructing a clever chain of
// ContinueWith() tasks so we don't cause a flood of async requests?
}
}
/// Ensures that the specified page has been fetched
/// Index of the page that needs to be fetched
private async void requirePage(int pageIndex) {
int itemCount;
lock(this) {
if(!this.assumedCount.HasValue) {
Debug.Assert(
this.assumedCount.HasValue,
"This method should only be called when item count is already known"
);
return;
}
itemCount = this.assumedCount.Value;
}
// If the page is already fetched (or in flight), do nothing
if((pageIndex >= this.fetchedPages.Length) || this.fetchedPages[pageIndex]) {
return;
}
// The page was not fetched, so synchronously fill it with place holder items
// as a first step (their creation should be fast and immediate).
int offset = pageIndex * this.pageSize;
int count = Math.Min(itemCount - offset, this.pageSize);
CreatePlaceholderItems(this.typedList, offset, count);
// Ensure the items are present before marking the page as fetched
Interlocked.MemoryBarrier();
this.fetchedPages[pageIndex] = true; // Prevent double-fetch
// Remember the placeholder items that are going to be replaced by
// the fetch operation below,allowing us to report these in our change notification
var placeholderItems = new List(capacity: count);
for(int index = offset; index < count; ++index) {
placeholderItems.Add(this.typedList[index + offset]);
// We act as if the whole list was filled with place holders from the start,
// but only realize these as needed (so no change notification for these).
// If we did it right, the user will not be able to ever see the uninitialized
// items, yet we only create placeholders when they are really needed.
//OnReplaced(default(TItem), this.typedList[index + offset], index);
}
// Now request the items on the page. This request will run asynchronously,
// but we'll use an await to get to deliver change notifications once
// the page has been fetched and the items are there.
int fetchedItemCount;
try {
fetchedItemCount = await FetchItemsAsync(this.typedList, offset, count);
}
catch(Exception error) {
// Do not let the error bubble up into what is likely an unsuspecting
// data grid control or virtualized list widget. Let the user decide what
// to do about the error - i.e. set an error flag in their view model.
HandleFetchError(
error, $"Failed to fetch list items {offset} to {offset + count}"
);
this.fetchedPages[pageIndex] = false; // We failed!
return; // Leave the placeholder items in
}
if(fetchedItemCount < this.pageSize) {
itemCount = offset + fetchedItemCount;
lock(this) {
this.assumedCount = itemCount;
}
}
// The count may have been adjusted if this truncated the list,
// so recalculate the actual number of items. Then send out change
// notifications for the items that have now been fetched.
count = Math.Min(itemCount - offset, this.pageSize);
for(int index = offset; index < count; ++index) {
OnReplaced(placeholderItems[index - offset], this.typedList[index], index);
}
}
/// Number of items the collection believes it has
private int? assumedCount;
/// Number of items to fetch in a single request
private readonly int pageSize;
/// Tracks which pages have been fetched so far
private bool[] fetchedPages;
/// The wrapped list under its type-safe interface
private IList typedList;
/// The wrapped list under its object interface
private IList objectList;
#if DEBUG
/// Used to detect when enumerators go out of sync
private int version;
#endif
}
} // namespace Nuclex.Avalonia.Collections