Browse Source

Add AllowTapRangeSelection for Calendar (#19367)

* Add AllowTapRangeSelection for Calendar

* Fix spacing

* Let it be true...

* Update CalendarTests.cs
Tim Miller 2 months ago
parent
commit
eff1c36ea8

+ 5 - 0
samples/ControlCatalog/Pages/CalendarPage.xaml

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

+ 135 - 0
src/Avalonia.Controls/Calendar/Calendar.cs

@@ -237,6 +237,8 @@ namespace Avalonia.Controls
 
         private bool _isShiftPressed;
         private bool _displayDateIsChanging;
+        private bool _isTapRangeSelectionActive;
+        private DateTime? _tapRangeStart;
 
         internal CalendarDayButton? FocusButton { get; set; }
         internal CalendarButton? FocusCalendarButton { get; set; }
@@ -437,6 +439,11 @@ namespace Avalonia.Controls
                 nameof(SelectionMode),
                 defaultValue: CalendarSelectionMode.SingleDate);
 
+        public static readonly StyledProperty<bool> AllowTapRangeSelectionProperty =
+            AvaloniaProperty.Register<Calendar, bool>(
+                nameof(AllowTapRangeSelection),
+                defaultValue: true);
+
         /// <summary>
         /// Gets or sets a value that indicates what kind of selections are
         /// allowed.
@@ -462,6 +469,24 @@ namespace Avalonia.Controls
             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)
         {
             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;
+        }
+
         /// <summary>
         /// Inherited code: Requires comment.
         /// </summary>
@@ -1450,6 +1485,94 @@ namespace Avalonia.Controls
                 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)
         {
             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<Calendar>((x, e) => x.OnIsTodayHighlightedChanged(e));
             DisplayModeProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnDisplayModePropertyChanged(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));
             DisplayDateProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnDisplayDateChanged(e));
             DisplayDateStartProperty.Changed.AddClassHandler<Calendar>((x, e) => x.OnDisplayDateStartChanged(e));

+ 9 - 0
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)

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