Skip to content

Backport CarouselView layout SR6 regressions #29062

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 22, 2025
Merged
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
@@ -32,7 +32,10 @@ public override void ConstrainTo(CGSize constraint)

public override CGSize Measure()
{
return new CGSize(_constraint.Width, _constraint.Height);
// Go through the measure pass even if the constraints are fixed
// to ensure arrange pass has the appropriate desired size in place.
PlatformHandler.VirtualView.Measure(_constraint.Width, _constraint.Height);
return _constraint;
}

protected override (bool, Size) NeedsContentSizeUpdate(Size currentSize)
@@ -42,7 +45,7 @@ protected override (bool, Size) NeedsContentSizeUpdate(Size currentSize)

protected override bool AttributesConsistentWithConstrainedDimension(UICollectionViewLayoutAttributes attributes)
{
return false;
return _constraint.IsCloseTo(attributes.Frame.Size);
}
}
}
Original file line number Diff line number Diff line change
@@ -185,6 +185,9 @@ protected override string DetermineCellReuseId(NSIndexPath indexPath)
return base.DetermineCellReuseId(NSIndexPath.FromItemSection(itemIndex, 0));
}

private protected override (Type CellType, string CellTypeReuseId) DetermineTemplatedCellType()
=> (typeof(CarouselTemplatedCell), "maui_carousel");

