diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml
index 681c7747c9..ec7a11067c 100644
--- a/samples/ControlCatalog/MainView.xaml
+++ b/samples/ControlCatalog/MainView.xaml
@@ -29,6 +29,9 @@
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
+
+
+
diff --git a/samples/ControlCatalog/Pages/DateTimePickerPage.xaml b/samples/ControlCatalog/Pages/DateTimePickerPage.xaml
new file mode 100644
index 0000000000..af6b6e8605
--- /dev/null
+++ b/samples/ControlCatalog/Pages/DateTimePickerPage.xaml
@@ -0,0 +1,123 @@
+
+
+ DatePicker and TimePicker
+
+
+
+
+ A simple DatePicker with a header
+
+
+
+
+
+
+
+
+ <DatePicker Header="Pick a date" />
+
+
+
+
+
+
+ A DatePicker with day formatted and year hidden.
+
+
+
+
+
+
+
+
+ <DatePicker DayFormat="d (ddd)" YearVisible="False" />
+
+
+
+
+
+
+
+
+ A simple TimePicker.
+
+
+
+
+
+
+
+
+ <TimePicker />
+
+
+
+
+
+
+ A TimePicker with a header and minute increments specified.
+
+
+
+
+
+
+
+
+ <TimePicker Header="Arrival time" MinuteIncrement="15" />
+
+
+
+
+
+
+ A TimePicker using a 12-hour clock.
+
+
+
+
+
+
+
+
+ <TimePicker ClockIdentifier="12HourClock" Header="12 hour clock" />
+
+
+
+
+
+
+ A TimePicker using a 24-hour clock.
+
+
+
+
+
+
+
+
+ <TimePicker ClockIdentifier="24HourClock" Header="24 hour clock" />
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/DateTimePickerPage.xaml.cs b/samples/ControlCatalog/Pages/DateTimePickerPage.xaml.cs
new file mode 100644
index 0000000000..6c7ae3437e
--- /dev/null
+++ b/samples/ControlCatalog/Pages/DateTimePickerPage.xaml.cs
@@ -0,0 +1,30 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace ControlCatalog.Pages
+{
+ public class DateTimePickerPage : UserControl
+ {
+ public DateTimePickerPage()
+ {
+ this.InitializeComponent();
+ this.FindControl("DatePickerDesc").Text = "Use a DatePicker to let users set a date in your app, " +
+ "for example to schedule an appointment. The DatePicker displays three controls for month, day, and year. " +
+ "These controls are easy to use with touch or mouse, and they can be styled and configured in several different ways. " +
+ "Order of month, day, and year is dynamically set based on user date settings";
+
+ this.FindControl("TimePickerDesc").Text = "Use a TimePicker to let users set a time in your app, for example " +
+ "to set a reminder. The TimePicker displays three controls for hour, minute, and AM / PM(if necessary).These controls " +
+ "are easy to use with touch or mouse, and they can be styled and configured in several different ways. " +
+ "12 - hour or 24 - hour clock and visiblility of AM / PM is dynamically set based on user time settings, or can be overridden.";
+
+
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/DateTimePickers/DatePicker.cs b/src/Avalonia.Controls/DateTimePickers/DatePicker.cs
new file mode 100644
index 0000000000..5d3311e8c6
--- /dev/null
+++ b/src/Avalonia.Controls/DateTimePickers/DatePicker.cs
@@ -0,0 +1,412 @@
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Shapes;
+using Avalonia.Controls.Templates;
+using Avalonia.Interactivity;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace Avalonia.Controls
+{
+ ///
+ /// A control to allow the user to select a date
+ ///
+ public class DatePicker : TemplatedControl
+ {
+ ///
+ /// Define the Property
+ ///
+ public static readonly DirectProperty DayFormatProperty =
+ AvaloniaProperty.RegisterDirect(nameof(DayFormat),
+ x => x.DayFormat, (x, v) => x.DayFormat = v);
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly DirectProperty DayVisibleProperty =
+ AvaloniaProperty.RegisterDirect(nameof(DayVisible),
+ x => x.DayVisible, (x, v) => x.DayVisible = v);
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly StyledProperty HeaderProperty =
+ AvaloniaProperty.Register(nameof(Header));
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly StyledProperty HeaderTemplateProperty =
+ AvaloniaProperty.Register(nameof(HeaderTemplate));
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly DirectProperty MaxYearProperty =
+ AvaloniaProperty.RegisterDirect(nameof(MaxYear),
+ x => x.MaxYear, (x, v) => x.MaxYear = v);
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly DirectProperty MinYearProperty =
+ AvaloniaProperty.RegisterDirect(nameof(MinYear),
+ x => x.MinYear, (x, v) => x.MinYear = v);
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly DirectProperty MonthFormatProperty =
+ AvaloniaProperty.RegisterDirect(nameof(MonthFormat),
+ x => x.MonthFormat, (x, v) => x.MonthFormat = v);
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly DirectProperty MonthVisibleProperty =
+ AvaloniaProperty.RegisterDirect(nameof(MonthVisible),
+ x => x.MonthVisible, (x, v) => x.MonthVisible = v);
+
+ ///
+ /// Defiens the Property
+ ///
+ public static readonly DirectProperty YearFormatProperty =
+ AvaloniaProperty.RegisterDirect(nameof(YearFormat),
+ x => x.YearFormat, (x, v) => x.YearFormat = v);
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly DirectProperty YearVisibleProperty =
+ AvaloniaProperty.RegisterDirect(nameof(YearVisible),
+ x => x.YearVisible, (x, v) => x.YearVisible = v);
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly DirectProperty SelectedDateProperty =
+ AvaloniaProperty.RegisterDirect(nameof(SelectedDate),
+ x => x.SelectedDate, (x, v) => x.SelectedDate = v);
+
+ //Template Items
+ private Button _flyoutButton;
+ private TextBlock _dayText;
+ private TextBlock _monthText;
+ private TextBlock _yearText;
+ private Grid _container;
+ private Rectangle _spacer1;
+ private Rectangle _spacer2;
+ private Popup _popup;
+ private DatePickerPresenter _presenter;
+
+ private bool _areControlsAvailable;
+
+ private string _dayFormat = "%d";
+ private bool _dayVisible = true;
+ private DateTimeOffset _maxYear;
+ private DateTimeOffset _minYear;
+ private string _monthFormat = "MMMM";
+ private bool _monthVisible = true;
+ private string _yearFormat = "yyyy";
+ private bool _yearVisible = true;
+ private DateTimeOffset? _selectedDate;
+
+ public DatePicker()
+ {
+ PseudoClasses.Set(":hasnodate", true);
+ var now = DateTimeOffset.Now;
+ _minYear = new DateTimeOffset(now.Date.Year - 100, 1, 1, 0, 0, 0, now.Offset);
+ _maxYear = new DateTimeOffset(now.Date.Year + 100, 12, 31, 0, 0, 0, now.Offset);
+ }
+
+ public string DayFormat
+ {
+ get => _dayFormat;
+ set => SetAndRaise(DayFormatProperty, ref _dayFormat, value);
+ }
+
+ ///
+ /// Gets or sets whether the day is visible
+ ///
+ public bool DayVisible
+ {
+ get => _dayVisible;
+ set
+ {
+ SetAndRaise(DayVisibleProperty, ref _dayVisible, value);
+ SetGrid();
+ }
+ }
+
+ ///
+ /// Gets or sets the DatePicker header
+ ///
+ public object Header
+ {
+ get => GetValue(HeaderProperty);
+ set => SetValue(HeaderProperty, value);
+ }
+
+ ///
+ /// Gets or sets the header template
+ ///
+ public IDataTemplate HeaderTemplate
+ {
+ get => GetValue(HeaderTemplateProperty);
+ set => SetValue(HeaderTemplateProperty, value);
+ }
+
+ ///
+ /// Gets or sets the maximum year for the picker
+ ///
+ public DateTimeOffset MaxYear
+ {
+ get => _maxYear;
+ set
+ {
+ if (value < MinYear)
+ throw new InvalidOperationException("MaxDate cannot be less than MinDate");
+ SetAndRaise(MaxYearProperty, ref _maxYear, value);
+
+ if (SelectedDate.HasValue && SelectedDate.Value > value)
+ SelectedDate = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the minimum year for the picker
+ ///
+ public DateTimeOffset MinYear
+ {
+ get => _minYear;
+ set
+ {
+ if (value > MaxYear)
+ throw new InvalidOperationException("MinDate cannot be greater than MaxDate");
+ SetAndRaise(MinYearProperty, ref _minYear, value);
+
+ if (SelectedDate.HasValue && SelectedDate.Value < value)
+ SelectedDate = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the month format
+ ///
+ public string MonthFormat
+ {
+ get => _monthFormat;
+ set => SetAndRaise(MonthFormatProperty, ref _monthFormat, value);
+ }
+
+ ///
+ /// Gets or sets whether the month is visible
+ ///
+ public bool MonthVisible
+ {
+ get => _monthVisible;
+ set
+ {
+ SetAndRaise(MonthVisibleProperty, ref _monthVisible, value);
+ SetGrid();
+ }
+ }
+
+ ///
+ /// Gets or sets the year format
+ ///
+ public string YearFormat
+ {
+ get => _yearFormat;
+ set => SetAndRaise(YearFormatProperty, ref _yearFormat, value);
+ }
+
+ ///
+ /// Gets or sets whether the year is visible
+ ///
+ public bool YearVisible
+ {
+ get => _yearVisible;
+ set
+ {
+ SetAndRaise(YearVisibleProperty, ref _yearVisible, value);
+ SetGrid();
+ }
+ }
+
+ ///
+ /// Gets or sets the Selected Date for the picker, can be null
+ ///
+ public DateTimeOffset? SelectedDate
+ {
+ get => _selectedDate;
+ set
+ {
+ var old = _selectedDate;
+ SetAndRaise(SelectedDateProperty, ref _selectedDate, value);
+ SetSelectedDateText();
+ OnSelectedDateChanged(this, new DatePickerSelectedValueChangedEventArgs(old, value));
+ }
+ }
+
+ ///
+ /// Raised when the changes
+ ///
+ public event EventHandler SelectedDateChanged;
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ _areControlsAvailable = false;
+ if (_flyoutButton != null)
+ _flyoutButton.Click -= OnFlyoutButtonClicked;
+ if (_presenter != null)
+ {
+ _presenter.Confirmed -= OnConfirmed;
+ _presenter.Dismissed -= OnDismissPicker;
+ }
+
+ base.OnApplyTemplate(e);
+ _flyoutButton = e.NameScope.Find("FlyoutButton");
+ _dayText = e.NameScope.Find("DayText");
+ _monthText = e.NameScope.Find("MonthText");
+ _yearText = e.NameScope.Find("YearText");
+ _container = e.NameScope.Find("ButtonContentGrid");
+ _spacer1 = e.NameScope.Find("FirstSpacer");
+ _spacer2 = e.NameScope.Find("SecondSpacer");
+ _popup = e.NameScope.Find("Popup");
+ _presenter = e.NameScope.Find("PickerPresenter");
+
+ _areControlsAvailable = true;
+
+ SetGrid();
+ SetSelectedDateText();
+
+ if (_flyoutButton != null)
+ _flyoutButton.Click += OnFlyoutButtonClicked;
+
+ if (_presenter != null)
+ {
+ _presenter.Confirmed += OnConfirmed;
+ _presenter.Dismissed += OnDismissPicker;
+
+ _presenter[!DatePickerPresenter.MaxYearProperty] = this[!MaxYearProperty];
+ _presenter[!DatePickerPresenter.MinYearProperty] = this[!MinYearProperty];
+
+ _presenter[!DatePickerPresenter.MonthVisibleProperty] = this[!MonthVisibleProperty];
+ _presenter[!DatePickerPresenter.MonthFormatProperty] = this[!MonthFormatProperty];
+
+ _presenter[!DatePickerPresenter.DayVisibleProperty] = this[!DayVisibleProperty];
+ _presenter[!DatePickerPresenter.DayFormatProperty] = this[!DayFormatProperty];
+
+ _presenter[!DatePickerPresenter.YearVisibleProperty] = this[!YearVisibleProperty];
+ _presenter[!DatePickerPresenter.YearFormatProperty] = this[!YearFormatProperty];
+ }
+ }
+
+ private void OnDismissPicker(object sender, EventArgs e)
+ {
+ _popup.Close();
+ Focus();
+ }
+
+ private void OnConfirmed(object sender, EventArgs e)
+ {
+ _popup.Close();
+ SelectedDate = _presenter.Date;
+ }
+
+ private void SetGrid()
+ {
+ if (_container == null)
+ return;
+
+ var fmt = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern;
+ var columns = new List<(TextBlock, int)>
+ {
+ (_monthText, MonthVisible ? fmt.IndexOf("m", StringComparison.OrdinalIgnoreCase) : -1),
+ (_yearText, YearVisible ? fmt.IndexOf("y", StringComparison.OrdinalIgnoreCase) : -1),
+ (_dayText, DayVisible ? fmt.IndexOf("d", StringComparison.OrdinalIgnoreCase) : -1),
+ };
+
+ columns.Sort((x, y) => x.Item2 - y.Item2);
+ _container.ColumnDefinitions.Clear();
+
+ var columnIndex = 0;
+
+ foreach (var column in columns)
+ {
+ if (column.Item1 is null)
+ continue;
+
+ column.Item1.IsVisible = column.Item2 != -1;
+
+ if (column.Item2 != -1)
+ {
+ if (columnIndex > 0)
+ {
+ _container.ColumnDefinitions.Add(new ColumnDefinition(0, GridUnitType.Auto));
+ }
+
+ _container.ColumnDefinitions.Add(
+ new ColumnDefinition(column.Item1 == _monthText ? 138 : 78, GridUnitType.Star));
+
+ if (column.Item1.Parent is null)
+ {
+ _container.Children.Add(column.Item1);
+ }
+
+ Grid.SetColumn(column.Item1, (columnIndex++ * 2));
+ }
+ }
+
+ Grid.SetColumn(_spacer1, 1);
+ Grid.SetColumn(_spacer2, 3);
+ _spacer1.IsVisible = columnIndex > 1;
+ _spacer2.IsVisible = columnIndex > 2;
+ }
+
+ private void SetSelectedDateText()
+ {
+ if (!_areControlsAvailable)
+ return;
+
+ if (SelectedDate.HasValue)
+ {
+ PseudoClasses.Set(":hasnodate", false);
+ var selDate = SelectedDate.Value;
+ _monthText.Text = selDate.ToString(MonthFormat);
+ _yearText.Text = selDate.ToString(YearFormat);
+ _dayText.Text = selDate.ToString(DayFormat);
+ }
+ else
+ {
+ PseudoClasses.Set(":hasnodate", true);
+ _monthText.Text = "month";
+ _yearText.Text = "year";
+ _dayText.Text = "day";
+ }
+ }
+
+ private void OnFlyoutButtonClicked(object sender, RoutedEventArgs e)
+ {
+ if (_presenter == null)
+ throw new InvalidOperationException("No DatePickerPresenter found");
+
+ _presenter.Date = SelectedDate ?? DateTimeOffset.Now;
+
+ _popup.IsOpen = true;
+
+ var deltaY = _presenter.GetOffsetForPopup();
+
+ //The extra 5 px I think is related to default popup placement behavior
+ _popup.Host.ConfigurePosition(_popup.PlacementTarget, PlacementMode.AnchorAndGravity, new Point(0, deltaY + 5),
+ Primitives.PopupPositioning.PopupAnchor.Bottom, Primitives.PopupPositioning.PopupGravity.Bottom,
+ Primitives.PopupPositioning.PopupPositionerConstraintAdjustment.SlideY);
+ }
+
+ protected virtual void OnSelectedDateChanged(object sender, DatePickerSelectedValueChangedEventArgs e)
+ {
+ SelectedDateChanged?.Invoke(sender, e);
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/DateTimePickers/DatePickerPresenter.cs b/src/Avalonia.Controls/DateTimePickers/DatePickerPresenter.cs
new file mode 100644
index 0000000000..8b86e46e88
--- /dev/null
+++ b/src/Avalonia.Controls/DateTimePickers/DatePickerPresenter.cs
@@ -0,0 +1,531 @@
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Shapes;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace Avalonia.Controls
+{
+ ///
+ /// Defines the presenter used for selecting a date for a
+ ///
+ ///
+ public class DatePickerPresenter : PickerPresenterBase
+ {
+ ///
+ /// Defines the Property
+ ///
+ public static readonly DirectProperty DateProperty =
+ AvaloniaProperty.RegisterDirect(nameof(Date),
+ x => x.Date, (x, v) => x.Date = v);
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly DirectProperty DayFormatProperty =
+ DatePicker.DayFormatProperty.AddOwner(x =>
+ x.DayFormat, (x, v) => x.DayFormat = v);
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly DirectProperty DayVisibleProperty =
+ DatePicker.DayVisibleProperty.AddOwner(x =>
+ x.DayVisible, (x, v) => x.DayVisible = v);
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly DirectProperty MaxYearProperty =
+ DatePicker.MaxYearProperty.AddOwner(x =>
+ x.MaxYear, (x, v) => x.MaxYear = v);
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly DirectProperty MinYearProperty =
+ DatePicker.MinYearProperty.AddOwner(x =>
+ x.MinYear, (x, v) => x.MinYear = v);
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly DirectProperty MonthFormatProperty =
+ DatePicker.MonthFormatProperty.AddOwner(x =>
+ x.MonthFormat, (x, v) => x.MonthFormat = v);
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly DirectProperty MonthVisibleProperty =
+ DatePicker.MonthVisibleProperty.AddOwner(x =>
+ x.MonthVisible, (x, v) => x.MonthVisible = v);
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly DirectProperty YearFormatProperty =
+ DatePicker.YearFormatProperty.AddOwner(x =>
+ x.YearFormat, (x, v) => x.YearFormat = v);
+
+ ///
+ /// Defines the Property
+ ///
+ public static readonly DirectProperty YearVisibleProperty =
+ DatePicker.YearVisibleProperty.AddOwner(x =>
+ x.YearVisible, (x, v) => x.YearVisible = v);
+
+ //Template Items
+ private Grid _pickerContainer;
+ private Button _acceptButton;
+ private Button _dismissButton;
+ private Rectangle _spacer1;
+ private Rectangle _spacer2;
+ private Panel _monthHost;
+ private Panel _yearHost;
+ private Panel _dayHost;
+ private DateTimePickerPanel _monthSelector;
+ private DateTimePickerPanel _yearSelector;
+ private DateTimePickerPanel _daySelector;
+ private Button _monthUpButton;
+ private Button _dayUpButton;
+ private Button _yearUpButton;
+ private Button _monthDownButton;
+ private Button _dayDownButton;
+ private Button _yearDownButton;
+
+ private DateTimeOffset _date;
+ private string _dayFormat = "%d";
+ private bool _dayVisible = true;
+ private DateTimeOffset _maxYear;
+ private DateTimeOffset _minYear;
+ private string _monthFormat = "MMMM";
+ private bool _monthVisible = true;
+ private string _yearFormat = "yyyy";
+ private bool _yearVisible = true;
+ private DateTimeOffset _syncDate;
+
+ private GregorianCalendar _calendar;
+ private bool _suppressUpdateSelection;
+
+ public DatePickerPresenter()
+ {
+ var now = DateTimeOffset.Now;
+ _minYear = new DateTimeOffset(now.Year - 100, 1, 1, 0, 0, 0, now.Offset);
+ _maxYear = new DateTimeOffset(now.Year + 100, 12, 31, 0, 0, 0, now.Offset);
+ _date = now;
+ _calendar = new GregorianCalendar();
+ KeyboardNavigation.SetTabNavigation(this, KeyboardNavigationMode.Cycle);
+ }
+
+ ///
+ /// Gets or sets the current Date for the picker
+ ///
+ public DateTimeOffset Date
+ {
+ get => _date;
+ set
+ {
+ SetAndRaise(DateProperty, ref _date, value);
+ _syncDate = Date;
+ InitPicker();
+ }
+ }
+
+ ///
+ /// Gets or sets the DayFormat
+ ///
+ public string DayFormat
+ {
+ get => _dayFormat;
+ set => SetAndRaise(DayFormatProperty, ref _dayFormat, value);
+ }
+
+ ///
+ /// Get or sets whether the Day selector is visible
+ ///
+ public bool DayVisible
+ {
+ get => _dayVisible;
+ set
+ {
+ SetAndRaise(DayVisibleProperty, ref _dayVisible, value);
+ }
+ }
+
+ ///
+ /// Gets or sets the maximum pickable year
+ ///
+ public DateTimeOffset MaxYear
+ {
+ get => _maxYear;
+ set
+ {
+ if (value < MinYear)
+ throw new InvalidOperationException("MaxDate cannot be less than MinDate");
+ SetAndRaise(MaxYearProperty, ref _maxYear, value);
+
+ if (Date > value)
+ Date = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the minimum pickable year
+ ///
+ public DateTimeOffset MinYear
+ {
+ get => _minYear;
+ set
+ {
+ if (value > MaxYear)
+ throw new InvalidOperationException("MinDate cannot be greater than MaxDate");
+ SetAndRaise(MinYearProperty, ref _minYear, value);
+
+ if (Date < value)
+ Date = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the month format
+ ///
+ public string MonthFormat
+ {
+ get => _monthFormat;
+ set => SetAndRaise(MonthFormatProperty, ref _monthFormat, value);
+ }
+
+ ///
+ /// Gets or sets whether the month selector is visible
+ ///
+ public bool MonthVisible
+ {
+ get => _monthVisible;
+ set
+ {
+ SetAndRaise(MonthVisibleProperty, ref _monthVisible, value);
+ }
+ }
+
+ ///
+ /// Gets or sets the year format
+ ///
+ public string YearFormat
+ {
+ get => _yearFormat;
+ set => SetAndRaise(YearFormatProperty, ref _yearFormat, value);
+ }
+
+ ///
+ /// Gets or sets whether the year selector is visible
+ ///
+ public bool YearVisible
+ {
+ get => _yearVisible;
+ set
+ {
+ SetAndRaise(YearVisibleProperty, ref _yearVisible, value);
+ }
+ }
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+ //These are requirements, so throw if not found
+ _pickerContainer = e.NameScope.Get("PickerContainer");
+ _monthHost = e.NameScope.Get("MonthHost");
+ _dayHost = e.NameScope.Get("DayHost");
+ _yearHost = e.NameScope.Get("YearHost");
+
+ _monthSelector = e.NameScope.Get("MonthSelector");
+ _monthSelector.SelectionChanged += OnMonthChanged;
+
+ _daySelector = e.NameScope.Get("DaySelector");
+ _daySelector.SelectionChanged += OnDayChanged;
+
+ _yearSelector = e.NameScope.Get("YearSelector");
+ _yearSelector.SelectionChanged += OnYearChanged;
+
+ _acceptButton = e.NameScope.Get("AcceptButton");
+
+ _monthUpButton = e.NameScope.Find("MonthUpButton");
+ if (_monthUpButton != null)
+ {
+ _monthUpButton.Click += OnSelectorButtonClick;
+ }
+ _monthDownButton = e.NameScope.Find("MonthDownButton");
+ if (_monthDownButton != null)
+ {
+ _monthDownButton.Click += OnSelectorButtonClick;
+ }
+
+ _dayUpButton = e.NameScope.Find("DayUpButton");
+ if (_dayUpButton != null)
+ {
+ _dayUpButton.Click += OnSelectorButtonClick;
+ }
+ _dayDownButton = e.NameScope.Find("DayDownButton");
+ if (_dayDownButton != null)
+ {
+ _dayDownButton.Click += OnSelectorButtonClick;
+ }
+
+ _yearUpButton = e.NameScope.Find("YearUpButton");
+ if (_yearUpButton != null)
+ {
+ _yearUpButton.Click += OnSelectorButtonClick;
+ }
+ _yearDownButton = e.NameScope.Find("YearDownButton");
+ if (_yearDownButton != null)
+ {
+ _yearDownButton.Click += OnSelectorButtonClick;
+ }
+
+ _dismissButton = e.NameScope.Find("DismissButton");
+ _spacer1 = e.NameScope.Find("FirstSpacer");
+ _spacer2 = e.NameScope.Find("SecondSpacer");
+
+ if (_acceptButton != null)
+ {
+ _acceptButton.Click += OnAcceptButtonClicked;
+ }
+ if (_dismissButton != null)
+ {
+ _dismissButton.Click += OnDismissButtonClicked;
+ }
+ InitPicker();
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ switch (e.Key)
+ {
+ case Key.Escape:
+ OnDismiss();
+ e.Handled = true;
+ break;
+ case Key.Tab:
+ var nextFocus = KeyboardNavigationHandler.GetNext(FocusManager.Instance.Current, NavigationDirection.Next);
+ KeyboardDevice.Instance?.SetFocusedElement(nextFocus, NavigationMethod.Tab, KeyModifiers.None);
+ e.Handled = true;
+ break;
+ case Key.Enter:
+ Date = _syncDate;
+ OnConfirmed();
+ e.Handled = true;
+ break;
+ }
+ base.OnKeyDown(e);
+ }
+
+ ///
+ /// Initializes the picker selectors.
+ ///
+ private void InitPicker()
+ {
+ //OnApplyTemplate must've been called before we can init here...
+ if (_pickerContainer == null)
+ return;
+
+ _suppressUpdateSelection = true;
+
+ _monthSelector.MaximumValue = 12;
+ _monthSelector.MinimumValue = 1;
+ _monthSelector.ItemFormat = MonthFormat;
+
+ _daySelector.ItemFormat = DayFormat;
+
+ _yearSelector.MaximumValue = MaxYear.Year;
+ _yearSelector.MinimumValue = MinYear.Year;
+ _yearSelector.ItemFormat = YearFormat;
+
+ SetGrid();
+
+ //Date should've been set when we reach this point
+ var dt = Date;
+ if (DayVisible)
+ {
+ GregorianCalendar gc = new GregorianCalendar();
+ var maxDays = gc.GetDaysInMonth(dt.Year, dt.Month);
+ _daySelector.MaximumValue = maxDays;
+ _daySelector.MinimumValue = 1;
+ _daySelector.SelectedValue = dt.Day;
+ _daySelector.FormatDate = dt.Date;
+ }
+
+ if (MonthVisible)
+ _monthSelector.SelectedValue = dt.Month;
+
+ if (YearVisible)
+ _yearSelector.SelectedValue = dt.Year;
+ _suppressUpdateSelection = false;
+
+ SetInitialFocus();
+ }
+
+ private void SetGrid()
+ {
+ var fmt = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern;
+ var columns = new List<(Panel, int)>
+ {
+ (_monthHost, MonthVisible ? fmt.IndexOf("m", StringComparison.OrdinalIgnoreCase) : -1),
+ (_yearHost, YearVisible ? fmt.IndexOf("y", StringComparison.OrdinalIgnoreCase) : -1),
+ (_dayHost, DayVisible ? fmt.IndexOf("d", StringComparison.OrdinalIgnoreCase) : -1),
+ };
+
+ columns.Sort((x, y) => x.Item2 - y.Item2);
+ _pickerContainer.ColumnDefinitions.Clear();
+
+ var columnIndex = 0;
+
+ foreach (var column in columns)
+ {
+ if (column.Item1 is null)
+ continue;
+
+ column.Item1.IsVisible = column.Item2 != -1;
+
+ if (column.Item2 != -1)
+ {
+ if (columnIndex > 0)
+ {
+ _pickerContainer.ColumnDefinitions.Add(new ColumnDefinition(0, GridUnitType.Auto));
+ }
+
+ _pickerContainer.ColumnDefinitions.Add(
+ new ColumnDefinition(column.Item1 == _monthHost ? 138 : 78, GridUnitType.Star));
+
+ if (column.Item1.Parent is null)
+ {
+ _pickerContainer.Children.Add(column.Item1);
+ }
+
+ Grid.SetColumn(column.Item1, (columnIndex++ * 2));
+ }
+ }
+
+ Grid.SetColumn(_spacer1, 1);
+ Grid.SetColumn(_spacer2, 3);
+ _spacer1.IsVisible = columnIndex > 1;
+ _spacer2.IsVisible = columnIndex > 2;
+ }
+
+ private void SetInitialFocus()
+ {
+ int monthCol = MonthVisible ? Grid.GetColumn(_monthHost) : int.MaxValue;
+ int dayCol = DayVisible ? Grid.GetColumn(_dayHost) : int.MaxValue;
+ int yearCol = YearVisible ? Grid.GetColumn(_yearHost) : int.MaxValue;
+
+ if (monthCol < dayCol && monthCol < yearCol)
+ {
+ KeyboardDevice.Instance?.SetFocusedElement(_monthSelector, NavigationMethod.Pointer, KeyModifiers.None);
+ }
+ else if (dayCol < monthCol && dayCol < yearCol)
+ {
+ KeyboardDevice.Instance?.SetFocusedElement(_daySelector, NavigationMethod.Pointer, KeyModifiers.None);
+ }
+ else if (yearCol < monthCol && yearCol < dayCol)
+ {
+ KeyboardDevice.Instance?.SetFocusedElement(_yearSelector, NavigationMethod.Pointer, KeyModifiers.None);
+ }
+ }
+
+ private void OnDismissButtonClicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ OnDismiss();
+ }
+
+ private void OnAcceptButtonClicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ Date = _syncDate;
+ OnConfirmed();
+ }
+
+ private void OnSelectorButtonClick(object sender, RoutedEventArgs e)
+ {
+ if (sender == _monthUpButton)
+ _monthSelector.ScrollUp();
+ else if (sender == _monthDownButton)
+ _monthSelector.ScrollDown();
+ else if (sender == _yearUpButton)
+ _yearSelector.ScrollUp();
+ else if (sender == _yearDownButton)
+ _yearSelector.ScrollDown();
+ else if (sender == _dayUpButton)
+ _daySelector.ScrollUp();
+ else if (sender == _dayDownButton)
+ _daySelector.ScrollDown();
+ }
+
+ private void OnYearChanged(object sender, EventArgs e)
+ {
+ if (_suppressUpdateSelection)
+ return;
+
+ int maxDays = _calendar.GetDaysInMonth(_yearSelector.SelectedValue, _syncDate.Month);
+ var newDate = new DateTimeOffset(_yearSelector.SelectedValue, _syncDate.Month,
+ _syncDate.Day > maxDays ? maxDays : _syncDate.Day, 0, 0, 0, _syncDate.Offset);
+
+ _syncDate = newDate;
+
+ //We don't need to update the days if not displaying day, not february
+ if (!DayVisible || _syncDate.Month != 2)
+ return;
+
+ _suppressUpdateSelection = true;
+
+ _daySelector.FormatDate = newDate.Date;
+
+ if (_daySelector.MaximumValue != maxDays)
+ _daySelector.MaximumValue = maxDays;
+ else
+ _daySelector.RefreshItems();
+
+ _suppressUpdateSelection = false;
+ }
+
+ private void OnDayChanged(object sender, EventArgs e)
+ {
+ if (_suppressUpdateSelection)
+ return;
+ _syncDate = new DateTimeOffset(_syncDate.Year, _syncDate.Month, _daySelector.SelectedValue, 0, 0, 0, _syncDate.Offset);
+ }
+
+ private void OnMonthChanged(object sender, EventArgs e)
+ {
+ if (_suppressUpdateSelection)
+ return;
+
+ int maxDays = _calendar.GetDaysInMonth(_syncDate.Year, _monthSelector.SelectedValue);
+ var newDate = new DateTimeOffset(_syncDate.Year, _monthSelector.SelectedValue,
+ _syncDate.Day > maxDays ? maxDays : _syncDate.Day, 0, 0, 0, _syncDate.Offset);
+
+ if (!DayVisible)
+ {
+ _syncDate = newDate;
+ return;
+ }
+
+ _suppressUpdateSelection = true;
+
+ _daySelector.FormatDate = newDate.Date;
+ _syncDate = newDate;
+
+ if (_daySelector.MaximumValue != maxDays)
+ _daySelector.MaximumValue = maxDays;
+ else
+ _daySelector.RefreshItems();
+
+ _suppressUpdateSelection = false;
+ }
+
+ internal double GetOffsetForPopup()
+ {
+ var acceptDismissButtonHeight = _acceptButton != null ? _acceptButton.Bounds.Height : 41;
+ return -(MaxHeight - acceptDismissButtonHeight) / 2 - (_monthSelector.ItemHeight / 2);
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/DateTimePickers/DatePickerSelectedValueChangedEventArgs.cs b/src/Avalonia.Controls/DateTimePickers/DatePickerSelectedValueChangedEventArgs.cs
new file mode 100644
index 0000000000..6c4c53b623
--- /dev/null
+++ b/src/Avalonia.Controls/DateTimePickers/DatePickerSelectedValueChangedEventArgs.cs
@@ -0,0 +1,19 @@
+using System;
+
+namespace Avalonia.Controls
+{
+ ///
+ /// Defines the argument passed when the SelectedDate changes
+ ///
+ public class DatePickerSelectedValueChangedEventArgs
+ {
+ public DateTimeOffset? NewDate { get; }
+ public DateTimeOffset? OldDate { get; }
+
+ public DatePickerSelectedValueChangedEventArgs(DateTimeOffset? oldDate, DateTimeOffset? newDate)
+ {
+ NewDate = newDate;
+ OldDate = oldDate;
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/DateTimePickers/DateTimePickerPanel.cs b/src/Avalonia.Controls/DateTimePickers/DateTimePickerPanel.cs
new file mode 100644
index 0000000000..daee7968eb
--- /dev/null
+++ b/src/Avalonia.Controls/DateTimePickers/DateTimePickerPanel.cs
@@ -0,0 +1,566 @@
+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(nameof(ItemHeight), 40.0);
+
+ ///
+ /// Defines the property
+ ///
+ public static readonly StyledProperty PanelTypeProperty =
+ AvaloniaProperty.Register(nameof(PanelType));
+
+ ///
+ /// Defines the property
+ ///
+ public static readonly StyledProperty ItemFormatProperty =
+ AvaloniaProperty.Register(nameof(ItemFormat), "yyyy");
+
+ ///
+ /// Defines the property
+ ///
+ public static readonly StyledProperty ShouldLoopProperty =
+ AvaloniaProperty.Register(nameof(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;
+ default:
+ 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);
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/DateTimePickers/PickerPresenterBase.cs b/src/Avalonia.Controls/DateTimePickers/PickerPresenterBase.cs
new file mode 100644
index 0000000000..639b187ad8
--- /dev/null
+++ b/src/Avalonia.Controls/DateTimePickers/PickerPresenterBase.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Avalonia.Controls.Primitives
+{
+ ///
+ /// Defines the base class for Date and Time PickerPresenters
+ ///
+ public abstract class PickerPresenterBase : TemplatedControl
+ {
+ protected virtual void OnConfirmed()
+ {
+ Confirmed?.Invoke(this, EventArgs.Empty);
+ }
+
+ protected virtual void OnDismiss()
+ {
+ Dismissed.Invoke(this, EventArgs.Empty);
+ }
+
+ public event EventHandler Confirmed;
+ public event EventHandler Dismissed;
+ }
+}
diff --git a/src/Avalonia.Controls/DateTimePickers/TimePicker.cs b/src/Avalonia.Controls/DateTimePickers/TimePicker.cs
new file mode 100644
index 0000000000..3fa426b1d3
--- /dev/null
+++ b/src/Avalonia.Controls/DateTimePickers/TimePicker.cs
@@ -0,0 +1,292 @@
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Shapes;
+using Avalonia.Controls.Templates;
+using System;
+using System.Globalization;
+
+namespace Avalonia.Controls
+{
+ ///
+ /// A control to allow the user to select a time
+ ///
+ public class TimePicker : TemplatedControl
+ {
+ ///
+ /// Defines the property
+ ///
+ public static readonly DirectProperty MinuteIncrementProperty =
+ AvaloniaProperty.RegisterDirect(nameof(MinuteIncrement),
+ x => x.MinuteIncrement, (x, v) => x.MinuteIncrement = v);
+
+ ///
+ /// Defines the property
+ ///
+ public static readonly StyledProperty HeaderProperty =
+ AvaloniaProperty.Register(nameof(Header));
+
+ ///
+ /// Defines the property
+ ///
+ public static readonly StyledProperty HeaderTemplateProperty =
+ AvaloniaProperty.Register(nameof(HeaderTemplate));
+
+ ///
+ /// Defines the property
+ ///
+ public static readonly DirectProperty ClockIdentifierProperty =
+ AvaloniaProperty.RegisterDirect(nameof(ClockIdentifier),
+ x => x.ClockIdentifier, (x, v) => x.ClockIdentifier = v);
+
+ ///
+ /// Defines the property
+ ///
+ public static readonly DirectProperty SelectedTimeProperty =
+ AvaloniaProperty.RegisterDirect(nameof(SelectedTime),
+ x => x.SelectedTime, (x, v) => x.SelectedTime = v);
+
+ //Template Items
+ private TimePickerPresenter _presenter;
+ private Button _flyoutButton;
+ private Border _firstPickerHost;
+ private Border _secondPickerHost;
+ private Border _thirdPickerHost;
+ private TextBlock _hourText;
+ private TextBlock _minuteText;
+ public TextBlock _periodText;
+ private Rectangle _firstSplitter;
+ private Rectangle _secondSplitter;
+ private Grid _contentGrid;
+ private Popup _popup;
+
+ private TimeSpan? _selectedTime;
+ private int _minuteIncrement = 1;
+ private string _clockIdentifier = "12HourClock";
+
+ public TimePicker()
+ {
+ PseudoClasses.Set(":hasnotime", true);
+
+ var timePattern = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern;
+ if (timePattern.IndexOf("H") != -1)
+ _clockIdentifier = "24HourClock";
+ }
+
+ ///
+ /// Gets or sets the minute increment in the picker
+ ///
+ public int MinuteIncrement
+ {
+ get => _minuteIncrement;
+ set
+ {
+ if (value < 1 || value > 59)
+ throw new ArgumentOutOfRangeException("1 >= MinuteIncrement <= 59");
+ SetAndRaise(MinuteIncrementProperty, ref _minuteIncrement, value);
+ SetSelectedTimeText();
+ }
+ }
+
+ ///
+ /// Gets or sets the header
+ ///
+ public object Header
+ {
+ get => GetValue(HeaderProperty);
+ set => SetValue(HeaderProperty, value);
+ }
+
+ ///
+ /// Gets or sets the header template
+ ///
+ public IDataTemplate HeaderTemplate
+ {
+ get => GetValue(HeaderTemplateProperty);
+ set => SetValue(HeaderTemplateProperty, value);
+ }
+
+ ///
+ /// Gets or sets the clock identifier, either 12HourClock or 24HourClock
+ ///
+ public string ClockIdentifier
+ {
+ get => _clockIdentifier;
+ set
+ {
+ if (!(string.IsNullOrEmpty(value) || value == "" || value == "12HourClock" || value == "24HourClock"))
+ throw new ArgumentException("Invalid ClockIdentifier");
+ SetAndRaise(ClockIdentifierProperty, ref _clockIdentifier, value);
+ SetGrid();
+ SetSelectedTimeText();
+ }
+ }
+
+ ///
+ /// Gets or sets the selected time. Can be null.
+ ///
+ public TimeSpan? SelectedTime
+ {
+ get => _selectedTime;
+ set
+ {
+ var old = _selectedTime;
+ SetAndRaise(SelectedTimeProperty, ref _selectedTime, value);
+ OnSelectedTimeChanged(old, value);
+ SetSelectedTimeText();
+ }
+ }
+
+ ///
+ /// Raised when the property changes
+ ///
+ public event EventHandler SelectedTimeChanged;
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ if (_flyoutButton != null)
+ _flyoutButton.Click -= OnFlyoutButtonClicked;
+
+ if(_presenter != null)
+ {
+ _presenter.Confirmed -= OnConfirmed;
+ _presenter.Dismissed -= OnDismissPicker;
+ }
+ base.OnApplyTemplate(e);
+
+ _flyoutButton = e.NameScope.Find("FlyoutButton");
+
+ _firstPickerHost = e.NameScope.Find("FirstPickerHost");
+ _secondPickerHost = e.NameScope.Find("SecondPickerHost");
+ _thirdPickerHost = e.NameScope.Find("ThirdPickerHost");
+
+ _hourText = e.NameScope.Find("HourTextBlock");
+ _minuteText = e.NameScope.Find("MinuteTextBlock");
+ _periodText = e.NameScope.Find("PeriodTextBlock");
+
+ _firstSplitter = e.NameScope.Find("FirstColumnDivider");
+ _secondSplitter = e.NameScope.Find("SecondColumnDivider");
+
+ _contentGrid = e.NameScope.Find("FlyoutButtonContentGrid");
+
+ _popup = e.NameScope.Find("Popup");
+ _presenter = e.NameScope.Find("PickerPresenter");
+
+
+ if (_flyoutButton != null)
+ _flyoutButton.Click += OnFlyoutButtonClicked;
+
+ SetGrid();
+ SetSelectedTimeText();
+
+ if (_presenter != null)
+ {
+ _presenter.Confirmed += OnConfirmed;
+ _presenter.Dismissed += OnDismissPicker;
+
+ _presenter[!TimePickerPresenter.MinuteIncrementProperty] = this[!MinuteIncrementProperty];
+ _presenter[!TimePickerPresenter.ClockIdentifierProperty] = this[!ClockIdentifierProperty];
+ }
+
+ }
+
+ private void SetGrid()
+ {
+ if (_contentGrid == null)
+ return;
+
+ bool use24HourClock = ClockIdentifier == "24HourClock";
+
+ if (!use24HourClock)
+ {
+ _contentGrid.ColumnDefinitions = new ColumnDefinitions("*,Auto,*,Auto,*");
+ _thirdPickerHost.IsVisible = true;
+ _secondSplitter.IsVisible = true;
+
+ Grid.SetColumn(_firstPickerHost, 0);
+ Grid.SetColumn(_secondPickerHost, 2);
+ Grid.SetColumn(_thirdPickerHost, 4);
+
+ Grid.SetColumn(_firstSplitter, 1);
+ Grid.SetColumn(_secondSplitter, 3);
+ }
+ else
+ {
+ _contentGrid.ColumnDefinitions = new ColumnDefinitions("*,Auto,*");
+ _thirdPickerHost.IsVisible = false;
+ _secondSplitter.IsVisible = false;
+
+ Grid.SetColumn(_firstPickerHost, 0);
+ Grid.SetColumn(_secondPickerHost, 2);
+
+ Grid.SetColumn(_firstSplitter, 1);
+ }
+ }
+
+ private void SetSelectedTimeText()
+ {
+ if (_hourText == null || _minuteText == null || _periodText == null)
+ return;
+
+ var time = SelectedTime;
+ if (time.HasValue)
+ {
+ var newTime = SelectedTime.Value;
+
+ if (ClockIdentifier == "12HourClock")
+ {
+ var hr = newTime.Hours;
+ hr = hr > 12 ? hr - 12 : hr == 0 ? 12 : hr;
+ newTime = new TimeSpan(hr, newTime.Minutes, 0);
+ }
+ _hourText.Text = newTime.ToString("%h");
+
+ _minuteText.Text = newTime.ToString("mm");
+ PseudoClasses.Set(":hasnotime", false);
+
+ _periodText.Text = time.Value.Hours >= 12 ? CultureInfo.CurrentCulture.DateTimeFormat.PMDesignator :
+ CultureInfo.CurrentCulture.DateTimeFormat.AMDesignator;
+
+ }
+ else
+ {
+ _hourText.Text = "hour";
+ _minuteText.Text = "minute";
+ PseudoClasses.Set(":hasnotime", true);
+
+ _periodText.Text = DateTime.Now.Hour >= 12 ? CultureInfo.CurrentCulture.DateTimeFormat.PMDesignator :
+ CultureInfo.CurrentCulture.DateTimeFormat.AMDesignator;
+ }
+ }
+
+ protected virtual void OnSelectedTimeChanged(TimeSpan? oldTime, TimeSpan? newTime)
+ {
+ SelectedTimeChanged?.Invoke(this, new TimePickerSelectedValueChangedEventArgs(oldTime, newTime));
+ }
+
+ private void OnFlyoutButtonClicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ _presenter.Time = SelectedTime ?? DateTime.Now.TimeOfDay;
+
+ _popup.IsOpen = true;
+
+ var deltaY = _presenter.GetOffsetForPopup();
+
+ //The extra 5 px I think is related to default popup placement behavior
+ _popup.Host.ConfigurePosition(_popup.PlacementTarget, PlacementMode.AnchorAndGravity, new Point(0, deltaY + 5),
+ Primitives.PopupPositioning.PopupAnchor.Bottom, Primitives.PopupPositioning.PopupGravity.Bottom,
+ Primitives.PopupPositioning.PopupPositionerConstraintAdjustment.SlideY);
+ }
+
+ private void OnDismissPicker(object sender, EventArgs e)
+ {
+ _popup.Close();
+ Focus();
+ }
+
+ private void OnConfirmed(object sender, EventArgs e)
+ {
+ _popup.Close();
+ SelectedTime = _presenter.Time;
+ }
+
+ }
+}
diff --git a/src/Avalonia.Controls/DateTimePickers/TimePickerPresenter.cs b/src/Avalonia.Controls/DateTimePickers/TimePickerPresenter.cs
new file mode 100644
index 0000000000..5ab5b92e16
--- /dev/null
+++ b/src/Avalonia.Controls/DateTimePickers/TimePickerPresenter.cs
@@ -0,0 +1,262 @@
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Shapes;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using System;
+
+namespace Avalonia.Controls
+{
+ ///
+ /// Defines the presenter used for selecting a time. Intended for use with
+ /// but can be used independently
+ ///
+ public class TimePickerPresenter : PickerPresenterBase
+ {
+ ///
+ /// Defines the property
+ ///
+ public static readonly DirectProperty MinuteIncrementProperty =
+ TimePicker.MinuteIncrementProperty.AddOwner(x => x.MinuteIncrement,
+ (x, v) => x.MinuteIncrement = v);
+
+ ///
+ /// Defines the property
+ ///
+ public static readonly DirectProperty ClockIdentifierProperty =
+ TimePicker.ClockIdentifierProperty.AddOwner(x => x.ClockIdentifier,
+ (x, v) => x.ClockIdentifier = v);
+
+ ///
+ /// Defines the property
+ ///
+ public static readonly DirectProperty TimeProperty =
+ AvaloniaProperty.RegisterDirect(nameof(Time),
+ x => x.Time, (x, v) => x.Time = v);
+
+ public TimePickerPresenter()
+ {
+ Time = DateTime.Now.TimeOfDay;
+ KeyboardNavigation.SetTabNavigation(this, KeyboardNavigationMode.Cycle);
+ }
+
+ //TemplateItems
+ private Grid _pickerContainer;
+ private Button _acceptButton;
+ private Button _dismissButton;
+ private Rectangle _spacer2;
+ private Panel _periodHost;
+ private DateTimePickerPanel _hourSelector;
+ private DateTimePickerPanel _minuteSelector;
+ private DateTimePickerPanel _periodSelector;
+ private Button _hourUpButton;
+ private Button _minuteUpButton;
+ private Button _periodUpButton;
+ private Button _hourDownButton;
+ private Button _minuteDownButton;
+ private Button _periodDownButton;
+
+ //Backing Fields
+ private TimeSpan _Time;
+ private int _minuteIncrement = 1;
+ private string _clockIdentifier = "12HourClock";
+
+ ///
+ /// Gets or sets the minute increment in the selector
+ ///
+ public int MinuteIncrement
+ {
+ get => _minuteIncrement;
+ set
+ {
+ if (value < 1 || value > 59)
+ throw new ArgumentOutOfRangeException("1 >= MinuteIncrement <= 59");
+ SetAndRaise(MinuteIncrementProperty, ref _minuteIncrement, value);
+ InitPicker();
+ }
+ }
+
+ ///
+ /// Gets or sets the current clock identifier, either 12HourClock or 24HourClock
+ ///
+ public string ClockIdentifier
+ {
+ get => _clockIdentifier;
+ set
+ {
+ if (string.IsNullOrEmpty(value) || value == "" || !(value == "12HourClock" || value == "24HourClock"))
+ throw new ArgumentException("Invalid ClockIdentifier");
+ SetAndRaise(ClockIdentifierProperty, ref _clockIdentifier, value);
+ InitPicker();
+ }
+ }
+
+ ///
+ /// Gets or sets the current time
+ ///
+ public TimeSpan Time
+ {
+ get => _Time;
+ set
+ {
+ SetAndRaise(TimeProperty, ref _Time, value);
+ InitPicker();
+ }
+ }
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+
+ _pickerContainer = e.NameScope.Get("PickerContainer");
+ _periodHost = e.NameScope.Get("PeriodHost");
+
+ _hourSelector = e.NameScope.Get("HourSelector");
+ _minuteSelector = e.NameScope.Get("MinuteSelector");
+ _periodSelector = e.NameScope.Get("PeriodSelector");
+
+ _spacer2 = e.NameScope.Get("SecondSpacer");
+
+ _acceptButton = e.NameScope.Get("AcceptButton");
+ _acceptButton.Click += OnAcceptButtonClicked;
+
+ _hourUpButton = e.NameScope.Find("HourUpButton");
+ if (_hourUpButton != null)
+ _hourUpButton.Click += OnSelectorButtonClick;
+ _hourDownButton = e.NameScope.Find("HourDownButton");
+ if (_hourDownButton != null)
+ _hourDownButton.Click += OnSelectorButtonClick;
+
+ _minuteUpButton = e.NameScope.Find("MinuteUpButton");
+ if (_minuteUpButton != null)
+ _minuteUpButton.Click += OnSelectorButtonClick;
+ _minuteDownButton = e.NameScope.Find("MinuteDownButton");
+ if (_minuteDownButton != null)
+ _minuteDownButton.Click += OnSelectorButtonClick;
+
+ _periodUpButton = e.NameScope.Find("PeriodUpButton");
+ if (_periodUpButton != null)
+ _periodUpButton.Click += OnSelectorButtonClick;
+ _periodDownButton = e.NameScope.Find("PeriodDownButton");
+ if (_periodDownButton != null)
+ _periodDownButton.Click += OnSelectorButtonClick;
+
+ _dismissButton = e.NameScope.Find("DismissButton");
+ if (_dismissButton != null)
+ _dismissButton.Click += OnDismissButtonClicked;
+
+ InitPicker();
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ switch (e.Key)
+ {
+ case Key.Escape:
+ OnDismiss();
+ e.Handled = true;
+ break;
+ case Key.Tab:
+ var nextFocus = KeyboardNavigationHandler.GetNext(FocusManager.Instance.Current, NavigationDirection.Next);
+ KeyboardDevice.Instance?.SetFocusedElement(nextFocus, NavigationMethod.Tab, KeyModifiers.None);
+ e.Handled = true;
+ break;
+ case Key.Enter:
+ OnConfirmed();
+ e.Handled = true;
+ break;
+ }
+ base.OnKeyDown(e);
+ }
+
+ protected override void OnConfirmed()
+ {
+ var hr = _hourSelector.SelectedValue;
+ var min = _minuteSelector.SelectedValue;
+ var per = _periodSelector.SelectedValue;
+
+ if (ClockIdentifier == "12HourClock")
+ {
+ hr = per == 1 ? hr + 12 : per == 0 && hr == 12 ? 0 : hr;
+ }
+
+ Time = new TimeSpan(hr, min, 0);
+
+ base.OnConfirmed();
+ }
+
+ private void InitPicker()
+ {
+ if (_pickerContainer == null)
+ return;
+
+ bool clock12 = ClockIdentifier == "12HourClock";
+ _hourSelector.MaximumValue = clock12 ? 12 : 23;
+ _hourSelector.MinimumValue = clock12 ? 1 : 0;
+ _hourSelector.ItemFormat = "%h";
+ var hr = Time.Hours;
+ _hourSelector.SelectedValue = !clock12 ? hr :
+ hr > 12 ? hr - 12 : hr == 0 ? 12 : hr;
+
+ _minuteSelector.MaximumValue = 59;
+ _minuteSelector.MinimumValue = 0;
+ _minuteSelector.Increment = MinuteIncrement;
+ _minuteSelector.SelectedValue = Time.Minutes;
+ _minuteSelector.ItemFormat = "mm";
+
+ _periodSelector.MaximumValue = 1;
+ _periodSelector.MinimumValue = 0;
+ _periodSelector.SelectedValue = hr >= 12 ? 1 : 0;
+
+ SetGrid();
+ KeyboardDevice.Instance?.SetFocusedElement(_hourSelector, NavigationMethod.Pointer, KeyModifiers.None);
+ }
+
+ private void SetGrid()
+ {
+ if (ClockIdentifier == "12HourClock")
+ {
+ _pickerContainer.ColumnDefinitions = new ColumnDefinitions("*,Auto,*,Auto,*");
+ _spacer2.IsVisible = true;
+ _periodHost.IsVisible = true;
+ }
+ else
+ {
+ _pickerContainer.ColumnDefinitions = new ColumnDefinitions("*,Auto,*");
+ _spacer2.IsVisible = false;
+ _periodHost.IsVisible = false;
+ }
+ }
+
+ private void OnDismissButtonClicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ OnDismiss();
+ }
+
+ private void OnAcceptButtonClicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ OnConfirmed();
+ }
+
+ private void OnSelectorButtonClick(object sender, RoutedEventArgs e)
+ {
+ if (sender == _hourUpButton)
+ _hourSelector.ScrollUp();
+ else if (sender == _hourDownButton)
+ _hourSelector.ScrollDown();
+ else if (sender == _minuteUpButton)
+ _minuteSelector.ScrollUp();
+ else if (sender == _minuteDownButton)
+ _minuteSelector.ScrollDown();
+ else if (sender == _periodUpButton)
+ _periodSelector.ScrollUp();
+ else if (sender == _periodDownButton)
+ _periodSelector.ScrollDown();
+ }
+
+ internal double GetOffsetForPopup()
+ {
+ var acceptDismissButtonHeight = _acceptButton != null ? _acceptButton.Bounds.Height : 41;
+ return -(MaxHeight - acceptDismissButtonHeight) / 2 - (_hourSelector.ItemHeight / 2);
+ }
+ }
+}
diff --git a/src/Avalonia.Controls/DateTimePickers/TimePickerSelectedValueChangedEventArgs.cs b/src/Avalonia.Controls/DateTimePickers/TimePickerSelectedValueChangedEventArgs.cs
new file mode 100644
index 0000000000..c2954ba449
--- /dev/null
+++ b/src/Avalonia.Controls/DateTimePickers/TimePickerSelectedValueChangedEventArgs.cs
@@ -0,0 +1,15 @@
+using System;
+
+namespace Avalonia.Controls
+{
+ public class TimePickerSelectedValueChangedEventArgs
+ {
+ public TimeSpan? OldTime { get; }
+ public TimeSpan? NewTime { get; }
+ public TimePickerSelectedValueChangedEventArgs(TimeSpan? old, TimeSpan? newT)
+ {
+ OldTime = old;
+ NewTime = newT;
+ }
+ }
+}
diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml
index 9f81db9af1..5ab50f5b73 100644
--- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml
+++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesDark.xaml
@@ -667,6 +667,67 @@
8,5,8,7
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
XamlAutoFontFamily
XamlAutoFontFamily
diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml
index 94d1de7af6..88040a69e7 100644
--- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml
+++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResourcesLight.xaml
@@ -664,6 +664,67 @@
8,5,8,7
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
XamlAutoFontFamily
XamlAutoFontFamily
diff --git a/src/Avalonia.Themes.Fluent/DatePicker.xaml b/src/Avalonia.Themes.Fluent/DatePicker.xaml
new file mode 100644
index 0000000000..ce29869ed3
--- /dev/null
+++ b/src/Avalonia.Themes.Fluent/DatePicker.xaml
@@ -0,0 +1,338 @@
+
+
+
+
+ 0,0,0,4
+ 40
+ 40
+ 41
+ 296
+ 456
+ 0,3,0,6
+ 9,3,0,6
+ 0,3,0,6
+ 9,3,0,6
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia.Themes.Fluent/FluentTheme.xaml b/src/Avalonia.Themes.Fluent/FluentTheme.xaml
index 882eb0deae..0cbbb77d38 100644
--- a/src/Avalonia.Themes.Fluent/FluentTheme.xaml
+++ b/src/Avalonia.Themes.Fluent/FluentTheme.xaml
@@ -54,4 +54,6 @@
+
+
diff --git a/src/Avalonia.Themes.Fluent/TimePicker.xaml b/src/Avalonia.Themes.Fluent/TimePicker.xaml
new file mode 100644
index 0000000000..4a411796d8
--- /dev/null
+++ b/src/Avalonia.Themes.Fluent/TimePicker.xaml
@@ -0,0 +1,284 @@
+
+
+
+
+ 40
+ 1
+ 1
+ 0,0,0,4
+ 40
+ 41
+ 242
+ 456
+ 0,3,0,6
+ 0,3,0,6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Avalonia.Controls.UnitTests/DatePickerTests.cs b/tests/Avalonia.Controls.UnitTests/DatePickerTests.cs
new file mode 100644
index 0000000000..7c95f02a7a
--- /dev/null
+++ b/tests/Avalonia.Controls.UnitTests/DatePickerTests.cs
@@ -0,0 +1,253 @@
+using System;
+using System.Linq;
+using Avalonia.Controls.Shapes;
+using Avalonia.Controls.Templates;
+using Avalonia.Platform;
+using Avalonia.UnitTests;
+using Avalonia.VisualTree;
+using Moq;
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests
+{
+ public class DatePickerTests
+ {
+ [Fact]
+ public void SelectedDateChanged_Should_Fire_When_SelectedDate_Set()
+ {
+ using (UnitTestApplication.Start(Services))
+ {
+ bool handled = false;
+ DatePicker datePicker = new DatePicker();
+ datePicker.SelectedDateChanged += (s, e) =>
+ {
+ handled = true;
+ };
+ DateTimeOffset value = new DateTimeOffset(2000, 10, 10, 0, 0, 0, TimeSpan.Zero);
+ datePicker.SelectedDate = value;
+ Threading.Dispatcher.UIThread.RunJobs();
+ Assert.True(handled);
+ }
+ }
+
+ [Fact]
+ public void DayVisible_False_Should_Hide_Day()
+ {
+ using (UnitTestApplication.Start(Services))
+ {
+ DatePicker datePicker = new DatePicker
+ {
+ Template = CreateTemplate(),
+ DayVisible = false
+ };
+ datePicker.ApplyTemplate();
+ Threading.Dispatcher.UIThread.RunJobs();
+
+ var desc = datePicker.GetVisualDescendants();
+ Assert.True(desc.Count() > 1);//Should be layoutroot grid & button
+ TextBlock dayText = null;
+ Grid container = null;
+
+ Assert.True(desc.ElementAt(1) is Button);
+
+ container = (desc.ElementAt(1) as Button).Content as Grid;
+ Assert.True(container != null);
+
+ for(int i = 0; i < container.Children.Count; i++)
+ {
+ if(container.Children[i] is TextBlock tb && tb.Name == "DayText")
+ {
+ dayText = tb;
+ break;
+ }
+ }
+
+ Assert.True(dayText != null);
+ Assert.True(!dayText.IsVisible);
+ Assert.True(container.ColumnDefinitions.Count == 3);
+ }
+ }
+
+ [Fact]
+ public void MonthVisible_False_Should_Hide_Month()
+ {
+ using (UnitTestApplication.Start(Services))
+ {
+ DatePicker datePicker = new DatePicker
+ {
+ Template = CreateTemplate(),
+ MonthVisible = false
+ };
+ datePicker.ApplyTemplate();
+ Threading.Dispatcher.UIThread.RunJobs();
+
+ var desc = datePicker.GetVisualDescendants();
+ Assert.True(desc.Count() > 1);//Should be layoutroot grid & button
+ TextBlock monthText = null;
+ Grid container = null;
+
+ Assert.True(desc.ElementAt(1) is Button);
+
+ container = (desc.ElementAt(1) as Button).Content as Grid;
+ Assert.True(container != null);
+
+ for (int i = 0; i < container.Children.Count; i++)
+ {
+ if (container.Children[i] is TextBlock tb && tb.Name == "MonthText")
+ {
+ monthText = tb;
+ break;
+ }
+ }
+
+ Assert.True(monthText != null);
+ Assert.True(!monthText.IsVisible);
+ Assert.True(container.ColumnDefinitions.Count == 3);
+ }
+ }
+
+ [Fact]
+ public void YearVisible_False_Should_Hide_Year()
+ {
+ using (UnitTestApplication.Start(Services))
+ {
+ DatePicker datePicker = new DatePicker
+ {
+ Template = CreateTemplate(),
+ YearVisible = false
+ };
+ datePicker.ApplyTemplate();
+ Threading.Dispatcher.UIThread.RunJobs();
+
+ var desc = datePicker.GetVisualDescendants();
+ Assert.True(desc.Count() > 1);//Should be layoutroot grid & button
+ TextBlock yearText = null;
+ Grid container = null;
+
+ Assert.True(desc.ElementAt(1) is Button);
+
+ container = (desc.ElementAt(1) as Button).Content as Grid;
+ Assert.True(container != null);
+
+ for (int i = 0; i < container.Children.Count; i++)
+ {
+ if (container.Children[i] is TextBlock tb && tb.Name == "YearText")
+ {
+ yearText = tb;
+ break;
+ }
+ }
+
+ Assert.True(yearText != null);
+ Assert.True(!yearText.IsVisible);
+ Assert.True(container.ColumnDefinitions.Count == 3);
+ }
+ }
+
+ [Fact]
+ public void SelectedDate_null_Should_Use_Placeholders()
+ {
+ using (UnitTestApplication.Start(Services))
+ {
+ DatePicker datePicker = new DatePicker
+ {
+ Template = CreateTemplate(),
+ YearVisible = false
+ };
+ datePicker.ApplyTemplate();
+ Threading.Dispatcher.UIThread.RunJobs();
+
+ var desc = datePicker.GetVisualDescendants();
+ Assert.True(desc.Count() > 1);//Should be layoutroot grid & button
+ TextBlock yearText = null;
+ TextBlock monthText = null;
+ TextBlock dayText = null;
+ Grid container = null;
+
+ Assert.True(desc.ElementAt(1) is Button);
+
+ container = (desc.ElementAt(1) as Button).Content as Grid;
+ Assert.True(container != null);
+
+ for (int i = 0; i < container.Children.Count; i++)
+ {
+ if (container.Children[i] is TextBlock tb && tb.Name == "YearText")
+ {
+ yearText = tb;
+ }
+ else if (container.Children[i] is TextBlock tb1 && tb1.Name == "MonthText")
+ {
+ monthText = tb1;
+ }
+ else if (container.Children[i] is TextBlock tb2 && tb2.Name == "DayText")
+ {
+ dayText = tb2;
+ }
+ }
+
+ DateTimeOffset value = new DateTimeOffset(2000, 10, 10, 0, 0, 0, TimeSpan.Zero);
+ datePicker.SelectedDate = value;
+
+ Assert.False(dayText.Text == "day");
+ Assert.False(monthText.Text == "month");
+ Assert.False(yearText.Text == "year");
+ Assert.False(datePicker.Classes.Contains(":hasnodate"));
+
+ datePicker.SelectedDate = null;
+
+ Assert.True(dayText.Text == "day");
+ Assert.True(monthText.Text == "month");
+ Assert.True(yearText.Text == "year");
+ Assert.True(datePicker.Classes.Contains(":hasnodate"));
+ }
+ }
+
+ private static TestServices Services => TestServices.MockThreadingInterface.With(
+ standardCursorFactory: Mock.Of());
+
+ private IControlTemplate CreateTemplate()
+ {
+ return new FuncControlTemplate((control, scope) =>
+ {
+ var layoutRoot = new Grid
+ {
+ Name = "LayoutRoot"
+ }.RegisterInNameScope(scope);
+ //Skip contentpresenter
+ var flyoutButton = new Button
+ {
+ Name = "FlyoutButton"
+ }.RegisterInNameScope(scope);
+ var contentGrid = new Grid
+ {
+ Name = "ButtonContentGrid"
+ }.RegisterInNameScope(scope);
+ var dayText = new TextBlock
+ {
+ Name = "DayText"
+ }.RegisterInNameScope(scope);
+ var monthText = new TextBlock
+ {
+ Name = "MonthText"
+ }.RegisterInNameScope(scope);
+ var yearText = new TextBlock
+ {
+ Name = "YearText"
+ }.RegisterInNameScope(scope);
+ var firstSpacer = new Rectangle
+ {
+ Name = "FirstSpacer"
+ }.RegisterInNameScope(scope);
+ var secondSpacer = new Rectangle
+ {
+ Name = "SecondSpacer"
+ }.RegisterInNameScope(scope);
+
+ contentGrid.Children.AddRange(new IControl[] { dayText, monthText, yearText, firstSpacer, secondSpacer });
+ flyoutButton.Content = contentGrid;
+ layoutRoot.Children.Add(flyoutButton);
+ return layoutRoot;
+ });
+ }
+ }
+}
diff --git a/tests/Avalonia.Controls.UnitTests/TimePickerTests.cs b/tests/Avalonia.Controls.UnitTests/TimePickerTests.cs
new file mode 100644
index 0000000000..c803b93364
--- /dev/null
+++ b/tests/Avalonia.Controls.UnitTests/TimePickerTests.cs
@@ -0,0 +1,172 @@
+using System;
+using System.Linq;
+using Avalonia.Controls.Shapes;
+using Avalonia.Controls.Templates;
+using Avalonia.Platform;
+using Avalonia.UnitTests;
+using Avalonia.VisualTree;
+using Moq;
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests
+{
+ public class TimePickerTests
+ {
+ [Fact(Skip = "FIX ME ASAP")]
+ public void SelectedTimeChanged_Should_Fire_When_SelectedTime_Set()
+ {
+ using (UnitTestApplication.Start(Services))
+ {
+ bool handled = false;
+ TimePicker timePicker = new TimePicker();
+ timePicker.SelectedTimeChanged += (s, e) =>
+ {
+ handled = true;
+ };
+ TimeSpan value = TimeSpan.FromHours(10);
+ timePicker.SelectedTime = value;
+ Threading.Dispatcher.UIThread.RunJobs();
+ Assert.True(handled);
+ }
+ }
+
+ [Fact]
+ public void Using_24HourClock_Should_Hide_Period()
+ {
+ using (UnitTestApplication.Start(Services))
+ {
+ TimePicker timePicker = new TimePicker()
+ {
+ ClockIdentifier = "12HourClock",
+ Template = CreateTemplate()
+ };
+ timePicker.ApplyTemplate();
+
+ var desc = timePicker.GetVisualDescendants();
+ Assert.True(desc.Count() > 1);//Should be layoutroot grid & button
+ Grid container = null;
+
+ Assert.True(desc.ElementAt(1) is Button);
+
+ container = (desc.ElementAt(1) as Button).Content as Grid;
+ Assert.True(container != null);
+
+ var periodTextHost = container.Children[4] as Border;
+ Assert.True(periodTextHost != null);
+ Assert.True(periodTextHost.IsVisible);
+
+ timePicker.ClockIdentifier = "24HourClock";
+ Assert.False(periodTextHost.IsVisible);
+ }
+ }
+
+ [Fact]
+ public void SelectedTime_null_Should_Use_Placeholders()
+ {
+ using (UnitTestApplication.Start(Services))
+ {
+ TimePicker timePicker = new TimePicker()
+ {
+ Template = CreateTemplate()
+ };
+ timePicker.ApplyTemplate();
+
+ var desc = timePicker.GetVisualDescendants();
+ Assert.True(desc.Count() > 1);//Should be layoutroot grid & button
+ Grid container = null;
+
+ Assert.True(desc.ElementAt(1) is Button);
+
+ container = (desc.ElementAt(1) as Button).Content as Grid;
+ Assert.True(container != null);
+
+ var hourTextHost = container.Children[0] as Border;
+ Assert.True(hourTextHost != null);
+ var hourText = hourTextHost.Child as TextBlock;
+ var minuteTextHost = container.Children[2] as Border;
+ Assert.True(minuteTextHost != null);
+ var minuteText = minuteTextHost.Child as TextBlock;
+
+ TimeSpan ts = TimeSpan.FromHours(10);
+ timePicker.SelectedTime = ts;
+ Assert.False(hourText.Text == "hour");
+ Assert.False(minuteText.Text == "minute");
+
+ timePicker.SelectedTime = null;
+ Assert.True(hourText.Text == "hour");
+ Assert.True(minuteText.Text == "minute");
+ }
+ }
+
+
+ private static TestServices Services => TestServices.MockThreadingInterface.With(
+ standardCursorFactory: Mock.Of());
+
+ private IControlTemplate CreateTemplate()
+ {
+ return new FuncControlTemplate((control, scope) =>
+ {
+ var layoutRoot = new Grid
+ {
+ Name = "LayoutRoot"
+ }.RegisterInNameScope(scope);
+ //Skip contentpresenter
+ var flyoutButton = new Button
+ {
+ Name = "FlyoutButton"
+ }.RegisterInNameScope(scope);
+ var contentGrid = new Grid
+ {
+ Name = "FlyoutButtonContentGrid"
+ }.RegisterInNameScope(scope);
+
+ var firstPickerHost = new Border
+ {
+ Name = "FirstPickerHost",
+ Child = new TextBlock
+ {
+ Name = "HourTextBlock"
+ }.RegisterInNameScope(scope)
+ }.RegisterInNameScope(scope);
+ Grid.SetColumn(firstPickerHost, 0);
+
+ var secondPickerHost = new Border
+ {
+ Name = "SecondPickerHost",
+ Child = new TextBlock
+ {
+ Name = "MinuteTextBlock"
+ }.RegisterInNameScope(scope)
+ }.RegisterInNameScope(scope);
+ Grid.SetColumn(secondPickerHost, 2);
+
+ var thirdPickerHost = new Border
+ {
+ Name = "ThirdPickerHost",
+ Child = new TextBlock
+ {
+ Name = "PeriodTextBlock"
+ }.RegisterInNameScope(scope)
+ }.RegisterInNameScope(scope);
+ Grid.SetColumn(thirdPickerHost, 4);
+
+ var firstSpacer = new Rectangle
+ {
+ Name = "FirstColumnDivider"
+ }.RegisterInNameScope(scope);
+ Grid.SetColumn(firstSpacer, 1);
+
+ var secondSpacer = new Rectangle
+ {
+ Name = "SecondColumnDivider"
+ }.RegisterInNameScope(scope);
+ Grid.SetColumn(secondSpacer, 3);
+
+ contentGrid.Children.AddRange(new IControl[] { firstPickerHost, firstSpacer, secondPickerHost, secondSpacer, thirdPickerHost });
+ flyoutButton.Content = contentGrid;
+ layoutRoot.Children.Add(flyoutButton);
+ return layoutRoot;
+ });
+ }
+ }
+}