Finish ScrollTo implementations for CollectionView on UWP (#7509) partially implement...
authorE.Z. Hart <hartez@users.noreply.github.com>
Fri, 20 Sep 2019 22:03:55 +0000 (16:03 -0600)
committerRui Marinho <me@ruimarinho.net>
Fri, 20 Sep 2019 22:03:55 +0000 (23:03 +0100)
* Finish ScrollTo implementations for CollectionView on UWP

* Fix NRE when attempting to scroll to index greater than source length

Xamarin.Forms.Platform.UAP/CollectionView/ItemContentControl.cs
Xamarin.Forms.Platform.UAP/CollectionView/ItemsViewRenderer.cs
Xamarin.Forms.Platform.UAP/CollectionView/ItemsViewStyles.xaml
Xamarin.Forms.Platform.UAP/CollectionView/ScrollHelpers.cs [new file with mode: 0644]
Xamarin.Forms.Platform.UAP/Xamarin.Forms.Platform.UAP.csproj

index 00f20cc055eff900452baf62e28f4c95c5dc5407..6533dd7d020942ee33e9f3a144eaf5e460a98057 100644 (file)
@@ -119,6 +119,13 @@ namespace Xamarin.Forms.Platform.UWP
                        var formsTemplate = FormsDataTemplate;
                        var container = FormsContainer;
 
+                       var itemsView = container as ItemsView;
+
+                       if (itemsView != null && _renderer?.Element != null)
+                       {
+                               itemsView.RemoveLogicalChild(_renderer.Element);
+                       }
+
                        if (dataContext == null || formsTemplate == null || container == null)
                        {
                                return;
@@ -131,7 +138,7 @@ namespace Xamarin.Forms.Platform.UWP
 
                        Content = _renderer.ContainerElement;
 
-                       // TODO ezhart Add View as a logical child of the ItemsView
+                       itemsView?.AddLogicalChild(view);
                        
                        BindableObject.SetInheritedBindingContext(_renderer.Element, dataContext);
                }
index 1857f6b6cc719379332c9cff89c8b65d4f216851..f1333b057f60a80604b2f9e2897ce67aa77e647f 100644 (file)
@@ -199,137 +199,6 @@ namespace Xamarin.Forms.Platform.UWP
                        oldElement.ScrollToRequested -= ScrollToRequested;
                }
 
