Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ public class MauiCarouselRecyclerView : MauiRecyclerView<CarouselView, ItemsView
bool _isVisible;
bool _disposed;
bool _isInternalPositionUpdate;

readonly float _touchSlop;
float _initialTouchX;
float _initialTouchY;
bool _directionLocked;
bool _delegatingToChild;
List<View> _oldViews;
CarouselViewOnGlobalLayoutListener _carouselViewLayoutListener;

Expand All @@ -29,20 +33,94 @@ public MauiCarouselRecyclerView(Context context, Func<IItemsLayout> getItemsLayo
{
_oldViews = new List<View>();
_carouselViewLoopManager = new CarouselViewLoopManager();
_touchSlop = ViewConfiguration.Get(context).ScaledTouchSlop;
}

// Gets or sets a value indicating whether swipe gestures are enabled for the carousel.
public bool IsSwipeEnabled { get; set; }
Comment thread
Dhivya-SF4094 marked this conversation as resolved.

public override bool OnInterceptTouchEvent(MotionEvent ev)
{
// If ItemsView is explicitly disabled, defer to the base implementation so it can
// intercept all touch events and block interaction. Returning false here (for either
// the swipe-disabled or off-axis delegation paths) would bypass that guard and allow
// a disabled CarouselView to delegate gestures to a nested child.
if (ItemsView?.IsEnabled == false && !ItemsView.IsExplicitlyEnabled)
{
return base.OnInterceptTouchEvent(ev);
}

if (!IsSwipeEnabled)
{
return false;
}

switch (ev.Action)
{
case MotionEventActions.Down:
_initialTouchX = ev.GetX();
_initialTouchY = ev.GetY();
_directionLocked = false;
_delegatingToChild = false;
break;

case MotionEventActions.Move:
// Once a gesture has been delegated to a nested child, keep delegating for the
// rest of the gesture. This prevents a later ambiguous move - or the child
// reaching its scroll boundary - from letting the carousel hijack the swipe and
// transition to the next item.
if (_delegatingToChild)
{
return false;
}

if (!_directionLocked)
{
float deltaX = ev.GetX() - _initialTouchX;
float deltaY = ev.GetY() - _initialTouchY;

// Lock the gesture direction the first time movement exceeds touch slop.
if (Math.Abs(deltaX) > _touchSlop || Math.Abs(deltaY) > _touchSlop)
{
_directionLocked = true;

if (IsOffAxisGesture(deltaX, deltaY))
{
// Perpendicular gesture (e.g. a vertical swipe on a horizontal carousel):
// it belongs to nested scrollable content, never the carousel.
_delegatingToChild = true;
return false;
}
}
}
break;

case MotionEventActions.Cancel:
case MotionEventActions.Up:
// Reset gesture state at the end of the gesture
// to prevent old values from being used if we don't get a Down event
_initialTouchX = 0;
_initialTouchY = 0;
_directionLocked = false;
_delegatingToChild = false;
break;
}

return base.OnInterceptTouchEvent(ev);
}

// Determines whether the gesture's dominant axis is the opposite of the carousel's scroll
// orientation (e.g. a vertical swipe on a horizontal carousel). Off-axis gestures belong to
// nested scrollable content, so the carousel must not intercept them.
bool IsOffAxisGesture(float deltaX, float deltaY)
{
float absDeltaX = Math.Abs(deltaX);
float absDeltaY = Math.Abs(deltaY);
bool isVerticalGesture = absDeltaY > absDeltaX;

return IsHorizontal ? isVerticalGesture : !isVerticalGesture;
}

protected virtual bool IsHorizontal => (Carousel?.ItemsLayout)?.Orientation == ItemsLayoutOrientation.Horizontal;

protected override int DetermineTargetPosition(ScrollToRequestEventArgs args)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using Android.Views;
using Android.Widget;
using AndroidX.RecyclerView.Widget;
using Microsoft.Maui.Controls;
Expand Down Expand Up @@ -55,6 +56,60 @@ await CreateHandlerAndAddToWindow<CarouselViewHandler>(carouselView, async (hand
});
}

[Fact(DisplayName = "Vertical Drag On Horizontal CarouselView Is Not Intercepted")]
public async Task VerticalDragOnHorizontalCarouselIsNotIntercepted()
{
SetupBuilder();

var data = new ObservableCollection<string> { "Item 1", "Item 2", "Item 3" };

var template = new DataTemplate(() => new Grid { new Label() });

var carouselView = new CarouselView
{
ItemTemplate = template,
ItemsSource = data,
IsSwipeEnabled = true,
};

await CreateHandlerAndAddToWindow<CarouselViewHandler>(carouselView, async (handler) =>
{
var recyclerView = handler.PlatformView;
await recyclerView.WaitForLayoutOrNonZeroSize();

// A vertical-dominant drag with a horizontal component large enough that the base
// RecyclerView would otherwise treat it as a horizontal page swipe. The fix must
// detect the off-axis gesture and delegate it to nested scrollable content, so
// OnInterceptTouchEvent returns false. Without the fix, the carousel intercepts it.
bool intercepted = SimulateDragIntercept(recyclerView, deltaX: 80, deltaY: 400);

Assert.False(intercepted);
});
}

// Dispatches a synthetic down/move/up gesture to the RecyclerView's touch-interception
// pipeline and reports whether the move was intercepted by the carousel.
static bool SimulateDragIntercept(RecyclerView recyclerView, float deltaX, float deltaY)
{
const float startX = 200f;
const float startY = 200f;
long downTime = global::Android.OS.SystemClock.UptimeMillis();

var down = MotionEvent.Obtain(downTime, downTime, MotionEventActions.Down, startX, startY, 0);
recyclerView.OnInterceptTouchEvent(down);
down.Recycle();

var move = MotionEvent.Obtain(downTime, downTime + 16, MotionEventActions.Move, startX + deltaX, startY + deltaY, 0);
bool intercepted = recyclerView.OnInterceptTouchEvent(move);
move.Recycle();

var up = MotionEvent.Obtain(downTime, downTime + 32, MotionEventActions.Up, startX + deltaX, startY + deltaY, 0);
recyclerView.OnInterceptTouchEvent(up);
up.Recycle();

return intercepted;
}

RecyclerView GetPlatformCarouselView(CarouselViewHandler carouselViewHandler) =>
carouselViewHandler.PlatformView;

Expand Down
Loading