Skip to content
Merged
Show file tree
Hide file tree
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
Expand Up @@ -31,9 +31,7 @@ protected override void OnSelectedChanged()
return;
}

VisualStateManager.GoToState(View, IsSelected
? VisualStateManager.CommonStates.Selected
: VisualStateManager.CommonStates.Normal);
View.IsItemSelected = IsSelected;
}

public void Recycle(ItemsView itemsView)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,7 @@ internal void UpdateIsSelected(bool isSelected)
if (formsElement == null)
return;

VisualStateManager.GoToState(formsElement, isSelected
? VisualStateManager.CommonStates.Selected
: VisualStateManager.CommonStates.Normal);
formsElement.IsItemSelected = isSelected;
}

void OnViewMeasureInvalidated(object sender, EventArgs e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,7 @@ void UpdateVisualStates()

if (element != null)
{
VisualStateManager.GoToState(element, Selected
? VisualStateManager.CommonStates.Selected
: VisualStateManager.CommonStates.Normal);
element.IsItemSelected = Selected;
}
}
}
Expand Down
5 changes: 1 addition & 4 deletions src/Compatibility/Core/src/iOS/Renderers/UIContainerCell.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.IsItemSelected = baseShellItem.IsChecked;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.IsItemSelected = baseShellItem.IsChecked;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.IsItemSelected = baseShellItem.IsChecked;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,7 @@ protected override void OnSelectedChanged()
return;
}

VisualStateManager.GoToState(View, IsSelected
? VisualStateManager.CommonStates.Selected
: VisualStateManager.CommonStates.Normal);
View.IsItemSelected = IsSelected;
}

public void Recycle(ItemsView itemsView)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.IsItemSelected = false;
formsView.SetValue(VisualElement.IsFocusedPropertyKey, false);
break;
case ViewHolderState.Selected:
if (IsSelectable)
VisualStateManager.GoToState(formsView, VisualStateManager.CommonStates.Selected);
formsView.IsItemSelected = true;
break;
}
}
Expand Down
4 changes: 1 addition & 3 deletions src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs
Original file line number Diff line number Diff line change
Expand Up @@ -342,9 +342,7 @@ void UpdateVisualStates()
{
if (PlatformHandler?.VirtualView is VisualElement element)
{
VisualStateManager.GoToState(element, Selected
? VisualStateManager.CommonStates.Selected
: VisualStateManager.CommonStates.Normal);
element.IsItemSelected = Selected;
}
}

Expand Down
6 changes: 2 additions & 4 deletions src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ void BindVirtualView(View virtualView, object bindingContext, ItemsView itemsVie

virtualView.BindingContext = bindingContext;
itemsView.AddLogicalChild(virtualView);

if (this.Selected)
{
UpdateVisualStates();
Expand Down Expand Up @@ -369,9 +369,7 @@ void UpdateVisualStates()
{
if (PlatformHandler?.VirtualView is VisualElement element)
{
VisualStateManager.GoToState(element, Selected
? VisualStateManager.CommonStates.Selected
: VisualStateManager.CommonStates.Normal);
element.IsItemSelected = Selected;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.IsItemSelected = baseShellItem.IsChecked;
}
}

Expand Down
4 changes: 1 addition & 3 deletions src/Controls/src/Core/IndicatorView/IndicatorStackLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,7 @@ void ResetIndicatorStylesNonBatch()
: GetColorOrDefault(_indicatorView.IndicatorColor, Colors.Silver);


VisualStateManager.GoToState(visualElement, isSelected
? VisualStateManager.CommonStates.Selected
: VisualStateManager.CommonStates.Normal);
visualElement.IsItemSelected = isSelected;

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,7 @@ internal void UpdateIsSelected(bool isSelected)
if (formsElement == null)
return;

VisualStateManager.GoToState(formsElement, isSelected
? VisualStateManager.CommonStates.Selected
: VisualStateManager.CommonStates.Normal);
formsElement.IsItemSelected = isSelected;
}

void OnViewMeasureInvalidated(object sender, EventArgs e)
Expand Down
23 changes: 23 additions & 0 deletions src/Controls/src/Core/VisualElement/VisualElement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1653,6 +1653,29 @@ void PropagateBindingContextToStateTriggers()
internal void ChangeVisualStateInternal() => ChangeVisualState();

bool _isPointerOver;
bool _isItemSelected;

/// <summary>
/// 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 <see cref="ChangeVisualState"/> so that
/// IsEnabled and other state priorities (Disabled, PointerOver, Normal) are respected.
/// </summary>
internal bool IsItemSelected
{
get => _isItemSelected;
set
{
if (_isItemSelected == value)
{
return;
}

_isItemSelected = value;
ChangeVisualState();
}
}

internal bool IsPointerOver
{
Expand Down
11 changes: 1 addition & 10 deletions src/Controls/src/Core/VisualStateManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
133 changes: 133 additions & 0 deletions src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -646,5 +646,138 @@ 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.IsItemSelected = 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.IsItemSelected = 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.IsItemSelected = 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.IsItemSelected = 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);
}

[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
{
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading