[iOS, Android] Implement Snap alignment for CollectionView (#4414)

* Implement snap alignment for iOS CollectionView

* Add missing End/MandatorySingle implementation for Android

* Implement MandatorySingle snapping on iOS

* Fix issues with Android MandatorySingle skipping items;

* Fix rebase issue
This commit is contained in:
E.Z. Hart 2019-01-14 04:48:59 -07:00 коммит произвёл Rui Marinho
Родитель 29074eb045
Коммит f97fb08263
13 изменённых файлов: 463 добавлений и 92 удалений

Просмотреть файл

@ -9,9 +9,8 @@
var templateLayout = new Grid
{
RowDefinitions = new RowDefinitionCollection { new RowDefinition(), new RowDefinition() },
WidthRequest = 100,
HeightRequest = 100,
Padding = new Thickness(10)
WidthRequest = 200,
HeightRequest = 100
};
var image = new Image
@ -48,15 +47,15 @@
var templateLayout = new Grid
{
RowDefinitions = new RowDefinitionCollection { new RowDefinition(), new RowDefinition {Height = GridLength.Auto} },
WidthRequest = 100,
HeightRequest = 130
WidthRequest = 280,
HeightRequest = 310,
};
var image = new Image
{
Margin = new Thickness(5),
HeightRequest = 100,
WidthRequest = 100,
HeightRequest = 280,
WidthRequest = 280,
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
Aspect = Aspect.AspectFit

Просмотреть файл

@ -0,0 +1,23 @@
using Android.Support.V7.Widget;
using AView = Android.Views.View;
namespace Xamarin.Forms.Platform.Android
{
internal class EndSingleSnapHelper : SingleSnapHelper
{
public override int[] CalculateDistanceToFinalSnap(RecyclerView.LayoutManager layoutManager, AView targetView)
{
var orientationHelper = CreateOrientationHelper(layoutManager);
var isHorizontal = layoutManager.CanScrollHorizontally();
var rtl = isHorizontal && IsLayoutReversed(layoutManager);
var distance = rtl
? -orientationHelper.GetDecoratedStart(targetView)
: orientationHelper.TotalSpace - orientationHelper.GetDecoratedEnd(targetView);
return isHorizontal
? new[] { -distance, 1 }
: new[] { 1, -distance };
}
}
}

Просмотреть файл

@ -276,9 +276,10 @@ namespace Xamarin.Forms.Platform.Android
UpdateFlowDirection();
// Keep track of the ItemsLayout's property changes
if (_layout != null)
{
_layout.PropertyChanged += LayoutOnPropertyChanged;
// TODO hartez 2018/09/17 13:16:12 This propertychanged handler needs to be torn down in Dispose and TearDownElement
}
// Listen for ScrollTo requests
ItemsView.ScrollToRequested += ScrollToRequested;
@ -291,6 +292,12 @@ namespace Xamarin.Forms.Platform.Android
return;
}
// Stop listening for layout property changes
if (_layout != null)
{
_layout.PropertyChanged -= LayoutOnPropertyChanged;
}
// Stop listening for property changes
oldElement.PropertyChanged -= OnElementPropertyChanged;

Просмотреть файл

@ -0,0 +1,86 @@
using Android.Support.V7.Widget;
using AView = Android.Views.View;
namespace Xamarin.Forms.Platform.Android
{
internal class SingleSnapHelper : PagerSnapHelper
{
// CurrentTargetPosition will have this value until the user scrolls around
protected int CurrentTargetPosition = -1;
protected static OrientationHelper CreateOrientationHelper(RecyclerView.LayoutManager layoutManager)
{
return layoutManager.CanScrollHorizontally()
? OrientationHelper.CreateHorizontalHelper(layoutManager)
: OrientationHelper.CreateVerticalHelper(layoutManager);
}
protected static bool IsLayoutReversed(RecyclerView.LayoutManager layoutManager)
{
if (layoutManager is LinearLayoutManager linearLayoutManager)
{
return linearLayoutManager.ReverseLayout;
}
return false;
}
public override AView FindSnapView(RecyclerView.LayoutManager layoutManager)
{
if (layoutManager.ItemCount == 0)
{
return null;
}
if (!(layoutManager is LinearLayoutManager linearLayoutManager))
{
// Don't snap to anything if this isn't a LinearLayoutManager;
return null;
}
var targetItemPosition = CurrentTargetPosition;
if (targetItemPosition != -1)
{
return linearLayoutManager.FindViewByPosition(targetItemPosition);
}
return null;
}
public override int FindTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY)
{
if (CurrentTargetPosition == -1)
{
CurrentTargetPosition = base.FindTargetSnapPosition(layoutManager, velocityX, velocityY);
return CurrentTargetPosition;
}
var increment = 1;
if (layoutManager.CanScrollHorizontally())
{
if (velocityX < 0)
{
increment = -1;
}
}
else if (layoutManager.CanScrollVertically())
{
if (velocityY < 0)
{
increment = -1;
}
}
if (IsLayoutReversed(layoutManager))
{
increment = increment * -1;
}
CurrentTargetPosition = CurrentTargetPosition + increment;
return CurrentTargetPosition;
}
}
}

