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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ void UpdateTemplatedSupplementaryView(TemplatedCell2 cell, NSString elementKind,

var bindingContext = ItemsSource.Group(indexPath);

// Mark this templated cell as a supplementary view (header/footer)
cell.isSupplementaryView = true;
cell.isHeaderOrFooterChanged = true;
cell.Bind(template, bindingContext, ItemsView);
cell.isHeaderOrFooterChanged = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ public override UICollectionViewCell GetCell(UICollectionView collectionView, NS
{
TemplatedCell2.ScrollDirection = ScrollDirection;

// Ensure this cell is treated as a regular item cell (not a supplementary view)

Copilot AI Dec 15, 2025

Copy link

Choose a reason for hiding this comment

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

The comment "Ensure this cell is treated as a regular item cell (not a supplementary view)" is helpful, but it would be more informative to explain why this is necessary. Consider expanding it to: "Ensure this cell is treated as a regular item cell (not a supplementary view) so it can use the cached first-item measurement for MeasureFirstItem strategy" to provide better context about the fix's purpose.

Suggested change
// Ensure this cell is treated as a regular item cell (not a supplementary view)
// Ensure this cell is treated as a regular item cell (not a supplementary view)
// so it can use the cached first-item measurement for MeasureFirstItem strategy

Copilot uses AI. Check for mistakes.
TemplatedCell2.isSupplementaryView = false;
TemplatedCell2.Bind(ItemsView.ItemTemplate, ItemsSource[indexpathAdjusted], ItemsView);
}
else if (cell is DefaultCell2 DefaultCell2)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ void UpdateTemplatedSupplementaryView(TemplatedCell2 cell, NSString elementKind)
{
bool isHeader = elementKind == UICollectionElementKindSectionKey.Header;
cell.isHeaderOrFooterChanged = true;
cell.isSupplementaryView = true;

if (isHeader)
{
Expand Down
29 changes: 20 additions & 9 deletions src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ public event EventHandler<LayoutAttributesChangedEventArgs2> LayoutAttributesCha
Size _measuredSize;
Size _cachedConstraints;

// Indicates the cell is being used as a supplementary view (group header/footer)
internal bool isSupplementaryView = false;

Copilot AI Dec 15, 2025

Copy link

Choose a reason for hiding this comment

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

The field isSupplementaryView does not follow C# naming conventions. Internal fields should use PascalCase with an underscore prefix (e.g., _isSupplementaryView) to be consistent with other fields in this class like _measureInvalidated, _needsArrange, _measuredSize, and _cachedConstraints.

Copilot uses AI. Check for mistakes.
internal bool MeasureInvalidated => _measureInvalidated;

// Flags changes confined to the header/footer, preventing unnecessary recycling and revalidation of templated cells.
Expand Down Expand Up @@ -107,20 +109,28 @@ public override UICollectionViewLayoutAttributes PreferredLayoutAttributesFittin

if (_measureInvalidated || _cachedConstraints != constraints)
{
// Check if we should use the cached first item size for MeasureFirstItem optimization
var cachedSize = GetCachedFirstItemSizeFromHandler();
if (cachedSize != CGSize.Empty)
// Only use the cached first-item measurement for actual item cells (not headers/footers)
if (!isSupplementaryView)
{
_measuredSize = cachedSize.ToSize();
// Even when we have a cached measurement, we still need to call Measure
// to update the virtual view's internal state and bookkeeping
virtualView.Measure(constraints.Width, _measuredSize.Height);
var cachedSize = GetCachedFirstItemSizeFromHandler();
if (cachedSize != CGSize.Empty)
{
_measuredSize = cachedSize.ToSize();
// Even when we have a cached measurement, we still need to call Measure
// to update the virtual view's internal state and bookkeeping
virtualView.Measure(constraints.Width, _measuredSize.Height);
}
else
{
_measuredSize = virtualView.Measure(constraints.Width, constraints.Height);
// If this is the first item being measured, cache it for MeasureFirstItem strategy
SetCachedFirstItemSizeToHandler(_measuredSize.ToCGSize());
}
}
else
{
// For headers/footers, always measure directly without using or updating the first-item cache
_measuredSize = virtualView.Measure(constraints.Width, constraints.Height);
// If this is the first item being measured, cache it for MeasureFirstItem strategy
SetCachedFirstItemSizeToHandler(_measuredSize.ToCGSize());
}
_cachedConstraints = constraints;
_needsArrange = true;
Expand Down Expand Up @@ -194,6 +204,7 @@ public override void LayoutSubviews()
public override void PrepareForReuse()
{
//Unbind();
isSupplementaryView = false;
base.PrepareForReuse();
}

Expand Down
71 changes: 71 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue33130.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?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.Issue33130"
xmlns:local="clr-namespace:Maui.Controls.Sample"
Title="Issue33130">
<Grid Margin="20"
RowDefinitions="Auto, *">
<StackLayout Grid.Row="0"
Spacing="10"
Margin="0,10">
<Button Text="Switch to MeasureFirstItem"
Clicked="OnSwitchToMeasureFirstItem"
AutomationId="SwitchStrategyButton"/>
<Label x:Name="StatusLabel"
AutomationId="StatusLabel"
Text="ItemSizingStrategy: MeasureAllItems"/>
</StackLayout>

<local:CollectionView2 Grid.Row="1"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Set this to just CollectionView if this is passing on both CV1 and CV2

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Set this to just CollectionView if this is passing on both CV1 and CV2

Hi @PureWeen ,
I have changed CollectionView2 to CollectionView to ensure the test case passes on both CV1 and CV2.

x:Name="TestCollectionView"
ItemsSource="{Binding Animals}"
IsGrouped="true"
AutomationId="TestCollectionView"
ItemSizingStrategy="MeasureAllItems">
<local:CollectionView2.ItemTemplate>
<DataTemplate>
<Grid Padding="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Image Grid.RowSpan="2"
Source="{Binding ImageUrl}"
Aspect="AspectFill"
WidthRequest="80"
HeightRequest="80"/>
<Label Grid.Column="1"
Text="{Binding Name}"
FontAttributes="Bold"/>
<Label Grid.Row="1"
Grid.Column="1"
Text="{Binding Location}"
FontAttributes="Italic"
VerticalOptions="End"/>
</Grid>
</DataTemplate>
</local:CollectionView2.ItemTemplate>
<local:CollectionView2.GroupHeaderTemplate>
<DataTemplate>
<Label x:Name="GroupHeaderLabel"
Text="{Binding Name}"
BackgroundColor="LightGray"
FontSize="20"
FontAttributes="Bold"
AutomationId="GroupHeader"/>
</DataTemplate>
</local:CollectionView2.GroupHeaderTemplate>
<local:CollectionView2.GroupFooterTemplate>
<DataTemplate>
<Label Text="{Binding Count, StringFormat='Total animals: {0:D}'}"
Margin="0,0,0,10"/>
</DataTemplate>
</local:CollectionView2.GroupFooterTemplate>
</local:CollectionView2>
</Grid>
</ContentPage>
66 changes: 66 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue33130.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System;
using System.Collections.ObjectModel;
using Microsoft.Maui.Controls;

namespace Maui.Controls.Sample.Issues;

[Issue(IssueTracker.Github, 33130, "CollectionView group header size changes with ItemSizingStrategy", PlatformAffected.iOS | PlatformAffected.macOS)]
public partial class Issue33130 : ContentPage
{
public Issue33130()
{
InitializeComponent();
BindingContext = new GroupedAnimalsViewModel();
}

private void OnSwitchToMeasureFirstItem(object sender, EventArgs e)
{
TestCollectionView.ItemSizingStrategy = ItemSizingStrategy.MeasureFirstItem;
StatusLabel.Text = $"ItemSizingStrategy: {TestCollectionView.ItemSizingStrategy}";
}
}

public class GroupedAnimalsViewModel
{
public ObservableCollection<GroupHeaderTestAnimalGroup> Animals { get; set; }

public GroupedAnimalsViewModel()
{
Animals = new ObservableCollection<GroupHeaderTestAnimalGroup>
{
new GroupHeaderTestAnimalGroup("Bears")
{
new GroupHeaderTestAnimal { Name = "Grizzly Bear", Location = "North America", ImageUrl = "bear.jpg" },
new GroupHeaderTestAnimal { Name = "Polar Bear", Location = "Arctic", ImageUrl = "bear.jpg" },
},
new GroupHeaderTestAnimalGroup("Monkeys")
{
new GroupHeaderTestAnimal { Name = "Baboon", Location = "Africa", ImageUrl = "monkey.jpg" },
new GroupHeaderTestAnimal { Name = "Capuchin Monkey", Location = "South America", ImageUrl = "monkey.jpg" },
new GroupHeaderTestAnimal { Name = "Spider Monkey", Location = "Central America", ImageUrl = "monkey.jpg" },
},
new GroupHeaderTestAnimalGroup("Elephants")
{
new GroupHeaderTestAnimal { Name = "African Elephant", Location = "Africa", ImageUrl = "elephant.jpg" },
new GroupHeaderTestAnimal { Name = "Asian Elephant", Location = "Asia", ImageUrl = "elephant.jpg" },
}
};
}
}

public class GroupHeaderTestAnimalGroup : ObservableCollection<GroupHeaderTestAnimal>

Copilot AI Dec 15, 2025

Copy link

Choose a reason for hiding this comment

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

The class name GroupHeaderTestAnimalGroup is verbose and redundant. Following the naming pattern used in other test files (e.g., Issue12374Model), this should be named AnimalGroup or Issue33130AnimalGroup to be more concise while maintaining clarity.

Copilot uses AI. Check for mistakes.
{
public string Name { get; set; }

public GroupHeaderTestAnimalGroup(string name) : base()
{
Name = name;
}
}

public class GroupHeaderTestAnimal

Copilot AI Dec 15, 2025

Copy link

Choose a reason for hiding this comment

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

The class name GroupHeaderTestAnimal is verbose with redundant "GroupHeaderTest" prefix. Following the naming pattern used in other test files (e.g., Issue12374Model), this should be named Animal or Issue33130Animal to be more concise while maintaining clarity.

Suggested change
public ObservableCollection<GroupHeaderTestAnimalGroup> Animals { get; set; }
public GroupedAnimalsViewModel()
{
Animals = new ObservableCollection<GroupHeaderTestAnimalGroup>
{
new GroupHeaderTestAnimalGroup("Bears")
{
new GroupHeaderTestAnimal { Name = "Grizzly Bear", Location = "North America", ImageUrl = "bear.jpg" },
new GroupHeaderTestAnimal { Name = "Polar Bear", Location = "Arctic", ImageUrl = "bear.jpg" },
},
new GroupHeaderTestAnimalGroup("Monkeys")
{
new GroupHeaderTestAnimal { Name = "Baboon", Location = "Africa", ImageUrl = "monkey.jpg" },
new GroupHeaderTestAnimal { Name = "Capuchin Monkey", Location = "South America", ImageUrl = "monkey.jpg" },
new GroupHeaderTestAnimal { Name = "Spider Monkey", Location = "Central America", ImageUrl = "monkey.jpg" },
},
new GroupHeaderTestAnimalGroup("Elephants")
{
new GroupHeaderTestAnimal { Name = "African Elephant", Location = "Africa", ImageUrl = "elephant.jpg" },
new GroupHeaderTestAnimal { Name = "Asian Elephant", Location = "Asia", ImageUrl = "elephant.jpg" },
}
};
}
}
public class GroupHeaderTestAnimalGroup : ObservableCollection<GroupHeaderTestAnimal>
{
public string Name { get; set; }
public GroupHeaderTestAnimalGroup(string name) : base()
{
Name = name;
}
}
public class GroupHeaderTestAnimal
public ObservableCollection<Issue33130AnimalGroup> Animals { get; set; }
public GroupedAnimalsViewModel()
{
Animals = new ObservableCollection<Issue33130AnimalGroup>
{
new Issue33130AnimalGroup("Bears")
{
new Issue33130Animal { Name = "Grizzly Bear", Location = "North America", ImageUrl = "bear.jpg" },
new Issue33130Animal { Name = "Polar Bear", Location = "Arctic", ImageUrl = "bear.jpg" },
},
new Issue33130AnimalGroup("Monkeys")
{
new Issue33130Animal { Name = "Baboon", Location = "Africa", ImageUrl = "monkey.jpg" },
new Issue33130Animal { Name = "Capuchin Monkey", Location = "South America", ImageUrl = "monkey.jpg" },
new Issue33130Animal { Name = "Spider Monkey", Location = "Central America", ImageUrl = "monkey.jpg" },
},
new Issue33130AnimalGroup("Elephants")
{
new Issue33130Animal { Name = "African Elephant", Location = "Africa", ImageUrl = "elephant.jpg" },
new Issue33130Animal { Name = "Asian Elephant", Location = "Asia", ImageUrl = "elephant.jpg" },
}
};
}
}
public class Issue33130AnimalGroup : ObservableCollection<Issue33130Animal>
{
public string Name { get; set; }
public Issue33130AnimalGroup(string name) : base()
{
Name = name;
}
}
public class Issue33130Animal

Copilot uses AI. Check for mistakes.
{
public string Name { get; set; }
public string Location { get; set; }
public string ImageUrl { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues;

public class Issue33130 : _IssuesUITest
{
public override string Issue => "CollectionView group header size changes with ItemSizingStrategy";

public Issue33130(TestDevice device) : base(device) { }
[Test]
[Category(UITestCategories.CollectionView)]
public void GroupHeaderSizeShouldNotChangeWithItemSizingStrategy()
{
// Wait for the CollectionView to load
App.WaitForElement("TestCollectionView");
App.WaitForElement("GroupHeader");

// Get the initial header size (before changing ItemSizingStrategy)
var headerElementBefore = App.FindElement("GroupHeader");
var headerRectBefore = headerElementBefore.GetRect();

Assert.That(headerRectBefore.Height, Is.GreaterThan(0), "Header should have a height before strategy change");

// Switch ItemSizingStrategy
App.WaitForElement("SwitchStrategyButton");
App.Tap("SwitchStrategyButton");

// Get the header size after changing ItemSizingStrategy
var headerElementAfter = App.FindElement("GroupHeader");
var headerRectAfter = headerElementAfter.GetRect();

Assert.That(headerRectAfter.Height, Is.GreaterThan(0), "Header should have a height after strategy change");

// The header size should remain the same (within a small tolerance for rendering differences)
// Allow for small rounding differences but not significant changes
var heightDifference = Math.Abs(headerRectBefore.Height - headerRectAfter.Height);

// Assert that the height difference is minimal (less than 5 pixels tolerance)
Assert.That(heightDifference, Is.EqualTo(0),

Copilot AI Dec 15, 2025

Copy link

Choose a reason for hiding this comment

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

The assertion uses Is.EqualTo(0) which expects the height difference to be exactly zero. However, the comment on line 37 mentions "Allow for small rounding differences". This is inconsistent. If you want to allow for small rounding differences, the assertion should use a tolerance (e.g., Is.EqualTo(0).Within(1) or Is.LessThanOrEqualTo(1)) instead of requiring exact equality. As written, the test may fail due to minor floating-point precision differences across platforms.

Suggested change
Assert.That(heightDifference, Is.EqualTo(0),
Assert.That(heightDifference, Is.EqualTo(0).Within(5),

Copilot uses AI. Check for mistakes.
$"Header height should not change significantly. Before: {headerRectBefore.Height}, After: {headerRectAfter.Height}, Difference: {heightDifference}");
}
}
Loading