diff --git a/src/Controls/src/Core/VisualStateManager.cs b/src/Controls/src/Core/VisualStateManager.cs index 3910d823a91a..6dd574735c45 100644 --- a/src/Controls/src/Core/VisualStateManager.cs +++ b/src/Controls/src/Core/VisualStateManager.cs @@ -272,6 +272,21 @@ public void Add(VisualStateGroup item) throw new ArgumentNullException(nameof(item)); } + // If a group with the same name already exists (e.g., set by an implicit style), + // remove it so the explicitly-added group takes precedence. + if (!string.IsNullOrEmpty(item.Name)) + { + for (int i = _internalList.Count - 1; i >= 0; i--) + { + if (string.Equals(_internalList[i].Name, item.Name, StringComparison.Ordinal)) + { + _internalList[i].StatesChanged -= ValidateAndNotify; + _internalList.Remove(_internalList[i]); + break; + } + } + } + _internalList.Add(item); item.StatesChanged += ValidateAndNotify; @@ -751,7 +766,7 @@ internal static IList Clone(this IList group group.VisualElement = clone.VisualElement; clone.Add(group.Clone()); } - + // Preserve specificity when cloning (issue #27202) if (groups is VisualStateGroupList sourceList) { diff --git a/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs b/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs index 69050e72c2b9..5765e65d4041 100644 --- a/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs +++ b/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs @@ -141,12 +141,16 @@ public void StateNamesMustBeUniqueWithinGroupListWhenAddingGroup() } [Fact] - public void GroupNamesMustBeUniqueWithinGroupList() + public void GroupWithDuplicateNameReplacesExisting() { IList vsgs = CreateTestStateGroups(); var secondGroup = new VisualStateGroup { Name = CommonStatesGroupName }; + secondGroup.States.Add(new VisualState { Name = NormalStateName }); - Assert.Throws(() => vsgs.Add(secondGroup)); + // Adding a group with the same name should replace the existing one, not throw + vsgs.Add(secondGroup); + Assert.Single(vsgs); + Assert.Same(secondGroup, vsgs[0]); } [Fact] diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui34716.xaml b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34716.xaml new file mode 100644 index 000000000000..9e27bf4d0028 --- /dev/null +++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34716.xaml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + diff --git a/src/Controls/tests/Xaml.UnitTests/Issues/Maui34716.xaml.cs b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34716.xaml.cs new file mode 100644 index 000000000000..7639a78bc2b7 --- /dev/null +++ b/src/Controls/tests/Xaml.UnitTests/Issues/Maui34716.xaml.cs @@ -0,0 +1,121 @@ +using System; +using Microsoft.Maui.Controls.Core.UnitTests; +using Xunit; + +namespace Microsoft.Maui.Controls.Xaml.UnitTests; + +public partial class Maui34716 : ContentPage +{ + public Maui34716() => InitializeComponent(); + + [Collection("Issue")] + public class Tests : IDisposable + { + public Tests() + { + Application.SetCurrentApplication(new MockApplication()); + } + + public void Dispose() + { + Application.Current = null; + } + + // Registers an implicit Button style with VisualStateGroups (like the default MAUI template Styles.xaml) + void SetupImplicitButtonStyle() + { + Application.Current.Resources.Add(new Style(typeof(Button)) + { + Setters = + { + new Setter + { + Property = VisualStateManager.VisualStateGroupsProperty, + Value = new VisualStateGroupList + { + new VisualStateGroup + { + Name = "CommonStates", + States = + { + new VisualState { Name = "Normal" }, + new VisualState { Name = "Pressed" }, + new VisualState { Name = "Disabled" } + } + } + } + } + } + }); + } + + [Theory] + [XamlInflatorData] + internal void VisualStateGroupsOnElementShouldNotThrowDuplicateNames(XamlInflator inflator) + { + var page = new Maui34716(inflator); + Assert.NotNull(page); + + var groups = VisualStateManager.GetVisualStateGroups(page.button); + Assert.Single(groups); + Assert.Equal("CommonStates", groups[0].Name); + Assert.Equal(2, groups[0].States.Count); + } + + [Theory] + [XamlInflatorData] + internal void VisualStateGroupsWithImplicitStyleShouldNotThrowDuplicateNames(XamlInflator inflator) + { + // This is the real bug scenario: an implicit style already sets VisualStateGroups + // with "CommonStates", then the XAML also sets VisualStateGroups with "CommonStates". + // The SG calls GetValue() (returning the style's list) then Add() → duplicate name → crash. + SetupImplicitButtonStyle(); + + var page = new Maui34716(inflator); + Assert.NotNull(page); + + // The explicit XAML VisualStateGroups should replace the implicit style's groups + var groups = VisualStateManager.GetVisualStateGroups(page.button); + Assert.Single(groups); + Assert.Equal("CommonStates", groups[0].Name); + Assert.Equal(2, groups[0].States.Count); + } + + [Theory] + [XamlInflatorData] + internal void VisualStateGroupsViaStyleShouldNotThrow(XamlInflator inflator) + { + var page = new Maui34716(inflator); + Assert.NotNull(page); + + var groups = VisualStateManager.GetVisualStateGroups(page.button2); + Assert.Single(groups); + Assert.Equal("CommonStates", groups[0].Name); + } + + [Theory] + [XamlInflatorData] + internal void MultipleVisualStateGroupsShouldNotThrow(XamlInflator inflator) + { + var page = new Maui34716(inflator); + Assert.NotNull(page); + + var groups = VisualStateManager.GetVisualStateGroups(page.button3); + Assert.Equal(2, groups.Count); + Assert.Equal("CommonStates", groups[0].Name); + Assert.Equal("FocusStates", groups[1].Name); + } + + [Theory] + [XamlInflatorData] + internal void ExplicitVisualStateGroupListShouldNotThrow(XamlInflator inflator) + { + var page = new Maui34716(inflator); + Assert.NotNull(page); + + var groups = VisualStateManager.GetVisualStateGroups(page.button4); + Assert.Single(groups); + Assert.Equal("CommonStates", groups[0].Name); + } + } +}