protected override void RegisterViewTypes()
{
CollectionView.RegisterClassForCell(typeof(CarouselTemplatedCell), CarouselTemplatedCell.ReuseId);
Original file line number Diff line number Diff line change
@@ -503,9 +503,9 @@ protected virtual string DetermineCellReuseId(NSIndexPath indexPath)
var dataTemplate = ItemsView.ItemTemplate.SelectDataTemplate(item, ItemsView);

var cellOrientation = ItemsViewLayout.ScrollDirection == UICollectionViewScrollDirection.Vertical ? "v" : "h";
var cellType = ItemsViewLayout.ScrollDirection == UICollectionViewScrollDirection.Vertical ? typeof(VerticalCell) : typeof(HorizontalCell);
(Type cellType, var cellTypeReuseId) = DetermineTemplatedCellType();

var reuseId = $"_maui_{cellOrientation}_{dataTemplate.Id}";
var reuseId = $"_{cellTypeReuseId}_{cellOrientation}_{dataTemplate.Id}";

if (!_cellReuseIds.Contains(reuseId))
{
@@ -521,6 +521,11 @@ protected virtual string DetermineCellReuseId(NSIndexPath indexPath)
: VerticalDefaultCell.ReuseId;
}

private protected virtual (Type CellType, string CellTypeReuseId) DetermineTemplatedCellType()
{
return (ItemsViewLayout.ScrollDirection == UICollectionViewScrollDirection.Vertical ? typeof(VerticalCell) : typeof(HorizontalCell), "maui");
}

[Obsolete("Use DetermineCellReuseId(NSIndexPath indexPath) instead.")]
protected virtual string DetermineCellReuseId()
{
Original file line number Diff line number Diff line change
@@ -38,8 +38,7 @@ protected override CarouselViewController2 CreateController(CarouselView newElem

protected override UICollectionViewLayout SelectLayout()
{
bool IsHorizontal = VirtualView.ItemsLayout.Orientation == ItemsLayoutOrientation.Horizontal;
UICollectionViewScrollDirection scrollDirection = IsHorizontal ? UICollectionViewScrollDirection.Horizontal : UICollectionViewScrollDirection.Vertical;
bool isHorizontal = VirtualView.ItemsLayout.Orientation == ItemsLayoutOrientation.Horizontal;

NSCollectionLayoutDimension itemWidth = NSCollectionLayoutDimension.CreateFractionalWidth(1);
NSCollectionLayoutDimension itemHeight = NSCollectionLayoutDimension.CreateFractionalHeight(1);
@@ -55,7 +54,7 @@ protected override UICollectionViewLayout SelectLayout()
return null;
}
double sectionMargin = 0.0;
if (!IsHorizontal)
if (!isHorizontal)
{
sectionMargin = VirtualView.PeekAreaInsets.VerticalThickness / 2;
var newGroupHeight = environment.Container.ContentSize.Height - VirtualView.PeekAreaInsets.VerticalThickness;
@@ -81,19 +80,19 @@ protected override UICollectionViewLayout SelectLayout()

if (OperatingSystem.IsIOSVersionAtLeast(16))
{
group = IsHorizontal ? NSCollectionLayoutGroup.GetHorizontalGroup(groupSize, item, 1) :
group = isHorizontal ? NSCollectionLayoutGroup.GetHorizontalGroup(groupSize, item, 1) :
NSCollectionLayoutGroup.GetVerticalGroup(groupSize, item, 1);
}
else
{
group = IsHorizontal ? NSCollectionLayoutGroup.CreateHorizontal(groupSize, item, 1) :
group = isHorizontal ? NSCollectionLayoutGroup.CreateHorizontal(groupSize, item, 1) :
NSCollectionLayoutGroup.CreateVertical(groupSize, item, 1);
}

// Create our section layout
var section = NSCollectionLayoutSection.Create(group: group);
section.InterGroupSpacing = itemSpacing;
section.OrthogonalScrollingBehavior = IsHorizontal ? UICollectionLayoutSectionOrthogonalScrollingBehavior.GroupPagingCentered : UICollectionLayoutSectionOrthogonalScrollingBehavior.None;
section.OrthogonalScrollingBehavior = isHorizontal ? UICollectionLayoutSectionOrthogonalScrollingBehavior.GroupPagingCentered : UICollectionLayoutSectionOrthogonalScrollingBehavior.None;
section.VisibleItemsInvalidationHandler = (items, offset, env) =>
{
//This will allow us to SetPosition when we are scrolling the items
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#nullable disable
using CoreGraphics;
using Foundation;
using Microsoft.Maui.Graphics;
using UIKit;

namespace Microsoft.Maui.Controls.Handlers.Items2
{
internal sealed class CarouselTemplatedCell2 : TemplatedCell2
{
internal new const string ReuseId = "Microsoft.Maui.Controls.CarouselTemplatedCell2";

[Export("initWithFrame:")]
[Microsoft.Maui.Controls.Internals.Preserve(Conditional = true)]
public CarouselTemplatedCell2(CGRect frame) : base(frame)
{
}

private protected override Size GetMeasureConstraints(UICollectionViewLayoutAttributes preferredAttributes)
=> preferredAttributes.Size.ToSize();
}
}
Original file line number Diff line number Diff line change
@@ -117,6 +117,9 @@ protected override string DetermineCellReuseId(NSIndexPath indexPath)
return base.DetermineCellReuseId(itemIndex);
}

private protected override (Type CellType, string CellTypeReuseId) DetermineTemplatedCellType()
=> (typeof(CarouselTemplatedCell2), CarouselTemplatedCell2.ReuseId);

protected override Items.IItemsViewSource CreateItemsViewSource()
{
var itemsSource = ItemsSourceFactory2.CreateForCarouselView(ItemsView.ItemsSource, this, ItemsView.Loop);
@@ -506,54 +509,49 @@ async Task UpdateInitialPosition()
return;
}

int position = carousel.Position;
var currentItem = carousel.CurrentItem;

if (currentItem != null)
{
// Sometimes the item could be just being removed while we navigate back to the CarouselView
var positionCurrentItem = ItemsSource.GetIndexForItem(currentItem).Row;
if (positionCurrentItem != -1)
{
position = positionCurrentItem;
}
}

var projectedPosition = NSIndexPath.FromItemSection(position, _section);

if (LoopItemsSource.Loop)
{
//We need to set the position to the correct position since we added 1 item at the beginning
projectedPosition = GetScrollToIndexPath(position);
}

var uICollectionViewScrollPosition = IsHorizontal ? UICollectionViewScrollPosition.CenteredHorizontally : UICollectionViewScrollPosition.CenteredVertically;

await Task.Delay(100).ContinueWith((t) =>
await Task.Delay(100).ContinueWith(_ =>
{
MainThread.BeginInvokeOnMainThread(() =>
{
if (!IsViewLoaded)
{
return;
}

InitialPositionSet = true;

if (ItemsSource is null || ItemsSource.ItemCount == 0)
{
return;
}

int position = carousel.Position;
var currentItem = carousel.CurrentItem;

if (currentItem != null)
{
// Sometimes the item could be just being removed while we navigate back to the CarouselView
var positionCurrentItem = ItemsSource.GetIndexForItem(currentItem).Row;
if (positionCurrentItem != -1)
{
position = positionCurrentItem;
}
}

var projectedPosition = LoopItemsSource.Loop
? GetScrollToIndexPath(position) // We need to set the position to the correct position since we added 1 item at the beginning
: NSIndexPath.FromItemSection(position, _section);

var uICollectionViewScrollPosition = IsHorizontal ? UICollectionViewScrollPosition.CenteredHorizontally : UICollectionViewScrollPosition.CenteredVertically;

CollectionView.ScrollToItem(projectedPosition, uICollectionViewScrollPosition, false);

//Set the position on VirtualView to update the CurrentItem also
SetPosition(position);

UpdateVisualStates();
});

});

}

void UpdateVisualStates()
Original file line number Diff line number Diff line change
@@ -61,6 +61,9 @@ public void UpdateLayout(UICollectionViewLayout newLayout)

if (newLayout is UICollectionViewCompositionalLayout compositionalLayout)
{
// Note: on carousel layout, the scroll direction is always vertical to achieve horizontal paging with snapping.
// Thanks to it, we can use OrthogonalScrollingBehavior.GroupPagingCentered to scroll the section horizontally.
// And even if CarouselView is vertically oriented, each section scrolls horizontally — which results in the carousel-style behavior.
ScrollDirection = compositionalLayout.Configuration.ScrollDirection;
}

@@ -330,10 +333,9 @@ protected virtual string DetermineCellReuseId(NSIndexPath indexPath)

var dataTemplate = ItemsView.ItemTemplate.SelectDataTemplate(item, ItemsView);

var cellType = typeof(TemplatedCell2);

var orientation = ScrollDirection == UICollectionViewScrollDirection.Horizontal ? "Horizontal" : "Vertical";
var reuseId = $"{TemplatedCell2.ReuseId}.{orientation}.{dataTemplate.Id}";
(Type cellType, var cellTypeReuseId) = DetermineTemplatedCellType();
var reuseId = $"{cellTypeReuseId}.{orientation}.{dataTemplate.Id}";

if (!_cellReuseIds.Contains(reuseId))
{
@@ -347,6 +349,9 @@ protected virtual string DetermineCellReuseId(NSIndexPath indexPath)
return ScrollDirection == UICollectionViewScrollDirection.Horizontal ? HorizontalDefaultCell2.ReuseId : VerticalDefaultCell2.ReuseId;
}

private protected virtual (Type CellType, string CellTypeReuseId) DetermineTemplatedCellType()
=> (typeof(TemplatedCell2), TemplatedCell2.ReuseId);

protected virtual void RegisterViewTypes()
{
CollectionView.RegisterClassForCell(typeof(HorizontalDefaultCell2), HorizontalDefaultCell2.ReuseId);
21 changes: 15 additions & 6 deletions src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs
Original file line number Diff line number Diff line change
@@ -86,9 +86,7 @@ public override UICollectionViewLayoutAttributes PreferredLayoutAttributesFittin

if (PlatformHandler?.VirtualView is { } virtualView)
{
var constraints = ScrollDirection == UICollectionViewScrollDirection.Vertical
? new Size(preferredAttributes.Size.Width, double.PositiveInfinity)
: new Size(double.PositiveInfinity, preferredAttributes.Size.Height);
var constraints = GetMeasureConstraints(preferredAttributes);

if (_measureInvalidated || _cachedConstraints != constraints)
{
@@ -98,9 +96,12 @@ public override UICollectionViewLayoutAttributes PreferredLayoutAttributesFittin
_needsArrange = true;
}

var size = ScrollDirection == UICollectionViewScrollDirection.Vertical
? new Size(preferredAttributes.Size.Width, _measuredSize.Height)
: new Size(_measuredSize.Width, preferredAttributes.Size.Height);
var preferredSize = preferredAttributes.Size;
// Use measured size only when unconstrained
var size = new Size(
double.IsPositiveInfinity(constraints.Width) ? _measuredSize.Width : preferredSize.Width,
double.IsPositiveInfinity(constraints.Height) ? _measuredSize.Height : preferredSize.Height
);

preferredAttributes.Frame = new CGRect(preferredAttributes.Frame.Location, size);
preferredAttributes.ZIndex = 2;
@@ -111,6 +112,14 @@ public override UICollectionViewLayoutAttributes PreferredLayoutAttributesFittin
return preferredAttributes;
}

private protected virtual Size GetMeasureConstraints(UICollectionViewLayoutAttributes preferredAttributes)
{
var constraints = ScrollDirection == UICollectionViewScrollDirection.Vertical
? new Size(preferredAttributes.Size.Width, double.PositiveInfinity)
: new Size(double.PositiveInfinity, preferredAttributes.Size.Height);
return constraints;
}

public override void LayoutSubviews()
{
base.LayoutSubviews();
Original file line number Diff line number Diff line change
@@ -100,8 +100,6 @@ void ExecuteRemoveItemsCommand()
while (Items.Count > 0)
{
Items.Remove(Items.Last());
Items.Remove(Items.Last());
Items.Remove(Items.Last());
}
RemoveAllItemsCommand.ChangeCanExecute();
RemoveLastItemCommand.ChangeCanExecute();
146 changes: 146 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue28930.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="utf-8"?>

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Maui.Controls.Sample.Issues.Issue28930">
<VerticalStackLayout>
<Border Stroke="Violet"
StrokeThickness="2"
Padding="10">
<VerticalStackLayout Spacing="10">
<Label Text="CAROUSEL"
TextTransform="Uppercase" />
<CarouselView x:Name="MyCarousel"
AutomationId="MyCarousel"
HorizontalOptions="Fill"
VerticalOptions="Start"
MinimumHeightRequest="120"
HeightRequest="140"
HorizontalScrollBarVisibility="Never"
EmptyView="No data">
<CarouselView.ItemsSource>
<x:Array Type="{x:Type x:String}">
<x:String>Item 1</x:String>
<x:String>Item 2</x:String>
<x:String>Item 3</x:String>
</x:Array>
</CarouselView.ItemsSource>
<CarouselView.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="*,*,*,*"
RowDefinitions="*,Auto">
<Label Grid.ColumnSpan="4"
Grid.Row="1"
AutomationId="ItemLabel"
Text="{Binding .}"
VerticalTextAlignment="Center"
HorizontalTextAlignment="Center" />
<Grid Grid.Column="0"
RowDefinitions="Auto,Auto"
RowSpacing="2">
<Border BackgroundColor="GreenYellow"
StrokeShape="RoundRectangle 7"
HeightRequest="65"
WidthRequest="65"
HorizontalOptions="Center"
Margin="0,5,0,0"
Stroke="Violet"
StrokeThickness="1">
<Image Source="dotnet_bot.png"
HorizontalOptions="Center"
VerticalOptions="Center"
AutomationId="dotnetbot1"
HeightRequest="40"
WidthRequest="40" />
</Border>
<Label Grid.Row="1"
Text="Long text that should be truncated at the end of the line. This is a test "
HorizontalOptions="Center"
HorizontalTextAlignment="Center"
LineBreakMode="WordWrap"
MaxLines="2" />
</Grid>
<Grid Grid.Column="1"
RowDefinitions="Auto,Auto"
RowSpacing="2">
<Border BackgroundColor="Red"
StrokeShape="RoundRectangle 7"
HeightRequest="65"
WidthRequest="65"
HorizontalOptions="Center"
Margin="0,5,0,0"
Stroke="Violet"
StrokeThickness="1">
<Image Source="dotnet_bot.png"
HorizontalOptions="Center"
VerticalOptions="Center"
AutomationId="dotnetbot2"
HeightRequest="40"
WidthRequest="40" />
</Border>
<Label Grid.Row="1"
Text="Long text that should be truncated at the end of the line. This is a test "
HorizontalOptions="Center"
HorizontalTextAlignment="Center"
LineBreakMode="WordWrap"
MaxLines="2" />
</Grid>
<Grid Grid.Column="2"
RowDefinitions="Auto,Auto"
RowSpacing="2">
<Border BackgroundColor="Blue"
StrokeShape="RoundRectangle 7"
HeightRequest="65"
WidthRequest="65"
HorizontalOptions="Center"
Margin="0,5,0,0"
Stroke="Violet"
StrokeThickness="1">
<Image Source="dotnet_bot.png"
HorizontalOptions="Center"
VerticalOptions="Center"
HeightRequest="40"
AutomationId="dotnetbot3"
WidthRequest="40" />
</Border>
<Label Grid.Row="1"
Text="Long text that should be truncated at the end of the line. This is a test "
HorizontalOptions="Center"
HorizontalTextAlignment="Center"
LineBreakMode="WordWrap"
MaxLines="2" />
</Grid>

<Grid Grid.Column="3"
RowDefinitions="Auto,Auto"
RowSpacing="2">
<Border BackgroundColor="Yellow"
StrokeShape="RoundRectangle 7"
HeightRequest="65"
WidthRequest="65"
HorizontalOptions="Center"
Margin="0,5,0,0"
Stroke="Violet"
StrokeThickness="1">
<Image Source="dotnet_bot.png"
HorizontalOptions="Center"
VerticalOptions="Center"
AutomationId="dotnetbot4"
HeightRequest="40"
WidthRequest="40" />
</Border>
<Label Grid.Row="1"
Text="Long text that should be truncated at the end of the line. This is a test "
HorizontalOptions="Center"
HorizontalTextAlignment="Center"
LineBreakMode="WordWrap"
MaxLines="2" />
</Grid>
</Grid>
</DataTemplate>
</CarouselView.ItemTemplate>
</CarouselView>
</VerticalStackLayout>
</Border>
</VerticalStackLayout>
</ContentPage>
10 changes: 10 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue28930.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Maui.Controls.Sample.Issues;

[Issue(IssueTracker.Github, 28930, "Incorrect label LineBreakMode in IOS inside CarouselView", PlatformAffected.All)]
public partial class Issue28930 : ContentPage
{
public Issue28930()
{
InitializeComponent();
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#if TEST_FAILS_ON_WINDOWS

using NUnit.Framework;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues;

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

public override string Issue => "Incorrect label LineBreakMode in IOS inside CarouselView";

[Test]
[Category(UITestCategories.CarouselView)]
public void LineBreakModeInCarouselViewShouldWork()
{
App.WaitForElement("dotnetbot1");
App.WaitForElement("dotnetbot2");
App.WaitForElement("dotnetbot3");
App.WaitForElement("dotnetbot4");

App.ScrollRight("MyCarousel", swipePercentage: 0.9, swipeSpeed: 200);

if (!App.WaitForTextToBePresentInElement("ItemLabel", "Item 2"))
{
Assert.Fail("Item 2 not found");
}
}
}

#endif
13 changes: 6 additions & 7 deletions src/Core/src/Layouts/GridLayoutManager.cs
Original file line number Diff line number Diff line change
@@ -894,13 +894,6 @@ static void ExpandStars(double targetSize, double currentSize, Definition[] defs
{
Debug.Assert(starCount > 0, "Assume that the caller has already checked for the existence of star rows/columns before using this.");

var availableSpace = targetSize - currentSize;

if (availableSpace <= 0)
{
return;
}

// Figure out which is the biggest star definition in this dimension (absolute value and star scale)
var maxCurrentSize = 0.0;
var maxCurrentStarSize = 0.0;
@@ -934,6 +927,12 @@ static void ExpandStars(double targetSize, double currentSize, Definition[] defs
return;
}

var availableSpace = targetSize - currentSize;
if (availableSpace <= 0)
{
return;
}

// We don't have enough room for all the star rows/columns to expand to their full size.
// But we still need to fill up the rest of the space, so we'll expand them proportionally.

28 changes: 28 additions & 0 deletions src/Core/tests/UnitTests/Layouts/GridLayoutManagerTests.cs
Original file line number Diff line number Diff line change
@@ -3016,6 +3016,34 @@ public void StarColsCalculateCorrectlyWhenGridHeightNearsMinHeight(double widthC
AssertArranged(view1, new Rect(0, view1ExpectedX, widths, viewHeight));
}

[Theory, Category(GridStarSizing)]
[InlineData(80, 60)]
[InlineData(100, 60)]
[InlineData(100, 70)]
[InlineData(100, 80)]
[InlineData(100, 90)]
public void StarColumnsCalculateCorrectlyWhenArrangeIsNotInSyncWithMeasure(double widthConstraint, double arrangeWidth)
{
var heights = 100;

var grid = CreateGridLayout(rows: "*,*", columns: "*,*");

var view0 = CreateTestView(new Size(50, heights));
var view2 = CreateTestView(new Size(90, heights));

SubstituteChildren(grid, view0, view2);
SetLocation(grid, view0, col: 0);
SetLocation(grid, view2, row: 1, colSpan: 2);

var manager = new GridLayoutManager(grid);
manager.Measure(widthConstraint, heights * 2);

var arrangeSize = new Size(arrangeWidth, heights * 2);
manager.ArrangeChildren(new Rect(Point.Zero, arrangeSize));

AssertArranged(view2, new Rect(0, heights, arrangeWidth, heights));
}


[Fact]
public void StarRowExpansionWorksWithDifferingScalars()
18 changes: 17 additions & 1 deletion src/TestUtils/src/UITest.Appium/HelperExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Drawing;
using OpenQA.Selenium.Appium;
using OpenQA.Selenium.Appium.Android.Enums;
@@ -91,6 +92,20 @@ public static void PressDown(this IApp app, string element)
return (string?)response.Value;
}

public static bool TryGetText(this IUIElement element, [NotNullWhen(true)] out string? text)
{
try
{
text = GetText(element);
return text is not null;
}
catch
{
text = null;
return false;
}
}

public static string? ReadText(this IUIElement element)
=> element.GetText();

@@ -799,7 +814,8 @@ public static bool WaitForTextToBePresentInElement(this IApp app, string automat
while (true)
{
var element = app.FindElements(automationId).FirstOrDefault();
if (element != null && (element.GetText()?.Contains(text, StringComparison.OrdinalIgnoreCase) ?? false))

if (element is not null && element.TryGetText(out var s) && s.Contains(text, StringComparison.OrdinalIgnoreCase))
{
return true;
}