Skip to content

Commit 84345dc

Browse files
AdamEssenmacherbot
authored andcommitted
Fix Android nested carousel scrolling (#35656)
### Description of Change Fixes Android touch arbitration for RecyclerView-backed MAUI items controls when nested inside a perpendicular parent scroll container. A horizontal `CarouselView` or `CollectionView` inside a vertical `ScrollView` now releases vertical gestures to the nearest scrollable parent instead of continuing to hold the gesture in the RecyclerView. Own-axis gestures are still handled by the RecyclerView, and existing disabled `ItemsView` behavior plus `CarouselView.IsSwipeEnabled` behavior are preserved. Adds an Android Appium regression page and test for the nested `CarouselView` scenario. The test verifies that vertical scrolling from inside the carousel works before and after a separate horizontal scroll gesture. ### Issues Fixed Fixes #7814 ---------
1 parent 8db201c commit 84345dc

4 files changed

Lines changed: 525 additions & 0 deletions

File tree

src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ public class MauiRecyclerView<TItemsView, TAdapter, TItemsViewSource> : Recycler
3939
EmptyViewAdapter _emptyViewAdapter;
4040
readonly DataChangeObserver _emptyCollectionObserver;
4141
readonly DataChangeObserver _itemsUpdateScrollObserver;
42+
readonly Func<MotionEvent, bool> _dispatchTouchEventToRecyclerView;
43+
ParentScrollGestureDispatcher _parentScrollGestureDispatcher;
4244

4345
ScrollBarVisibility _defaultHorizontalScrollVisibility = ScrollBarVisibility.Default;
4446
ScrollBarVisibility _defaultVerticalScrollVisibility = ScrollBarVisibility.Default;
@@ -60,6 +62,8 @@ public MauiRecyclerView(Context context, Func<IItemsLayout> getItemsLayout, Func
6062

6163
_emptyCollectionObserver = new DataChangeObserver(UpdateEmptyViewVisibility);
6264
_itemsUpdateScrollObserver = new DataChangeObserver(AdjustScrollForItemUpdate);
65+
_dispatchTouchEventToRecyclerView = DispatchTouchEventToRecyclerView;
66+
_parentScrollGestureDispatcher = new ParentScrollGestureDispatcher(this);
6367
}
6468

6569
protected override void OnAttachedToWindow()
@@ -624,6 +628,23 @@ public override bool OnTouchEvent(MotionEvent e)
624628
return base.OnTouchEvent(e);
625629
}
626630

