Browse Source

Add AllowTapRangeSelection for Calendar (#19367)

* Add AllowTapRangeSelection for Calendar

* Fix spacing

* Let it be true...

* Update CalendarTests.cs
pull/19461/head
Tim Miller 6 months ago
committed by GitHub
parent
commit
eff1c36ea8
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      samples/ControlCatalog/Pages/CalendarPage.xaml
  2. 135
      src/Avalonia.Controls/Calendar/Calendar.cs
  3. 9
      src/Avalonia.Controls/Calendar/CalendarItem.cs
  4. 92
      tests/Avalonia.Controls.UnitTests/CalendarTests.cs

5
samples/ControlCatalog/Pages/CalendarPage.xaml

@ -32,6 +32,11 @@
<TextBlock Text="SelectionMode: MultipleRange"/> <TextBlock Text="SelectionMode: MultipleRange"/>
<Calendar SelectionMode="MultipleRange" /> <Calendar SelectionMode="MultipleRange" />
</StackPanel> </StackPanel>
<StackPanel>
<TextBlock Text="Tap Range Selection" />
<Calendar SelectionMode="SingleRange"
AllowTapRangeSelection="True" />
</StackPanel>
<StackPanel> <StackPanel>
<TextBlock Text="DisplayDates"/> <TextBlock Text="DisplayDates"/>
<Calendar Name="DisplayDatesCalendar" <Calendar Name="DisplayDatesCalendar"

135
src/Avalonia.Controls/Calendar/Calendar.cs

@ -237,6 +237,8 @@ namespace Avalonia.Controls
private bool _isShiftPressed; private bool _isShiftPressed;
private bool _displayDateIsChanging; private bool _displayDateIsChanging;
private bool _isTapRangeSelectionActive;
private DateTime? _tapRangeStart;
internal CalendarDayButton? FocusButton { get; set; } internal CalendarDayButton? FocusButton { get; set; }
internal CalendarButton? FocusCalendarButton { get; set; } internal CalendarButton? FocusCalendarButton { get; set; }
@ -437,6 +439,11 @@ namespace Avalonia.Controls
nameof(SelectionMode), nameof(SelectionMode),
defaultValue: CalendarSelectionMode.SingleDate); defaultValue: CalendarSelectionMode.SingleDate);
public static readonly StyledProperty<bool> AllowTapRangeSelectionProperty =
AvaloniaProperty.Register<Calendar, bool>(
nameof(AllowTapRangeSelection),
defaultValue: true);
/// <summary> /// <summary>
/// Gets or sets a value that indicates what kind of selections are /// Gets or sets a value that indicates what kind of selections are
/// allowed. /// allowed.
@ -462,6 +469,24 @@ namespace Avalonia.Controls
set => SetValue(SelectionModeProperty, value); set => SetValue(SelectionModeProperty, value);
} }
/// <summary>
/// Gets or sets a value indicating whether tap-to-select range mode is enabled.
/// When enabled, users can tap a start date and then tap an end date to select a range.
/// </summary>
/// <value>
/// True to enable tap range selection; otherwise, false. The default is false.
/// </value>
/// <remarks>
/// This feature only works when SelectionMode is set to SingleRange.
/// When enabled, the first tap selects the start date, and the second tap selects
/// the end date to complete the range. Tapping a third date starts a new range.
/// </remarks>
public bool AllowTapRangeSelection
{
get => GetValue(AllowTapRangeSelectionProperty);
set => SetValue(AllowTapRangeSelectionProperty, value);
}
private void OnSelectionModeChanged(AvaloniaPropertyChangedEventArgs e) private void OnSelectionModeChanged(AvaloniaPropertyChangedEventArgs e)
{ {
if (IsValidSelectionMode(e.NewValue!)) if (IsValidSelectionMode(e.NewValue!))
@ -470,6 +495,10 @@ namespace Avalonia.Controls
SetCurrentValue(SelectedDateProperty, null); SetCurrentValue(SelectedDateProperty, null);
_displayDateIsChanging = false; _displayDateIsChanging = false;
SelectedDates.Clear(); SelectedDates.Clear();
// Reset tap range selection state when mode changes
_isTapRangeSelectionActive = false;
_tapRangeStart = null;
} }
else else
{ {
@ -477,6 +506,12 @@ namespace Avalonia.Controls
} }
} }
private void OnAllowTapRangeSelectionChanged(AvaloniaPropertyChangedEventArgs e)
{
_isTapRangeSelectionActive = false;
_tapRangeStart = null;
}
/// <summary> /// <summary>
/// Inherited code: Requires comment. /// Inherited code: Requires comment.
/// </summary> /// </summary>
@ -1450,6 +1485,94 @@ namespace Avalonia.Controls
SelectedDates.AddRange(HoverStart.Value, HoverEnd.Value); SelectedDates.AddRange(HoverStart.Value, HoverEnd.Value);
} }
} }
/// <summary>
/// Handles tap range selection logic for date range selection.
/// </summary>
/// <param name="selectedDate">The date that was tapped.</param>
/// <returns>True if the tap was handled as part of range selection; otherwise, false.</returns>
internal bool ProcessTapRangeSelection(DateTime selectedDate)
{
if (!AllowTapRangeSelection ||
(SelectionMode != CalendarSelectionMode.SingleRange && SelectionMode != CalendarSelectionMode.MultipleRange))
{
return false;
}
if (!IsValidDateSelection(this, selectedDate))
{
return false;
}
if (!_isTapRangeSelectionActive || !_tapRangeStart.HasValue)
{
_isTapRangeSelectionActive = true;
_tapRangeStart = selectedDate;
if (SelectionMode == CalendarSelectionMode.SingleRange)
{
foreach (DateTime item in SelectedDates)
{
RemovedItems.Add(item);
}
SelectedDates.ClearInternal();
}
if (!SelectedDates.Contains(selectedDate))
{
SelectedDates.Add(selectedDate);
}
return true;
}
else
{
DateTime startDate = _tapRangeStart.Value;
DateTime endDate = selectedDate;
if (DateTime.Compare(startDate, endDate) > 0)
{
(startDate, endDate) = (endDate, startDate);
}
CalendarDateRange range = new CalendarDateRange(startDate, endDate);
if (BlackoutDates.ContainsAny(range))
{
_tapRangeStart = selectedDate;
if (SelectionMode == CalendarSelectionMode.SingleRange)
{
foreach (DateTime item in SelectedDates)
{
RemovedItems.Add(item);
}
SelectedDates.ClearInternal();
}
if (!SelectedDates.Contains(selectedDate))
{
SelectedDates.Add(selectedDate);
}
return true;
}
if (SelectionMode == CalendarSelectionMode.SingleRange)
{
foreach (DateTime item in SelectedDates)
{
RemovedItems.Add(item);
}
SelectedDates.ClearInternal();
}
SelectedDates.AddRange(startDate, endDate);
_isTapRangeSelectionActive = false;
_tapRangeStart = null;
return true;
}
}
private void ProcessSelection(bool shift, DateTime? lastSelectedDate, int? index) private void ProcessSelection(bool shift, DateTime? lastSelectedDate, int? index)
{ {
if (SelectionMode == CalendarSelectionMode.None && lastSelectedDate != null) if (SelectionMode == CalendarSelectionMode.None && lastSelectedDate != null)
@ -1457,6 +1580,17 @@ namespace Avalonia.Controls
OnDayClick(lastSelectedDate.Value); OnDayClick(lastSelectedDate.Value);
return; return;
} }
// Handle tap range selection.
if (lastSelectedDate != null && index == null && !shift)
{
if (ProcessTapRangeSelection(lastSelectedDate.Value))
{
OnDayClick(lastSelectedDate.Value);
return;
}
}
if (lastSelectedDate != null && IsValidKeyboardSelection(this, lastSelectedDate.Value)) if (lastSelectedDate != null && IsValidKeyboardSelection(this, lastSelectedDate.Value))
{ {
if (SelectionMode == CalendarSelectionMode.SingleRange || SelectionMode == CalendarSelectionMode.MultipleRange) if (SelectionMode == CalendarSelectionMode.SingleRange || SelectionMode == CalendarSelectionMode.MultipleRange)
@ -2069,6 +2203,7 @@ namespace Avalonia.Controls
IsTodayHighlightedProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnIsTodayHighlightedChanged(e)); IsTodayHighlightedProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnIsTodayHighlightedChanged(e));
DisplayModeProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnDisplayModePropertyChanged(e)); DisplayModeProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnDisplayModePropertyChanged(e));
SelectionModeProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnSelectionModeChanged(e)); SelectionModeProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnSelectionModeChanged(e));
AllowTapRangeSelectionProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnAllowTapRangeSelectionChanged(e));
SelectedDateProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnSelectedDateChanged(e)); SelectedDateProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnSelectedDateChanged(e));
DisplayDateProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnDisplayDateChanged(e)); DisplayDateProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnDisplayDateChanged(e));
DisplayDateStartProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnDisplayDateStartChanged(e)); DisplayDateStartProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnDisplayDateStartChanged(e));

