#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; } } }