From ddbf23ff7e8353a0eb7157d2959eb9c94a5fc3d9 Mon Sep 17 00:00:00 2001 From: amwx Date: Mon, 22 Jun 2020 22:41:20 -0500 Subject: [PATCH] Add DateTimePickerPanel --- .../DateTimePickers/DateTimePickerPanel.cs | 565 ++++++++++++++++++ 1 file changed, 565 insertions(+) create mode 100644 src/Avalonia.Controls/DateTimePickers/DateTimePickerPanel.cs diff --git a/src/Avalonia.Controls/DateTimePickers/DateTimePickerPanel.cs b/src/Avalonia.Controls/DateTimePickers/DateTimePickerPanel.cs new file mode 100644 index 0000000000..b50b4cab9a --- /dev/null +++ b/src/Avalonia.Controls/DateTimePickers/DateTimePickerPanel.cs @@ -0,0 +1,565 @@ +using System; +using System.Globalization; +using System.Linq; +using Avalonia.Input; +using Avalonia.VisualTree; + +namespace Avalonia.Controls.Primitives +{ + public enum DateTimePickerPanelType + { + Year, + Month, + Day, + Hour, + Minute, + TimePeriod //AM or PM + } + + public class DateTimePickerPanel : Panel, ILogicalScrollable + { + /// + /// Defines the property + /// + public static readonly StyledProperty ItemHeightProperty = + AvaloniaProperty.Register("ItemHeight", 40.0); + + /// + /// Defines the property + /// + public static readonly StyledProperty PanelTypeProperty = + AvaloniaProperty.Register("PanelType"); + + /// + /// Defines the property + /// + public static readonly StyledProperty ItemFormatProperty = + AvaloniaProperty.Register("ItemFormat", "yyyy"); + + /// + /// Defines the property + /// + public static readonly StyledProperty ShouldLoopProperty = + AvaloniaProperty.Register("ShouldLoop"); + + //Backing fields for properties + private int _minimumValue = 1; + private int _maximumValue = 2; + private int _selectedValue = 1; + private int _increment = 1; + + //Helper fields + private int _selectedIndex = 0; + private int _totalItems; + private int _numItemsAboveBelowSelected; + private int _range; + private double _extentOne; + private Size _extent; + private Vector _offset; + private bool _hasInit; + private bool _suppressUpdateOffset; + private ListBoxItem _pressedItem; + + public DateTimePickerPanel() + { + FormatDate = DateTime.Now; + AddHandler(ListBoxItem.PointerPressedEvent, OnItemPointerDown, Avalonia.Interactivity.RoutingStrategies.Bubble); + AddHandler(ListBoxItem.PointerReleasedEvent, OnItemPointerUp, Avalonia.Interactivity.RoutingStrategies.Bubble); + } + + static DateTimePickerPanel() + { + FocusableProperty.OverrideDefaultValue(true); + AffectsMeasure(ItemHeightProperty); + } + + /// + /// Gets or sets what this panel displays in date or time units + /// + public DateTimePickerPanelType PanelType + { + get => GetValue(PanelTypeProperty); + set => SetValue(PanelTypeProperty, value); + } + + /// + /// Gets or sets the height of each item + /// + public double ItemHeight + { + get => GetValue(ItemHeightProperty); + set => SetValue(ItemHeightProperty, value); + } + + /// + /// Gets or sets the string format for the items, using standard + /// .net DateTime or TimeSpan formatting. Format must match panel type + /// + public string ItemFormat + { + get => GetValue(ItemFormatProperty); + set => SetValue(ItemFormatProperty, value); + } + + /// + /// Gets or sets whether the panel should loop + /// + public bool ShouldLoop + { + get => GetValue(ShouldLoopProperty); + set => SetValue(ShouldLoopProperty, value); + } + + /// + /// Gets or sets the minimum value + /// + public int MinimumValue + { + get => _minimumValue; + set + { + if (value > MaximumValue) + throw new InvalidOperationException("Minimum cannot be greater than Maximum"); + _minimumValue = value; + UpdateHelperInfo(); + var sel = CoerceSelected(SelectedValue); + if (sel != SelectedValue) + SelectedValue = sel; + UpdateItems(); + InvalidateArrange(); + RaiseScrollInvalidated(EventArgs.Empty); + } + } + + /// + /// Gets or sets the maximum value + /// + public int MaximumValue + { + get => _maximumValue; + set + { + if (value < MinimumValue) + throw new InvalidOperationException("Maximum cannot be less than Minimum"); + _maximumValue = value; + UpdateHelperInfo(); + var sel = CoerceSelected(SelectedValue); + if (sel != SelectedValue) + SelectedValue = sel; + UpdateItems(); + InvalidateArrange(); + RaiseScrollInvalidated(EventArgs.Empty); + } + } + + /// + /// Gets or sets the selected value + /// + public int SelectedValue + { + get => _selectedValue; + set + { + if (value > MaximumValue || value < MinimumValue) + throw new ArgumentOutOfRangeException("SelectedValue"); + + var sel = CoerceSelected(value); + _selectedValue = sel; + _selectedIndex = (value - MinimumValue) / Increment; + + if (!ShouldLoop) + CreateOrDestroyItems(Children); + + if (!_suppressUpdateOffset) + _offset = new Vector(0, ShouldLoop ? _selectedIndex * ItemHeight + (_extentOne * 50) : + _selectedIndex * ItemHeight); + + UpdateItems(); + InvalidateArrange(); + RaiseScrollInvalidated(EventArgs.Empty); + + SelectionChanged?.Invoke(this, EventArgs.Empty); + } + } + + /// + /// Gets or sets the increment + /// + public int Increment + { + get => _increment; + set + { + if (value <= 0 || value > _range) + throw new ArgumentOutOfRangeException("Increment"); + _increment = value; + UpdateHelperInfo(); + var sel = CoerceSelected(SelectedValue); + if (sel != SelectedValue) + SelectedValue = sel; + UpdateItems(); + InvalidateArrange(); + RaiseScrollInvalidated(EventArgs.Empty); + } + } + + //Used to help format the date (if applicable), for ex., + //if we're want to display the day of week, we need context + //for the month/year, this is our context + internal DateTime FormatDate { get; set; } + + public Vector Offset + { + get => _offset; + set + { + var old = _offset; + _offset = value; + var dy = _offset.Y - old.Y; + var children = Children; + + if (dy > 0) // Scroll Down + { + int numContsToMove = 0; + for (int i = 0; i < children.Count; i++) + { + if (children[i].Bounds.Bottom - dy < 0) + numContsToMove++; + else + break; + } + children.MoveRange(0, numContsToMove, children.Count); + + var scrollHeight = _extent.Height - Viewport.Height; + if (ShouldLoop && value.Y >= scrollHeight - _extentOne) + _offset = new Vector(0, value.Y - (_extentOne * 50)); + } + else if (dy < 0) // Scroll Up + { + int numContsToMove = 0; + for (int i = children.Count - 1; i >= 0; i--) + { + if (children[i].Bounds.Top - dy > Bounds.Height) + numContsToMove++; + else + break; + } + children.MoveRange(children.Count - numContsToMove, numContsToMove, 0); + if (ShouldLoop && value.Y < _extentOne) + _offset = new Vector(0, value.Y + (_extentOne * 50)); + } + + //Setting selection will handle all invalidation + var newSel = (Offset.Y / ItemHeight) % _totalItems; + _suppressUpdateOffset = true; + SelectedValue = (int)newSel * Increment + MinimumValue; + _suppressUpdateOffset = false; + } + } + + public bool CanHorizontallyScroll { get => false; set { } } + + public bool CanVerticallyScroll { get => true; set { } } + + public bool IsLogicalScrollEnabled => true; + + public Size ScrollSize => new Size(0, ItemHeight); + + public Size PageScrollSize => new Size(0, ItemHeight * 4); + + public Size Extent => _extent; + + public Size Viewport => new Size(0, ItemHeight); + + public event EventHandler ScrollInvalidated; + + public event EventHandler SelectionChanged; + + protected override Size MeasureOverride(Size availableSize) + { + if (double.IsInfinity(availableSize.Width) || + double.IsInfinity(availableSize.Height)) + throw new InvalidOperationException("Panel must have finite height"); + + if (!_hasInit) + UpdateHelperInfo(); + + double initY = (availableSize.Height / 2.0) - (ItemHeight / 2.0); + _numItemsAboveBelowSelected = (int)Math.Ceiling(initY / ItemHeight) + 1; + + var children = Children; + + CreateOrDestroyItems(children); + + for (int i = 0; i < children.Count; i++) + children[i].Measure(availableSize); + + if (!_hasInit) + { + UpdateItems(); + RaiseScrollInvalidated(EventArgs.Empty); + _hasInit = true; + } + + return availableSize; + } + + protected override Size ArrangeOverride(Size finalSize) + { + if (Children.Count == 0) + return base.ArrangeOverride(finalSize); + + var itemHgt = ItemHeight; + var children = Children; + Rect rc; + double initY = (finalSize.Height / 2.0) - (itemHgt / 2.0); + + if (ShouldLoop) + { + var currentSet = Math.Truncate(Offset.Y / _extentOne); + initY += (_extentOne * currentSet) + (_selectedIndex - _numItemsAboveBelowSelected) * ItemHeight; + + for (int i = 0; i < children.Count; i++) + { + rc = new Rect(0, initY - Offset.Y, finalSize.Width, itemHgt); + children[i].Arrange(rc); + initY += itemHgt; + } + } + else + { + var first = Math.Max(0, (_selectedIndex - _numItemsAboveBelowSelected)); + for (int i = 0; i < children.Count; i++) + { + rc = new Rect(0, (initY + first * itemHgt) - Offset.Y, finalSize.Width, itemHgt); + children[i].Arrange(rc); + initY += itemHgt; + } + } + + return finalSize; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + switch (e.Key) + { + case Key.Up: + ScrollUp(); + e.Handled = true; + break; + case Key.Down: + ScrollDown(); + e.Handled = true; + break; + case Key.PageUp: + ScrollUp(4); + e.Handled = true; + break; + case Key.PageDown: + ScrollDown(4); + e.Handled = true; + break; + } + base.OnKeyDown(e); + } + + /// + /// Refreshes the content of the visible items + /// + public void RefreshItems() + { + UpdateItems(); + } + + /// + /// Scrolls up the specified number of items + /// + public void ScrollUp(int numItems = 1) + { + var newY = Math.Max(Offset.Y - (numItems * ItemHeight), 0); + Offset = new Vector(0, newY); + } + + /// + /// Scrolls down the specified number of items + /// + public void ScrollDown(int numItems = 1) + { + var scrollHeight = _extent.Height - Viewport.Height; + var newY = Math.Min(Offset.Y + (numItems * ItemHeight), scrollHeight); + Offset = new Vector(0, newY); + } + + /// + /// Updates helper fields used in various calculations + /// + private void UpdateHelperInfo() + { + _range = _maximumValue - _minimumValue + 1; + _totalItems = (int)Math.Ceiling((double)_range / _increment); + + var itemHgt = ItemHeight; + //If looping, measure 100x as many items as we actually have + _extent = new Size(0, ShouldLoop ? _totalItems * itemHgt * 100 : _totalItems * itemHgt); + + //Height of 1 "set" of items + _extentOne = _totalItems * itemHgt; + _offset = new Vector(0, ShouldLoop ? _extentOne * 50 + _selectedIndex * itemHgt : _selectedIndex * itemHgt); + + } + + /// + /// Ensures enough containers are visible in the viewport + /// + /// + private void CreateOrDestroyItems(Controls children) + { + int totalItemsInViewport = _numItemsAboveBelowSelected * 2 + 1; + + if (!ShouldLoop) + { + int numItemAboveSelect = _numItemsAboveBelowSelected; + if (_selectedIndex - _numItemsAboveBelowSelected < 0) + numItemAboveSelect = _selectedIndex; + int numItemBelowSelect = _numItemsAboveBelowSelected; + if (_selectedIndex + _numItemsAboveBelowSelected >= _totalItems) + numItemBelowSelect = _totalItems - _selectedIndex - 1; + + totalItemsInViewport = numItemBelowSelect + numItemAboveSelect + 1; + } + + while (children.Count < totalItemsInViewport) + { + children.Add(new ListBoxItem + { + Height = ItemHeight, + Classes = new Classes("DateTimePickerItem", $"{PanelType}Item"), + VerticalContentAlignment = Avalonia.Layout.VerticalAlignment.Center, + Focusable = false + }); + } + if (children.Count > totalItemsInViewport) + { + var numToRemove = children.Count - totalItemsInViewport; + children.RemoveRange(children.Count - numToRemove, numToRemove); + } + } + + /// + /// Updates item content based on the current selection + /// and the panel type + /// + private void UpdateItems() + { + var children = Children; + var min = MinimumValue; + var panelType = PanelType; + var selected = SelectedValue; + var max = MaximumValue; + + int first; + if (ShouldLoop) + { + first = (_selectedIndex - _numItemsAboveBelowSelected) % _totalItems; + first = first < 0 ? min + (first + _totalItems) * Increment : min + first * Increment; + } + else + { + first = min + Math.Max(0, _selectedIndex - _numItemsAboveBelowSelected) * Increment; + } + + for (int i = 0; i < children.Count; i++) + { + ListBoxItem item = (ListBoxItem)children[i]; + item.Content = FormatContent(first, panelType); + item.Tag = first; + item.IsSelected = first == selected; + first += Increment; + if (first > max) + first = min; + } + } + + private string FormatContent(int value, DateTimePickerPanelType panelType) + { + switch (panelType) + { + case DateTimePickerPanelType.Year: + return new DateTime(value, FormatDate.Month, FormatDate.Day).ToString(ItemFormat); + case DateTimePickerPanelType.Month: + return new DateTime(FormatDate.Year, value, FormatDate.Day).ToString(ItemFormat); + case DateTimePickerPanelType.Day: + return new DateTime(FormatDate.Year, FormatDate.Month, value).ToString(ItemFormat); + case DateTimePickerPanelType.Hour: + return new TimeSpan(value, 0, 0).ToString(ItemFormat); + case DateTimePickerPanelType.Minute: + return new TimeSpan(0, value, 0).ToString(ItemFormat); + case DateTimePickerPanelType.TimePeriod: + return value == MinimumValue ? CultureInfo.CurrentCulture.DateTimeFormat.AMDesignator : + CultureInfo.CurrentCulture.DateTimeFormat.PMDesignator; + } + return ""; + } + + /// + /// Ensures the is within the bounds and + /// follows the current Increment + /// + private int CoerceSelected(int newValue) + { + if (newValue < MinimumValue) + return MinimumValue; + if (newValue > MaximumValue) + return MaximumValue; + + if (newValue % Increment != 0) + { + var items = Enumerable.Range(MinimumValue, MaximumValue + 1).Where(i => i % Increment == 0).ToList(); + var nearest = items.Aggregate((x, y) => Math.Abs(x - newValue) > Math.Abs(y - newValue) ? y : x); + return items.IndexOf(nearest) * Increment; + } + return newValue; + } + + private void OnItemPointerDown(object sender, PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + _pressedItem = GetItemFromSource((IVisual)e.Source); + e.Handled = true; + } + } + + private void OnItemPointerUp(object sender, PointerReleasedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.PointerUpdateKind == PointerUpdateKind.LeftButtonReleased && + _pressedItem != null) + { + SelectedValue = (int)GetItemFromSource((IVisual)e.Source).Tag; + _pressedItem = null; + e.Handled = true; + } + } + + //Helper to get ListBoxItem from pointerevent source + private ListBoxItem GetItemFromSource(IVisual src) + { + var item = src; + while (item != null && !(item is ListBoxItem)) + { + item = item.VisualParent; + } + return (ListBoxItem)item; + } + + public bool BringIntoView(IControl target, Rect targetRect) { return false; } + + public IControl GetControlInDirection(NavigationDirection direction, IControl from) { return null; } + + public void RaiseScrollInvalidated(EventArgs e) + { + ScrollInvalidated?.Invoke(this, e); + } + } +}