Created
September 3, 2019 14:48
-
-
Save ashelleyPurdue/c81fcb706079d4736c20380c067c5156 to your computer and use it in GitHub Desktop.
Proof of concept smooth-scrolling virtualized ListBox for Avalonia.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#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