Skip to content

Instantly share code, notes, and snippets.

@ashelleyPurdue
Created September 3, 2019 14:48
Show Gist options
  • Save ashelleyPurdue/c81fcb706079d4736c20380c067c5156 to your computer and use it in GitHub Desktop.
Save ashelleyPurdue/c81fcb706079d4736c20380c067c5156 to your computer and use it in GitHub Desktop.
Proof of concept smooth-scrolling virtualized ListBox for Avalonia.
#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Input;
using AutoPropertyChanged;
using System.Diagnostics;
namespace JustTagAvalonia
{
public class DialListBox<TItem> : UserControl, INotifyPropertyChanged
{
public IEnumerable<TItem> Items
{
get => _items;
set
{
_items = value.ToArray();
RaiseChanged("Items");
RaiseChanged("ItemsCount");
}
}
public int ItemsCount => _items.Length;
[NotifyChanged] public int SelectedIndex { get; set; }
[NotifyChanged] public double ScrollIndex { get; set; } // How far through the list the user has scrolled.
// Corresponds to the index of the item at the top of the screen.
// EG:
// At value 0, Items[0] is at the top of the screen.
// At value 1, Items[1] is at the top of the screen.
// At value 1.5, the top of the screen is halfway between items 1 and 2
// Etc.
public new event PropertyChangedEventHandler PropertyChanged;
private TItem[] _items = new TItem[] { };
private StackPanel itemsPanel;
private Func<Control> templateControlFactory;
private Action<TItem, Control> templateControlSetter;
private List<ItemContainer> itemContainerPool = new List<ItemContainer>();
private class ItemContainer : UserControl { }
private bool isDragging = false;
private Point prevDragPos;
private Stopwatch dragTimer = new Stopwatch();
public DialListBox()
{
itemsPanel = new StackPanel()
{
Orientation = Orientation.Vertical,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top
};
this.Content = itemsPanel;
PointerWheelChanged += ScrollWheelChanged;
PointerPressed += DialListBox_PointerPressed;
PointerMoved += DialListBox_PointerMoved;
PointerReleased += DialListBox_PointerReleased;
// Default template: Just textblocks with the item's ToString()
templateControlFactory = () => new TextBlock();
templateControlSetter = (item, control) =>
{
((TextBlock)control).Text = item?.ToString() ?? "";
};
// HACK: Update some things when properties change
PropertyChanged += (s, e) =>
{
switch (e.PropertyName)
{
case "Items":
UpdateItemScroll();
UpdateSelectedItemHighlight();
break;
case "ScrollIndex":
UpdateItemScroll();
UpdateSelectedItemHighlight();
break;
case "SelectedIndex":
UpdateSelectedItemHighlight();
break;
}
};
}
// Helpers
private void RaiseChanged(string propertyName)
{
PropertyChanged?.Invoke
(
this,
new PropertyChangedEventArgs(propertyName)
);
}
/// <summary>
/// Finds the index of the item that corresponds to a given control
/// from the container pool.
/// </summary>
/// <param name="poolIndex"></param>
/// <returns></returns>
private int GetItemIndex(int poolIndex) => (int)Math.Floor(ScrollIndex) + poolIndex;
/// <summary>
/// Gets the index of the item that the mouse is over.
/// Returns null if it's not over anything.
/// </summary>
/// <param name="point"></param>
/// <returns></returns>
private int? GetItemIndexUnderPoint(Point point)
{
double totalHeight = 0;
// We need to shift the y-value to account for the fact that
// the margin shifts when partially scrolled between two items.
double shiftedY = point.Y - itemsPanel.Margin.Top;
for (int poolIndex = 0; poolIndex < itemContainerPool.Count; poolIndex++)
{
totalHeight += itemContainerPool[poolIndex].Bounds.Height;
if (totalHeight >= shiftedY)
return GetItemIndex(poolIndex);
}
return null;
}
private void UpdateItemScroll()
{
// Hide all the existing controls. We will
// re-enable only the ones that are being used.
foreach (ItemContainer c in itemContainerPool)
{
c.IsVisible = false;
}
// HACK: Avoid a null reference exception if
// the items list is null.
if (Items == null)
return;
// Show(or create) controls until we go past the boundaries of the list
double maxHeight = this.Bounds.Height;
int poolIndex = 0;
double totalHeight = 0;
while (true)
{
int itemIndex = GetItemIndex(poolIndex);
// Bail out when done
if (totalHeight > maxHeight || itemIndex >= ItemsCount)
break;
// Create (or reuse) a control
ItemContainer container;
if (poolIndex >= itemContainerPool.Count)
{
container = new ItemContainer();
container.Content = templateControlFactory();
itemContainerPool.Add(container);
itemsPanel.Children.Add(container);
}
container = itemContainerPool[poolIndex];
container.IsVisible = true;
// Apply the template to the control, with the current item
// as the parameter.
TItem item = Items.ElementAt(itemIndex);
templateControlSetter(item, (Control)container.Content);
// Move on to the next one
poolIndex++;
totalHeight += container.Bounds.Height;
}
// Shift the render margin up/down to account for "partially" scrolling between two items.
// TODO: Use a render transform instead
double controlHeight = itemContainerPool[0].Bounds.Height;
double residual = ScrollIndex - GetItemIndex(0);
itemsPanel.Margin = new Thickness(0, -residual * controlHeight);
}
private void UpdateSelectedItemHighlight()
{
for (int poolIndex = 0; poolIndex < itemContainerPool.Count; poolIndex++)
{
ItemContainer container = itemContainerPool[poolIndex];
int itemIndex = GetItemIndex(poolIndex);
container.Background = (itemIndex == SelectedIndex)
? Brushes.SeaGreen
: Brushes.Transparent;
}
}
// Event handlers
private void ScrollWheelChanged(object? sender, PointerWheelEventArgs e)
{
// Scroll up/down with the wheel
double newScrollIndex = ScrollIndex + e.Delta.Y;
if (newScrollIndex < 0)
newScrollIndex = 0;
if (newScrollIndex >= ItemsCount)
newScrollIndex = ItemsCount - 1;
ScrollIndex = newScrollIndex;
}
private void DialListBox_PointerPressed(object? sender, PointerPressedEventArgs e)
{
isDragging = true;
e.Device.Capture(this);
prevDragPos = e.GetPosition(this);
dragTimer.Restart();
}
private void DialListBox_PointerMoved(object? sender, PointerEventArgs e)
{
if (!isDragging)
return;
// HACK: Avoid index out of bounds error by not scrolling if there aren't
// any items.
if (ItemsCount < 1)
return;
// Calculate the delta Y and update prevDragPos
Point currDragPos = e.GetPosition(this);
double delta = currDragPos.Y - prevDragPos.Y;
prevDragPos = currDragPos;
// Update the scroll index
double newScrollIndex = ScrollIndex - delta / itemContainerPool[0].Bounds.Height;
if (newScrollIndex < 0)
newScrollIndex = 0;
if (newScrollIndex >= ItemsCount)
newScrollIndex = ItemsCount - 1;
ScrollIndex = newScrollIndex;
}
private void DialListBox_PointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (!isDragging)
return;
// Stop dragging
dragTimer.Stop();
isDragging = false;
e.Device.Capture(null);
// If we weren't dragging very long, select the item under the cursor.
if (dragTimer.ElapsedMilliseconds <= 250)
{
int? itemIndex = GetItemIndexUnderPoint(e.GetPosition(this));
if (itemIndex != null)
SelectedIndex = itemIndex.Value;
}
}
/// <summary>
///
/// </summary>
/// <typeparam name="TControl"></typeparam>
/// <param name="factory">
/// A factory function that creates a new instance of the control that should represent
/// an item.
/// </param>
/// <param name="setter">
/// A function that modifies a control(created by factory) to display a given item.
/// </param>
/// <returns></returns>
public DialListBox<TItem> SetItemTemplate<TControl>(Func<TControl> factory, Action<TItem, TControl> setter)
where TControl : Control
{
templateControlFactory = factory;
templateControlSetter = (item, control) => setter(item, (TControl)control);
UpdateItemScroll();
return this;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment