diff --git a/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml.cs b/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml.cs
index b7744fa262ea..6e467ed53a97 100644
--- a/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml.cs
+++ b/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml.cs
@@ -6,4 +6,4 @@ public MainPage()
{
InitializeComponent();
}
-}
\ No newline at end of file
+}
diff --git a/src/Controls/src/Core/VisualElement/VisualElement.cs b/src/Controls/src/Core/VisualElement/VisualElement.cs
index 6161fcd8a8c1..fdbf5e1e61d6 100644
--- a/src/Controls/src/Core/VisualElement/VisualElement.cs
+++ b/src/Controls/src/Core/VisualElement/VisualElement.cs
@@ -20,7 +20,6 @@ namespace Microsoft.Maui.Controls
///
/// The base class for most .NET MAUI on-screen elements. Provides most properties, events, and methods for presenting an item on screen.
///
-
[DebuggerDisplay("{GetDebuggerDisplay(), nq}")]
public partial class VisualElement : NavigableElement, IAnimatable, IVisualElementController, IResourcesProvider, IStyleElement, IFlowDirectionController, IPropertyPropagationController, IVisualController, IWindowController, IView, IControlsVisualElement
{
@@ -1607,26 +1606,43 @@ private protected void SetPointerOver(bool value, bool callChangeVisualState = t
///
protected internal virtual void ChangeVisualState()
{
+ // A disabled control should never be in a focused state as part of the feature
+ // of being disabled is that it cannot receive focus. If it was in focus, then
+ // it has to go out of focus.
+ var shouldFocus = IsFocused && IsEnabled;
+
+ // If the control cannot have focus, make sure it appears unfocused by moving to
+ // the Unfocused state.
+ if (!shouldFocus)
+ {
+ VisualStateManager.GoToState(this, VisualStateManager.FocusStates.Unfocused);
+ }
+
+ // Set the Disabled or Normal states depending on the value of IsEnabled and
+ // IsPointerOver. We set the PointerOver state later, after the Focused state.
if (!IsEnabled)
{
VisualStateManager.GoToState(this, VisualStateManager.CommonStates.Disabled);
}
- else if (IsPointerOver)
+ else if (!IsPointerOver)
{
- VisualStateManager.GoToState(this, VisualStateManager.CommonStates.PointerOver);
+ VisualStateManager.GoToState(this, VisualStateManager.CommonStates.Normal);
}
- else
+
+ // Go to the Focus state after the Normal state, so that the Focus state can
+ // override the Normal state's properties if a control is both focused and
+ // hovered.
+ if (shouldFocus)
{
- VisualStateManager.GoToState(this, VisualStateManager.CommonStates.Normal);
+ VisualStateManager.GoToState(this, VisualStateManager.FocusStates.Focused);
}
- if (IsEnabled)
+ // The PointerOver state is applied last so that it can override all the states. Even
+ // though this state is separate here, it should still be part of the CommonStates
+ // visual state group.
+ if (IsPointerOver)
{
- // 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);
+ VisualStateManager.GoToState(this, VisualStateManager.CommonStates.PointerOver);
}
}
diff --git a/src/Controls/src/Core/VisualStateManager.cs b/src/Controls/src/Core/VisualStateManager.cs
index e80baabda1b2..46d5aca6368e 100644
--- a/src/Controls/src/Core/VisualStateManager.cs
+++ b/src/Controls/src/Core/VisualStateManager.cs
@@ -15,10 +15,16 @@ public class CommonStates
{
public const string Normal = "Normal";
public const string Disabled = "Disabled";
- public const string Focused = "Focused";
+ public const string Focused = FocusStates.Focused;
public const string Selected = "Selected";
public const string PointerOver = "PointerOver";
- internal const string Unfocused = "Unfocused";
+ }
+
+ // TODO: .NET 10 - make public
+ internal class FocusStates
+ {
+ public const string Focused = "Focused";
+ public const string Unfocused = "Unfocused";
}
/// Bindable property for attached property VisualStateGroups.
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue19752.xaml b/src/Controls/tests/TestCases.HostApp/Issues/Issue19752.xaml
new file mode 100644
index 000000000000..db7586278000
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue19752.xaml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue19752.xaml.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue19752.xaml.cs
new file mode 100644
index 000000000000..06b9fe70d2f6
--- /dev/null
+++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue19752.xaml.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Windows.Input;
+using System.Collections.Specialized;
+
+namespace Maui.Controls.Sample.Issues
+{
+ [XamlCompilation(XamlCompilationOptions.Compile)]
+ [Issue(IssueTracker.Github, 19752, "Button does not behave properly when pointer hovers over the button because it's in focused state.")]
+ public partial class Issue19752
+ {
+ public Issue19752()
+ {
+ InitializeComponent();
+ }
+
+ private void OnButtonClicked(object sender, EventArgs e)
+ {
+ // this code just enables all the buttons and disables the current one
+ // except for the first button which is always enabled
+
+ button2.IsEnabled = sender != button2;
+ button3.IsEnabled = sender != button3;
+ }
+ }
+}
diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue19752.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue19752.cs
new file mode 100644
index 000000000000..29f1c657b951
--- /dev/null
+++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue19752.cs
@@ -0,0 +1,125 @@
+using NUnit.Framework;
+using UITest.Appium;
+using UITest.Core;
+
+namespace Microsoft.Maui.TestCases.Tests.Issues;
+
+[Category(UITestCategories.Focus)]
+public class Issue19752(TestDevice device) : _IssuesUITest(device)
+{
+ public override string Issue => "Button does not behave properly when pointer hovers over the button because it's in focused state.";
+
+ protected override bool ResetAfterEachTest => true;
+
+ [Test]
+ public void InitialStateAreAllCorrect()
+ {
+ Assert.That(App.FindElement("button1").GetText(), Is.EqualTo("Normal"));
+ Assert.That(App.FindElement("button2").GetText(), Is.EqualTo("Disabled"));
+ Assert.That(App.FindElement("button3").GetText(), Is.EqualTo("Normal"));
+ }
+
+ [Test]
+ public void HoveringOverButtonMovesToPointerOverState()
+ {
+ App.MoveCursor("button1");
+
+ // when the mouse moves over a button, it gets a state
+ Assert.That(App.FindElement("button1").GetText(), Is.EqualTo("PointerOver"));
+ }
+
+ // TODO: find a way to send actions to appium and then read values simultaneously
+ // [Test]
+ // public void PressingButtonMovesToPressedState()
+ // {
+ // Task.Run(() =>
+ // {
+ //#if MACCATALYST
+ // App.LongPress("button1");
+ //#else
+ // App.TouchAndHold("button1");
+ //#endif
+ // });
+
+ // // pressing and holding the mouse is pressed
+ // App.WaitForTextToBePresentInElement("button1", "Pressed");
+ // Assert.That(App.FindElement("button1").GetText(), Is.EqualTo("Pressed"));
+ // }
+
+ [Test]
+ public void PressingAndReleasingButtonMovesToPointerOverState()
+ {
+ var rectBefore = App.FindElement("button1").GetRect();
+
+ App.Tap("button1");
+
+ // pressing a button sets it to be focused, but the pointer over state is appplied after
+ Assert.That(App.FindElement("button1").GetText(), Is.EqualTo("PointerOver"));
+
+ // we are shrinking the focused button a bit
+ var rectAfter = App.FindElement("button1").GetRect();
+ Assert.That(rectBefore, Is.Not.EqualTo(rectAfter));
+ }
+
+ [Test]
+ public void HoveringOverButtonAndThenMovingOffMovesToNormalState()
+ {
+ var rectBefore = App.FindElement("button1").GetRect();
+
+ App.MoveCursor("button1");
+ App.MoveCursor("button2");
+
+ // hovering over a button and then moving off goes back to the normal state
+ // and does not affect focus
+ Assert.That(App.FindElement("button1").GetText(), Is.EqualTo("Normal"));
+
+ // we are shrinking the focused button a bit, but the button is still not focused
+ var rectAfter = App.FindElement("button1").GetRect();
+ Assert.That(rectBefore, Is.EqualTo(rectAfter));
+ }
+
+ [Test]
+ public void EnablingButtonMovesToNormalState()
+ {
+ App.Tap("button1");
+
+ // enabling a button just switches to the normal state
+ Assert.That(App.FindElement("button2").GetText(), Is.EqualTo("Normal"));
+ }
+
+ [Test]
+ public void DisablingUnfocusedButtonMovesToDisabledState()
+ {
+ var rectBefore = App.FindElement("button2").GetRect();
+
+ App.Tap("button1"); // focus button 1
+ App.Tap("button2"); // move the focus to button 2, but then disable it
+
+ // the button is disabled without a focus chnage as it never had focus
+ Assert.That(App.FindElement("button2").GetText(), Is.EqualTo("Disabled"));
+
+ // we are shrinking the focused button a bit, but the button never had focus
+ var rectAfter = App.FindElement("button2").GetRect();
+ Assert.That(rectBefore, Is.EqualTo(rectAfter));
+
+ // this forces focus to button 3 which is set on top of the normal state
+ Assert.That(App.FindElement("button3").GetText(), Is.EqualTo("Focused"));
+ }
+
+ [Test]
+ public void DisablingFocusedButtonMovesToDisabledState()
+ {
+ var rectBefore = App.FindElement("button3").GetRect();
+
+ App.Tap("button1"); // focus button 1
+ App.Tap("button2"); // move the focus to button 2, but then disable it forcing focus to button 3
+ App.Tap("button3"); // disable the focused button
+
+ // this disables the button, but the unfocus change is applied before all states
+ Assert.That(App.FindElement("button3").GetText(), Is.EqualTo("Disabled"));
+
+ // we are shrinking the focused button a bit, so it should have been unfocused after disabling
+ var rectAfter = App.FindElement("button3").GetRect();
+ Assert.That(rectBefore, Is.EqualTo(rectAfter));
+ }
+}
diff --git a/src/TestUtils/src/UITest.Appium/Actions/AppiumMouseActions.cs b/src/TestUtils/src/UITest.Appium/Actions/AppiumMouseActions.cs
index 18bb7ec0683f..9944e1dce550 100644
--- a/src/TestUtils/src/UITest.Appium/Actions/AppiumMouseActions.cs
+++ b/src/TestUtils/src/UITest.Appium/Actions/AppiumMouseActions.cs
@@ -1,4 +1,5 @@
using System.Drawing;
+using System.Runtime.InteropServices;
using OpenQA.Selenium;
using OpenQA.Selenium.Appium;
using OpenQA.Selenium.Appium.Interactions;
@@ -9,13 +10,15 @@
namespace UITest.Appium
{
- public class AppiumMouseActions : ICommandExecutionGroup
+ public partial class AppiumMouseActions : ICommandExecutionGroup
{
const string ClickCommand = "click";
const string ClickCoordinatesCommand = "clickCoordinates";
const string DoubleClickCommand = "doubleClick";
const string DoubleClickCoordinatesCommand = "doubleClickCoordinates";
const string LongPressCommand = "longPress";
+ const string MoveCursorCommand = "moveCursor";
+ const string MoveCursorCoordinatesCommand = "moveCursorCoordinates";
readonly AppiumApp _appiumApp;
@@ -25,7 +28,9 @@ public class AppiumMouseActions : ICommandExecutionGroup
ClickCoordinatesCommand,
DoubleClickCommand,
DoubleClickCoordinatesCommand,
- LongPressCommand
+ LongPressCommand,
+ MoveCursorCommand,
+ MoveCursorCoordinatesCommand,
};
public AppiumMouseActions(AppiumApp appiumApp)
@@ -47,6 +52,8 @@ public CommandResponse Execute(string commandName, IDictionary p
DoubleClickCommand => DoubleClick(parameters),
DoubleClickCoordinatesCommand => DoubleClickCoordinates(parameters),
LongPressCommand => LongPress(parameters),
+ MoveCursorCommand => MoveCursor(parameters),
+ MoveCursorCoordinatesCommand => MoveCursorCoordinates(parameters),
_ => CommandResponse.FailedEmptyResponse,
};
}
@@ -201,6 +208,85 @@ CommandResponse LongPress(IDictionary parameters)
return CommandResponse.SuccessEmptyResponse;
}
+ CommandResponse TapCoordinates(IDictionary parameters)
+ {
+ if (parameters.TryGetValue("x", out var x) &&
+ parameters.TryGetValue("y", out var y))
+ {
+ return ClickCoordinates(Convert.ToSingle(x), Convert.ToSingle(y));
+ }
+
+ return CommandResponse.FailedEmptyResponse;
+ }
+
+ CommandResponse MoveCursor(IDictionary parameters)
+ {
+ var element = GetAppiumElement(parameters["element"]);
+ if (element is null)
+ {
+ return CommandResponse.FailedEmptyResponse;
+ }
+
+ // Mouse actions are not supported on Windows
+ if (_appiumApp.GetTestDevice() == TestDevice.Windows)
+ {
+ var rect = GetAbsoluteRect(element);
+
+ var centerX = rect.X + rect.Width / 2;
+ var centerY = rect.Y + rect.Height / 2;
+
+ _appiumApp.Driver.ExecuteScript("windows: hover", new Dictionary
+ {
+ { "startX", centerX },
+ { "startY", centerY },
+ { "endX", centerX },
+ { "endY", centerY },
+ });
+ }
+ else
+ {
+ var touchDevice = new OpenQA.Selenium.Appium.Interactions.PointerInputDevice(PointerKind.Mouse);
+
+ var sequence = new ActionSequence(touchDevice, 0);
+ sequence.AddAction(touchDevice.CreatePointerMove(element, 0, 0, TimeSpan.FromMilliseconds(5)));
+
+ _appiumApp.Driver.PerformActions(new List { sequence });
+ }
+
+ return CommandResponse.SuccessEmptyResponse;
+ }
+
+ CommandResponse MoveCursorCoordinates(IDictionary parameters)
+ {
+ if (!parameters.TryGetValue("x", out var x) || !parameters.TryGetValue("y", out var y))
+ {
+ return CommandResponse.FailedEmptyResponse;
+ }
+
+ // Mouse actions are not supported on Windows
+ if (_appiumApp.GetTestDevice() == TestDevice.Windows)
+ {
+ _appiumApp.Driver.ExecuteScript("windows: hover", new Dictionary
+ {
+ { "startX", 0 },
+ { "startY", 0 },
+ { "endX", x },
+ { "endY", y },
+ });
+ }
+ else
+ {
+ var touchDevice = new OpenQA.Selenium.Appium.Interactions.PointerInputDevice(PointerKind.Mouse);
+
+ var sequence = new ActionSequence(touchDevice, 0);
+ sequence.AddAction(touchDevice.CreatePointerMove(CoordinateOrigin.Viewport, (int)x, (int)y, TimeSpan.FromMilliseconds(5)));
+
+ _appiumApp.Driver.PerformActions(new List { sequence });
+ }
+
+ return CommandResponse.SuccessEmptyResponse;
+ }
+
static AppiumElement? GetAppiumElement(object element)
{
if (element is AppiumElement appiumElement)
@@ -224,5 +310,50 @@ static PointF ElementToClickablePoint(AppiumElement element)
return new PointF(x, y);
}
+
+ static Rectangle GetAbsoluteRect(AppiumElement element)
+ {
+ var rect = element.Rect;
+ var wndPos = element.WrappedDriver.Manage().Window.Position;
+
+ var screenDensity = GetCurrentMonitorScaleFactor(wndPos);
+
+ var x = (int)(rect.X / screenDensity);
+ var y = (int)(rect.Y / screenDensity);
+ var w = (int)(rect.Width / screenDensity);
+ var h = (int)(rect.Height / screenDensity);
+
+ x += (int)(wndPos.X / screenDensity);
+ y += (int)(wndPos.Y / screenDensity);
+
+ return new Rectangle(x, y, w, h);
+ }
+
+ static double GetCurrentMonitorScaleFactor(Point windowLocation)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return 1;
+ }
+
+ var hwnd = GetForegroundWindow();
+ if (hwnd == 0)
+ {
+ return 1;
+ }
+
+ var dpi = GetDpiForWindow(hwnd);
+
+ // Calculate the scale factor (assuming 96 DPI is 100%)
+ var scaleFactor = dpi / 96.0;
+
+ return Math.Max(1, scaleFactor);
+ }
+
+ [LibraryImport("user32.dll")]
+ private static partial IntPtr GetForegroundWindow();
+
+ [LibraryImport("user32.dll")]
+ private static partial uint GetDpiForWindow(IntPtr hWnd);
}
}
\ No newline at end of file
diff --git a/src/TestUtils/src/UITest.Appium/HelperExtensions.cs b/src/TestUtils/src/UITest.Appium/HelperExtensions.cs
index f5ae30c7f878..9dfe08165831 100644
--- a/src/TestUtils/src/UITest.Appium/HelperExtensions.cs
+++ b/src/TestUtils/src/UITest.Appium/HelperExtensions.cs
@@ -2315,6 +2315,64 @@ public static void TapTab(this IApp app, string tabName, bool isTopTab = false)
///
/// For Android apps, the tab name is converted to uppercase before searching.
///
+
+
+ ///
+ /// Moves the mouse cursor to the specified element.
+ ///
+ /// Represents the main gateway to interact with an app.
+ /// Target Element.
+ public static void MoveCursor(this IApp app, string element)
+ {
+ var elementToMoveTo = app.FindElement(element);
+ app.MoveCursor(elementToMoveTo);
+ }
+
+ ///
+ /// Moves the mouse cursor to the matched element by 'query'.
+ ///
+ /// Represents the main gateway to interact with an app.
+ ///
+ public static void MoveCursor(this IApp app, IQuery query)
+ {
+ var element = app.FindElement(query);
+ app.MoveCursor(element);
+ }
+
+ internal static void MoveCursor(this IApp app, IUIElement? element)
+ {
+ if (element is not null)
+ {
+ app.CommandExecutor.Execute("moveCursor", new Dictionary
+ {
+ { "element", element },
+ });
+ }
+ }
+
+ ///
+ /// Moves the mouse cursor to the given coordinates.
+ ///
+ /// Represents the main gateway to interact with an app.
+ /// The X coordinate to move to.
+ /// The Y coordinate to move to.
+ public static void MoveCursorCoordinates(this IApp app, float x, float y)
+ {
+ app.CommandExecutor.Execute("moveCursorCoordinates", new Dictionary
+ {
+ { "x", x },
+ { "y", y }
+ });
+ }
+
+ ///
+ /// Waits for a tab element with the specified name to appear and for page navigation to settle.
+ ///
+ /// The IApp instance.
+ /// The name of the tab to wait for.
+ ///
+ /// For Android apps, the tab name is converted to uppercase before searching.
+ ///
public static IUIElement WaitForTabElement(this IApp app, string tabName)
{
tabName = app is AppiumAndroidApp ? tabName.ToUpperInvariant() : tabName;
diff --git a/src/TestUtils/src/UITest.Appium/UITest.Appium.csproj b/src/TestUtils/src/UITest.Appium/UITest.Appium.csproj
index ea2061c87a42..d69200d7249d 100644
--- a/src/TestUtils/src/UITest.Appium/UITest.Appium.csproj
+++ b/src/TestUtils/src/UITest.Appium/UITest.Appium.csproj
@@ -4,6 +4,7 @@
$(_MauiDotNetTfm)enableenable
+ True