Просмотреть файл

@ -65,11 +65,11 @@ namespace Xamarin.Forms.Platform.Android
switch (alignment)
{
case SnapPointsAlignment.Start:
return new StartPagerSnapHelper();
return new StartSingleSnapHelper();
case SnapPointsAlignment.Center:
return new PagerSnapHelper();
return new SingleSnapHelper();
case SnapPointsAlignment.End:
break;
return new EndSingleSnapHelper();
default:
throw new ArgumentOutOfRangeException(nameof(alignment), alignment, null);
}

Просмотреть файл

@ -1,72 +0,0 @@
using Android.Support.V7.Widget;
using AView = Android.Views.View;
namespace Xamarin.Forms.Platform.Android
{
internal class StartPagerSnapHelper : PagerSnapHelper
{
protected static OrientationHelper CreateOrientationHelper(RecyclerView.LayoutManager layoutManager)
{
return layoutManager.CanScrollHorizontally()
? OrientationHelper.CreateHorizontalHelper(layoutManager)
: OrientationHelper.CreateVerticalHelper(layoutManager);
}
protected static bool IsLayoutReversed(RecyclerView.LayoutManager layoutManager)
{
if (layoutManager is LinearLayoutManager linearLayoutManager)
{
return linearLayoutManager.ReverseLayout;
}
return false;
}
public override int FindTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY)
{
var x = base.FindTargetSnapPosition(layoutManager, velocityX, velocityY);
return x;
}
public override AView FindSnapView(RecyclerView.LayoutManager layoutManager)
{
if (layoutManager.ItemCount == 0)
{
return null;
}
if (!(layoutManager is LinearLayoutManager linearLayoutManager))
{
// Don't snap to anything if this isn't a LinearLayoutManager;
return null;
}
// Find the first fully visible item
var firstVisibleItemPosition = linearLayoutManager.FindFirstCompletelyVisibleItemPosition();
if (firstVisibleItemPosition == RecyclerView.NoPosition)
{
// If there are no fully visible items, drop back to default PagerSnapHelper behavior
return base.FindSnapView(layoutManager);
}
// Return the view to snap
return linearLayoutManager.FindViewByPosition(firstVisibleItemPosition);
}
public override int[] CalculateDistanceToFinalSnap(RecyclerView.LayoutManager layoutManager, AView targetView)
{
var orientationHelper = CreateOrientationHelper(layoutManager);
var isHorizontal = layoutManager.CanScrollHorizontally();
var rtl = isHorizontal && IsLayoutReversed(layoutManager);
var distance = rtl
? -orientationHelper.GetDecoratedEnd(targetView)
: orientationHelper.GetDecoratedStart(targetView);
return isHorizontal
? new[] { distance, 1 }
: new[] { 1, distance };
}
}
}

Просмотреть файл

@ -0,0 +1,23 @@
using Android.Support.V7.Widget;
using AView = Android.Views.View;
namespace Xamarin.Forms.Platform.Android
{
internal class StartSingleSnapHelper : SingleSnapHelper
{
public override int[] CalculateDistanceToFinalSnap(RecyclerView.LayoutManager layoutManager, AView targetView)
{
var orientationHelper = CreateOrientationHelper(layoutManager);
var isHorizontal = layoutManager.CanScrollHorizontally();
var rtl = isHorizontal && IsLayoutReversed(layoutManager);
var distance = rtl
? -orientationHelper.GetDecoratedEnd(targetView)
: orientationHelper.GetDecoratedStart(targetView);
return isHorizontal
? new[] { distance, 1 }
: new[] { 1, distance };
}
}
}

Просмотреть файл