-               async void ScrollToRequested(object sender, ScrollToRequestEventArgs args)
-               {
-                       await ScrollTo(args);
-               }
-
-               object FindBoundItem(ScrollToRequestEventArgs args)
-               {
-                       if (args.Mode == ScrollToMode.Position)
-                       {
-                               return _collectionViewSource.View[args.Index];
-                       }
-
-                       if (Element.ItemTemplate == null)
-                       {
-                               return args.Item;
-                       }
-
-                       for (int n = 0; n < _collectionViewSource.View.Count; n++)
-                       {
-                               if (_collectionViewSource.View[n] is ItemTemplateContext pair)
-                               {
-                                       if (pair.Item == args.Item)
-                                       {
-                                               return _collectionViewSource.View[n];
-                                       }
-                               }
-                       }
-
-                       return null;
-               }
-
-               async Task JumpTo(ListViewBase list, object targetItem, ScrollToPosition scrollToPosition)
-               {
-                       var tcs = new TaskCompletionSource<object>();
-                       void ViewChanged(object s, ScrollViewerViewChangedEventArgs e) => tcs.TrySetResult(null);
-                       var scrollViewer = list.GetFirstDescendant<ScrollViewer>();
-
-                       try
-                       {
-                               scrollViewer.ViewChanged += ViewChanged;
-
-                               if (scrollToPosition == ScrollToPosition.Start)
-                               {
-                                       list.ScrollIntoView(targetItem, ScrollIntoViewAlignment.Leading);
-                               }
-                               else if (scrollToPosition == ScrollToPosition.MakeVisible)
-                               {
-                                       list.ScrollIntoView(targetItem, ScrollIntoViewAlignment.Default);
-                               }
-                               else
-                               {
-                                       // Center and End are going to be more complicated.
-                               }
-
-                               await tcs.Task;
-                       }
-                       finally
-                       {
-                               scrollViewer.ViewChanged -= ViewChanged;
-                       }
-
-               }
-
-               async Task ChangeViewAsync(ScrollViewer scrollViewer, double? horizontalOffset, double? verticalOffset, bool disableAnimation)
-               {
-                       var tcs = new TaskCompletionSource<object>();
-                       void ViewChanged(object s, ScrollViewerViewChangedEventArgs e) => tcs.TrySetResult(null);
-
-                       try
-                       {
-                               scrollViewer.ViewChanged += ViewChanged;
-                               scrollViewer.ChangeView(horizontalOffset, verticalOffset, null, disableAnimation);
-                               await tcs.Task;
-                       }
-                       finally
-                       {
-                               scrollViewer.ViewChanged -= ViewChanged;
-                       }
-               }
-
-               async Task AnimateTo(ListViewBase list, object targetItem, ScrollToPosition scrollToPosition)
-               {
-                       var scrollViewer = list.GetFirstDescendant<ScrollViewer>();
-
-                       var targetContainer = list.ContainerFromItem(targetItem) as UIElement;
-
-                       if (targetContainer == null)
-                       {
-                               var horizontalOffset = scrollViewer.HorizontalOffset;
-                               var verticalOffset = scrollViewer.VerticalOffset;
-
-                               await JumpTo(list, targetItem, scrollToPosition);
-                               targetContainer = list.ContainerFromItem(targetItem) as UIElement;
-                               await ChangeViewAsync(scrollViewer, horizontalOffset, verticalOffset, true);
-                       }
-
-                       if (targetContainer == null)
-                       {
-                               // Did not find the target item anywhere
-                               return;
-                       }
-
-                       // TODO hartez 2018/10/04 16:37:35 Okay, this sort of works for vertical lists but fails totally on horizontal lists. 
-                       var transform = targetContainer.TransformToVisual(scrollViewer.Content as UIElement);
-                       var position = transform?.TransformPoint(new Windows.Foundation.Point(0, 0));
-
-                       if (!position.HasValue)
-                       {
-                               return;
-                       }
-
-                       // TODO hartez 2018/10/05 17:23:23 The animated scroll works fine vertically if we are scrolling to a greater Y offset. 
-                       // If we're scrolling back up to a lower Y offset, it just gives up and sends us to 0 (first item)
-                       // Works fine if we disable animation, but that's not very helpful
-
-                       scrollViewer.ChangeView(position.Value.X, position.Value.Y, null, false);
-
-                       //if (scrollToPosition == ScrollToPosition.End)
-                       //{
-                       //      // Modify position
-                       //}
-                       //else if (scrollToPosition == ScrollToPosition.Center)
-                       //{
-                       //      // Modify position
-                       //}
-                       //else
-                       //{
-
-                       //}
-               }
-
                void UpdateVerticalScrollBarVisibility()
                {
                        if (_defaultVerticalScrollVisibility == null)
@@ -375,18 +244,60 @@ namespace Xamarin.Forms.Platform.UWP
                                return;
                        }
 
-                       var targetItem = FindBoundItem(args);
+                       var item = FindBoundItem(args);
+
+                       if (item == null)
+                       {
+                               // Item wasn't found in the list, so there's nothing to scroll to
+                               return;
+                       }
 
                        if (args.IsAnimated)
                        {
-                               await AnimateTo(list, targetItem, args.ScrollToPosition);
+                               await ScrollHelpers.AnimateToItemAsync(list, item, args.ScrollToPosition);
                        }
                        else
                        {
-                               await JumpTo(list, targetItem, args.ScrollToPosition);
+                               await ScrollHelpers.JumpToItemAsync(list, item, args.ScrollToPosition);
                        }
                }
 