631+
bool DispatchTouchEventToRecyclerView(MotionEvent e) => base.DispatchTouchEvent(e);
632+
633+
public override bool DispatchTouchEvent(MotionEvent e)
634+
{
635+
if (ItemsView?.IsEnabled == false && !ItemsView.IsExplicitlyEnabled)
636+
{
637+
return base.DispatchTouchEvent(e);
638+
}
639+
640+
if (_parentScrollGestureDispatcher?.TryDispatchToParent(e, _dispatchTouchEventToRecyclerView, out var handled) == true)
641+
{
642+
return handled;
643+
}
644+
645+
return base.DispatchTouchEvent(e);
646+
}
647+
627648
public override bool OnInterceptTouchEvent(MotionEvent e)
628649
{
629650
// If ItemsView is disabled, intercept all touch events to prevent interactions.
@@ -653,6 +674,12 @@ protected override void OnLayout(bool changed, int l, int t, int r, int b)
653674

654675
protected override void Dispose(bool disposing)
655676
{
677+
if (disposing)
678+
{
679+
_parentScrollGestureDispatcher?.Dispose();
680+
_parentScrollGestureDispatcher = null;
681+
}
682+
656683
base.Dispose(disposing);
657684
if (disposing)
658685
{
@@ -662,6 +689,236 @@ protected override void Dispose(bool disposing)
662689

663690
internal ScrollHelper ScrollHelper => _scrollHelper ??= new ScrollHelper(this);
664691

692+
bool CanHandleOwnScrollDirection => this is not MauiCarouselRecyclerView carouselRecyclerView || carouselRecyclerView.IsSwipeEnabled;
693+
694+
class ParentScrollGestureDispatcher : IDisposable
695+
{
696+
readonly MauiRecyclerView<TItemsView, TAdapter, TItemsViewSource> _owner;
697+
readonly int[] _targetLocation = new int[2];
698+
MotionEvent _downEvent;
699+
AView _parentScrollTarget;
700+
float _touchStartX;
701+
float _touchStartY;
702+
int? _scaledTouchSlop;
703+
GestureOwner _gestureOwner;
704+
705+
public ParentScrollGestureDispatcher(MauiRecyclerView<TItemsView, TAdapter, TItemsViewSource> owner)
706+
{
707+
_owner = owner;
708+
}
709+
710+
public bool TryDispatchToParent(MotionEvent e, Func<MotionEvent, bool> dispatchToRecyclerView, out bool handled)
711+
{
712+
handled = false;
713+
714+
if (_gestureOwner == GestureOwner.Parent)
715+
{
716+
ForwardToParent(e);
717+
718+
if (IsTouchEnd(e))
719+
{
720+
Reset();
721+
}
722+
723+
handled = true;
724+
return true;
725+
}
726+
727+
if (_gestureOwner == GestureOwner.RecyclerView)
728+
{
729+
if (IsTouchEnd(e))
730+
{
731+
Reset();
732+
}
733+
734+
return false;
735+
}
736+
737+
switch (e.ActionMasked)
738+
{
739+
case MotionEventActions.Down:
740+
TrackDown(e);
741+
return false;
742+
case MotionEventActions.Move:
743+
return TryStartForwardingToParent(e, dispatchToRecyclerView, out handled);
744+
case MotionEventActions.Up:
745+
case MotionEventActions.Cancel:
746+
Reset();
747+
return false;
748+
}
749+
750+
return false;
751+
}
752+
753+
public void Dispose()
754+
{
755+
Reset();
756+
}
757+
758+
void TrackDown(MotionEvent e)
759+
{
760+
Reset();
761+
_touchStartX = e.RawX;
762+
_touchStartY = e.RawY;
763+
_downEvent = MotionEvent.Obtain(e);
764+
_owner.Parent?.RequestDisallowInterceptTouchEvent(false);
765+
}
766+
767+
bool TryStartForwardingToParent(MotionEvent e, Func<MotionEvent, bool> dispatchToRecyclerView, out bool handled)
768+
{
769+
handled = false;
770+
771+
var layoutManager = _owner.GetLayoutManager();
772+
773+
if (layoutManager is null)
774+
{
775+
return false;
776+
}
777+
778+
var canScrollHorizontally = layoutManager.CanScrollHorizontally();
779+
var canScrollVertically = layoutManager.CanScrollVertically();
780+
781+
if (canScrollHorizontally == canScrollVertically)
782+
{
783+
return false;
784+
}
785+
786+
var deltaX = Math.Abs(e.RawX - _touchStartX);
787+
var deltaY = Math.Abs(e.RawY - _touchStartY);
788+
789+
if (deltaX < ScaledTouchSlop && deltaY < ScaledTouchSlop)
790+
{
791+
return false;
792+
}
793+
794+
var movesInOwnScrollDirection = canScrollHorizontally
795+
? deltaX >= deltaY
796+
: deltaY >= deltaX;
797+
798+
if (movesInOwnScrollDirection)
799+
{
800+
_gestureOwner = GestureOwner.RecyclerView;
801+
_owner.Parent?.RequestDisallowInterceptTouchEvent(_owner.CanHandleOwnScrollDirection);
802+
return false;
803+
}
804+
805+
var target = FindParentScrollTarget(e, canScrollHorizontally);
806+
807+
if (target is null)
808+
{
809+
return false;
810+
}
811+
812+
_parentScrollTarget = target;
813+
_gestureOwner = GestureOwner.Parent;
814+
_owner.Parent?.RequestDisallowInterceptTouchEvent(false);
815+
CancelRecyclerViewGesture(e, dispatchToRecyclerView);
816+
817+
if (_downEvent is not null)
818+
{
819+
ForwardToParent(_downEvent);
820+
}
821+
822+
ForwardToParent(e);
823+
handled = true;
824+
return true;
825+
}
826+
827+
AView FindParentScrollTarget(MotionEvent e, bool recyclerViewScrollsHorizontally)
828+
{
829+
var scrollDirection = recyclerViewScrollsHorizontally
830+
? Math.Sign(_touchStartY - e.RawY)
831+
: Math.Sign(_touchStartX - e.RawX);
832+
833+
if (scrollDirection == 0)
834+
{
835+
return null;
836+
}
837+
838+
var parent = _owner.Parent;
839+
840+
while (parent is not null)
841+
{
842+
if (parent is AView view)
843+
{
844+
var canScroll = recyclerViewScrollsHorizontally
845+
? view.CanScrollVertically(scrollDirection)
846+
: view.CanScrollHorizontally(scrollDirection);
847+
848+
if (canScroll)
849+
{
850+
return view;
851+
}
852+
}
853+
854+
parent = parent.GetParent();
855+
}
856+
857+
return null;
858+
}
859+
860+
void CancelRecyclerViewGesture(MotionEvent e, Func<MotionEvent, bool> dispatchToRecyclerView)
861+
{
862+
var cancelEvent = MotionEvent.Obtain(e);
863+
cancelEvent.Action = MotionEventActions.Cancel;
864+
865+
try
866+
{
867+
dispatchToRecyclerView(cancelEvent);
868+
}
869+
finally
870+
{
871+
cancelEvent.Recycle();
872+
}
873+
}
874+
875+
void ForwardToParent(MotionEvent source)
876+
{
877+
if (_parentScrollTarget is null)
878+
{
879+
return;
880+
}
881+
882+
var targetEvent = MotionEvent.Obtain(source);
883+
_parentScrollTarget.GetLocationOnScreen(_targetLocation);
884+
targetEvent.SetLocation(source.RawX - _targetLocation[0], source.RawY - _targetLocation[1]);
885+
886+
try
887+
{
888+
_parentScrollTarget.OnTouchEvent(targetEvent);
889+
}
890+
finally
891+
{
892+
targetEvent.Recycle();
893+
}
894+
}
895+
896+
void Reset()
897+
{
898+
_owner.Parent?.RequestDisallowInterceptTouchEvent(false);
899+
_parentScrollTarget = null;
900+
_gestureOwner = GestureOwner.Undecided;
901+
902+
if (_downEvent is not null)
903+
{
904+
_downEvent.Recycle();
905+
_downEvent = null;
906+
}
907+
}
908+
909+
int ScaledTouchSlop => _scaledTouchSlop ??= ViewConfiguration.Get(_owner.Context).ScaledTouchSlop;
910+
911+
static bool IsTouchEnd(MotionEvent e) =>
912+
e.ActionMasked == MotionEventActions.Up || e.ActionMasked == MotionEventActions.Cancel;
913+
914+
enum GestureOwner
915+
{
916+
Undecided,
917+
RecyclerView,
918+
Parent
919+
}
920+
}
921+
665922
internal void UpdateEmptyViewVisibility()
666923
{
667924
if (ItemsViewAdapter == null)

src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void
99
~static Microsoft.Maui.Controls.Handlers.Compatibility.ShellRenderer.DefaultTitleColor.get -> Microsoft.Maui.Graphics.Color
1010
~static Microsoft.Maui.Controls.Handlers.Compatibility.ShellRenderer.DefaultUnselectedColor.get -> Microsoft.Maui.Graphics.Color
1111
override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void
12+
~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView<TItemsView, TAdapter, TItemsViewSource>.DispatchTouchEvent(Android.Views.MotionEvent e) -> bool
1213
~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView<TItemsView, TAdapter, TItemsViewSource>.OnInterceptTouchEvent(Android.Views.MotionEvent e) -> bool
1314
~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView<TItemsView, TAdapter, TItemsViewSource>.OnTouchEvent(Android.Views.MotionEvent e) -> bool
1415
~override Microsoft.Maui.Controls.Handlers.Items.RecyclerViewScrollListener<TItemsView, TItemsViewSource>.OnScrollStateChanged(AndroidX.RecyclerView.Widget.RecyclerView recyclerView, int newState) -> void

0 commit comments

Comments
 (0)