@ -71,7 +71,9 @@
<Compile Include="CollectionView\CarouselViewRenderer.cs" />
<Compile Include="CollectionView\DataChangeObserver.cs" />
<Compile Include="CollectionView\EmptySource.cs" />
<Compile Include="CollectionView\SingleSnapHelper.cs" />
<Compile Include="CollectionView\EmptyViewAdapter.cs" />
<Compile Include="CollectionView\EndSingleSnapHelper.cs" />
<Compile Include="CollectionView\ItemsViewAdapter.cs" />
<Compile Include="CollectionView\EdgeSnapHelper.cs" />
<Compile Include="CollectionView\EndSnapHelper.cs" />
@ -89,7 +91,7 @@
<Compile Include="CollectionView\SelectableViewHolder.cs" />
<Compile Include="CollectionView\SizedItemContentView.cs" />
<Compile Include="CollectionView\SnapManager.cs" />
<Compile Include="CollectionView\StartPagerSnapHelper.cs" />
<Compile Include="CollectionView\StartSingleSnapHelper.cs" />
<Compile Include="CollectionView\StartSnapHelper.cs" />
<Compile Include="CollectionView\TemplatedItemViewHolder.cs" />
<Compile Include="CollectionView\TextViewHolder.cs" />

Просмотреть файл

@ -1,4 +1,7 @@
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using CoreGraphics;
using Foundation;
using UIKit;

Просмотреть файл

@ -18,7 +18,8 @@ namespace Xamarin.Forms.Platform.iOS
public class CollectionViewRenderer : ViewRenderer<CollectionView, UIView>
{
CollectionViewController _collectionViewController;
ItemsViewLayout _layout;
ItemsViewLayout _flowLayout;
IItemsLayout _layout;
bool _disposed;
public CollectionViewRenderer()
@ -89,11 +90,12 @@ namespace Xamarin.Forms.Platform.iOS
return;
}
_layout = SelectLayout(newElement.ItemsLayout);
_collectionViewController = new CollectionViewController(newElement, _layout);
_layout = newElement.ItemsLayout;
_flowLayout = SelectLayout(_layout);
_collectionViewController = new CollectionViewController(newElement, _flowLayout);
SetNativeControl(_collectionViewController.View);
_collectionViewController.CollectionView.BackgroundColor = UIColor.Clear;
_collectionViewController.CollectionView.WeakDelegate = _layout;
_collectionViewController.CollectionView.WeakDelegate = _flowLayout;
_collectionViewController.UpdateEmptyView();
// Listen for ScrollTo requests
@ -123,7 +125,7 @@ namespace Xamarin.Forms.Platform.iOS
}
_collectionViewController.CollectionView.ScrollToItem(indexPath,
args.ScrollToPosition.ToCollectionViewScrollPosition(_layout.ScrollDirection),
args.ScrollToPosition.ToCollectionViewScrollPosition(_flowLayout.ScrollDirection),
args.IsAnimated);
}

Просмотреть файл