9
src/Avalonia.Controls/Calendar/CalendarItem.cs

@ -1030,6 +1030,15 @@ namespace Avalonia.Controls.Primitives
Owner.OnDayClick(selectedDate); Owner.OnDayClick(selectedDate);
return; return;
} }
if (Owner.AllowTapRangeSelection &&
(Owner.SelectionMode == CalendarSelectionMode.SingleRange || Owner.SelectionMode == CalendarSelectionMode.MultipleRange))
{
if (Owner.ProcessTapRangeSelection(selectedDate))
{
Owner.OnDayClick(selectedDate);
return;
}
}
if (Owner.HoverStart.HasValue) if (Owner.HoverStart.HasValue)
{ {
switch (Owner.SelectionMode) switch (Owner.SelectionMode)

92
tests/Avalonia.Controls.UnitTests/CalendarTests.cs

@ -271,5 +271,97 @@ namespace Avalonia.Controls.UnitTests
Assert.True(CompareDates(calendar.SelectedDate.Value, DateTime.Today.AddDays(100))); Assert.True(CompareDates(calendar.SelectedDate.Value, DateTime.Today.AddDays(100)));
Assert.True(calendar.SelectedDates.Count == 1); Assert.True(calendar.SelectedDates.Count == 1);
} }
[Fact]
public void AllowTapRangeSelection_Should_Disable_TapToSelectRange()
{
var calendar = new Calendar();
Assert.True(calendar.AllowTapRangeSelection); // Default should be true
calendar.AllowTapRangeSelection = false;
Assert.False(calendar.AllowTapRangeSelection);
}
[Fact]
public void TapRangeSelection_Should_Work_In_SingleRange_Mode()
{
var calendar = new Calendar();
calendar.SelectionMode = CalendarSelectionMode.SingleRange;
calendar.AllowTapRangeSelection = true;
var startDate = new DateTime(2023, 10, 10);
var endDate = new DateTime(2023, 10, 15);
// First tap should select start date
var firstTapResult = calendar.ProcessTapRangeSelection(startDate);
Assert.True(firstTapResult);
Assert.Equal(1, calendar.SelectedDates.Count);
Assert.True(calendar.SelectedDates.Contains(startDate));
// Second tap should complete the range
var secondTapResult = calendar.ProcessTapRangeSelection(endDate);
Assert.True(secondTapResult);
Assert.Equal(6, calendar.SelectedDates.Count); // 5 days inclusive
Assert.True(calendar.SelectedDates.Contains(startDate));
Assert.True(calendar.SelectedDates.Contains(endDate));
}
[Fact]
public void TapRangeSelection_Should_Not_Work_In_SingleDate_Mode()
{
var calendar = new Calendar();
calendar.SelectionMode = CalendarSelectionMode.SingleDate;
calendar.AllowTapRangeSelection = true;
var date = new DateTime(2023, 10, 10);
var result = calendar.ProcessTapRangeSelection(date);
Assert.False(result); // Should not handle tap range selection
}
[Fact]
public void TapRangeSelection_Should_Handle_Blackout_Dates()
{
var calendar = new Calendar();
calendar.SelectionMode = CalendarSelectionMode.SingleRange;
calendar.AllowTapRangeSelection = true;
var startDate = new DateTime(2023, 10, 10);
var blackoutDate = new DateTime(2023, 10, 12);
var endDate = new DateTime(2023, 10, 15);
// Add blackout date in the middle
calendar.BlackoutDates.Add(new CalendarDateRange(blackoutDate, blackoutDate));
// First tap
calendar.ProcessTapRangeSelection(startDate);
Assert.Equal(1, calendar.SelectedDates.Count);
// Second tap should restart selection due to blackout date
calendar.ProcessTapRangeSelection(endDate);
Assert.Equal(1, calendar.SelectedDates.Count);
Assert.True(calendar.SelectedDates.Contains(endDate));
Assert.False(calendar.SelectedDates.Contains(startDate));
}
[Fact]
public void TapRangeSelection_Should_Handle_Reverse_Order_Dates()
{
var calendar = new Calendar();
calendar.SelectionMode = CalendarSelectionMode.SingleRange;
calendar.AllowTapRangeSelection = true;
var laterDate = new DateTime(2023, 10, 15);
var earlierDate = new DateTime(2023, 10, 10);
// First tap on later date
calendar.ProcessTapRangeSelection(laterDate);
Assert.Equal(1, calendar.SelectedDates.Count);
// Second tap on earlier date should still create correct range
calendar.ProcessTapRangeSelection(earlierDate);
Assert.Equal(6, calendar.SelectedDates.Count);
Assert.True(calendar.SelectedDates.Contains(earlierDate));
Assert.True(calendar.SelectedDates.Contains(laterDate));
}
} }
} }

Loading…
Cancel
Save