Skip to content
Open
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue29547.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Maui.Controls.Sample.Issues;

[Issue(IssueTracker.Github, 29547, "SearchBar with IsReadOnly=True still allows text deletion While pressing delete icon", PlatformAffected.Android)]
public class Issue29547 : ContentPage
{
public Issue29547()
{
var searchBar = new SearchBar
{
Text = "Search",
IsReadOnly = true,
AutomationId = "searchbar"
};

var grid = new Grid();
grid.Children.Add(searchBar);
Content = grid;
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues;

public class Issue29547 : _IssuesUITest
{
public override string Issue => "SearchBar with IsReadOnly=True still allows text deletion While pressing delete icon";

public Issue29547(TestDevice device)
: base(device)
{ }

[Test]
[Category(UITestCategories.SearchBar)]
public void VerifySearchBarDeleteIconBehavior()
{
App.WaitForElement("searchbar");
App.TapSearchBarClearButton("searchbar");
VerifyScreenshot();
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ public static void MapMaxLength(ISearchBarHandler handler, ISearchBar searchBar)
public static void MapIsReadOnly(ISearchBarHandler handler, ISearchBar searchBar)
{
handler.QueryEditor?.UpdateIsReadOnly(searchBar);
handler.PlatformView?.UpdateCancelButtonState(searchBar);
}

public static void MapCancelButtonColor(ISearchBarHandler handler, ISearchBar searchBar)
Expand Down
47 changes: 37 additions & 10 deletions src/Core/src/Platform/Android/SearchViewExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using Android.Content;
using Android.Content.Res;
using Android.Graphics;
Expand Down Expand Up @@ -119,23 +119,34 @@ public static void UpdateIsReadOnly(this EditText editText, ISearchBar searchBar
editText.SetCursorVisible(isReadOnly);
}

public static void UpdateCancelButtonColor(this SearchView searchView, ISearchBar searchBar)
internal static void UpdateCancelButtonState(this SearchView searchView, ISearchBar searchBar)
{
if (searchView.Resources == null)
return;

var searchCloseButtonIdentifier = Resource.Id.search_close_btn;

if (searchCloseButtonIdentifier > 0)
var cancelButton = searchView.GetCancelButton();
if (cancelButton is not null)
{
var image = searchView.FindViewById<ImageView>(searchCloseButtonIdentifier);
cancelButton.Enabled = !searchBar.IsReadOnly;
}
}

if (image is not null && image.Drawable is Drawable drawable)
public static void UpdateCancelButtonColor(this SearchView searchView, ISearchBar searchBar)
{
var cancelButton = searchView.GetCancelButton();
if (cancelButton?.Drawable is Drawable drawable)
{
if (cancelButton.Enabled)
{
if (searchBar.CancelButtonColor is not null)
{
drawable.SetColorFilter(searchBar.CancelButtonColor, FilterMode.SrcIn);
}
else if (TryGetDefaultStateColor(searchView, AAttribute.TextColorPrimary, out var color))
{
drawable.SetColorFilter(color, FilterMode.SrcIn);
}
}
else
{
drawable.ClearColorFilter();
}
}
}
Expand All @@ -161,6 +172,22 @@ internal static void UpdateSearchIconColor(this SearchView searchView, ISearchBa
}
}

static ImageView? GetCancelButton(this SearchView searchView)
{
if (searchView.Resources is null)
{
return null;
}

var closeButtonId = Resource.Id.search_close_btn;
if (closeButtonId <= 0)
{
return null;
}

return searchView.FindViewById<ImageView>(closeButtonId);
}

public static void UpdateIsTextPredictionEnabled(this SearchView searchView, ISearchBar searchBar, EditText? editText = null)
{
editText ??= searchView.GetFirstChildOfType<EditText>();
Expand Down
17 changes: 12 additions & 5 deletions src/Core/src/Platform/Windows/MauiAutoSuggestBox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,26 @@ public static void InvalidateAttachedProperties(DependencyObject obj)
public static bool GetIsReadOnly(DependencyObject obj) =>
(bool)obj.GetValue(IsReadOnlyProperty);

public static void SetIsReadOnly(DependencyObject obj, bool value) =>
obj.SetValue(IsReadOnlyProperty, value);
public static void SetIsReadOnly(DependencyObject obj, bool value)
{
if (obj is FrameworkElement element && element.IsLoaded)
{
obj.SetValue(IsReadOnlyProperty, value);
Comment on lines +21 to +23
Copy link

Copilot AI May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By only calling SetValue when the element is already loaded, initial IsReadOnly values set before loading will be ignored. Consider always setting the value and/or registering a Loaded event to update it after load.

Suggested change
if (obj is FrameworkElement element && element.IsLoaded)
{
obj.SetValue(IsReadOnlyProperty, value);
obj.SetValue(IsReadOnlyProperty, value);
if (obj is FrameworkElement element)
{
if (element.IsLoaded)
{
OnIsReadOnlyPropertyChanged(obj, new DependencyPropertyChangedEventArgs(IsReadOnlyProperty, null, value));
}
else
{
element.Loaded += OnElementLoaded;
}

Copilot uses AI. Check for mistakes.
}
}

public static readonly DependencyProperty IsReadOnlyProperty = DependencyProperty.RegisterAttached(
"IsReadOnly", typeof(bool), typeof(MauiTextBox),
new PropertyMetadata(true, OnIsReadOnlyPropertyChanged));
new PropertyMetadata(false, OnIsReadOnlyPropertyChanged));
Copy link

Copilot AI May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the default of IsReadOnly from true to false, which can be a breaking change for consumers relying on the old default. Please confirm this aligns with the minor version policy or bump the major version.

Copilot uses AI. Check for mistakes.

static void OnIsReadOnlyPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs? e = null)
{
var element = d as FrameworkElement;
var textBox = element?.GetDescendantByName<TextBox>("TextBox");
if (textBox != null)
textBox.IsReadOnly = true;
if (textBox is not null && e?.NewValue is bool isReadOnly)
{
textBox.IsReadOnly = isReadOnly;
}
}
}
}
2 changes: 2 additions & 0 deletions src/Core/src/Platform/Windows/SearchBarExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ public static void UpdateMaxLength(this AutoSuggestBox platformControl, ISearchB
var maxLength = searchBar.MaxLength;

if (maxLength == -1)
{
maxLength = int.MaxValue;
}

var children = platformControl.GetChildren<TextBox>();
if (children is not null)
Expand Down
27 changes: 27 additions & 0 deletions src/TestUtils/src/UITest.Appium/HelperExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2671,6 +2671,33 @@ public static IUIElement GetShellSearchHandler(this IApp app)
return element;
}

/// <summary>
/// Taps the clear button in a search bar control with platform-specific implementations.
/// </summary>
/// <param name="app">Represents the main gateway to interact with an app.</param>
/// <param name="automationId">The automation ID of the search bar.</param>
/// <param name="timeout">Optional timeout for waiting for the clear button. Default is null, which uses the default timeout.</param>
public static void TapSearchBarClearButton(this IApp app, string automationId, TimeSpan? timeout = null)
{
if (app is AppiumAndroidApp)
{
app.WaitForElement(AppiumQuery.ByXPath("//android.widget.ImageView[@content-desc='Clear query']"), timeout: timeout);
app.Tap(AppiumQuery.ByXPath("//android.widget.ImageView[@content-desc='Clear query']"));
}
else if (app is AppiumIOSApp || app is AppiumCatalystApp)
{
app.WaitForElement("Clear text", timeout: timeout);
app.Tap("Clear text");
}
else if (app is AppiumWindowsApp)
{
var searchBar = app.WaitForElement(AppiumQuery.ByAccessibilityId(automationId), timeout: timeout);
var rect = searchBar.GetRect();
app.Tap(automationId);
app.TapCoordinates(rect.Right - 84, rect.Y + rect.Height / 2);
Comment on lines +2696 to +2697
Copy link

Copilot AI May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The hardcoded offset 84 is a magic number. Extract it into a named constant or calculate it dynamically based on the clear button's size to improve readability and maintainability.

Suggested change
app.Tap(automationId);
app.TapCoordinates(rect.Right - 84, rect.Y + rect.Height / 2);
var clearButtonOffset = rect.Width * 0.1; // Assuming the clear button is 10% of the search bar's width
app.Tap(automationId);
app.TapCoordinates(rect.Right - clearButtonOffset, rect.Y + rect.Height / 2);

Copilot uses AI. Check for mistakes.
}
}

/// <summary>
/// Taps an element and retries until another element appears and is ready for interaction.
/// Sometimes elements may appear but are not yet ready for interaction; this helper method retries the tap until the target element is interactable or the retry limit is reached.
Expand Down
Loading