From f77dc8e8f9f66c0a0bf6e3449b2a965f7ad49bf4 Mon Sep 17 00:00:00 2001 From: Dhivya-SF4094 <127717131+Dhivya-SF4094@users.noreply.github.com> Date: Wed, 13 May 2026 19:26:13 +0530 Subject: [PATCH 1/3] Fix VisualElement's ChangeVisualState gets stuck in Selected state --- .../Items/Android/TemplatedItemViewHolder.cs | 1 + .../Core/Handlers/Items/iOS/TemplatedCell.cs | 1 + .../Handlers/Items2/iOS/TemplatedCell2.cs | 1 + .../CollectionView/ItemContentControl.cs | 1 + .../src/Core/VisualElement/VisualElement.cs | 17 ++- src/Controls/src/Core/VisualStateManager.cs | 11 +- ...sualStateManagerCollectionViewPage.xaml.cs | 6 +- .../TestCases.HostApp/Issues/Issue35399.cs | 129 ++++++++++++++++++ .../Tests/Issues/Issue35399.cs | 38 ++++++ 9 files changed, 191 insertions(+), 14 deletions(-) create mode 100644 src/Controls/tests/TestCases.HostApp/Issues/Issue35399.cs create mode 100644 src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue35399.cs diff --git a/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs b/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs index 184d53455b63..c20974cf09e0 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs @@ -29,6 +29,7 @@ protected override void OnSelectedChanged() return; } + View.IsItemSelected = IsSelected; VisualStateManager.GoToState(View, IsSelected ? VisualStateManager.CommonStates.Selected : VisualStateManager.CommonStates.Normal); diff --git a/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs b/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs index f0ef75743234..0d01dc8c25c8 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs @@ -342,6 +342,7 @@ void UpdateVisualStates() { if (PlatformHandler?.VirtualView is VisualElement element) { + element.IsItemSelected = Selected; VisualStateManager.GoToState(element, Selected ? VisualStateManager.CommonStates.Selected : VisualStateManager.CommonStates.Normal); diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs index b8dad3ef4324..dc5dbae4a3de 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs @@ -369,6 +369,7 @@ void UpdateVisualStates() { if (PlatformHandler?.VirtualView is VisualElement element) { + element.IsItemSelected = Selected; VisualStateManager.GoToState(element, Selected ? VisualStateManager.CommonStates.Selected : VisualStateManager.CommonStates.Normal); diff --git a/src/Controls/src/Core/Platform/Windows/CollectionView/ItemContentControl.cs b/src/Controls/src/Core/Platform/Windows/CollectionView/ItemContentControl.cs index 12505d7839b6..a0c40121bc9d 100644 --- a/src/Controls/src/Core/Platform/Windows/CollectionView/ItemContentControl.cs +++ b/src/Controls/src/Core/Platform/Windows/CollectionView/ItemContentControl.cs @@ -261,6 +261,7 @@ internal void UpdateIsSelected(bool isSelected) if (formsElement == null) return; + formsElement.IsItemSelected = isSelected; VisualStateManager.GoToState(formsElement, isSelected ? VisualStateManager.CommonStates.Selected : VisualStateManager.CommonStates.Normal); diff --git a/src/Controls/src/Core/VisualElement/VisualElement.cs b/src/Controls/src/Core/VisualElement/VisualElement.cs index 27503d9fdd0d..61a94e95d637 100644 --- a/src/Controls/src/Core/VisualElement/VisualElement.cs +++ b/src/Controls/src/Core/VisualElement/VisualElement.cs @@ -1653,6 +1653,19 @@ void PropagateBindingContextToStateTriggers() internal void ChangeVisualStateInternal() => ChangeVisualState(); bool _isPointerOver; + bool _isItemSelected; + + /// + /// Tracks whether this element has been explicitly put in the Selected visual state + /// by platform handlers (e.g., CollectionView, Shell flyout). This provides a non-circular + /// source of truth for ChangeVisualState() to preserve the Selected state during + /// pointer and focus transitions. + /// + internal bool IsItemSelected + { + get => _isItemSelected; + set => _isItemSelected = value; + } internal bool IsPointerOver { @@ -1665,8 +1678,10 @@ private protected void SetPointerOver(bool value, bool callChangeVisualState = t return; _isPointerOver = value; - if (callChangeVisualState) + if (callChangeVisualState && !_isItemSelected) + { ChangeVisualState(); + } } /// diff --git a/src/Controls/src/Core/VisualStateManager.cs b/src/Controls/src/Core/VisualStateManager.cs index 6c74249d2959..1e745a84c065 100644 --- a/src/Controls/src/Core/VisualStateManager.cs +++ b/src/Controls/src/Core/VisualStateManager.cs @@ -816,16 +816,7 @@ internal static bool HasVisualState(this VisualElement element, string name) internal static bool IsElementInSelectedState(this VisualElement element) { - var groups = VisualStateManager.GetVisualStateGroups(element); - foreach (var group in groups) - { - if (group.CurrentState?.Name == VisualStateManager.CommonStates.Selected) - { - return true; - } - } - - return false; + return element.IsItemSelected; } } diff --git a/src/Controls/tests/TestCases.HostApp/FeatureMatrix/VisualStateManager/VSMCollectionViewPage/VisualStateManagerCollectionViewPage.xaml.cs b/src/Controls/tests/TestCases.HostApp/FeatureMatrix/VisualStateManager/VSMCollectionViewPage/VisualStateManagerCollectionViewPage.xaml.cs index 673992f896d3..4a421b88905f 100644 --- a/src/Controls/tests/TestCases.HostApp/FeatureMatrix/VisualStateManager/VSMCollectionViewPage/VisualStateManagerCollectionViewPage.xaml.cs +++ b/src/Controls/tests/TestCases.HostApp/FeatureMatrix/VisualStateManager/VSMCollectionViewPage/VisualStateManagerCollectionViewPage.xaml.cs @@ -73,7 +73,7 @@ void OnItemPointerExited(object sender, PointerEventArgs e) if (sender is VisualElement ve) { var bc = ve.BindingContext; - var isSelected = IsItemSelected(bc); + var isSelected = IsItemCurrentlySelected(bc); VisualStateManager.GoToState(ve, isSelected ? "Selected" : "Normal"); CVState.Text = GetSelectedCount() > 0 ? $"State: Selected ({GetSelectedCount()})" : "State: Normal"; } @@ -86,7 +86,7 @@ int GetSelectedCount() return MyCollectionView.SelectedItem != null ? 1 : 0; } - bool IsItemSelected(object item) + bool IsItemCurrentlySelected(object item) { if (item is null) return false; @@ -123,7 +123,7 @@ void OnSelectItem(object sender, EventArgs e) void OnSetNormal(object sender, EventArgs e) { - if(!MyCollectionView.IsEnabled) + if (!MyCollectionView.IsEnabled) { CVState.Text = "State: Disabled"; return; diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue35399.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue35399.cs new file mode 100644 index 000000000000..d994eebb8fae --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue35399.cs @@ -0,0 +1,129 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 35399, "VisualElement.ChangeVisualState() gets stuck in Selected state", PlatformAffected.All)] +public class Issue35399 : TestContentPage +{ + const string SelectButtonId = "SelectButton"; + const string DeselectButtonId = "DeselectButton"; + const string StateLabelId = "StateLabel"; + + SelectableBox _box = null!; + Label _stateLabel = null!; + + protected override void Init() + { + _stateLabel = new Label + { + AutomationId = StateLabelId, + HorizontalOptions = LayoutOptions.Center, + FontSize = 14 + }; + + _box = new SelectableBox + { + HeightRequest = 100, + WidthRequest = 200, + HorizontalOptions = LayoutOptions.Center + }; + + Content = new VerticalStackLayout + { + Padding = 24, + Spacing = 16, + Children = + { + _box, + _stateLabel, + new HorizontalStackLayout + { + HorizontalOptions = LayoutOptions.Center, + Spacing = 12, + Children = + { + new Button { Text = "Select", AutomationId = SelectButtonId, Command = new Command(OnSelect) }, + new Button { Text = "Deselect", AutomationId = DeselectButtonId, Command = new Command(OnDeselect) } + } + } + } + }; + } + + void OnSelect() + { + _box.IsSelected = true; + UpdateStateLabel(); + } + + void OnDeselect() + { + _box.IsSelected = false; + UpdateStateLabel(); + } + + void UpdateStateLabel() + { + var groups = VisualStateManager.GetVisualStateGroups(_box); + _stateLabel.Text = groups.Count > 0 ? (groups[0].CurrentState?.Name ?? "None") : "None"; + } + + // Reproduces the ChangeVisualState() pattern from the issue report: + // when IsSelected goes false, base.ChangeVisualState() is called while the VSM + // group's CurrentState is still "Selected", causing the base to re-apply it. + class SelectableBox : ContentView + { + public static readonly BindableProperty IsSelectedProperty = + BindableProperty.Create( + nameof(IsSelected), + typeof(bool), + typeof(SelectableBox), + defaultValue: false, + propertyChanged: (b, _, _) => ((SelectableBox)b).ChangeVisualState()); + + public bool IsSelected + { + get => (bool)GetValue(IsSelectedProperty); + set => SetValue(IsSelectedProperty, value); + } + + public SelectableBox() + { + VisualStateManager.SetVisualStateGroups(this, new VisualStateGroupList + { + new VisualStateGroup + { + Name = "CommonStates", + States = + { + new VisualState + { + Name = VisualStateManager.CommonStates.Normal, + Setters = { new Setter { Property = BackgroundColorProperty, Value = Colors.LightGray } } + }, + new VisualState + { + Name = VisualStateManager.CommonStates.Selected, + Setters = { new Setter { Property = BackgroundColorProperty, Value = Colors.MediumSeaGreen } } + }, + new VisualState + { + Name = VisualStateManager.CommonStates.Disabled, + Setters = { new Setter { Property = BackgroundColorProperty, Value = Colors.DarkGray } } + } + } + } + }); + } + + protected internal override void ChangeVisualState() + { + if (IsSelected && IsEnabled) + { + VisualStateManager.GoToState(this, VisualStateManager.CommonStates.Selected); + } + else + { + base.ChangeVisualState(); + } + } + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue35399.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue35399.cs new file mode 100644 index 000000000000..7b7d72215079 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue35399.cs @@ -0,0 +1,38 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue35399 : _IssuesUITest +{ + public Issue35399(TestDevice device) : base(device) { } + + public override string Issue => "VisualElement.ChangeVisualState() gets stuck in Selected state"; + + [Test] + [Category(UITestCategories.VisualStateManager)] + public void ChangeVisualStateShouldExitSelectedStateAfterDeselect() + { + App.WaitForElement(SelectButtonId); + + // Transition to Selected state + App.Tap(SelectButtonId); + var stateAfterSelect = App.FindElement(StateLabelId).GetText(); + Assert.That(stateAfterSelect, Is.EqualTo("Selected"), + "After selecting, the element should be in the Selected visual state."); + + // Deselect: base.ChangeVisualState() should transition back to Normal. + // Regression: on .NET 10, IsElementInSelectedState() still reads "Selected" from + // the VSM group's CurrentState, causing base to re-apply Selected even though + // IsSelected is now false. + App.Tap(DeselectButtonId); + var stateAfterDeselect = App.FindElement(StateLabelId).GetText(); + Assert.That(stateAfterDeselect, Is.EqualTo("Normal"), + "After deselecting, base.ChangeVisualState() must transition the element to Normal, not remain stuck in Selected."); + } + + const string SelectButtonId = "SelectButton"; + const string DeselectButtonId = "DeselectButton"; + const string StateLabelId = "StateLabel"; +} From e11a8b2c77a36fb81bb723aba8fe9a4e92506601 Mon Sep 17 00:00:00 2001 From: Dhivya-SF4094 <127717131+Dhivya-SF4094@users.noreply.github.com> Date: Wed, 13 May 2026 20:20:58 +0530 Subject: [PATCH 2/3] Updated fix --- .../CollectionView/TemplatedItemViewHolder.cs | 4 +- .../CollectionView/ItemContentControl.cs | 4 +- .../src/iOS/CollectionView/TemplatedCell.cs | 4 +- .../Core/src/iOS/Renderers/UIContainerCell.cs | 5 +- .../Android/ShellFlyoutRecyclerAdapter.cs | 5 +- .../Handlers/Shell/iOS/UIContainerCell.cs | 5 +- .../Items/Android/TemplatedItemViewHolder.cs | 5 +- .../Items/Tizen/ItemTemplateAdaptor.cs | 4 +- .../Core/Handlers/Items/iOS/TemplatedCell.cs | 5 +- .../Handlers/Items2/iOS/TemplatedCell2.cs | 7 +- .../Shell/Windows/ShellFlyoutItemView.cs | 5 +- .../IndicatorView/IndicatorStackLayout.cs | 4 +- .../CollectionView/ItemContentControl.cs | 5 +- .../src/Core/VisualElement/VisualElement.cs | 24 ++++- .../Core.UnitTests/VisualStateManagerTests.cs | 91 +++++++++++++++++++ 15 files changed, 127 insertions(+), 50 deletions(-) diff --git a/src/Compatibility/Core/src/Android/CollectionView/TemplatedItemViewHolder.cs b/src/Compatibility/Core/src/Android/CollectionView/TemplatedItemViewHolder.cs index 26d9193ff92d..67eebc634e80 100644 --- a/src/Compatibility/Core/src/Android/CollectionView/TemplatedItemViewHolder.cs +++ b/src/Compatibility/Core/src/Android/CollectionView/TemplatedItemViewHolder.cs @@ -31,9 +31,7 @@ protected override void OnSelectedChanged() return; } - VisualStateManager.GoToState(View, IsSelected - ? VisualStateManager.CommonStates.Selected - : VisualStateManager.CommonStates.Normal); + View.SetSelectedState(IsSelected); } public void Recycle(ItemsView itemsView) diff --git a/src/Compatibility/Core/src/Windows/CollectionView/ItemContentControl.cs b/src/Compatibility/Core/src/Windows/CollectionView/ItemContentControl.cs index a19d0bdf7e3f..fd5b6211c366 100644 --- a/src/Compatibility/Core/src/Windows/CollectionView/ItemContentControl.cs +++ b/src/Compatibility/Core/src/Windows/CollectionView/ItemContentControl.cs @@ -187,9 +187,7 @@ internal void UpdateIsSelected(bool isSelected) if (formsElement == null) return; - VisualStateManager.GoToState(formsElement, isSelected - ? VisualStateManager.CommonStates.Selected - : VisualStateManager.CommonStates.Normal); + formsElement.SetSelectedState(isSelected); } void OnViewMeasureInvalidated(object sender, EventArgs e) diff --git a/src/Compatibility/Core/src/iOS/CollectionView/TemplatedCell.cs b/src/Compatibility/Core/src/iOS/CollectionView/TemplatedCell.cs index 119c3cc2b474..4bd803fe4919 100644 --- a/src/Compatibility/Core/src/iOS/CollectionView/TemplatedCell.cs +++ b/src/Compatibility/Core/src/iOS/CollectionView/TemplatedCell.cs @@ -295,9 +295,7 @@ void UpdateVisualStates() if (element != null) { - VisualStateManager.GoToState(element, Selected - ? VisualStateManager.CommonStates.Selected - : VisualStateManager.CommonStates.Normal); + element.SetSelectedState(Selected); } } } diff --git a/src/Compatibility/Core/src/iOS/Renderers/UIContainerCell.cs b/src/Compatibility/Core/src/iOS/Renderers/UIContainerCell.cs index d8dd7c43dff6..d7b24f38bfb0 100644 --- a/src/Compatibility/Core/src/iOS/Renderers/UIContainerCell.cs +++ b/src/Compatibility/Core/src/iOS/Renderers/UIContainerCell.cs @@ -107,10 +107,7 @@ void UpdateVisualState() { if (BindingContext is BaseShellItem baseShellItem && baseShellItem != null) { - if (baseShellItem.IsChecked) - VisualStateManager.GoToState(View, "Selected"); - else - VisualStateManager.GoToState(View, "Normal"); + View.SetSelectedState(baseShellItem.IsChecked); } } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs index 558f99d4c436..077dc58ab3f4 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs @@ -308,10 +308,7 @@ void UpdateVisualState() { if (Element is BaseShellItem baseShellItem && baseShellItem != null) { - if (baseShellItem.IsChecked) - VisualStateManager.GoToState(View, "Selected"); - else - VisualStateManager.GoToState(View, "Normal"); + View.SetSelectedState(baseShellItem.IsChecked); } } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs index b93e55b34e83..3a8f42e5485d 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs @@ -131,10 +131,7 @@ void UpdateVisualState() { if (BindingContext is BaseShellItem baseShellItem && baseShellItem != null) { - if (baseShellItem.IsChecked) - VisualStateManager.GoToState(View, "Selected"); - else - VisualStateManager.GoToState(View, "Normal"); + View.SetSelectedState(baseShellItem.IsChecked); } } diff --git a/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs b/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs index c20974cf09e0..860dfc005c84 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs @@ -29,10 +29,7 @@ protected override void OnSelectedChanged() return; } - View.IsItemSelected = IsSelected; - VisualStateManager.GoToState(View, IsSelected - ? VisualStateManager.CommonStates.Selected - : VisualStateManager.CommonStates.Normal); + View.SetSelectedState(IsSelected); } public void Recycle(ItemsView itemsView) diff --git a/src/Controls/src/Core/Handlers/Items/Tizen/ItemTemplateAdaptor.cs b/src/Controls/src/Core/Handlers/Items/Tizen/ItemTemplateAdaptor.cs index 82e7a05f34ab..f7ea969bef63 100644 --- a/src/Controls/src/Core/Handlers/Items/Tizen/ItemTemplateAdaptor.cs +++ b/src/Controls/src/Core/Handlers/Items/Tizen/ItemTemplateAdaptor.cs @@ -80,12 +80,12 @@ public override void UpdateViewState(NView view, ViewHolderState state) formsView.SetValue(VisualElement.IsFocusedPropertyKey, true); break; case ViewHolderState.Normal: - VisualStateManager.GoToState(formsView, VisualStateManager.CommonStates.Normal); + formsView.SetSelectedState(false); formsView.SetValue(VisualElement.IsFocusedPropertyKey, false); break; case ViewHolderState.Selected: if (IsSelectable) - VisualStateManager.GoToState(formsView, VisualStateManager.CommonStates.Selected); + formsView.SetSelectedState(true); break; } } diff --git a/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs b/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs index 0d01dc8c25c8..4b06f9594bfc 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs @@ -342,10 +342,7 @@ void UpdateVisualStates() { if (PlatformHandler?.VirtualView is VisualElement element) { - element.IsItemSelected = Selected; - VisualStateManager.GoToState(element, Selected - ? VisualStateManager.CommonStates.Selected - : VisualStateManager.CommonStates.Normal); + element.SetSelectedState(Selected); } } diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs index dc5dbae4a3de..79dd3610bdec 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs @@ -267,7 +267,7 @@ void BindVirtualView(View virtualView, object bindingContext, ItemsView itemsVie virtualView.BindingContext = bindingContext; itemsView.AddLogicalChild(virtualView); - + if (this.Selected) { UpdateVisualStates(); @@ -369,10 +369,7 @@ void UpdateVisualStates() { if (PlatformHandler?.VirtualView is VisualElement element) { - element.IsItemSelected = Selected; - VisualStateManager.GoToState(element, Selected - ? VisualStateManager.CommonStates.Selected - : VisualStateManager.CommonStates.Normal); + element.SetSelectedState(Selected); } } diff --git a/src/Controls/src/Core/Handlers/Shell/Windows/ShellFlyoutItemView.cs b/src/Controls/src/Core/Handlers/Shell/Windows/ShellFlyoutItemView.cs index b61e4525b862..b2fd02efd85a 100644 --- a/src/Controls/src/Core/Handlers/Shell/Windows/ShellFlyoutItemView.cs +++ b/src/Controls/src/Core/Handlers/Shell/Windows/ShellFlyoutItemView.cs @@ -141,10 +141,7 @@ void UpdateVisualState() { if (_content?.BindingContext is BaseShellItem baseShellItem && baseShellItem != null) { - if (baseShellItem.IsChecked) - VisualStateManager.GoToState(_content, "Selected"); - else - VisualStateManager.GoToState(_content, "Normal"); + _content.SetSelectedState(baseShellItem.IsChecked); } } diff --git a/src/Controls/src/Core/IndicatorView/IndicatorStackLayout.cs b/src/Controls/src/Core/IndicatorView/IndicatorStackLayout.cs index 9e1ee609a23e..c86e7c7db894 100644 --- a/src/Controls/src/Core/IndicatorView/IndicatorStackLayout.cs +++ b/src/Controls/src/Core/IndicatorView/IndicatorStackLayout.cs @@ -135,9 +135,7 @@ void ResetIndicatorStylesNonBatch() : GetColorOrDefault(_indicatorView.IndicatorColor, Colors.Silver); - VisualStateManager.GoToState(visualElement, isSelected - ? VisualStateManager.CommonStates.Selected - : VisualStateManager.CommonStates.Normal); + visualElement.SetSelectedState(isSelected); } diff --git a/src/Controls/src/Core/Platform/Windows/CollectionView/ItemContentControl.cs b/src/Controls/src/Core/Platform/Windows/CollectionView/ItemContentControl.cs index a0c40121bc9d..8be0048504b2 100644 --- a/src/Controls/src/Core/Platform/Windows/CollectionView/ItemContentControl.cs +++ b/src/Controls/src/Core/Platform/Windows/CollectionView/ItemContentControl.cs @@ -261,10 +261,7 @@ internal void UpdateIsSelected(bool isSelected) if (formsElement == null) return; - formsElement.IsItemSelected = isSelected; - VisualStateManager.GoToState(formsElement, isSelected - ? VisualStateManager.CommonStates.Selected - : VisualStateManager.CommonStates.Normal); + formsElement.SetSelectedState(isSelected); } void OnViewMeasureInvalidated(object sender, EventArgs e) diff --git a/src/Controls/src/Core/VisualElement/VisualElement.cs b/src/Controls/src/Core/VisualElement/VisualElement.cs index 61a94e95d637..4c9b219f0253 100644 --- a/src/Controls/src/Core/VisualElement/VisualElement.cs +++ b/src/Controls/src/Core/VisualElement/VisualElement.cs @@ -1667,6 +1667,26 @@ internal bool IsItemSelected set => _isItemSelected = value; } + /// + /// Sets the selected state flag and transitions the visual state accordingly. + /// All platform code that selects/deselects items should use this single entry point + /// so that IsItemSelected and the VSM state are always kept in sync. + /// + internal void SetSelectedState(bool isSelected) + { + _isItemSelected = isSelected; + if (isSelected) + { + VisualStateManager.GoToState(this, VisualStateManager.CommonStates.Selected); + } + else + { + // Re-evaluate the full state chain so that PointerOver, Disabled, etc. + // are correctly applied instead of blindly going to Normal. + ChangeVisualState(); + } + } + internal bool IsPointerOver { get { return _isPointerOver; } @@ -1678,10 +1698,8 @@ private protected void SetPointerOver(bool value, bool callChangeVisualState = t return; _isPointerOver = value; - if (callChangeVisualState && !_isItemSelected) - { + if (callChangeVisualState) ChangeVisualState(); - } } /// diff --git a/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs b/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs index 09e74cdb3c7c..9dd603597997 100644 --- a/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs +++ b/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs @@ -646,5 +646,96 @@ public void CustomImplicitStyleVSMStateDoesNotOverrideLocalValue() VisualStateManager.GoToState(button, customStateName); Assert.Equal(localColor, button.BackgroundColor); } + + [Fact] + // https://github.com/dotnet/maui/issues/35399 + public void SelectHoverDeselectRestoresPointerOverState() + { + var element = new Label(); + var groups = CreateStateGroupsWithSelectedAndPointerOver(); + VisualStateManager.SetVisualStateGroups(element, groups); + + // 1. Select the item (simulates CollectionView selection) + element.SetSelectedState(true); + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + + // 2. Simulate pointer hover while selected — Selected takes priority + SetIsPointerOver(element, true); + element.ChangeVisualState(); + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + + // 3. Deselect while pointer is still hovering — should restore to PointerOver + element.SetSelectedState(false); + Assert.Equal(VisualStateManager.CommonStates.PointerOver, groups[0].CurrentState.Name); + } + + [Fact] + // https://github.com/dotnet/maui/issues/35399 + public void SelectedStatePreservedAcrossMouseHover() + { + var element = new Label(); + var groups = CreateStateGroupsWithSelectedAndPointerOver(); + VisualStateManager.SetVisualStateGroups(element, groups); + + // Select the item (simulates Shell flyout item selection) + element.SetSelectedState(true); + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + + // Pointer enters — Selected should be preserved + SetIsPointerOver(element, true); + element.ChangeVisualState(); + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + + // Pointer exits — Selected should still be preserved + SetIsPointerOver(element, false); + element.ChangeVisualState(); + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + } + + [Fact] + // https://github.com/dotnet/maui/issues/35399 + public void IsEnabledToggleWhileSelectedPreservesState() + { + var element = new Label(); + var groups = CreateStateGroupsWithSelectedAndPointerOver(); + VisualStateManager.SetVisualStateGroups(element, groups); + + // Select the item + element.SetSelectedState(true); + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + + // Disable — should go to Disabled + element.IsEnabled = false; + Assert.Equal(DisabledStateName, groups[0].CurrentState.Name); + Assert.True(element.IsItemSelected); // selection flag preserved + + // Re-enable — should restore to Selected since IsItemSelected is still true + element.IsEnabled = true; + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + } + + static VisualStateGroupList CreateStateGroupsWithSelectedAndPointerOver() + { + return new VisualStateGroupList + { + new VisualStateGroup + { + Name = CommonStatesGroupName, + States = + { + new VisualState { Name = NormalStateName }, + new VisualState { Name = VisualStateManager.CommonStates.Selected }, + new VisualState { Name = VisualStateManager.CommonStates.PointerOver }, + new VisualState { Name = DisabledStateName }, + } + } + }; + } + + static void SetIsPointerOver(VisualElement element, bool value) + { + var field = typeof(VisualElement).GetField("_isPointerOver", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + field!.SetValue(element, value); + } } } From b21fad9ac64380c65d42174634049b3ba061d832 Mon Sep 17 00:00:00 2001 From: Dhivya-SF4094 <127717131+Dhivya-SF4094@users.noreply.github.com> Date: Mon, 18 May 2026 16:20:35 +0530 Subject: [PATCH 3/3] Addressed AI summary concern --- .../CollectionView/TemplatedItemViewHolder.cs | 2 +- .../CollectionView/ItemContentControl.cs | 2 +- .../src/iOS/CollectionView/TemplatedCell.cs | 2 +- .../Core/src/iOS/Renderers/UIContainerCell.cs | 2 +- .../Android/ShellFlyoutRecyclerAdapter.cs | 2 +- .../Handlers/Shell/iOS/UIContainerCell.cs | 2 +- .../Items/Android/TemplatedItemViewHolder.cs | 2 +- .../Items/Tizen/ItemTemplateAdaptor.cs | 4 +- .../Core/Handlers/Items/iOS/TemplatedCell.cs | 2 +- .../Handlers/Items2/iOS/TemplatedCell2.cs | 2 +- .../Shell/Windows/ShellFlyoutItemView.cs | 2 +- .../IndicatorView/IndicatorStackLayout.cs | 2 +- .../CollectionView/ItemContentControl.cs | 2 +- .../src/Core/VisualElement/VisualElement.cs | 34 +++++-------- .../Core.UnitTests/VisualStateManagerTests.cs | 50 +++++++++++++++++-- 15 files changed, 72 insertions(+), 40 deletions(-) diff --git a/src/Compatibility/Core/src/Android/CollectionView/TemplatedItemViewHolder.cs b/src/Compatibility/Core/src/Android/CollectionView/TemplatedItemViewHolder.cs index 67eebc634e80..2a9fa6f3d875 100644 --- a/src/Compatibility/Core/src/Android/CollectionView/TemplatedItemViewHolder.cs +++ b/src/Compatibility/Core/src/Android/CollectionView/TemplatedItemViewHolder.cs @@ -31,7 +31,7 @@ protected override void OnSelectedChanged() return; } - View.SetSelectedState(IsSelected); + View.IsItemSelected = IsSelected; } public void Recycle(ItemsView itemsView) diff --git a/src/Compatibility/Core/src/Windows/CollectionView/ItemContentControl.cs b/src/Compatibility/Core/src/Windows/CollectionView/ItemContentControl.cs index fd5b6211c366..83cf767a7380 100644 --- a/src/Compatibility/Core/src/Windows/CollectionView/ItemContentControl.cs +++ b/src/Compatibility/Core/src/Windows/CollectionView/ItemContentControl.cs @@ -187,7 +187,7 @@ internal void UpdateIsSelected(bool isSelected) if (formsElement == null) return; - formsElement.SetSelectedState(isSelected); + formsElement.IsItemSelected = isSelected; } void OnViewMeasureInvalidated(object sender, EventArgs e) diff --git a/src/Compatibility/Core/src/iOS/CollectionView/TemplatedCell.cs b/src/Compatibility/Core/src/iOS/CollectionView/TemplatedCell.cs index 4bd803fe4919..aaed48a54f9c 100644 --- a/src/Compatibility/Core/src/iOS/CollectionView/TemplatedCell.cs +++ b/src/Compatibility/Core/src/iOS/CollectionView/TemplatedCell.cs @@ -295,7 +295,7 @@ void UpdateVisualStates() if (element != null) { - element.SetSelectedState(Selected); + element.IsItemSelected = Selected; } } } diff --git a/src/Compatibility/Core/src/iOS/Renderers/UIContainerCell.cs b/src/Compatibility/Core/src/iOS/Renderers/UIContainerCell.cs index d7b24f38bfb0..6d31743daff2 100644 --- a/src/Compatibility/Core/src/iOS/Renderers/UIContainerCell.cs +++ b/src/Compatibility/Core/src/iOS/Renderers/UIContainerCell.cs @@ -107,7 +107,7 @@ void UpdateVisualState() { if (BindingContext is BaseShellItem baseShellItem && baseShellItem != null) { - View.SetSelectedState(baseShellItem.IsChecked); + View.IsItemSelected = baseShellItem.IsChecked; } } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs index 077dc58ab3f4..16acb52466c7 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutRecyclerAdapter.cs @@ -308,7 +308,7 @@ void UpdateVisualState() { if (Element is BaseShellItem baseShellItem && baseShellItem != null) { - View.SetSelectedState(baseShellItem.IsChecked); + View.IsItemSelected = baseShellItem.IsChecked; } } diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs index 3a8f42e5485d..b827357f96ee 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/UIContainerCell.cs @@ -131,7 +131,7 @@ void UpdateVisualState() { if (BindingContext is BaseShellItem baseShellItem && baseShellItem != null) { - View.SetSelectedState(baseShellItem.IsChecked); + View.IsItemSelected = baseShellItem.IsChecked; } } diff --git a/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs b/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs index 860dfc005c84..78e45ec2dbd3 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/TemplatedItemViewHolder.cs @@ -29,7 +29,7 @@ protected override void OnSelectedChanged() return; } - View.SetSelectedState(IsSelected); + View.IsItemSelected = IsSelected; } public void Recycle(ItemsView itemsView) diff --git a/src/Controls/src/Core/Handlers/Items/Tizen/ItemTemplateAdaptor.cs b/src/Controls/src/Core/Handlers/Items/Tizen/ItemTemplateAdaptor.cs index f7ea969bef63..e1b75635e944 100644 --- a/src/Controls/src/Core/Handlers/Items/Tizen/ItemTemplateAdaptor.cs +++ b/src/Controls/src/Core/Handlers/Items/Tizen/ItemTemplateAdaptor.cs @@ -80,12 +80,12 @@ public override void UpdateViewState(NView view, ViewHolderState state) formsView.SetValue(VisualElement.IsFocusedPropertyKey, true); break; case ViewHolderState.Normal: - formsView.SetSelectedState(false); + formsView.IsItemSelected = false; formsView.SetValue(VisualElement.IsFocusedPropertyKey, false); break; case ViewHolderState.Selected: if (IsSelectable) - formsView.SetSelectedState(true); + formsView.IsItemSelected = true; break; } } diff --git a/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs b/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs index 4b06f9594bfc..9467843bf5fe 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs @@ -342,7 +342,7 @@ void UpdateVisualStates() { if (PlatformHandler?.VirtualView is VisualElement element) { - element.SetSelectedState(Selected); + element.IsItemSelected = Selected; } } diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs index 79dd3610bdec..6d9379ead92d 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs @@ -369,7 +369,7 @@ void UpdateVisualStates() { if (PlatformHandler?.VirtualView is VisualElement element) { - element.SetSelectedState(Selected); + element.IsItemSelected = Selected; } } diff --git a/src/Controls/src/Core/Handlers/Shell/Windows/ShellFlyoutItemView.cs b/src/Controls/src/Core/Handlers/Shell/Windows/ShellFlyoutItemView.cs index b2fd02efd85a..9ee09d4d10c5 100644 --- a/src/Controls/src/Core/Handlers/Shell/Windows/ShellFlyoutItemView.cs +++ b/src/Controls/src/Core/Handlers/Shell/Windows/ShellFlyoutItemView.cs @@ -141,7 +141,7 @@ void UpdateVisualState() { if (_content?.BindingContext is BaseShellItem baseShellItem && baseShellItem != null) { - _content.SetSelectedState(baseShellItem.IsChecked); + _content.IsItemSelected = baseShellItem.IsChecked; } } diff --git a/src/Controls/src/Core/IndicatorView/IndicatorStackLayout.cs b/src/Controls/src/Core/IndicatorView/IndicatorStackLayout.cs index c86e7c7db894..80bbe27eeb27 100644 --- a/src/Controls/src/Core/IndicatorView/IndicatorStackLayout.cs +++ b/src/Controls/src/Core/IndicatorView/IndicatorStackLayout.cs @@ -135,7 +135,7 @@ void ResetIndicatorStylesNonBatch() : GetColorOrDefault(_indicatorView.IndicatorColor, Colors.Silver); - visualElement.SetSelectedState(isSelected); + visualElement.IsItemSelected = isSelected; } diff --git a/src/Controls/src/Core/Platform/Windows/CollectionView/ItemContentControl.cs b/src/Controls/src/Core/Platform/Windows/CollectionView/ItemContentControl.cs index 8be0048504b2..19865b7e3a08 100644 --- a/src/Controls/src/Core/Platform/Windows/CollectionView/ItemContentControl.cs +++ b/src/Controls/src/Core/Platform/Windows/CollectionView/ItemContentControl.cs @@ -261,7 +261,7 @@ internal void UpdateIsSelected(bool isSelected) if (formsElement == null) return; - formsElement.SetSelectedState(isSelected); + formsElement.IsItemSelected = isSelected; } void OnViewMeasureInvalidated(object sender, EventArgs e) diff --git a/src/Controls/src/Core/VisualElement/VisualElement.cs b/src/Controls/src/Core/VisualElement/VisualElement.cs index 4c9b219f0253..b94f6549048a 100644 --- a/src/Controls/src/Core/VisualElement/VisualElement.cs +++ b/src/Controls/src/Core/VisualElement/VisualElement.cs @@ -1656,33 +1656,23 @@ void PropagateBindingContextToStateTriggers() bool _isItemSelected; /// - /// Tracks whether this element has been explicitly put in the Selected visual state - /// by platform handlers (e.g., CollectionView, Shell flyout). This provides a non-circular - /// source of truth for ChangeVisualState() to preserve the Selected state during - /// pointer and focus transitions. + /// Gets or sets whether this element is in the Selected visual state. + /// Platform handlers (CollectionView, Shell flyout, IndicatorView) use this property + /// to select/deselect items. The setter includes an equality guard to avoid redundant + /// state recomputation and routes through so that + /// IsEnabled and other state priorities (Disabled, PointerOver, Normal) are respected. /// internal bool IsItemSelected { get => _isItemSelected; - set => _isItemSelected = value; - } - - /// - /// Sets the selected state flag and transitions the visual state accordingly. - /// All platform code that selects/deselects items should use this single entry point - /// so that IsItemSelected and the VSM state are always kept in sync. - /// - internal void SetSelectedState(bool isSelected) - { - _isItemSelected = isSelected; - if (isSelected) - { - VisualStateManager.GoToState(this, VisualStateManager.CommonStates.Selected); - } - else + set { - // Re-evaluate the full state chain so that PointerOver, Disabled, etc. - // are correctly applied instead of blindly going to Normal. + if (_isItemSelected == value) + { + return; + } + + _isItemSelected = value; ChangeVisualState(); } } diff --git a/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs b/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs index 9dd603597997..0f3a357a1528 100644 --- a/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs +++ b/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs @@ -656,7 +656,7 @@ public void SelectHoverDeselectRestoresPointerOverState() VisualStateManager.SetVisualStateGroups(element, groups); // 1. Select the item (simulates CollectionView selection) - element.SetSelectedState(true); + element.IsItemSelected = true; Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); // 2. Simulate pointer hover while selected — Selected takes priority @@ -665,7 +665,7 @@ public void SelectHoverDeselectRestoresPointerOverState() Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); // 3. Deselect while pointer is still hovering — should restore to PointerOver - element.SetSelectedState(false); + element.IsItemSelected = false; Assert.Equal(VisualStateManager.CommonStates.PointerOver, groups[0].CurrentState.Name); } @@ -678,7 +678,7 @@ public void SelectedStatePreservedAcrossMouseHover() VisualStateManager.SetVisualStateGroups(element, groups); // Select the item (simulates Shell flyout item selection) - element.SetSelectedState(true); + element.IsItemSelected = true; Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); // Pointer enters — Selected should be preserved @@ -701,7 +701,7 @@ public void IsEnabledToggleWhileSelectedPreservesState() VisualStateManager.SetVisualStateGroups(element, groups); // Select the item - element.SetSelectedState(true); + element.IsItemSelected = true; Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); // Disable — should go to Disabled @@ -714,6 +714,48 @@ public void IsEnabledToggleWhileSelectedPreservesState() Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); } + [Fact] + // https://github.com/dotnet/maui/issues/35399 + public void SelectingWhileDisabledStaysDisabledUntilReEnabled() + { + var element = new Label(); + var groups = CreateStateGroupsWithSelectedAndPointerOver(); + VisualStateManager.SetVisualStateGroups(element, groups); + + // Disable first + element.IsEnabled = false; + Assert.Equal(DisabledStateName, groups[0].CurrentState.Name); + + // Select while disabled — visual state should remain Disabled + element.IsItemSelected = true; + Assert.Equal(DisabledStateName, groups[0].CurrentState.Name); + + // Re-enable — now it should go to Selected + element.IsEnabled = true; + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + } + + [Fact] + // https://github.com/dotnet/maui/issues/35399 + public void AssigningSameIsItemSelectedValueIsNoOp() + { + var element = new Label(); + var groups = CreateStateGroupsWithSelectedAndPointerOver(); + VisualStateManager.SetVisualStateGroups(element, groups); + + // Select the item + element.IsItemSelected = true; + Assert.Equal(VisualStateManager.CommonStates.Selected, groups[0].CurrentState.Name); + + // Manually set to Normal to detect if ChangeVisualState fires again + VisualStateManager.GoToState(element, VisualStateManager.CommonStates.Normal); + Assert.Equal(VisualStateManager.CommonStates.Normal, groups[0].CurrentState.Name); + + // Assign same value — should be a no-op (equality guard) + element.IsItemSelected = true; + Assert.Equal(VisualStateManager.CommonStates.Normal, groups[0].CurrentState.Name); + } + static VisualStateGroupList CreateStateGroupsWithSelectedAndPointerOver() { return new VisualStateGroupList