diff --git a/.github/instructions/collectionview-handler-detection.instructions.md b/.github/instructions/collectionview-handler-detection.instructions.md index 24556e39b869..d7f01a61629a 100644 --- a/.github/instructions/collectionview-handler-detection.instructions.md +++ b/.github/instructions/collectionview-handler-detection.instructions.md @@ -10,20 +10,35 @@ applyTo: "src/Controls/src/Core/Handlers/Items/**,src/Controls/src/Core/Handlers There are **TWO separate handler implementations**, but they apply to **different platforms**: 1. **Items/** (`Handlers/Items/`) - Contains code for **ALL platforms** (Android, iOS, Windows, MacCatalyst, Tizen) -2. **Items2/** (`Handlers/Items2/`) - Contains code for **iOS/MacCatalyst ONLY** +2. **Items2/** (`Handlers/Items2/`) - Primarily the **iOS/MacCatalyst** path, **plus** the **Android CarouselView Material3** handler (see exception below) ### Platform-Specific Deprecation The deprecation of Items/ **only applies to iOS/MacCatalyst**: -| Platform | Active Handler | Notes | -|----------|----------------|-------| -| **Android** | `Items/Android/` | **ONLY implementation** - Items2/ has no Android code | -| **Windows** | `Items/` | **ONLY implementation** - Items2/ has no Windows code | -| **iOS** | `Items2/iOS/` | Items/ iOS code is deprecated | -| **MacCatalyst** | `Items2/iOS/` | Items/ MacCatalyst code is deprecated | +| Platform | Control | Active Handler | Notes | +| --------------- | ----------------------------- | ---------------------------------------- | --------------------------------------------------------------------------- | +| **Android** | CollectionView | `Items/Android/` | **ONLY implementation** | +| **Android** | CarouselView (Material3) | `Items2/` (`CarouselViewHandler2`) | Material3-only; registered when `RuntimeFeature.IsMaterial3Enabled` is true | +| **Android** | CarouselView (non-Material3) | `Items/Android/` (`CarouselViewHandler`) | Default when Material3 is disabled | +| **Windows** | CollectionView / CarouselView | `Items/` | **ONLY implementation** - Items2/ has no Windows code | +| **iOS** | CollectionView / CarouselView | `Items2/iOS/` | Items/ iOS code is deprecated | +| **MacCatalyst** | CollectionView / CarouselView | `Items2/iOS/` | Items/ MacCatalyst code is deprecated | -**CRITICAL**: Items2/ is **iOS/MacCatalyst only**. There is NO Items2/ code for Android or Windows. +**CRITICAL**: Items2/ is **iOS/MacCatalyst only**, with **one Android exception**: the Android **CarouselView Material3** handler (`CarouselViewHandler2` + `CarouselViewHandler2.Android.cs`, `MauiCarouselRecyclerView2`, `CarouselViewAdapter2`). There is **no** Items2/ code for Android CollectionView, and **no** Items2/ code for Windows. + +### Android CarouselView Material3 Exception + +When `RuntimeFeature.IsMaterial3Enabled` is `true`, Android `CarouselView` is registered to +`Items2.CarouselViewHandler2` (see `AppHostBuilderExtensions.AddControlsHandlers`). This handler +lives in `Items2/` but has **Android-specific** code (`*.Android.cs` partials) that uses Material's +`CarouselLayoutManager`. Its Android types subclass the shared `Items.*` base classes +(`CarouselViewAdapter2 : Items.CarouselViewAdapter`, `MauiCarouselRecyclerView2 : Items.MauiCarouselRecyclerView`), +so changes to the shared Android carousel base classes in `Items/Android/` can affect both handlers. + +- **Android CarouselView Material3 work** → `Items2/` (`CarouselViewHandler2.Android.cs` and friends) +- **Android CarouselView non-Material3 work** → `Items/Android/` (`CarouselViewHandler`) +- **Android CollectionView work** → `Items/Android/` (unchanged — no Items2 Android CollectionView) --- @@ -32,8 +47,12 @@ The deprecation of Items/ **only applies to iOS/MacCatalyst**: ### Decision Tree by Platform ``` +Is the issue/PR for Android CarouselView with Material3 enabled? + YES → Work on Items2/ (CarouselViewHandler2 Android partials) + NO → Continue... + Is the issue/PR for Android or Windows? - YES → Work on Items/ (it's the ONLY implementation) + YES → Work on Items/ (it's the ONLY implementation for these) NO → Continue... Is the issue/PR for iOS or MacCatalyst? @@ -50,7 +69,8 @@ Check which handler directory the files are in: git diff .. --name-only | grep -i "handlers/items" # Look for path pattern: -# - Contains "/Items/Android/" → Android (ONLY implementation, work here) +# - Contains "/Items/Android/" → Android CollectionView, or non-Material3 CarouselView (work here) +# - Contains "/Items2/*.Android.cs" → Android CarouselView Material3 (CarouselViewHandler2) # - Contains "/Items/Windows/" or ".Windows.cs" → Windows (ONLY implementation, work here) # - Contains "/Items2/iOS/" or "Items2/*.iOS.cs" → iOS/MacCatalyst (CURRENT) # - Contains "/Items/*.iOS.cs" (not Items2) → iOS (DEPRECATED, prefer Items2/) @@ -58,15 +78,18 @@ git diff .. --name-only | grep -i "handlers/items" ### Default Behavior by Platform -| Platform | Default Action | -|----------|----------------| -| **Android** | ✅ Work on `Items/Android/` - it's the only option | -| **Windows** | ✅ Work on `Items/` Windows files - it's the only option | -| **iOS/MacCatalyst** | ✅ Work on `Items2/` - Items/ is deprecated for iOS | +| Platform | Default Action | +| ----------------------------------------- | -------------------------------------------------------------- | +| **Android** (CollectionView) | ✅ Work on `Items/Android/` - it's the only option | +| **Android** (CarouselView, Material3) | ✅ Work on `Items2/` - `CarouselViewHandler2` Android partials | +| **Android** (CarouselView, non-Material3) | ✅ Work on `Items/Android/` - `CarouselViewHandler` | +| **Windows** | ✅ Work on `Items/` Windows files - it's the only option | +| **iOS/MacCatalyst** | ✅ Work on `Items2/` - Items/ is deprecated for iOS | ### When to Work on Items/ for iOS (Deprecated) Only work on Items/ iOS code when: + - PR explicitly modifies Items/ iOS files - User explicitly requests changes to deprecated handlers - Maintaining backward compatibility for a specific fix @@ -75,21 +98,30 @@ Only work on Items/ iOS code when: ## Quick Reference -| Path Pattern | Platform | Status | -|--------------|----------|--------| -| `Handlers/Items/Android/` | Android | **ACTIVE** (only implementation) | -| `Handlers/Items/*.Windows.cs` | Windows | **ACTIVE** (only implementation) | -| `Handlers/Items2/iOS/` | iOS/MacCatalyst | **ACTIVE** (current) | -| `Handlers/Items/*.iOS.cs` | iOS/MacCatalyst | **DEPRECATED** (use Items2/) | +| Path Pattern | Platform | Status | +| --------------------------------------------- | ----------------------------------------------------- | ----------------------------------- | +| `Handlers/Items/Android/` | Android (CollectionView + non-Material3 CarouselView) | **ACTIVE** | +| `Handlers/Items2/*.Android.cs` (CarouselView) | Android (CarouselView, Material3) | **ACTIVE** (`CarouselViewHandler2`) | +| `Handlers/Items/*.Windows.cs` | Windows | **ACTIVE** (only implementation) | +| `Handlers/Items2/iOS/` | iOS/MacCatalyst | **ACTIVE** (current) | +| `Handlers/Items/*.iOS.cs` | iOS/MacCatalyst | **DEPRECATED** (use Items2/) | --- ## Common Mistakes to Avoid -❌ **Wrong**: "Items/ is deprecated, so I should check if Items2/ needs the same Android fix" -- Items2/ has NO Android code - there's nothing to check +❌ **Wrong**: "Items2/ has NO Android code at all" + +- The Android **CarouselView Material3** handler (`CarouselViewHandler2`) lives in `Items2/` with `*.Android.cs` partials. Android **CollectionView** still has no Items2/ code. + +❌ **Wrong**: "This Android CollectionView fix should also go in Items2/" + +- Android CollectionView only exists in `Items/Android/`. Only Android **CarouselView Material3** is in Items2/. + +❌ **Wrong**: "This Android CarouselView fix goes in Items/Android/" (without checking Material3) + +- If the issue is Material3-specific, the active Android CarouselView handler is `Items2.CarouselViewHandler2`. Only the non-Material3 path lives in `Items/Android/`. -❌ **Wrong**: "This Android fix should also go in Items2/" -- Items2/ is iOS-only, Android code only exists in Items/ +✅ **Correct**: "This is an Android CollectionView issue, so I work in `Items/Android/` — the only Android CollectionView implementation" -✅ **Correct**: "This is an Android-only issue, so I work in Items/Android/ which is the only Android implementation" +✅ **Correct**: "This is an Android CarouselView Material3 issue, so I work in `Items2/` (`CarouselViewHandler2.Android.cs`)" diff --git a/src/Controls/src/Core/Handlers/Items/Android/MauiCarouselRecyclerView.cs b/src/Controls/src/Core/Handlers/Items/Android/MauiCarouselRecyclerView.cs index d0dca45c42b2..86a3811d8105 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/MauiCarouselRecyclerView.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/MauiCarouselRecyclerView.cs @@ -46,12 +46,17 @@ public override bool OnInterceptTouchEvent(MotionEvent ev) protected virtual bool IsHorizontal => (Carousel?.ItemsLayout)?.Orientation == ItemsLayoutOrientation.Horizontal; + // Subclasses backed by a layout manager that has no virtual-range concept + // (e.g. Material's CarouselLayoutManager) can override this to force the + // non-loop code paths regardless of the public Carousel.Loop value. + protected virtual bool IsLoopEnabled => Carousel?.Loop == true; + protected override int DetermineTargetPosition(ScrollToRequestEventArgs args) { if (args.Mode == ScrollToMode.Element) return ItemsViewAdapter.GetPositionForItem(args.Item); - if (!Carousel.Loop) + if (!IsLoopEnabled) return args.Index; if (_carouselViewLoopManager == null) @@ -65,7 +70,7 @@ protected override int DetermineTargetPosition(ScrollToRequestEventArgs args) public override bool OnTouchEvent(MotionEvent e) { - if (Carousel.Loop) + if (IsLoopEnabled) _carouselViewLoopManager.CenterIfNeeded(this, IsHorizontal); return base.OnTouchEvent(e); @@ -195,7 +200,7 @@ public override void ScrollTo(ScrollToRequestEventArgs args) if (position == -1) { _gotoPosition = -1; - if (Carousel.Loop) + if (IsLoopEnabled) _carouselViewLoopManager.AddPendingScrollTo(args); return; @@ -260,7 +265,7 @@ void CollectionItemsSourceChanged(object sender, System.Collections.Specialized. else if (removingLastElement) carouselPosition = Carousel.Position - 1; - if (Carousel.Loop) + if (IsLoopEnabled) { UpdateAdapter(); ScrollToPosition(carouselPosition); @@ -420,7 +425,7 @@ void UpdateInitialPosition() else { position = Carousel.Position; - if (Carousel.Loop && position == 0) + if (IsLoopEnabled && position == 0) { itemCount = ItemsViewAdapter.ItemsSource.Count; } @@ -430,7 +435,7 @@ void UpdateInitialPosition() SetCurrentItem(_oldPosition); - var index = Carousel.Loop ? LoopedPosition(itemCount) + _oldPosition : _oldPosition; + var index = IsLoopEnabled ? LoopedPosition(itemCount) + _oldPosition : _oldPosition; ScrollHelper.JumpScrollToPosition(index, Microsoft.Maui.Controls.ScrollToPosition.Center); _gotoPosition = -1; } @@ -543,7 +548,7 @@ void CarouselViewScrolled(object sender, ItemsViewScrolledEventArgs e) _noNeedForScroll = false; var index = e.CenterItemIndex; - if (Carousel?.Loop == true) + if (IsLoopEnabled) { index = GetCarouselViewCurrentIndex(index); } @@ -607,7 +612,12 @@ void IMauiCarouselRecyclerView.UpdateFromCurrentItem() void IMauiCarouselRecyclerView.UpdateFromPosition() { - if (!_initialized) + // Only queue the initial-position pending scroll when looping is enabled: the queue + // is exclusively drained from loop-gated paths (LayoutReady and the scroll listener), + // so enqueuing it in the non-loop case (e.g. MauiCarouselRecyclerView2, or Loop=false) + // would leave a stale entry that is never flushed. The non-loop initial position is + // applied via the ScrollToItemPosition path below (and UpdateInitialPosition). + if (!_initialized && IsLoopEnabled) { _carouselViewLoopManager.AddPendingScrollTo(new ScrollToRequestEventArgs(Carousel.Position, -1, Microsoft.Maui.Controls.ScrollToPosition.Center, false)); } @@ -674,7 +684,7 @@ void LayoutReady() if (!_initialized) { ItemsView.Scrolled += CarouselViewScrolled; - if (Carousel.Loop) + if (IsLoopEnabled) { _carouselViewLoopManager.CenterIfNeeded(this, IsHorizontal); _carouselViewLoopManager.CheckPendingScrollToEvents(this); diff --git a/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs b/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs index 2f75ab79d05e..0cabef053ac0 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs @@ -387,7 +387,6 @@ public virtual void UpdateLayoutManager() protected virtual RecyclerViewScrollListener CreateScrollListener() => new(ItemsView, ItemsViewAdapter); - protected virtual void UpdateSnapBehavior() { _snapManager = GetSnapManager(); diff --git a/src/Controls/src/Core/Handlers/Items2/Android/Adapters/CarouselViewAdapter2.cs b/src/Controls/src/Core/Handlers/Items2/Android/Adapters/CarouselViewAdapter2.cs new file mode 100644 index 000000000000..279e9f054df8 --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/Android/Adapters/CarouselViewAdapter2.cs @@ -0,0 +1,181 @@ +#nullable disable +using System; +using Android.Content; +using Android.Views; +using Android.Widget; +using AndroidX.RecyclerView.Widget; +using Google.Android.Material.Carousel; +using Google.Android.Material.Shape; + +namespace Microsoft.Maui.Controls.Handlers.Items2; + +/// +/// An adapter for that wraps each item view in a +/// , satisfying the Material +/// requirement that every direct RecyclerView child must be a . +/// +public class CarouselViewAdapter2 + : Items.CarouselViewAdapter +{ + readonly Func _createItemContentView; + readonly Func _isHorizontal; + + public CarouselViewAdapter2( + CarouselView carouselView, + Func createItemContentView, + Func isHorizontal) + : base(carouselView) + { + _createItemContentView = createItemContentView; + _isHorizontal = isHorizontal; + } + + /// + /// Override the base + /// to ignore . + /// + /// The base adapter returns CarouselViewLoopManager.LoopScale (≈16384) when + /// Loop=true, which works with LinearLayoutManager + MAUI's + /// SnapManager. Material's was not designed + /// for that scale: every measure pass that triggers MeasureInvalidated → + /// RequestLayout re-enters layout from a different anchor in the 16384-item + /// virtual range, never converges, and inflates view holders without ever recycling. + /// That produces the "stuck on splash" / endless GC symptom on Android. + /// + /// CarouselLayoutManager has no native looping support, so for Handler2 we + /// expose the real item count. Callers should keep + /// set to false. + /// + public override int ItemCount => ItemsSource?.Count ?? 0; + public override RecyclerView.ViewHolder OnCreateViewHolder(ViewGroup parent, int viewType) + { + var context = parent.Context; + bool horizontal = _isHorizontal?.Invoke() ?? true; + + if (viewType == Items.ItemViewType.TextItem) + { + // CarouselLayoutManager rejects any direct RecyclerView child that is not a + // MaskableFrameLayout — it throws IllegalStateException from + // measureChildWithMargins on the very first measure pass. The base adapter's + // text path returns a plain TextView, so wrap it in a MaskableFrameLayout here + // too; otherwise a CarouselView bound to raw string items (no ItemTemplate) + // would crash. + // + // Unlike the templated path, the text path has no SizedItemContentView to push + // the viewport pixel size up through measurement. A WRAP_CONTENT carousel axis + // would therefore collapse to the TextView's intrinsic (text) size, and + // CarouselLayoutManager would build its KeylineState from that text-sized first + // child — producing incorrect keylines/masking. Force MATCH_PARENT on both axes + // so the keyline state is based on the viewport size, consistent with + // FullScreenCarouselStrategy. + var maskableText = CreateMaskableFrameLayout(context, horizontal, fillViewport: true); + var textView = new TextView(context) + { + LayoutParameters = new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MatchParent, + ViewGroup.LayoutParams.MatchParent), + }; + maskableText.AddView(textView); + return new MaskableTextItemViewHolder(maskableText, textView); + } + + var itemContentView = _createItemContentView(context); + itemContentView.LayoutParameters = new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MatchParent, + ViewGroup.LayoutParams.MatchParent); + + var maskable = CreateMaskableFrameLayout(context, horizontal); + maskable.AddView(itemContentView); + + // MaskableCarouselItemViewHolder.Bind resolves the correct DataTemplate per item + // via SelectDataTemplate, which handles both plain templates and DataTemplateSelector, + // so we pass the root ItemTemplate directly. + return new MaskableCarouselItemViewHolder(maskable, itemContentView, ItemsView.ItemTemplate); + } + + /// + /// Builds the root required by Material's + /// and applies the Material 3 "Corner Extra Large" + /// shape appearance so carousel items get the expected rounded-corner mask. + /// + /// + /// WRAP_CONTENT is used on the carousel axis so SizedItemContentView can push the desired + /// pixel size up through measurement: the strategy reads the measured width (horizontal) or + /// height (vertical) of the first child to build its KeylineState. A fixed pixel value + /// sampled before the RecyclerView is laid out would be 0 — Math.Max(1, 0) then yields + /// 1px items, which combined with looping (LoopScale ≈ 16384 items) causes an infinite + /// measure / GC loop and a stuck UI. + /// + /// The carousel/cross axis is baked in at holder-creation time, so a runtime orientation + /// change only swaps the layout manager and recycled holders keep their original axis sizing. + /// This is fine because the handler is locked to FullScreenCarouselStrategy: items fill the + /// viewport on both axes, so WRAP_CONTENT and MATCH_PARENT measure identically. Revisit holder + /// recreation if a non-full-screen strategy is ever wired up via CreateCarouselStrategy. + /// + /// forces MATCH_PARENT on both axes. The non-templated + /// (text) path uses this because it has no SizedItemContentView to push the viewport size + /// up through measurement, so a WRAP_CONTENT carousel axis would otherwise collapse to the + /// TextView's intrinsic size and corrupt the KeylineState. + /// + static MaskableFrameLayout CreateMaskableFrameLayout(Context context, bool horizontal, bool fillViewport = false) + { + var maskable = new MaskableFrameLayout(context) + { + LayoutParameters = new RecyclerView.LayoutParams( + fillViewport || !horizontal ? ViewGroup.LayoutParams.MatchParent : ViewGroup.LayoutParams.WrapContent, + fillViewport || horizontal ? ViewGroup.LayoutParams.MatchParent : ViewGroup.LayoutParams.WrapContent), + }; + + using (var value = new global::Android.Util.TypedValue()) + { + if (context.Theme.ResolveAttribute(Resource.Attribute.shapeAppearanceCornerExtraLarge, value, true) + && value.ResourceId != 0) + { + maskable.ShapeAppearanceModel = ShapeAppearanceModel + .InvokeBuilder(context, value.ResourceId, 0) + .Build(); + } + } + + return maskable; + } + + public override void OnBindViewHolder(RecyclerView.ViewHolder holder, int position) + { + if (holder is MaskableCarouselItemViewHolder maskableHolder) + { + if (CarouselView is null || ItemsSource is null || position < 0 || position >= ItemsSource.Count) + { + return; + } + + var item = ItemsSource.GetItem(position); + maskableHolder.Bind(item, CarouselView); + return; + } + + if (holder is MaskableTextItemViewHolder textHolder) + { + if (ItemsSource is null || position < 0 || position >= ItemsSource.Count) + { + return; + } + + textHolder.TextView.Text = ItemsSource.GetItem(position)?.ToString(); + return; + } + + base.OnBindViewHolder(holder, position); + } + + public override void OnViewRecycled(Java.Lang.Object holder) + { + if (holder is MaskableCarouselItemViewHolder maskableHolder) + { + maskableHolder.Recycle(CarouselView); + return; + } + + base.OnViewRecycled(holder); + } +} diff --git a/src/Controls/src/Core/Handlers/Items2/Android/CarouselViewOnScrollListener2.cs b/src/Controls/src/Core/Handlers/Items2/Android/CarouselViewOnScrollListener2.cs new file mode 100644 index 000000000000..1a813e1f9b7f --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/Android/CarouselViewOnScrollListener2.cs @@ -0,0 +1,119 @@ +#nullable disable +using System; +using AndroidX.RecyclerView.Widget; +using Google.Android.Material.Carousel; + +namespace Microsoft.Maui.Controls.Handlers.Items2; + +/// +/// Scroll listener for . +/// Inherits directly from +/// (not from ) so it has no dependency on +/// and can use +/// for item positions. +/// +internal class CarouselViewOnScrollListener2 : Items.RecyclerViewScrollListener +{ + readonly CarouselView _carouselView; + readonly Func _snapHelperProvider; + + public CarouselViewOnScrollListener2( + CarouselView carouselView, + Items.ItemsViewAdapter itemsViewAdapter, + Func snapHelperProvider) + : base(carouselView, itemsViewAdapter, true) + { + _carouselView = carouselView; + _snapHelperProvider = snapHelperProvider; + } + + public override void OnScrollStateChanged(RecyclerView recyclerView, int state) + { + base.OnScrollStateChanged(recyclerView, state); + + if (_carouselView.IsSwipeEnabled) + { + _carouselView.SetIsDragging(state == RecyclerView.ScrollStateDragging); + } + + _carouselView.IsScrolling = state != RecyclerView.ScrollStateIdle; + + } + + protected override (int First, int Center, int Last) GetVisibleItemsIndex(RecyclerView recyclerView) + { + if (recyclerView.GetLayoutManager() is not CarouselLayoutManager carouselLayoutManager) + { + return (-1, -1, -1); + } + + // CarouselLayoutManager doesn't extend LinearLayoutManager, so we walk children. + var (first, last) = GetFirstAndLastVisiblePositions(carouselLayoutManager); + + if (first == RecyclerView.NoPosition) + { + return (-1, -1, -1); + } + + // Prefer the CarouselSnapHelper's snap target as the "current" item; that's the + // position the user lands on after a fling. + int centerPosition = -1; + var snapHelper = _snapHelperProvider?.Invoke(); + if (snapHelper is not null) + { + var snapView = snapHelper.FindSnapView(carouselLayoutManager); + if (snapView is not null) + { + centerPosition = recyclerView.GetChildAdapterPosition(snapView); + } + } + + if (centerPosition == RecyclerView.NoPosition || centerPosition < 0) + { + centerPosition = (first + last) / 2; + } + + return (first, centerPosition, last); + } + + static (int First, int Last) GetFirstAndLastVisiblePositions(CarouselLayoutManager layoutManager) + { + int first = int.MaxValue; + int last = int.MinValue; + + for (int i = 0; i < layoutManager.ChildCount; i++) + { + var child = layoutManager.GetChildAt(i); + if (child is null) + { + continue; + } + + // GetPosition returns NoPosition (-1) for transient/removed views during layout + // animations. Skip those; otherwise a single such child would drag `first` down to + // -1 and make the whole method report no visible items even when valid children exist. + int pos = layoutManager.GetPosition(child); + if (pos == RecyclerView.NoPosition) + { + continue; + } + + if (pos < first) + { + first = pos; + } + + if (pos > last) + { + last = pos; + } + } + + if (first == int.MaxValue) + { + return (RecyclerView.NoPosition, RecyclerView.NoPosition); + } + + return (first, last); + } +} diff --git a/src/Controls/src/Core/Handlers/Items2/Android/MaskableCarouselItemViewHolder.cs b/src/Controls/src/Core/Handlers/Items2/Android/MaskableCarouselItemViewHolder.cs new file mode 100644 index 000000000000..c55ffc7836b3 --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/Android/MaskableCarouselItemViewHolder.cs @@ -0,0 +1,101 @@ +#nullable disable +using System; +using Microsoft.Maui.Controls.Internals; +using Google.Android.Material.Carousel; + +namespace Microsoft.Maui.Controls.Handlers.Items2; + +/// +/// A whose root +/// is a , satisfying the Material +/// requirement that every direct RecyclerView child must be a . +/// +/// MAUI content is rendered inside an inner that is added +/// as the sole child of the . +/// +internal sealed class MaskableCarouselItemViewHolder : Items.SelectableViewHolder +{ + readonly Items.ItemContentView _itemContentView; + readonly DataTemplate _template; + DataTemplate _selectedTemplate; + bool _logicalChildAdded; + + public Controls.View View { get; private set; } + + public MaskableCarouselItemViewHolder( + MaskableFrameLayout maskableRoot, + Items.ItemContentView itemContentView, + DataTemplate template) + : base(maskableRoot, isSelectionEnabled: false) + { + _itemContentView = itemContentView; + _template = template; + } + + public void Bind(object itemBindingContext, ItemsView itemsView) + { + // _template can be null in selector scenarios where GetItemViewType hasn't yet + // populated the adapter's view-type cache. Fall back to the current ItemTemplate + // and resolve via SelectDataTemplate (which handles both selectors and plain templates). + var rootTemplate = _template ?? itemsView?.ItemTemplate; + if (rootTemplate is null) + { + return; + } + + var template = rootTemplate.SelectDataTemplate(itemBindingContext, itemsView); + bool templateChanging = template != _selectedTemplate; + + if (templateChanging) + { + // Tear down the previous content (if any) before realizing the new template. + if (View is not null) + { + itemsView.RemoveLogicalChild(View); + _logicalChildAdded = false; + } + _itemContentView.Recycle(); + + var content = template.CreateContent(); + View = content as Controls.View + ?? throw new InvalidOperationException($"{template} could not be created from {content}"); + + View.BindingContext = itemBindingContext; + PropertyPropagationExtensions.PropagatePropertyChanged(null, View, itemsView); + _itemContentView.RealizeContent(View, itemsView); + _selectedTemplate = template; + } + else if (View is not null) + { + // Same template, new data — refresh binding context and re-propagate parent values + // so visual states / inherited bindings update on rebind. + View.BindingContext = itemBindingContext; + PropertyPropagationExtensions.PropagatePropertyChanged(null, View, itemsView); + } + + if (View is not null && !_logicalChildAdded) + { + itemsView.AddLogicalChild(View); + _logicalChildAdded = true; + } + } + + public void Recycle(ItemsView itemsView) + { + if (View is null) + { + return; + } + + if (_logicalChildAdded && itemsView is not null) + { + itemsView.RemoveLogicalChild(View); + } + + _itemContentView.Recycle(); + + View = null; + _selectedTemplate = null; + _logicalChildAdded = false; + } +} diff --git a/src/Controls/src/Core/Handlers/Items2/Android/MaskableTextItemViewHolder.cs b/src/Controls/src/Core/Handlers/Items2/Android/MaskableTextItemViewHolder.cs new file mode 100644 index 000000000000..5991feb6edcf --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/Android/MaskableTextItemViewHolder.cs @@ -0,0 +1,25 @@ +#nullable disable +using Android.Widget; +using Google.Android.Material.Carousel; + +namespace Microsoft.Maui.Controls.Handlers.Items2; + +/// +/// A for non-templated (plain text) CarouselView items +/// whose root is a +/// , satisfying the Material +/// requirement that every direct RecyclerView child must be a . +/// +/// The item text is rendered by an inner Android.Widget.TextView added as the sole +/// child of the . +/// +internal sealed class MaskableTextItemViewHolder : Items.SelectableViewHolder +{ + public TextView TextView { get; } + + public MaskableTextItemViewHolder(MaskableFrameLayout maskableRoot, TextView textView) + : base(maskableRoot, isSelectionEnabled: false) + { + TextView = textView; + } +} diff --git a/src/Controls/src/Core/Handlers/Items2/Android/MauiCarouselRecyclerView2.cs b/src/Controls/src/Core/Handlers/Items2/Android/MauiCarouselRecyclerView2.cs new file mode 100644 index 000000000000..086b61d70fb7 --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/Android/MauiCarouselRecyclerView2.cs @@ -0,0 +1,239 @@ +#nullable disable +using System; +using Android.Content; +using Android.Views; +using AndroidX.RecyclerView.Widget; +using Google.Android.Material.Carousel; + +namespace Microsoft.Maui.Controls.Handlers.Items2; + +/// +/// A variant that uses the Material Design +/// (with ) instead of +/// . +/// +/// Most MAUI CarouselView API surface (Position, CurrentItem, IsSwipeEnabled, IsBounceEnabled, +/// ItemsLayout) is preserved by inheriting the existing scroll machinery from +/// . +/// +/// +/// Per-item visual states are not supported on this handler. The inherited visual-state +/// machinery only runs for a with ItemContentView children, so it +/// does not drive the CurrentItem/PreviousItem/NextItem/DefaultItem states (or +/// ) under Material's . A +/// Material-specific override was intentionally omitted: the documented visual-state scenarios +/// (PreviousItem/NextItem/DefaultItem opacity) are only observable when adjacent items are partially +/// shown via , but peek is not supported under +/// — every item is masked to the full viewport and snaps one +/// per page, so only the current item is ever on screen. Revisit if a non-full-screen strategy with +/// peek support is added. +/// +/// +/// +/// Looping is not supported on this handler. Material's +/// has no concept of a virtual range, so the LoopScale (≈16384) trick used by MAUI's +/// LinearLayoutManager-based implementation does not work. Callers must keep +/// set to false; +/// is locked to ItemsSource.Count to guard the adapter side. +/// +/// +public class MauiCarouselRecyclerView2 : + Items.MauiCarouselRecyclerView +{ + CarouselSnapHelper _carouselSnapHelper; + bool _disposed; + + public MauiCarouselRecyclerView2( + Context context, + Func getItemsLayout, + Func> getAdapter) + : base(context, getItemsLayout, getAdapter) + { + } + + // Material's CarouselLayoutManager has no virtual-range concept, so the + // LoopScale (16384) trick used by the legacy LinearLayoutManager path does + // not apply here. Force every Loop-aware code path in the base class to + // take the non-loop branch regardless of CarouselView.Loop. + // TODO: Remove this override once a true looping mechanism is implemented + // for the Material3 handler (e.g. edge-jump strategy, duplicate-buffer + // adapter, or a CarouselLayoutManager fork with virtual-range support). + protected override bool IsLoopEnabled => false; + + protected override LayoutManager SelectLayoutManager(IItemsLayout layoutSpecification) + { + var orientation = RecyclerView.Horizontal; + + if (layoutSpecification is LinearItemsLayout linearItemsLayout) + { + orientation = linearItemsLayout.Orientation == ItemsLayoutOrientation.Vertical + ? RecyclerView.Vertical + : RecyclerView.Horizontal; + } + + // While the EmptyView is showing, the RecyclerView holds a single non-carousel + // item. Material's CarouselLayoutManager applies keyline masking sized for + // full-viewport carousel items; applied to a normally-sized EmptyView the mask + // collapses on a later layout pass, so the EmptyView appears for one frame and + // then disappears. Use a plain LinearLayoutManager for the empty state — this + // mirrors the LinearLayoutManager-based Handler1 path where the EmptyView renders + // correctly. The empty-view branch in UpdateEmptyViewVisibility swaps in the + // EmptyViewAdapter before calling SelectLayoutManager, so GetAdapter() reflects + // the empty state here. + if (GetAdapter() is Items.EmptyViewAdapter) + { + DetachSnapHelper(); + return new LinearLayoutManager(Context, orientation, false); + } + + return new CarouselLayoutManager(CreateCarouselStrategy(), orientation); + } + + /// + /// Creates the to use. + /// + /// Currently locked to : the other Material + /// strategies (MultiBrowse, Hero, Uncontained) require items to be smaller than the + /// viewport, which conflicts with how Handler2 sizes items (full RecyclerView width/ + /// height via ). If a future change wires up + /// strategy-aware sizing, this can become user-selectable via an attached property. + /// + protected virtual CarouselStrategy CreateCarouselStrategy() => new FullScreenCarouselStrategy(); + protected override void UpdateSnapBehavior() + { + // Detach any previous snap helper to avoid duplicate fling listeners. + DetachSnapHelper(); + + // CarouselSnapHelper requires a CarouselLayoutManager. While the EmptyView is + // showing we use a LinearLayoutManager, so don't attach the snap helper. + if (GetLayoutManager() is not CarouselLayoutManager) + { + return; + } + + // Honor SnapPointsType.None: when the user disables snapping, leave the carousel + // free-scrolling instead of forcing Material's page snap. This mirrors the legacy + // SnapManager behavior (see Items/Android/SnapHelpers/SnapManager.cs). + if (ItemsLayout is Microsoft.Maui.Controls.ItemsLayout { SnapPointsType: SnapPointsType.None }) + { + return; + } + + // CarouselLayoutManager ships its own snap helper; attach it directly. + // Deliberately do NOT call base.UpdateSnapBehavior() so MAUI's SnapManager + // does not attach a conflicting snap helper. + _carouselSnapHelper = new CarouselSnapHelper(); + _carouselSnapHelper.AttachToRecyclerView(this); + } + + void DetachSnapHelper() + { + _carouselSnapHelper?.AttachToRecyclerView(null); + _carouselSnapHelper = null; + } + + public override void UpdateFlowDirection() + { + // CarouselLayoutManager has no anchor/saved-state mechanism, so it always + // re-lays out from item 0 when LayoutDirection changes (unlike LinearLayoutManager). + // Save the position first, then set a pending scroll on the layout manager + // synchronously — before the layout pass runs — so onLayoutChildren() starts at + // the correct item instead of 0, with no intermediate frame visible. + var positionToRestore = Carousel?.Position ?? -1; + + base.UpdateFlowDirection(); + + if (positionToRestore > 0) + { + var itemCount = ItemsViewAdapter?.ItemsSource?.Count ?? 0; + if (positionToRestore < itemCount) + { + // Qualified call to avoid resolving to the Microsoft.Maui.Controls.ScrollToPosition enum. + ScrollHelper.JumpScrollToPosition(positionToRestore, Microsoft.Maui.Controls.ScrollToPosition.Center); + } + } + } + + public override void UpdateLayoutManager() + { + // Capture the ItemsLayout reference before the base call so we can tell whether + // base.UpdateLayoutManager() actually rebuilt the layout manager. It early-returns + // (and leaves ItemsLayout unchanged) when the ItemsLayout object is the same, which + // is the case on every EmptyView <-> items transition; otherwise it assigns the new + // ItemsLayout and calls SetLayoutManager(). + var previousItemsLayout = ItemsLayout; + base.UpdateLayoutManager(); + var baseRebuiltLayout = !ReferenceEquals(previousItemsLayout, ItemsLayout); + + // The base early-return leaves the layout manager from the previous state attached. + // Ensure items always use the Material CarouselLayoutManager and the EmptyView uses + // a plain LinearLayoutManager. + var needsCarousel = GetAdapter() is not Items.EmptyViewAdapter; + var hasCarousel = GetLayoutManager() is CarouselLayoutManager; + var switchedLayout = needsCarousel != hasCarousel; + if (switchedLayout) + { + SetLayoutManager(SelectLayoutManager(ItemsLayout)); + } + + // Only cycle the CarouselSnapHelper when the layout manager was actually (re)built — + // either by the base call or by the EmptyView <-> items switch above. Cycling it on a + // genuine no-op would needlessly detach/re-attach the helper; if that ever coincided + // with an in-progress fling, the recycler would briefly have no snap helper and the + // fling could overshoot before the replacement attaches. + if (baseRebuiltLayout || switchedLayout) + { + UpdateSnapBehavior(); + } + } + + protected override void ScrollToRequested(object sender, ScrollToRequestEventArgs args) + { + // Skip the MAUI snap-manager reset (no SingleSnapHelper attached) and go straight + // to the underlying scroll so CarouselSnapHelper continues to control snapping. + ScrollTo(args); + } + + // LinearItemsLayout.ItemSpacing is intentionally not honoured by this handler. + // + // The base class applies a CarouselSpacingItemDecoration, but that is incompatible with + // Material's CarouselLayoutManager for two reasons: + // 1. CarouselLayoutManager builds its entire KeylineState from the *decorated* measured + // size of the first child (onFirstChildMeasuredWithMargins). RecyclerView folds item- + // decoration offsets into that decorated size, so injecting spacing offsets would + // distort the measurement the carousel uses to lay out every item. + // 2. FullScreenCarouselStrategy masks each item to fill the viewport and snaps one item + // per page, so there is no inter-item gutter for ItemSpacing to occupy. Inter-item + // spacing is a property of the partially-visible-neighbour strategies (MultiBrowse, + // Hero, Uncontained) that this handler is deliberately locked out of (see + // CreateCarouselStrategy) — the same class of limitation as Loop and PeekAreaInsets. + // + // A no-op decoration is used (rather than no decoration) so the base class's decoration + // wiring still has an instance to add/remove. + // TODO: Provide a Material3-compatible spacing implementation once a peek-capable, + // strategy-aware layout path exists for this handler. + protected override RecyclerView.ItemDecoration CreateSpacingDecoration(IItemsLayout itemsLayout) + => new NoOpItemDecoration(); + + sealed class NoOpItemDecoration : RecyclerView.ItemDecoration { } + + protected override Items.RecyclerViewScrollListener CreateScrollListener() + => new CarouselViewOnScrollListener2(Carousel, ItemsViewAdapter, () => _carouselSnapHelper); + + protected override void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + _disposed = true; + DetachSnapHelper(); + } + + base.Dispose(disposing); + } + + public override void TearDownOldElement(CarouselView oldElement) + { + DetachSnapHelper(); + base.TearDownOldElement(oldElement); + } +} diff --git a/src/Controls/src/Core/Handlers/Items2/CarouselViewHandler2.Android.cs b/src/Controls/src/Core/Handlers/Items2/CarouselViewHandler2.Android.cs new file mode 100644 index 000000000000..26cdf1a434f9 --- /dev/null +++ b/src/Controls/src/Core/Handlers/Items2/CarouselViewHandler2.Android.cs @@ -0,0 +1,200 @@ +#nullable disable +using Android.Views; +using AndroidX.RecyclerView.Widget; +using Microsoft.Maui.Graphics; + +namespace Microsoft.Maui.Controls.Handlers.Items2; + +/// +/// Android-specific partial for . +/// Uses as the platform view, which replaces +/// with the Material Design +/// . +/// +/// +/// Looping is not supported by this handler. Callers must keep +/// set to false — see +/// for details. +/// +/// +public partial class CarouselViewHandler2 : Items.ItemsViewHandler +{ + double _widthConstraint; + double _heightConstraint; + + protected override IItemsLayout GetItemsLayout() => VirtualView.ItemsLayout; + + protected override Items.ItemsViewAdapter CreateAdapter() + { + // CarouselViewAdapter2 wraps each item in MaskableFrameLayout, which is required + // by CarouselLayoutManager. Items must be sized through SizedItemContentView + // (driven by GetItemWidth/GetItemHeight) so the Material carousel strategy gets + // a non-zero measured first child to build its KeylineState from. + return new CarouselViewAdapter2( + VirtualView, + context => new Items.SizedItemContentView(context, GetItemWidth, GetItemHeight), + IsHorizontal); + } + + bool IsHorizontal() => + VirtualView?.ItemsLayout is LinearItemsLayout { Orientation: ItemsLayoutOrientation.Horizontal } == true; + + protected override RecyclerView CreatePlatformView() + { + var carouselView = new MauiCarouselRecyclerView2(Context, GetItemsLayout, CreateAdapter); + carouselView.SetClipChildren(false); + carouselView.SetClipToPadding(false); + return carouselView; + } + + public static PropertyMapper Mapper = + new(Items.ItemsViewHandler.ItemsViewMapper) + { + [Controls.CarouselView.ItemsLayoutProperty.PropertyName] = MapItemsLayout, + [Controls.CarouselView.IsSwipeEnabledProperty.PropertyName] = MapIsSwipeEnabled, + [Controls.CarouselView.PeekAreaInsetsProperty.PropertyName] = MapPeekAreaInsets, + [Controls.CarouselView.IsBounceEnabledProperty.PropertyName] = MapIsBounceEnabled, + [Controls.CarouselView.PositionProperty.PropertyName] = MapPosition, + [Controls.CarouselView.CurrentItemProperty.PropertyName] = MapCurrentItem, + }; + + public CarouselViewHandler2() : base(Mapper) { } + + public CarouselViewHandler2(PropertyMapper mapper = null) : base(mapper ?? Mapper) { } + + public static void MapIsSwipeEnabled(CarouselViewHandler2 handler, CarouselView carouselView) + { + if (handler.PlatformView is Items.IMauiCarouselRecyclerView carousel) + { + carousel.IsSwipeEnabled = carouselView.IsSwipeEnabled; + } + } + + public static void MapIsBounceEnabled(CarouselViewHandler2 handler, CarouselView carouselView) + { + handler.PlatformView.OverScrollMode = + carouselView?.IsBounceEnabled == true ? OverScrollMode.Always : OverScrollMode.Never; + } + + public static void MapPeekAreaInsets(CarouselViewHandler2 handler, CarouselView carouselView) + { + // PeekAreaInsets is intentionally a no-op for this handler. + // + // PeekAreaInsets is meant to shrink each item below the viewport so a sliver of the + // adjacent item(s) "peeks" in at the edges. That requires a CarouselStrategy that lays + // out partially-visible neighbours (MultiBrowse, Hero, Uncontained). This handler is + // locked to FullScreenCarouselStrategy (see MauiCarouselRecyclerView2.CreateCarouselStrategy), + // which builds its KeylineState from the full container size and masks every child to fill + // the viewport — one full-screen item per page with no peeking neighbours. The child's + // measured size does not influence the keyline, so re-measuring items here would only + // rebuild the adapter (and reset the carousel position) without producing any peek effect. + // + // TODO: Wire this up once a peek-capable, strategy-aware sizing path exists for the + // Material3 handler. + } + + public static void MapPosition(CarouselViewHandler2 handler, CarouselView carouselView) + { + if (carouselView.Position < 0) + { + return; + } + + if (handler.PlatformView is Items.IMauiCarouselRecyclerView carousel) + { + carousel.UpdateFromPosition(); + } + } + + public static void MapCurrentItem(CarouselViewHandler2 handler, CarouselView carouselView) + { + if (handler.PlatformView is Items.IMauiCarouselRecyclerView carousel) + { + carousel.UpdateFromCurrentItem(); + } + } + + internal static void MapItemsLayout(CarouselViewHandler2 handler, CarouselView carouselView) + { + if (handler.PlatformView is Items.IMauiRecyclerView recyclerView) + { + recyclerView.UpdateLayoutManager(); + } + } + + public override Size GetDesiredSize(double widthConstraint, double heightConstraint) + { + _widthConstraint = widthConstraint; + _heightConstraint = heightConstraint; + + if (Context is not null) + { + if (!double.IsInfinity(_widthConstraint)) + { + _widthConstraint = Context.ToPixels(_widthConstraint); + } + + if (!double.IsInfinity(_heightConstraint)) + { + _heightConstraint = Context.ToPixels(_heightConstraint); + } + } + + return base.GetDesiredSize(widthConstraint, heightConstraint); + } + + public override void PlatformArrange(Rect frame) + { + if (Context is not null) + { + _widthConstraint = Context.ToPixels(frame.Width); + _heightConstraint = Context.ToPixels(frame.Height); + } + + base.PlatformArrange(frame); + } + + double GetItemWidth() + { + var itemWidth = _widthConstraint; + + if (PlatformView is Items.IMauiRecyclerView { ItemsLayout: LinearItemsLayout { Orientation: ItemsLayoutOrientation.Horizontal } }) + { + var width = PlatformView.MeasuredWidth == 0 ? _widthConstraint : PlatformView.MeasuredWidth; + if (double.IsInfinity(width)) + { + return width; + } + + // PeekAreaInsets is intentionally not honoured here: this handler is locked to + // FullScreenCarouselStrategy, which masks every item to fill the viewport (see + // MapPeekAreaInsets). Subtracting the insets would only shrink the inner content + // inside a still-full-width slot, never producing a peek. Use the full width. + itemWidth = (int)width; + } + + return itemWidth; + } + + double GetItemHeight() + { + var itemHeight = _heightConstraint; + + if (PlatformView is Items.IMauiRecyclerView { ItemsLayout: LinearItemsLayout { Orientation: ItemsLayoutOrientation.Vertical } }) + { + var height = PlatformView.MeasuredHeight == 0 ? _heightConstraint : PlatformView.MeasuredHeight; + if (double.IsInfinity(height)) + { + return height; + } + + // PeekAreaInsets is intentionally not honoured here: this handler is locked to + // FullScreenCarouselStrategy, which masks every item to fill the viewport (see + // MapPeekAreaInsets). Subtracting the insets would only shrink the inner content + // inside a still-full-height slot, never producing a peek. Use the full height. + itemHeight = (int)height; + } + + return itemHeight; + } +} diff --git a/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs b/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs index 94afc97e6c8a..e0c36d778681 100644 --- a/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs +++ b/src/Controls/src/Core/Hosting/AppHostBuilderExtensions.cs @@ -14,6 +14,7 @@ #if ANDROID using Microsoft.Maui.Controls.Handlers.Compatibility; using Microsoft.Maui.Controls.Compatibility.Platform.Android; +using Microsoft.Maui.Controls.Handlers.Items2; #elif WINDOWS using ResourcesProvider = Microsoft.Maui.Controls.Compatibility.Platform.UWP.WindowsResourcesProvider; using Microsoft.Maui.Controls.Compatibility.Platform.UWP; @@ -77,7 +78,14 @@ internal static IMauiHandlersCollection AddControlsHandlers(this IMauiHandlersCo handlersCollection.AddHandler(); } handlersCollection.AddHandler(); -#else +#elif TIZEN + handlersCollection.AddHandler(); + handlersCollection.AddHandler(); +#elif !ANDROID + // Non-platform host target (net/netstandard). Android registers these inside the + // #if ANDROID block below; every other platform is handled above. Without this + // fallback the plain net/netstandard TFM would register no CollectionView/CarouselView + // handler, causing HandlerNotFound in design-time/unit-test scenarios. handlersCollection.AddHandler(); handlersCollection.AddHandler(); #endif @@ -96,6 +104,8 @@ internal static IMauiHandlersCollection AddControlsHandlers(this IMauiHandlersCo handlersCollection.AddHandler(); handlersCollection.AddHandler(); handlersCollection.AddHandler(); + handlersCollection.AddHandler(); + handlersCollection.AddHandler(); handlersCollection.AddHandler(); } else @@ -112,6 +122,8 @@ internal static IMauiHandlersCollection AddControlsHandlers(this IMauiHandlersCo handlersCollection.AddHandler(); handlersCollection.AddHandler(); handlersCollection.AddHandler(); + handlersCollection.AddHandler(); + handlersCollection.AddHandler(); handlersCollection.AddHandler(); } #else diff --git a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt index cd90e7c16b70..fdfeec74987f 100644 --- a/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -18,6 +18,13 @@ Microsoft.Maui.Controls.HybridWebView.SetInvokeJavaScriptTarget(T! target, Sy Microsoft.Maui.Controls.AppThemeBinding Microsoft.Maui.Controls.AppThemeBinding.AppThemeBinding() -> void Microsoft.Maui.Controls.BoxView.~BoxView() -> void +Microsoft.Maui.Controls.Handlers.Items2.CarouselViewAdapter2 +~Microsoft.Maui.Controls.Handlers.Items2.CarouselViewAdapter2.CarouselViewAdapter2(Microsoft.Maui.Controls.CarouselView carouselView, System.Func createItemContentView, System.Func isHorizontal) -> void +Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2 +Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2.CarouselViewHandler2() -> void +~Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2.CarouselViewHandler2(Microsoft.Maui.PropertyMapper mapper = null) -> void +Microsoft.Maui.Controls.Handlers.Items2.MauiCarouselRecyclerView2 +~Microsoft.Maui.Controls.Handlers.Items2.MauiCarouselRecyclerView2.MauiCarouselRecyclerView2(Android.Content.Context context, System.Func getItemsLayout, System.Func> getAdapter) -> void Microsoft.Maui.Controls.ImageSource.InvalidateStyle() -> void Microsoft.Maui.Controls.LongPressGestureRecognizer Microsoft.Maui.Controls.LongPressGestureRecognizer.AllowableMovement.get -> double @@ -43,6 +50,14 @@ Microsoft.Maui.Controls.LongPressingEventArgs.Status.get -> Microsoft.Maui.Gestu Microsoft.Maui.Controls.Span.InvalidateStyle() -> void Microsoft.Maui.Controls.StyleableElement.InvalidateStyle() -> void override Microsoft.Maui.Controls.GraphicsView.OnBindingContextChanged() -> void +override Microsoft.Maui.Controls.Handlers.Items2.CarouselViewAdapter2.ItemCount.get -> int +override Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2.GetDesiredSize(double widthConstraint, double heightConstraint) -> Microsoft.Maui.Graphics.Size +override Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2.PlatformArrange(Microsoft.Maui.Graphics.Rect frame) -> void +override Microsoft.Maui.Controls.Handlers.Items2.MauiCarouselRecyclerView2.Dispose(bool disposing) -> void +override Microsoft.Maui.Controls.Handlers.Items2.MauiCarouselRecyclerView2.IsLoopEnabled.get -> bool +override Microsoft.Maui.Controls.Handlers.Items2.MauiCarouselRecyclerView2.UpdateFlowDirection() -> void +override Microsoft.Maui.Controls.Handlers.Items2.MauiCarouselRecyclerView2.UpdateLayoutManager() -> void +override Microsoft.Maui.Controls.Handlers.Items2.MauiCarouselRecyclerView2.UpdateSnapBehavior() -> void override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.OnHiddenChanged(bool hidden) -> void override Microsoft.Maui.Controls.Shapes.Shape.OnPropertyChanged(string? propertyName = null) -> void override Microsoft.Maui.Controls.TitleBar.OnBindingContextChanged() -> void @@ -65,6 +80,7 @@ static readonly Microsoft.Maui.Controls.LongPressGestureRecognizer.CommandProper static readonly Microsoft.Maui.Controls.LongPressGestureRecognizer.MinimumPressDurationProperty -> Microsoft.Maui.Controls.BindableProperty! static readonly Microsoft.Maui.Controls.LongPressGestureRecognizer.NumberOfTouchesRequiredProperty -> Microsoft.Maui.Controls.BindableProperty! static readonly Microsoft.Maui.Controls.LongPressGestureRecognizer.StateProperty -> Microsoft.Maui.Controls.BindableProperty! +virtual Microsoft.Maui.Controls.Handlers.Items.MauiCarouselRecyclerView.IsLoopEnabled.get -> bool virtual Microsoft.Maui.Controls.LongPressedEventArgs.GetPosition(Microsoft.Maui.Controls.Element? relativeTo) -> Microsoft.Maui.Graphics.Point? virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui.Controls.Element? relativeTo) -> Microsoft.Maui.Graphics.Point? ~Microsoft.Maui.Controls.AppThemeBinding.Dark.get -> object @@ -105,6 +121,17 @@ virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui ~override Microsoft.Maui.Controls.Handlers.Items.MauiRecyclerView.OnTouchEvent(Android.Views.MotionEvent e) -> bool ~override Microsoft.Maui.Controls.Handlers.Items.RecyclerViewScrollListener.OnScrollStateChanged(AndroidX.RecyclerView.Widget.RecyclerView recyclerView, int newState) -> void ~override Microsoft.Maui.Controls.Handlers.Items.SelectableItemsViewAdapter.IsSelectionEnabled(Android.Views.ViewGroup parent, int viewType) -> bool +~override Microsoft.Maui.Controls.Handlers.Items2.CarouselViewAdapter2.OnBindViewHolder(AndroidX.RecyclerView.Widget.RecyclerView.ViewHolder holder, int position) -> void +~override Microsoft.Maui.Controls.Handlers.Items2.CarouselViewAdapter2.OnCreateViewHolder(Android.Views.ViewGroup parent, int viewType) -> AndroidX.RecyclerView.Widget.RecyclerView.ViewHolder +~override Microsoft.Maui.Controls.Handlers.Items2.CarouselViewAdapter2.OnViewRecycled(Java.Lang.Object holder) -> void +~override Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2.CreateAdapter() -> Microsoft.Maui.Controls.Handlers.Items.ItemsViewAdapter +~override Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2.CreatePlatformView() -> AndroidX.RecyclerView.Widget.RecyclerView +~override Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2.GetItemsLayout() -> Microsoft.Maui.Controls.IItemsLayout +~override Microsoft.Maui.Controls.Handlers.Items2.MauiCarouselRecyclerView2.CreateScrollListener() -> Microsoft.Maui.Controls.Handlers.Items.RecyclerViewScrollListener +~override Microsoft.Maui.Controls.Handlers.Items2.MauiCarouselRecyclerView2.CreateSpacingDecoration(Microsoft.Maui.Controls.IItemsLayout itemsLayout) -> AndroidX.RecyclerView.Widget.RecyclerView.ItemDecoration +~override Microsoft.Maui.Controls.Handlers.Items2.MauiCarouselRecyclerView2.ScrollToRequested(object sender, Microsoft.Maui.Controls.ScrollToRequestEventArgs args) -> void +~override Microsoft.Maui.Controls.Handlers.Items2.MauiCarouselRecyclerView2.SelectLayoutManager(Microsoft.Maui.Controls.IItemsLayout layoutSpecification) -> AndroidX.RecyclerView.Widget.RecyclerView.LayoutManager +~override Microsoft.Maui.Controls.Handlers.Items2.MauiCarouselRecyclerView2.TearDownOldElement(Microsoft.Maui.Controls.CarouselView oldElement) -> void ~override Microsoft.Maui.Controls.RadioButton.OnPropertyChanged(string propertyName = null) -> void ~static Microsoft.Maui.Controls.Editor.MapText(Microsoft.Maui.Handlers.EditorHandler2 handler, Microsoft.Maui.Controls.Editor editor) -> void ~static Microsoft.Maui.Controls.Entry.MapImeOptions(Microsoft.Maui.Handlers.EntryHandler2 handler, Microsoft.Maui.Controls.Entry entry) -> void @@ -112,6 +139,12 @@ virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui ~static Microsoft.Maui.Controls.Handlers.Compatibility.ShellRenderer.DefaultForegroundColor.get -> Microsoft.Maui.Graphics.Color ~static Microsoft.Maui.Controls.Handlers.Compatibility.ShellRenderer.DefaultTitleColor.get -> Microsoft.Maui.Graphics.Color ~static Microsoft.Maui.Controls.Handlers.Compatibility.ShellRenderer.DefaultUnselectedColor.get -> Microsoft.Maui.Graphics.Color +~static Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2.MapCurrentItem(Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2 handler, Microsoft.Maui.Controls.CarouselView carouselView) -> void +~static Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2.MapIsBounceEnabled(Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2 handler, Microsoft.Maui.Controls.CarouselView carouselView) -> void +~static Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2.MapIsSwipeEnabled(Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2 handler, Microsoft.Maui.Controls.CarouselView carouselView) -> void +~static Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2.MapPeekAreaInsets(Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2 handler, Microsoft.Maui.Controls.CarouselView carouselView) -> void +~static Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2.MapPosition(Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2 handler, Microsoft.Maui.Controls.CarouselView carouselView) -> void +~static Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2.Mapper -> Microsoft.Maui.PropertyMapper ~static Microsoft.Maui.Controls.NavigationPage.GetBackButtonAccessibilityLabel(Microsoft.Maui.Controls.BindableObject page) -> string ~static Microsoft.Maui.Controls.NavigationPage.SetBackButtonAccessibilityLabel(Microsoft.Maui.Controls.BindableObject page, string value) -> void ~static Microsoft.Maui.Controls.SearchBar.MapText(Microsoft.Maui.Handlers.SearchBarHandler2 handler, Microsoft.Maui.Controls.SearchBar searchBar) -> void @@ -128,6 +161,7 @@ virtual Microsoft.Maui.Controls.LongPressingEventArgs.GetPosition(Microsoft.Maui ~static readonly Microsoft.Maui.Controls.ToolbarItem.BadgeColorProperty -> Microsoft.Maui.Controls.BindableProperty ~static readonly Microsoft.Maui.Controls.ToolbarItem.BadgeTextColorProperty -> Microsoft.Maui.Controls.BindableProperty ~static readonly Microsoft.Maui.Controls.ToolbarItem.BadgeTextProperty -> Microsoft.Maui.Controls.BindableProperty +~virtual Microsoft.Maui.Controls.Handlers.Items2.MauiCarouselRecyclerView2.CreateCarouselStrategy() -> Google.Android.Material.Carousel.CarouselStrategy ~virtual Microsoft.Maui.Controls.Platform.Compatibility.ShellItemRenderer.UpdateShellSectionBadge(Microsoft.Maui.Controls.ShellSection shellSection, int index) -> void Microsoft.Maui.Controls.TitleBar.TitleFontAttributes.get -> Microsoft.Maui.Controls.FontAttributes Microsoft.Maui.Controls.TitleBar.TitleFontAttributes.set -> void diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/FeatureMatrix/Material3CarouselViewFeatureTests.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/FeatureMatrix/Material3CarouselViewFeatureTests.cs new file mode 100644 index 000000000000..6bcb63b36202 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/FeatureMatrix/Material3CarouselViewFeatureTests.cs @@ -0,0 +1,300 @@ +#if ANDROID +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests; + +public class Material3CarouselViewFeatureTests : _GalleryUITest +{ + public const string CarouselViewFeatureMatrix = "CarouselView Feature Matrix"; + private const string CarouselViewControl = "CarouselViewControl"; + private const string Options = "Options"; + private const string Apply = "Apply"; + private const string ItemTemplateGrid = "ItemTemplateGrid"; + private const string ItemTemplateCustomView = "ItemTemplateCustomView"; + private const string EmptyViewString = "EmptyViewString"; + private const string EmptyViewCustomView = "EmptyViewCustomView"; + private const string EmptyViewDataTemplate = "EmptyViewDataTemplate"; + private const string ItemsSourceNone = "ItemsSourceNone"; + private const string ItemsLayoutVertical = "ItemsLayoutVertical"; + private const string KeepItemsInView = "KeepItemsInView"; + private const string KeepLastItemInView = "KeepLastItemInView"; + private const string AddButton = "AddButton"; + private const string PositionEntry = "PositionEntry"; + private const string CurrentPositionLabel = "CurrentPositionLabel"; + private const string CurrentItemLabel = "CurrentItemLabel"; + private const string PreviousItemLabel = "PreviousItemLabel"; + private const string ScrollToIndexEntry = "ScrollToIndexEntry"; + private const string ScrollToButton = "ScrollToButton"; + + public override string GalleryPageName => CarouselViewFeatureMatrix; + + public Material3CarouselViewFeatureTests(TestDevice device) : base(device) + { + } + + [Test, Order(1)] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewLoadsItems() + { + // Regression guard for #35637: the Material CarouselLayoutManager must lay out the + // real item count and render the first item instead of getting stuck on splash. + App.WaitForElement("CarouselViewButton"); + App.Tap("CarouselViewButton"); + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("Item 1"); + } + + [Test] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewWithKeepItemInView() + { + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(KeepItemsInView); + App.Tap(KeepItemsInView); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("Item 1"); + App.WaitForElement(AddButton); + App.Tap(AddButton); + App.WaitForElement("Item 6"); + } + + [Test] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewWithKeepLastItemInView() + { + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(KeepLastItemInView); + App.Tap(KeepLastItemInView); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("Item 1"); + App.WaitForElement(AddButton); + App.Tap(AddButton); + App.WaitForElement("Item 5"); + } + + [Test] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewWithGridLayout() + { + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(ItemTemplateGrid); + App.Tap(ItemTemplateGrid); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("Item 1 (Grid Template)"); + } + + [Test] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewWithImageView() + { + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(ItemTemplateCustomView); + App.Tap(ItemTemplateCustomView); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("Item 1 (Image View)"); + } + + [Test] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewWithEmptyViewString() + { + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(ItemsSourceNone); + App.Tap(ItemsSourceNone); + App.WaitForElement(EmptyViewString); + App.Tap(EmptyViewString); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("No items available"); + } + + [Test] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewWithEmptyViewImageView() + { + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(ItemsSourceNone); + App.Tap(ItemsSourceNone); + App.WaitForElement(EmptyViewCustomView); + App.Tap(EmptyViewCustomView); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("No items available(Custom View)"); + } + + [Test] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewWithEmptyViewTemplate() + { + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(ItemsSourceNone); + App.Tap(ItemsSourceNone); + App.WaitForElement(EmptyViewDataTemplate); + App.Tap(EmptyViewDataTemplate); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("No items available (DataTemplate)"); + } + + [Test] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewWithPosition() + { + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(PositionEntry); + App.Tap(PositionEntry); + App.ClearText(PositionEntry); + App.EnterText(PositionEntry, "2"); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("Item 3"); + } + + [Test] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewWithPositionAndCurrentItem() + { + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(PositionEntry); + App.Tap(PositionEntry); + App.ClearText(PositionEntry); + App.EnterText(PositionEntry, "1"); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("Item 2"); + Assert.That(App.WaitForElement(CurrentItemLabel).GetText(), Is.EqualTo("Item 2")); + } + + [Test] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewWithPositionAndCurrentPosition() + { + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(PositionEntry); + App.Tap(PositionEntry); + App.ClearText(PositionEntry); + App.EnterText(PositionEntry, "1"); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("Item 2"); + Assert.That(App.WaitForElement(CurrentPositionLabel).GetText(), Is.EqualTo("1")); + } + + [Test] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewWithScrollTo() + { + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("Item 1"); + App.WaitForElement(ScrollToIndexEntry); + App.ClearText(ScrollToIndexEntry); + App.EnterText(ScrollToIndexEntry, "3"); + App.WaitForElement(ScrollToButton); + App.Tap(ScrollToButton); + App.WaitForElement("Item 4"); + } + + [Test] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewWithScrollToAndCurrentItem() + { + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("Item 1"); + App.WaitForElement(ScrollToIndexEntry); + App.ClearText(ScrollToIndexEntry); + App.EnterText(ScrollToIndexEntry, "1"); + App.WaitForElement(ScrollToButton); + App.Tap(ScrollToButton); + App.WaitForElement("Item 2"); + Assert.That(App.WaitForElement(CurrentItemLabel).GetText(), Is.EqualTo("Item 2")); + } + + [Test] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewWithCurrentItems() + { + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("Item 1"); + Assert.That(App.WaitForElement(CurrentItemLabel).GetText(), Is.EqualTo("Item 1")); + App.ScrollRight(CarouselViewControl, ScrollStrategy.Gesture, 0.9, 500); + App.WaitForElement("Item 2"); + Assert.That(App.WaitForElement(CurrentItemLabel).GetText(), Is.EqualTo("Item 2")); + } + + [Test] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewWithPreviousItem() + { + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("Item 1"); + Assert.That(App.WaitForElement(PreviousItemLabel).GetText(), Is.EqualTo("No previous item")); + App.ScrollRight(CarouselViewControl, ScrollStrategy.Gesture, 0.9, 500); + App.WaitForElement("Item 2"); + Assert.That(App.WaitForElement(PreviousItemLabel).GetText(), Is.EqualTo("Item 1")); + } + + [Test] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewWithVerticalLayout() + { + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(ItemsLayoutVertical); + App.Tap(ItemsLayoutVertical); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("Item 1"); + App.ScrollDown(CarouselViewControl, ScrollStrategy.Gesture, 0.9, 500); + App.WaitForElement("Item 2"); + } + + [Test] + [Category(UITestCategories.Material3)] + public void Material3VerifyCarouselViewWithVerticalLayoutAndKeepItemInView() + { + App.WaitForElement(Options); + App.Tap(Options); + App.WaitForElement(ItemsLayoutVertical); + App.Tap(ItemsLayoutVertical); + App.WaitForElement(KeepItemsInView); + App.Tap(KeepItemsInView); + App.WaitForElement(Apply); + App.Tap(Apply); + App.WaitForElement("Item 1"); + App.WaitForElement(AddButton); + App.Tap(AddButton); + App.WaitForElement("Item 6"); + } +} +#endif