@ -1,9 +1,11 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using CoreGraphics;
using Foundation;
using UIKit;
using Xamarin.Forms.Internals;
namespace Xamarin.Forms.Platform.iOS
{
@ -215,5 +217,102 @@ namespace Xamarin.Forms.Platform.iOS
{
_needCellSizeUpdate = true;
}
public override CGPoint TargetContentOffset(CGPoint proposedContentOffset, CGPoint scrollingVelocity)
{
var snapPointsType = _itemsLayout.SnapPointsType;
if (snapPointsType == SnapPointsType.None)
{
// Nothing to do here; fall back to the default
return base.TargetContentOffset(proposedContentOffset, scrollingVelocity);
}
var alignment = _itemsLayout.SnapPointsAlignment;
if (snapPointsType == SnapPointsType.MandatorySingle)
{
// Mandatory snapping, single element
return ScrollSingle(alignment, proposedContentOffset, scrollingVelocity);
}
// Get the viewport of the UICollectionView at the proposed content offset
var viewport = new CGRect(proposedContentOffset, CollectionView.Bounds.Size);
// And find all the elements currently visible in the viewport
var visibleElements = LayoutAttributesForElementsInRect(viewport);
if (visibleElements.Length == 0)
{
// Nothing to see here; fall back to the default
return base.TargetContentOffset(proposedContentOffset, scrollingVelocity);
}
if (visibleElements.Length == 1)
{
// If there is only one item in the viewport, then we need to align the viewport with it
return SnapHelpers.AdjustContentOffset(proposedContentOffset, visibleElements[0].Frame, viewport,
alignment, ScrollDirection);
}
// If there are multiple items in the viewport, we need to choose the one which is
// closest to the relevant part of the viewport while being sufficiently visible
// Find the spot in the viewport we're trying to align with
var alignmentTarget = SnapHelpers.FindAlignmentTarget(alignment, proposedContentOffset,
CollectionView, ScrollDirection);
// Find the closest sufficiently visible candidate
var bestCandidate = SnapHelpers.FindBestSnapCandidate(visibleElements, viewport, alignmentTarget);
if (bestCandidate != null)
{
return SnapHelpers.AdjustContentOffset(proposedContentOffset, bestCandidate.Frame, viewport, alignment,
ScrollDirection);
}
// If we got this far an nothing matched, it means that we have multiple items but somehow
// none of them fit at least half in the viewport. So just fall back to the first item
return SnapHelpers.AdjustContentOffset(proposedContentOffset, visibleElements[0].Frame, viewport, alignment,
ScrollDirection);
}
CGPoint ScrollSingle(SnapPointsAlignment alignment, CGPoint proposedContentOffset, CGPoint scrollingVelocity)
{
// Get the viewport of the UICollectionView at the current content offset
var contentOffset = CollectionView.ContentOffset;
var viewport = new CGRect(contentOffset, CollectionView.Bounds.Size);
// Find the spot in the viewport we're trying to align with
var alignmentTarget = SnapHelpers.FindAlignmentTarget(alignment, contentOffset, CollectionView, ScrollDirection);
var visibleElements = LayoutAttributesForElementsInRect(viewport);
// Find the current aligned item
var currentItem = SnapHelpers.FindBestSnapCandidate(visibleElements, viewport, alignmentTarget);
if (currentItem == null)
{
// Somehow we don't currently have an item in the viewport near the target; fall back to the
// default behavior
return base.TargetContentOffset(proposedContentOffset, scrollingVelocity);
}
// Determine the index of the current item
var currentIndex = visibleElements.IndexOf(currentItem);
// Figure out the step size when jumping to the "next" element
var span = 1;
if (_itemsLayout is GridItemsLayout gridItemsLayout)
{
span = gridItemsLayout.Span;
}
// Find the next item in the
currentItem = SnapHelpers.FindNextItem(visibleElements, ScrollDirection, span, scrollingVelocity, currentIndex);
return SnapHelpers.AdjustContentOffset(CollectionView.ContentOffset, currentItem.Frame, viewport, alignment,
ScrollDirection);
}
}
}

Просмотреть файл

