Skip to content
71 changes: 71 additions & 0 deletions src/Controls/src/Core/Handlers/Items/Android/ItemContentView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
using Android.Content;
using Android.Views;
using Microsoft.Maui.Graphics;
using AndroidX.Core.Widget;
using AndroidX.RecyclerView.Widget;
using AView = Android.Views.View;

namespace Microsoft.Maui.Controls.Handlers.Items
Expand Down Expand Up @@ -37,6 +39,75 @@ internal Func<Size?> RetrieveStaticSize
set => _retrieveStaticSize = new WeakReference(value);
}

public override bool DispatchTouchEvent(MotionEvent e)
{
if (IsHeaderOrFooterContent())
{

if (e.Action == MotionEventActions.Up || e.Action == MotionEventActions.Cancel)
{
Parent?.RequestDisallowInterceptTouchEvent(false);
}
}

return base.DispatchTouchEvent(e);
}

public override bool OnInterceptTouchEvent(MotionEvent ev)
{
if (IsHeaderOrFooterContent())
{
if (ev.Action == MotionEventActions.Down)
{
Parent?.RequestDisallowInterceptTouchEvent(true);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Android Platform Specifics - Disallowing parent interception for the entire gesture means a drag that starts in a header/footer scroller cannot hand off to the outer CollectionView when the nested scroller is already at, or reaches, its top/bottom edge. The user-visible case is a scrollable header at its boundary: the same swipe should continue moving the collection, but the parent remains disallowed until Up/Cancel. Please scope this to actual scrollable-child handling, or release/re-evaluate once the nested target cannot scroll in the gesture direction.

return false;
}
}

return base.OnInterceptTouchEvent(ev);
}

/// <summary>
/// Determines if this ItemContentView is being used for header or footer content
/// by checking if the contained View is the same as the ItemsView's Header or Footer
/// </summary>
bool IsHeaderOrFooterContent()
{
if (View == null)
{
return false;
}

// Find the parent ItemsView by traversing up the view hierarchy
var itemsView = FindParentItemsView();
if (itemsView is StructuredItemsView structuredItemsView)
{
// Check if our View is the same object reference as the header or footer
return ReferenceEquals(View, structuredItemsView.Header) ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] CollectionView Android - This only recognizes direct Header/Footer views. CreateHeaderFooterViewHolder also supports HeaderTemplate/FooterTemplate, where the realized View is created from the template and is not reference-equal to structuredItemsView.Header or .Footer, so templated scrollable headers/footers still allow the parent RecyclerView to steal the gesture. Consider tagging header/footer ItemContentViews when the adapter creates those holders instead of inferring it from the realized view reference.

ReferenceEquals(View, structuredItemsView.Footer);
}

return false;
}

/// <summary>
/// Finds the parent StructuredItemsView by traversing the logical parent chain
/// </summary>
StructuredItemsView FindParentItemsView()
{
var current = View?.Parent;
while (current != null)
{
if (current is StructuredItemsView itemsView)
{
return itemsView;
}

current = current.Parent;
}
return null;
}

internal void RealizeContent(View view, ItemsView itemsView)
{
Content = CreateHandler(view, itemsView);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void
~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView<TItemsView, TAdapter, TItemsViewSource>.OnInterceptTouchEvent(Android.Views.MotionEvent e) -> bool
~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView<TItemsView, TAdapter, TItemsViewSource>.OnTouchEvent(Android.Views.MotionEvent e) -> bool
~override Microsoft.Maui.Controls.Handlers.Items.ItemContentView.DispatchTouchEvent(Android.Views.MotionEvent e) -> bool
~override Microsoft.Maui.Controls.Handlers.Items.ItemContentView.OnInterceptTouchEvent(Android.Views.MotionEvent ev) -> bool
override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void
override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.OnHiddenChanged(bool hidden) -> void
~override Microsoft.Maui.Controls.Handlers.Items.RecyclerViewScrollListener<TItemsView, TItemsViewSource>.OnScrollStateChanged(AndroidX.RecyclerView.Widget.RecyclerView recyclerView, int newState) -> void
Expand Down
86 changes: 86 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue22120.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System.Collections.ObjectModel;
using Microsoft.Maui.Controls;

namespace Maui.Controls.Sample.Issues
{
[Issue(IssueTracker.Github, 22120, "CollectionView.Header is not scrollable in Android platform",
PlatformAffected.Android)]
public class Issue22120 : ContentPage
{
public Issue22120()
{
Title = "Issue 22120";

// Create header items for the ListView inside ScrollView
var headerItems = new List<string>();
for (int i = 1; i <= 15; i++)
{
headerItems.Add($"Header Item {i}");
}

// Create collection items
var collectionItems = new List<string>
{
"Pink", "Green", "Blue", "Yellow", "Orange", "Purple", "SkyBlue", "PaleGreen"
};

// Create the ListView for the header content with ItemTemplate
var headerListView = new ListView
{
AutomationId = "Issue22120HeaderListView",
HorizontalOptions = LayoutOptions.Center,
ItemsSource = headerItems,
HeightRequest = 400,
ItemTemplate = new DataTemplate(() =>
{
var label = new Label
{
Padding = new Thickness(10),
BackgroundColor = Colors.LightBlue
};
label.SetBinding(Label.TextProperty, ".");

return new ViewCell
{
View = label
};
})
};

// Create the ScrollView for the header containing the ListView
var headerScrollView = new ScrollView
{
AutomationId = "Issue22120HeaderScrollView",
HeightRequest = 200,
Content = headerListView
};

// Create the main CollectionView
var collectionView = new CollectionView
{
AutomationId = "Issue22120CollectionView",
Margin = new Thickness(20),
HorizontalOptions = LayoutOptions.Fill,
VerticalOptions = LayoutOptions.Fill,
ItemsLayout = new GridItemsLayout(3, ItemsLayoutOrientation.Vertical),
Header = headerScrollView,
ItemsSource = collectionItems,
ItemTemplate = new DataTemplate(() =>
{
var button = new Button
{
Margin = new Thickness(5),
Padding = new Thickness(0),
HeightRequest = 60,
HorizontalOptions = LayoutOptions.Fill,
VerticalOptions = LayoutOptions.Center
};
button.SetBinding(Button.TextProperty, ".");
return button;
})
};

Content = collectionView;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues;

public class Issue22120 : _IssuesUITest
{
public Issue22120(TestDevice device) : base(device) { }

public override string Issue => "CollectionView.Header is not scrollable in Android platform";

[Test]
[Category(UITestCategories.CollectionView)]
public void CollectionViewHeaderScrollViewIsScrollable()
{
App.WaitForElement("Issue22120HeaderScrollView");
App.WaitForElement("Header Item 1");
App.ScrollDown("Issue22120HeaderScrollView", ScrollStrategy.Gesture, swipePercentage: 0.9, swipeSpeed: 1000);
App.WaitForElement("Header Item 7", timeout: TimeSpan.FromSeconds(5));
App.ScrollDown("Issue22120HeaderScrollView", ScrollStrategy.Gesture, swipePercentage: 0.9, swipeSpeed: 1000);
App.WaitForElement("Header Item 12", timeout: TimeSpan.FromSeconds(5));
App.ScrollUp("Issue22120HeaderScrollView", ScrollStrategy.Gesture, swipePercentage: 0.9, swipeSpeed: 1000);
App.ScrollUp("Issue22120HeaderScrollView", ScrollStrategy.Gesture, swipePercentage: 0.9, swipeSpeed: 1000);
App.WaitForElement("Header Item 1", timeout: TimeSpan.FromSeconds(5));
}
}
Loading