diff --git a/samples/ControlCatalog/Pages/CalendarPage.xaml b/samples/ControlCatalog/Pages/CalendarPage.xaml index cfae7140b0..e142c4da72 100644 --- a/samples/ControlCatalog/Pages/CalendarPage.xaml +++ b/samples/ControlCatalog/Pages/CalendarPage.xaml @@ -32,6 +32,11 @@ + + + + AllowTapRangeSelectionProperty = + AvaloniaProperty.Register( + nameof(AllowTapRangeSelection), + defaultValue: true); + /// /// Gets or sets a value that indicates what kind of selections are /// allowed. @@ -462,6 +469,24 @@ namespace Avalonia.Controls set => SetValue(SelectionModeProperty, value); } + /// + /// 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. + /// + /// + /// True to enable tap range selection; otherwise, false. The default is false. + /// + /// + /// 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. + /// + public bool AllowTapRangeSelection + { + get => GetValue(AllowTapRangeSelectionProperty); + set => SetValue(AllowTapRangeSelectionProperty, value); + } + private void OnSelectionModeChanged(AvaloniaPropertyChangedEventArgs e) { if (IsValidSelectionMode(e.NewValue!)) @@ -470,6 +495,10 @@ namespace Avalonia.Controls SetCurrentValue(SelectedDateProperty, null); _displayDateIsChanging = false; SelectedDates.Clear(); + + // Reset tap range selection state when mode changes + _isTapRangeSelectionActive = false; + _tapRangeStart = null; } else { @@ -477,6 +506,12 @@ namespace Avalonia.Controls } } + private void OnAllowTapRangeSelectionChanged(AvaloniaPropertyChangedEventArgs e) + { + _isTapRangeSelectionActive = false; + _tapRangeStart = null; + } + /// /// Inherited code: Requires comment. /// @@ -1450,6 +1485,94 @@ namespace Avalonia.Controls SelectedDates.AddRange(HoverStart.Value, HoverEnd.Value); } } + + /// + /// Handles tap range selection logic for date range selection. + /// + /// The date that was tapped. + /// True if the tap was handled as part of range selection; otherwise, false. + 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) { if (SelectionMode == CalendarSelectionMode.None && lastSelectedDate != null) @@ -1457,6 +1580,17 @@ namespace Avalonia.Controls OnDayClick(lastSelectedDate.Value); 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 (SelectionMode == CalendarSelectionMode.SingleRange || SelectionMode == CalendarSelectionMode.MultipleRange) @@ -2069,6 +2203,7 @@ namespace Avalonia.Controls IsTodayHighlightedProperty.Changed.AddClassHandler((x, e) => x.OnIsTodayHighlightedChanged(e)); DisplayModeProperty.Changed.AddClassHandler((x, e) => x.OnDisplayModePropertyChanged(e)); SelectionModeProperty.Changed.AddClassHandler((x, e) => x.OnSelectionModeChanged(e)); + AllowTapRangeSelectionProperty.Changed.AddClassHandler((x, e) => x.OnAllowTapRangeSelectionChanged(e)); SelectedDateProperty.Changed.AddClassHandler((x, e) => x.OnSelectedDateChanged(e)); DisplayDateProperty.Changed.AddClassHandler((x, e) => x.OnDisplayDateChanged(e)); DisplayDateStartProperty.Changed.AddClassHandler((x, e) => x.OnDisplayDateStartChanged(e)); diff --git a/src/Avalonia.Controls/Calendar/CalendarItem.cs b/src/Avalonia.Controls/Calendar/CalendarItem.cs index c2801e6e64..2bfe3c9be2 100644 --- a/src/Avalonia.Controls/Calendar/CalendarItem.cs +++ b/src/Avalonia.Controls/Calendar/CalendarItem.cs @@ -1030,6 +1030,15 @@ namespace Avalonia.Controls.Primitives Owner.OnDayClick(selectedDate); return; } + if (Owner.AllowTapRangeSelection && + (Owner.SelectionMode == CalendarSelectionMode.SingleRange || Owner.SelectionMode == CalendarSelectionMode.MultipleRange)) + { + if (Owner.ProcessTapRangeSelection(selectedDate)) + { + Owner.OnDayClick(selectedDate); + return; + } + } if (Owner.HoverStart.HasValue) { switch (Owner.SelectionMode) diff --git a/tests/Avalonia.Controls.UnitTests/CalendarTests.cs b/tests/Avalonia.Controls.UnitTests/CalendarTests.cs index 7f464827d7..864f92f511 100644 --- a/tests/Avalonia.Controls.UnitTests/CalendarTests.cs +++ b/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(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)); + } } }