@ -0,0 +1,198 @@
using System;
using CoreGraphics;
using UIKit;
namespace Xamarin.Forms.Platform.iOS
{
internal class SnapHelpers
{
public static CGPoint AdjustContentOffset(CGPoint proposedContentOffset, CGRect itemFrame,
CGRect viewport, SnapPointsAlignment alignment, UICollectionViewScrollDirection scrollDirection)
{
var offset = GetViewportOffset(itemFrame, viewport, alignment, scrollDirection);
return new CGPoint(proposedContentOffset.X - offset.X, proposedContentOffset.Y - offset.Y);
}
public static CGPoint FindAlignmentTarget(SnapPointsAlignment snapPointsAlignment,
CGPoint contentOffset, UICollectionView collectionView, UICollectionViewScrollDirection scrollDirection)
{
var inset = collectionView.ContentInset;
var bounds = collectionView.Bounds;
switch (scrollDirection)
{
case UICollectionViewScrollDirection.Vertical:
var y = FindAlignmentTarget(snapPointsAlignment, contentOffset.Y, inset.Top,
contentOffset.Y + bounds.Height, inset.Bottom);
return new CGPoint(contentOffset.X, y);
case UICollectionViewScrollDirection.Horizontal:
var x = FindAlignmentTarget(snapPointsAlignment, contentOffset.X, inset.Left,
contentOffset.X + bounds.Width, inset.Right);
return new CGPoint(x, contentOffset.Y);
default:
throw new ArgumentOutOfRangeException();
}
}
public static UICollectionViewLayoutAttributes FindBestSnapCandidate(UICollectionViewLayoutAttributes[] items,
CGRect viewport, CGPoint alignmentTarget)
{
UICollectionViewLayoutAttributes bestCandidate = null;
foreach (UICollectionViewLayoutAttributes item in items)
{
if (!IsAtLeastHalfVisible(item, viewport))
{
continue;
}
bestCandidate = bestCandidate == null ? item : Nearer(bestCandidate, item, alignmentTarget);
}
return bestCandidate;
}
static nfloat Area(CGRect rect)
{
return rect.Height * rect.Width;
}
static CGPoint Center(CGRect rect)
{
return new CGPoint(rect.X + rect.Width / 2, rect.Y + rect.Height / 2);
}
static nfloat DistanceSquared(CGRect rect, CGPoint target)
{
var rectCenter = Center(rect);
return (target.X - rectCenter.X) * (target.X - rectCenter.X) +
(target.Y - rectCenter.Y) * (target.Y - rectCenter.Y);
}
static int Clamp(int n, int min, int max)
{
if (n < min)
{
return min;
}
if (n > max)
{
return max;
}
return n;
}
static nfloat FindAlignmentTarget(SnapPointsAlignment snapPointsAlignment, nfloat start, nfloat startInset,
nfloat end, nfloat endInset)
{
switch (snapPointsAlignment)
{
case SnapPointsAlignment.Center:
var viewPortStart = start + startInset;
var viewPortEnd = end - endInset;
var viewPortSize = viewPortEnd - viewPortStart;
return viewPortStart + (viewPortSize / 2);
case SnapPointsAlignment.End:
return end - endInset;
case SnapPointsAlignment.Start:
default:
return start + startInset;
}
}
static CGPoint GetViewportOffset(CGRect itemFrame, CGRect viewport, SnapPointsAlignment snapPointsAlignment,
UICollectionViewScrollDirection scrollDirection)
{
if (scrollDirection == UICollectionViewScrollDirection.Horizontal)
{
if (snapPointsAlignment == SnapPointsAlignment.Start)
{
return new CGPoint(viewport.Left - itemFrame.Left, 0);
}
if (snapPointsAlignment == SnapPointsAlignment.End)
{
return new CGPoint(viewport.Right - itemFrame.Right, 0);
}
var centerViewport = Center(viewport);
var centerItem = Center(itemFrame);
return new CGPoint(centerViewport.X - centerItem.X, 0);
}
if (snapPointsAlignment == SnapPointsAlignment.Start)
{
return new CGPoint(0, viewport.Top - itemFrame.Top);
}
if (snapPointsAlignment == SnapPointsAlignment.End)
{
return new CGPoint(0, viewport.Bottom - itemFrame.Bottom);
}
var centerViewport1 = Center(viewport);
var centerItem1 = Center(itemFrame);
return new CGPoint(0, centerViewport1.Y - centerItem1.Y);
}
static bool IsAtLeastHalfVisible(UICollectionViewLayoutAttributes item, CGRect viewport)
{
var itemFrame = item.Frame;
var visibleArea = Area(CGRect.Intersect(itemFrame, viewport));
return visibleArea >= Area(itemFrame) / 2;
}
static UICollectionViewLayoutAttributes Nearer(UICollectionViewLayoutAttributes a,
UICollectionViewLayoutAttributes b,
CGPoint target)
{
var dA = DistanceSquared(a.Frame, target);
var dB = DistanceSquared(b.Frame, target);
if (dA < dB)
{
return a;
}
return b;
}
public static UICollectionViewLayoutAttributes FindNextItem(UICollectionViewLayoutAttributes[] items,
UICollectionViewScrollDirection direction, int step, CGPoint scrollingVelocity, int currentIndex)
{
var velocity = direction == UICollectionViewScrollDirection.Horizontal
? scrollingVelocity.X
: scrollingVelocity.Y;
if (velocity == 0)
{
// The user isn't scrolling at all, just stay where we are
return items[currentIndex];
}
// Move the index up or down by increment, depending on the velocity
if (velocity > 0)
{
currentIndex = currentIndex + step;
}
else if (velocity < 0)
{
currentIndex = currentIndex - step;
}
// Make sure we're not out of bounds
currentIndex = Clamp(currentIndex, 0, items.Length - 1);
return items[currentIndex];
}
}
}

Просмотреть файл

@ -129,6 +129,7 @@
<Compile Include="CollectionView\ListViewLayout.cs" />
<Compile Include="CollectionView\ObservableItemsSource.cs" />
<Compile Include="CollectionView\PropertyChangedEventArgsExtensions.cs" />
<Compile Include="CollectionView\SnapHelpers.cs" />
<Compile Include="CollectionView\TemplatedCell.cs" />
<Compile Include="CollectionView\HorizontalTemplatedCell.cs" />
<Compile Include="CollectionView\VerticalTemplatedCell.cs" />