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);
+ }
+ }
+}