+               async void ScrollToRequested(object sender, ScrollToRequestEventArgs args)
+               {
+                       await ScrollTo(args);
+               }
+
+               object FindBoundItem(ScrollToRequestEventArgs args)
+               {
+                       if (args.Mode == ScrollToMode.Position)
+                       {
+                               if (args.Index >= _collectionViewSource.View.Count)
+                               {
+                                       return null;
+                               }
+
+                               return _collectionViewSource.View[args.Index];
+                       }
+
+                       if (Element.ItemTemplate == null)
+                       {
+                               return args.Item;
+                       }
+
+                       for (int n = 0; n < _collectionViewSource.View.Count; n++)
+                       {
+                               if (_collectionViewSource.View[n] is ItemTemplateContext pair)
+                               {
+                                       if (pair.Item == args.Item)
+                                       {
+                                               return _collectionViewSource.View[n];
+                                       }
+                               }
+                       }
+
+                       return null;
+               }
+
                protected virtual void UpdateEmptyView()
                {
                        if (Element == null || ListViewBase == null)
index c74b732e32e1c5b551572e42a7f965092af828a1..69f851059c01efb4192b6017a0713429445148f3 100644 (file)
@@ -2,10 +2,10 @@
     xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
     xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
     xmlns:local="using:Xamarin.Forms.Platform.UWP">
-
-    <ItemsPanelTemplate x:Key="HorizontalListItemsPanel">
-        <VirtualizingStackPanel Orientation="Horizontal" />
-    </ItemsPanelTemplate>
+  
+       <ItemsPanelTemplate x:Key="HorizontalListItemsPanel">
+               <ItemsStackPanel Orientation="Horizontal"  />
+       </ItemsPanelTemplate>
 
     <ItemsPanelTemplate x:Key="HorizontalGridItemsPanel">
         <!-- Yes, this is counterintuitive. Orientation here means "direction we lay out the items until we hit the 
diff --git a/Xamarin.Forms.Platform.UAP/CollectionView/ScrollHelpers.cs b/Xamarin.Forms.Platform.UAP/CollectionView/ScrollHelpers.cs
new file mode 100644 (file)
index 0000000..18cc60c
--- /dev/null
@@ -0,0 +1,338 @@
+using System;
+using System.Threading.Tasks;
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+using UWPPoint = Windows.Foundation.Point;
+using UWPSize = Windows.Foundation.Size;
+
+namespace Xamarin.Forms.Platform.UWP
+{
+       internal static class ScrollHelpers
+       {
+               static UWPPoint Zero = new UWPPoint(0, 0);
+
+               static bool IsVertical(ScrollViewer scrollViewer)
+               {
+                       return scrollViewer.HorizontalScrollMode == ScrollMode.Disabled;
+               }
+
+               static UWPPoint AdjustToMakeVisible(UWPPoint point, UWPSize itemSize, ScrollViewer scrollViewer)
+               {
+                       if (IsVertical(scrollViewer))
+                       {
+                               return AdjustToMakeVisibleVertical(point, itemSize, scrollViewer);
+                       }
+
+                       return AdjustToMakeVisibleHorizontal(point, itemSize, scrollViewer);
+               }
+
+               static UWPPoint AdjustToMakeVisibleVertical(UWPPoint point, UWPSize itemSize, ScrollViewer scrollViewer)
+               {
+                       if (point.Y > (scrollViewer.VerticalOffset + scrollViewer.ViewportHeight))
+                       {
+                               return AdjustToEndVertical(point, itemSize, scrollViewer);
+                       }
+
+                       if (point.Y >= scrollViewer.VerticalOffset 
+                               && point.Y < (scrollViewer.VerticalOffset + scrollViewer.ViewportHeight - itemSize.Height))
+                       {
+                               // The target is already in the viewport, no reason to scroll at all
+                               return new UWPPoint(scrollViewer.HorizontalOffset, scrollViewer.VerticalOffset);
+                       }
+
+                       return point;
+               }
+
+               static UWPPoint AdjustToMakeVisibleHorizontal(UWPPoint point, UWPSize itemSize, ScrollViewer scrollViewer)
+               {
+                       if (point.X > (scrollViewer.HorizontalOffset + scrollViewer.ViewportWidth))
+                       {
+                               return AdjustToEndHorizontal(point, itemSize, scrollViewer);
+                       }
+
+                       if (point.X >= scrollViewer.HorizontalOffset 
+                               && point.X < (scrollViewer.HorizontalOffset + scrollViewer.ViewportWidth - itemSize.Width))
+                       {
+                               // The target is already in the viewport, no reason to scroll at all
+                               return new UWPPoint(scrollViewer.HorizontalOffset, scrollViewer.VerticalOffset);
+                       }
+
+                       return point;
+               }
+
+               static UWPPoint AdjustToEnd(UWPPoint point, UWPSize itemSize, ScrollViewer scrollViewer)
+               {
+                       if (IsVertical(scrollViewer))
+                       {
+                               return AdjustToEndVertical(point, itemSize, scrollViewer);
+                       }
+
+                       return AdjustToEndHorizontal(point, itemSize, scrollViewer);
+               }
+
+               static UWPPoint AdjustToEndHorizontal(UWPPoint point, UWPSize itemSize, ScrollViewer scrollViewer)
+               {
+                       var adjustment = scrollViewer.ViewportWidth - itemSize.Width;
+                       return new UWPPoint(point.X - adjustment, point.Y);
+               }
+
+               static UWPPoint AdjustToEndVertical(UWPPoint point, UWPSize itemSize, ScrollViewer scrollViewer)
+               {
+                       var adjustment = scrollViewer.ViewportHeight - itemSize.Height;
+                       return new UWPPoint(point.X, point.Y - adjustment);
+               }
+
+               static async Task AdjustToEndAsync(ListViewBase list, ScrollViewer scrollViewer, object targetItem)
+               {
+                       var point = new UWPPoint(scrollViewer.HorizontalOffset, scrollViewer.VerticalOffset);
+                       var targetContainer = list.ContainerFromItem(targetItem) as UIElement;
+                       point = AdjustToEnd(point, targetContainer.DesiredSize, scrollViewer);
+                       await JumpToOffsetAsync(scrollViewer, point.X, point.Y);
+               }
+
+               static UWPPoint AdjustToCenter(UWPPoint point, UWPSize itemSize, ScrollViewer scrollViewer)
+               {
+                       if (IsVertical(scrollViewer))
+                       {
+                               return AdjustToCenterVertical(point, itemSize, scrollViewer);
+                       }
+
+                       return AdjustToCenterHorizontal(point, itemSize, scrollViewer);
+               }
+
+               static UWPPoint AdjustToCenterHorizontal(UWPPoint point, UWPSize itemSize, ScrollViewer scrollViewer)
+               {
+                       var adjustment = (scrollViewer.ViewportWidth / 2) - (itemSize.Width / 2);
+                       return new UWPPoint(point.X - adjustment, point.Y);
+               }
+
+               static UWPPoint AdjustToCenterVertical(UWPPoint point, UWPSize itemSize, ScrollViewer scrollViewer)
+               {
+                       var adjustment = (scrollViewer.ViewportHeight / 2) - (itemSize.Height / 2);
+                       return new UWPPoint(point.X, point.Y - adjustment);
+               }
+
+               static async Task AdjustToCenterAsync(ListViewBase list, ScrollViewer scrollViewer, object targetItem)
+               {
+                       var point = new UWPPoint(scrollViewer.HorizontalOffset, scrollViewer.VerticalOffset);
+                       var targetContainer = list.ContainerFromItem(targetItem) as UIElement;
+                       point = AdjustToCenter(point, targetContainer.DesiredSize, scrollViewer);
+                       await JumpToOffsetAsync(scrollViewer, point.X, point.Y);
+               }
+
+               static async Task JumpToOffsetAsync(ScrollViewer scrollViewer, double targetHorizontalOffset, double targetVerticalOffset)
+               {
+                       var tcs = new TaskCompletionSource<object>();
+
+                       void ViewChanged(object s, ScrollViewerViewChangedEventArgs e)
+                       {
+                               tcs.TrySetResult(null);
+                       }
+
+                       try
+                       {
+                               scrollViewer.ViewChanged += ViewChanged;
+                               scrollViewer.ChangeView(targetHorizontalOffset, targetVerticalOffset, null, true);
+                               await tcs.Task;
+                       }
+                       finally
+                       {
+                               scrollViewer.ViewChanged -= ViewChanged;
+                       }
+               }
+
+               static async Task<UWPPoint> GetApproximateTargetAsync(ListViewBase list, ScrollViewer scrollViewer, object targetItem)
+               {
+                       // Keep track of where we are now
+                       var horizontalOffset = scrollViewer.HorizontalOffset;
+                       var verticalOffset = scrollViewer.VerticalOffset;
+
+                       // Jump to the target item and record its position. This won't be completely accurate because of 
+                       // virtualization, but it'll be close enough to give us a direction to scroll toward
+                       await JumpToItemAsync(list, targetItem, ScrollToPosition.Start);
+                       var targetContainer = list.ContainerFromItem(targetItem) as UIElement;
+                       var transform = targetContainer.TransformToVisual(scrollViewer.Content as UIElement);
+
+                       // Return to the original position
+                       await JumpToOffsetAsync(scrollViewer, horizontalOffset, verticalOffset);
+
+                       // Return the transformed point
+                       return transform.TransformPoint(Zero);
+               }
+
+               public static async Task JumpToItemAsync(ListViewBase list, object targetItem, ScrollToPosition scrollToPosition)
+               {
+                       var scrollViewer = list.GetFirstDescendant<ScrollViewer>();
+
+                       var tcs = new TaskCompletionSource<object>();
+                       Func<Task> adjust = null;
+
+                       async void ViewChanged(object s, ScrollViewerViewChangedEventArgs e)
+                       {
+                               if (e.IsIntermediate)
+                               {
+                                       return;
+                               }
+
+                               scrollViewer.ViewChanged -= ViewChanged;
+
+                               if (adjust != null)
+                               {
+                                       // Handle adjustments for non-natively supported scroll positions
+                                       await adjust();
+                               }
+
+                               tcs.TrySetResult(null);
+                       }
+
+                       try
+                       {
+                               scrollViewer.ViewChanged += ViewChanged;
+
+                               switch (scrollToPosition)
+                               {
+                                       case ScrollToPosition.MakeVisible:
+                                               list.ScrollIntoView(targetItem, ScrollIntoViewAlignment.Default);
+                                               break;
+                                       case ScrollToPosition.Start:
+                                               list.ScrollIntoView(targetItem, ScrollIntoViewAlignment.Leading);
+                                               break;
+                                       case ScrollToPosition.Center:
+                                               list.ScrollIntoView(targetItem, ScrollIntoViewAlignment.Leading);
+                                               adjust = () => AdjustToCenterAsync(list, scrollViewer, targetItem);
+                                               break;
+                                       case ScrollToPosition.End:
+                                               list.ScrollIntoView(targetItem, ScrollIntoViewAlignment.Leading);
+                                               adjust = () => AdjustToEndAsync(list, scrollViewer, targetItem);
+                                               break;
+                               }
+
+                               await tcs.Task;
+                       }
+                       finally
+                       {
+                               scrollViewer.ViewChanged -= ViewChanged;
+                       }
+               }
+
+               static async Task<bool> ScrollToItemAsync(ListViewBase list, object targetItem, ScrollViewer scrollViewer, ScrollToPosition scrollToPosition)
+               {
+                       var targetContainer = list.ContainerFromItem(targetItem) as UIElement;
+
+                       if (targetContainer != null)
+                       {
+                               await ScrollToTargetContainerAsync(targetContainer, scrollViewer, scrollToPosition);
+                               return true;
+                       }
+
+                       return false;
+               }
+
+               public static async Task AnimateToItemAsync(ListViewBase list, object targetItem, ScrollToPosition scrollToPosition)
+               {
+                       var scrollViewer = list.GetFirstDescendant<ScrollViewer>();
+
+                       // ScrollToItemAsync will only scroll to the item if it actually exists in the list (that is, it has been
+                       // been realized and isn't just a virtual item)
+                       if (await ScrollToItemAsync(list, targetItem, scrollViewer, scrollToPosition))
+                       {
+                               // Happy path; the item was already realized and we could just scroll to it
+                               return;
+                       }
+
+                       // This is the unhappy path. Because of virtualization, the item has not actually been created yet.
+                       // So we make our best guess about the location of the item
+                       var targetPoint = await GetApproximateTargetAsync(list, scrollViewer, targetItem);
+
+                       // And then we scroll toward that position. The interruptCheck parameter will be run as we're scrolling
+                       // to see if the item exists yet; if it does, AnimateToOffsetAsync will be canceled and we'll finish
+                       // off with a smooth scroll to the item
+                       await AnimateToOffsetAsync(scrollViewer, targetPoint.X, targetPoint.Y,
+                               async () => await ScrollToItemAsync(list, targetItem, scrollViewer, scrollToPosition));
+               }
+
+               static async Task AnimateToOffsetAsync(ScrollViewer scrollViewer, double targetHorizontalOffset, double targetVerticalOffset,
+                       Func<Task<bool>> interruptCheck = null)
+               {
+                       var tcs = new TaskCompletionSource<object>();
+
+                       // This method will fire as the scrollview scrolls along
+                       async void ViewChanged(object s, ScrollViewerViewChangedEventArgs e)
+                       {
+                               if (tcs.Task.IsCompleted)
+                               {
+                                       return;
+                               }
+
+                               if (e.IsIntermediate)
+                               {
+                                       // This is an intermediate scroll as part of the larger scroll; we're not all the way there yet
+                                       // We take this opportunity to see if we should interrupt the scrolling
+
+                                       if (interruptCheck == null)
+                                       {
+                                               return;
+                                       }
+
+                                       if (await interruptCheck())
+                                       {
+                                               // Cancel the current scrolling and just stop where we are
+                                               scrollViewer.ChangeView(scrollViewer.HorizontalOffset, scrollViewer.VerticalOffset, 1.0f, true);
+                                               tcs.TrySetResult(null);
+                                       }
+                               }
+                               else
+                               {
+                                       tcs.TrySetResult(null);
+                               }
+                       }
+
+                       try
+                       {
+                               scrollViewer.ViewChanged += ViewChanged;
+                               scrollViewer.ChangeView(targetHorizontalOffset, targetVerticalOffset, null, false);
+                               await tcs.Task;
+                       }
+                       finally
+                       {
+                               scrollViewer.ViewChanged -= ViewChanged;
+                       }
+               }
+
+               static async Task ScrollToTargetContainerAsync(UIElement targetContainer, ScrollViewer scrollViewer, ScrollToPosition scrollToPosition)
+               {
+                       var transform = targetContainer.TransformToVisual(scrollViewer.Content as UIElement);
+                       var position = transform?.TransformPoint(Zero);
+
+                       if (!position.HasValue)
+                       {
+                               return;
+                       }
+
+                       UWPPoint offset = position.Value;
+
+                       // We'll use the desired size of the item because the actual size may not be actualized yet, and
+                       // we'll get a very unhelpful cast exception when it tries to cast to IUIElement10(!)
+                       var itemSize = targetContainer.DesiredSize;
+
+                       switch (scrollToPosition)
+                       {
+                               case ScrollToPosition.Start:
+                                       // The transform will put the container at the top of the ScrollViewer; we'll need to adjust for
+                                       // other scroll positions
+                                       break;
+                               case ScrollToPosition.MakeVisible:
+                                       offset = AdjustToMakeVisible(offset, itemSize, scrollViewer);
+                                       break;
+                               case ScrollToPosition.Center:
+                                       offset = AdjustToCenter(offset, itemSize, scrollViewer);
+                                       break;
+                               case ScrollToPosition.End:
+                                       offset = AdjustToEnd(offset, itemSize, scrollViewer);
+                                       break;
+                       }
+
+                       await AnimateToOffsetAsync(scrollViewer, offset.X, offset.Y);
+               }
+       }
+}
\ No newline at end of file
index 961dafa16be16599f3c9e6308d5500be264ac51f..72dfcdc8ce5c93e4be850bc383d80ef1320affdf 100644 (file)
@@ -44,6 +44,7 @@
     <Compile Include="CollectionView\FormsListView.cs" />
     <Compile Include="CollectionView\IEmptyView.cs" />
     <Compile Include="CollectionView\ItemsViewRenderer.cs" />
+    <Compile Include="CollectionView\ScrollHelpers.cs" />
     <Compile Include="CollectionView\SelectableItemsViewRenderer.cs" />
     <Compile Include="CollectionView\StructuredItemsViewRenderer.cs" />
     <Compile Include="ColorExtensions.cs" />