diff --git a/src/Controls/src/Core/ButtonElement.cs b/src/Controls/src/Core/ButtonElement.cs index eeadfdc49723..d2cd95931512 100644 --- a/src/Controls/src/Core/ButtonElement.cs +++ b/src/Controls/src/Core/ButtonElement.cs @@ -110,13 +110,11 @@ public static void ElementPressed(VisualElement visualElement, IButtonElement Bu /// The button element implementation to trigger the commands and events on. public static void ElementReleased(VisualElement visualElement, IButtonElement ButtonElementManager) { - if (visualElement.IsEnabled == true) - { - IButtonController buttonController = ButtonElementManager as IButtonController; - ButtonElementManager.SetIsPressed(false); - visualElement.ChangeVisualStateInternal(); - ButtonElementManager.PropagateUpReleased(); - } + // Even if the button is disabled, we still want to remove the Pressed state; + // the button may have been disabled by the the pressing action + ButtonElementManager.SetIsPressed(false); + visualElement.ChangeVisualStateInternal(); + ButtonElementManager.PropagateUpReleased(); } } } diff --git a/src/Controls/src/Core/VisualElement.cs b/src/Controls/src/Core/VisualElement.cs index 3e6b3dbedffe..fa871e0ab595 100644 --- a/src/Controls/src/Core/VisualElement.cs +++ b/src/Controls/src/Core/VisualElement.cs @@ -1096,13 +1096,26 @@ private protected set protected internal virtual void ChangeVisualState() { if (!IsEnabled) + { VisualStateManager.GoToState(this, VisualStateManager.CommonStates.Disabled); + } else if (IsPointerOver) + { VisualStateManager.GoToState(this, VisualStateManager.CommonStates.PointerOver); - else if (IsFocused) - VisualStateManager.GoToState(this, VisualStateManager.CommonStates.Focused); + } else + { VisualStateManager.GoToState(this, VisualStateManager.CommonStates.Normal); + } + + if (IsEnabled) + { + // Focus needs to be handled independently; otherwise, if no actual Focus state is supplied + // in the control's visual states, the state can end up stuck in PointerOver after the pointer + // exits and the control still has focus. + VisualStateManager.GoToState(this, + IsFocused ? VisualStateManager.CommonStates.Focused : VisualStateManager.CommonStates.Unfocused); + } } static void OnVisualChanged(BindableObject bindable, object oldValue, object newValue) diff --git a/src/Controls/src/Core/VisualStateManager.cs b/src/Controls/src/Core/VisualStateManager.cs index 08ceb562d44a..6673836cae76 100644 --- a/src/Controls/src/Core/VisualStateManager.cs +++ b/src/Controls/src/Core/VisualStateManager.cs @@ -18,6 +18,7 @@ public class CommonStates public const string Focused = "Focused"; public const string Selected = "Selected"; public const string PointerOver = "PointerOver"; + internal const string Unfocused = "Unfocused"; } /// diff --git a/src/Controls/tests/Core.UnitTests/ButtonUnitTest.cs b/src/Controls/tests/Core.UnitTests/ButtonUnitTest.cs index 77fe5832a610..4af959ad5d5e 100644 --- a/src/Controls/tests/Core.UnitTests/ButtonUnitTest.cs +++ b/src/Controls/tests/Core.UnitTests/ButtonUnitTest.cs @@ -1,5 +1,6 @@ using System; using Xunit; +using static Microsoft.Maui.Controls.Core.UnitTests.VisualStateTestHelpers; namespace Microsoft.Maui.Controls.Core.UnitTests { @@ -70,7 +71,10 @@ public void TestReleasedEvent(bool isEnabled) ((IButtonController)view).SendReleased(); - Assert.True(released == isEnabled ? true : false); + // Released should always fire, even if the button is disabled + // Otherwise, a press which disables a button will leave it in the + // Pressed state forever + Assert.True(released); } protected override Button CreateSource() @@ -230,5 +234,20 @@ private void AssertButtonContentLayoutsEqual(Button.ButtonContentLayout layout1, Assert.Equal(layout1.Position, bcl.Position); Assert.Equal(layout1.Spacing, bcl.Spacing); } + + [Fact] + public void PressedVisualState() + { + var vsgList = CreateTestStateGroups(); + var stateGroup = vsgList[0]; + var element = new Button(); + VisualStateManager.SetVisualStateGroups(element, vsgList); + + element.SendPressed(); + Assert.Equal(stateGroup.CurrentState.Name, PressedStateName); + + element.SendReleased(); + Assert.NotEqual(stateGroup.CurrentState.Name, PressedStateName); + } } } diff --git a/src/Controls/tests/Core.UnitTests/CheckBoxUnitTests.cs b/src/Controls/tests/Core.UnitTests/CheckBoxUnitTests.cs index 55c2e7bf3f31..faba353370e2 100644 --- a/src/Controls/tests/Core.UnitTests/CheckBoxUnitTests.cs +++ b/src/Controls/tests/Core.UnitTests/CheckBoxUnitTests.cs @@ -3,6 +3,7 @@ using System.Linq; using Xunit; +using static Microsoft.Maui.Controls.Core.UnitTests.VisualStateTestHelpers; namespace Microsoft.Maui.Controls.Core.UnitTests { @@ -43,6 +44,24 @@ public void TestOnEventNotDoubleFired() Assert.False(fired); } - } + [Fact] + public void CheckedVisualStates() + { + var vsgList = CreateTestStateGroups(); + string checkedStateName = CheckBox.IsCheckedVisualState; + var checkedState = new VisualState() { Name = checkedStateName }; + var stateGroup = vsgList[0]; + stateGroup.States.Add(checkedState); + + var element = new CheckBox(); + VisualStateManager.SetVisualStateGroups(element, vsgList); + + element.IsChecked = true; + Assert.Equal(checkedStateName, stateGroup.CurrentState.Name); + + element.IsChecked = false; + Assert.NotEqual(checkedStateName, stateGroup.CurrentState.Name); + } + } } diff --git a/src/Controls/tests/Core.UnitTests/ImageButtonUnitTest.cs b/src/Controls/tests/Core.UnitTests/ImageButtonUnitTest.cs index eeac3d37f904..da70d5bf0ca5 100644 --- a/src/Controls/tests/Core.UnitTests/ImageButtonUnitTest.cs +++ b/src/Controls/tests/Core.UnitTests/ImageButtonUnitTest.cs @@ -3,10 +3,10 @@ using System.Threading; using System.Threading.Tasks; using Xunit; +using static Microsoft.Maui.Controls.Core.UnitTests.VisualStateTestHelpers; namespace Microsoft.Maui.Controls.Core.UnitTests { - public class ImageButtonTests : CommandSourceTests { [Fact] @@ -42,7 +42,6 @@ public void TestAspectSizingWithConstrainedWidth() Assert.Equal(5, result.Request.Height); } - [Fact] public void TestAspectFillSizingWithConstrainedHeight() { @@ -319,7 +318,10 @@ public void TestReleasedEvent(bool isEnabled) ((IButtonController)view).SendReleased(); - Assert.True(released == isEnabled ? true : false); + // Released should always fire, even if the button is disabled + // Otherwise, a press which disables a button will leave it in the + // Pressed state forever + Assert.True(released); } protected override ImageButton CreateSource() @@ -347,7 +349,6 @@ protected override BindableProperty CommandParameterProperty get { return ImageButton.CommandParameterProperty; } } - [Fact] public void TestBindingContextPropagation() { @@ -418,5 +419,20 @@ public void ButtonClickWhenCommandCanExecuteFalse() Assert.False(invoked); } + + [Fact] + public void PressedVisualState() + { + var vsgList = CreateTestStateGroups(); + var stateGroup = vsgList[0]; + var element = new ImageButton(); + VisualStateManager.SetVisualStateGroups(element, vsgList); + + element.SendPressed(); + Assert.Equal(stateGroup.CurrentState.Name, PressedStateName); + + element.SendReleased(); + Assert.NotEqual(stateGroup.CurrentState.Name, PressedStateName); + } } } diff --git a/src/Controls/tests/Core.UnitTests/SwitchUnitTests.cs b/src/Controls/tests/Core.UnitTests/SwitchUnitTests.cs index 2d582ce396d6..acf3ac3221c7 100644 --- a/src/Controls/tests/Core.UnitTests/SwitchUnitTests.cs +++ b/src/Controls/tests/Core.UnitTests/SwitchUnitTests.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; - using Xunit; +using static Microsoft.Maui.Controls.Core.UnitTests.VisualStateTestHelpers; namespace Microsoft.Maui.Controls.Core.UnitTests { @@ -151,6 +151,29 @@ public void InitialStateIsNullIfNormalOnOffNotAvailable() var groups1 = VisualStateManager.GetVisualStateGroups(switch1); Assert.Null(groups1[0].CurrentState); } + + [Fact] + public void OnOffVisualStates() + { + var vsgList = VisualStateTestHelpers.CreateTestStateGroups(); + var stateGroup = vsgList[0]; + var element = new Switch(); + VisualStateManager.SetVisualStateGroups(element, vsgList); + + string onStateName = Switch.SwitchOnVisualState; + string offStateName = Switch.SwitchOffVisualState; + var onState = new VisualState() { Name = onStateName }; + var offState = new VisualState() { Name = offStateName }; + + stateGroup.States.Add(onState); + stateGroup.States.Add(offState); + + element.IsToggled = true; + Assert.Equal(stateGroup.CurrentState.Name, onStateName); + + element.IsToggled = false; + Assert.Equal(stateGroup.CurrentState.Name, offStateName); + } } } diff --git a/src/Controls/tests/Core.UnitTests/VisualElementTests.cs b/src/Controls/tests/Core.UnitTests/VisualElementTests.cs index 79b375e9c82c..f4be0a2c8ec2 100644 --- a/src/Controls/tests/Core.UnitTests/VisualElementTests.cs +++ b/src/Controls/tests/Core.UnitTests/VisualElementTests.cs @@ -1,5 +1,7 @@ using Microsoft.Maui.Primitives; using Xunit; +using static Microsoft.Maui.Controls.Core.UnitTests.VisualStateTestHelpers; + namespace Microsoft.Maui.Controls.Core.UnitTests { public class VisualElementTests @@ -68,5 +70,17 @@ public void BindingContextPropagatesToBackground() Assert.Equal(bc1, brush2.BindingContext); } + + [Fact] + public void FocusedElementGetsFocusedVisualState() + { + var vsgList = CreateTestStateGroups(); + var stateGroup = vsgList[0]; + var element = new Button(); + VisualStateManager.SetVisualStateGroups(element, vsgList); + + element.SetValue(VisualElement.IsFocusedPropertyKey, true); + Assert.Equal(stateGroup.CurrentState.Name, FocusedStateName); + } } } diff --git a/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs b/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs index b9a9c1d5974b..4e86bac22eca 100644 --- a/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs +++ b/src/Controls/tests/Core.UnitTests/VisualStateManagerTests.cs @@ -6,50 +6,12 @@ using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Graphics; using Xunit; +using static Microsoft.Maui.Controls.Core.UnitTests.VisualStateTestHelpers; namespace Microsoft.Maui.Controls.Core.UnitTests { - public class VisualStateManagerTests : IDisposable { - const string NormalStateName = "Normal"; - const string InvalidStateName = "Invalid"; - const string FocusedStateName = "Focused"; - const string DisabledStateName = "Disabled"; - const string CommonStatesName = "CommonStates"; - - static VisualStateGroupList CreateTestStateGroups() - { - var stateGroups = new VisualStateGroupList(); - var visualStateGroup = new VisualStateGroup { Name = CommonStatesName }; - var normalState = new VisualState { Name = NormalStateName }; - var invalidState = new VisualState { Name = InvalidStateName }; - var focusedState = new VisualState { Name = FocusedStateName }; - var disabledState = new VisualState { Name = DisabledStateName }; - - visualStateGroup.States.Add(normalState); - visualStateGroup.States.Add(invalidState); - visualStateGroup.States.Add(focusedState); - visualStateGroup.States.Add(disabledState); - - stateGroups.Add(visualStateGroup); - - return stateGroups; - } - - static VisualStateGroupList CreateStateGroupsWithoutNormalState() - { - var stateGroups = new VisualStateGroupList(); - var visualStateGroup = new VisualStateGroup { Name = CommonStatesName }; - var invalidState = new VisualState { Name = InvalidStateName }; - - visualStateGroup.States.Add(invalidState); - - stateGroups.Add(visualStateGroup); - - return stateGroups; - } - [Fact] public void InitialStateIsNormalIfAvailable() { @@ -182,7 +144,7 @@ public void StateNamesMustBeUniqueWithinGroupListWhenAddingGroup() public void GroupNamesMustBeUniqueWithinGroupList() { IList vsgs = CreateTestStateGroups(); - var secondGroup = new VisualStateGroup { Name = CommonStatesName }; + var secondGroup = new VisualStateGroup { Name = CommonStatesGroupName }; Assert.Throws(() => vsgs.Add(secondGroup)); } @@ -234,7 +196,6 @@ public void VerifyVisualStateChanges() label1.SetValue(VisualElement.IsFocusedPropertyKey, false); groups1 = VisualStateManager.GetVisualStateGroups(label1); Assert.Equal(groups1[0].CurrentState.Name, NormalStateName); - } [Fact] @@ -334,7 +295,7 @@ public void VisualElementGoesToCorrectStateWhenSetterHasTarget() public void CanRemoveAStateAndAddANewStateWithTheSameName() { var stateGroups = new VisualStateGroupList(); - var visualStateGroup = new VisualStateGroup { Name = CommonStatesName }; + var visualStateGroup = new VisualStateGroup { Name = CommonStatesGroupName }; var normalState = new VisualState { Name = NormalStateName }; var invalidState = new VisualState { Name = InvalidStateName }; @@ -353,7 +314,7 @@ public void CanRemoveAStateAndAddANewStateWithTheSameName() public void CanRemoveAGroupAndAddANewGroupWithTheSameName() { var stateGroups = new VisualStateGroupList(); - var visualStateGroup = new VisualStateGroup { Name = CommonStatesName }; + var visualStateGroup = new VisualStateGroup { Name = CommonStatesGroupName }; var secondVisualStateGroup = new VisualStateGroup { Name = "Whatevs" }; var normalState = new VisualState { Name = NormalStateName }; var invalidState = new VisualState { Name = InvalidStateName }; diff --git a/src/Controls/tests/Core.UnitTests/VisualStateTestHelpers.cs b/src/Controls/tests/Core.UnitTests/VisualStateTestHelpers.cs new file mode 100644 index 000000000000..f56b3c21a28f --- /dev/null +++ b/src/Controls/tests/Core.UnitTests/VisualStateTestHelpers.cs @@ -0,0 +1,49 @@ +namespace Microsoft.Maui.Controls.Core.UnitTests +{ + public class VisualStateTestHelpers + { + public const string NormalStateName = "Normal"; + public const string PressedStateName = "Pressed"; + public const string InvalidStateName = "Invalid"; + public const string UnfocusedStateName = "Unfocused"; + public const string FocusedStateName = "Focused"; + public const string DisabledStateName = "Disabled"; + public const string CommonStatesGroupName = "CommonStates"; + public const string FocusStatesGroupName = "FocusStates"; + + public static VisualStateGroupList CreateTestStateGroups() + { + var stateGroups = new VisualStateGroupList(); + var commonStatesGroup = new VisualStateGroup { Name = CommonStatesGroupName }; + var normalState = new VisualState { Name = NormalStateName }; + var focusState = new VisualState { Name = FocusedStateName }; + var pressedState = new VisualState { Name = PressedStateName }; + var invalidState = new VisualState { Name = InvalidStateName }; + + var disabledState = new VisualState { Name = DisabledStateName }; + + commonStatesGroup.States.Add(normalState); + commonStatesGroup.States.Add(pressedState); + commonStatesGroup.States.Add(invalidState); + commonStatesGroup.States.Add(focusState); + commonStatesGroup.States.Add(disabledState); + + stateGroups.Add(commonStatesGroup); + + return stateGroups; + } + + public static VisualStateGroupList CreateStateGroupsWithoutNormalState() + { + var stateGroups = new VisualStateGroupList(); + var visualStateGroup = new VisualStateGroup { Name = CommonStatesGroupName }; + var invalidState = new VisualState { Name = InvalidStateName }; + + visualStateGroup.States.Add(invalidState); + + stateGroups.Add(visualStateGroup); + + return stateGroups; + } + } +} \ No newline at end of file