From 6a23a84ba25e18079e571faa4bd71e16c3376660 Mon Sep 17 00:00:00 2001 From: Vignesh-SF3580 <102575140+Vignesh-SF3580@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:27:24 +0530 Subject: [PATCH 1/3] fixed-33964 : [iOS] Search Bar cancel button color not applied on iOS 26 --- .../src/Platform/iOS/SearchBarExtensions.cs | 161 +++++++++++++++++- 1 file changed, 155 insertions(+), 6 deletions(-) diff --git a/src/Core/src/Platform/iOS/SearchBarExtensions.cs b/src/Core/src/Platform/iOS/SearchBarExtensions.cs index ec3a3853df32..c975563e3c05 100644 --- a/src/Core/src/Platform/iOS/SearchBarExtensions.cs +++ b/src/Core/src/Platform/iOS/SearchBarExtensions.cs @@ -1,4 +1,5 @@ using System; +using CoreGraphics; using Foundation; using Microsoft.Maui.Graphics; using UIKit; @@ -119,25 +120,173 @@ public static void UpdateIsReadOnly(this UISearchBar uiSearchBar, ISearchBar sea internal static bool ShouldShowCancelButton(this ISearchBar searchBar) => !string.IsNullOrEmpty(searchBar.Text); + // Tag used to identify the cancel button color overlay view added on iOS 26+. + // Value 0x53424343 encodes "SBCC" (SearchBarCancelColor) to avoid collisions with system-assigned tags. + const nint CancelButtonColorOverlayTag = unchecked((nint)0x53424343); + public static void UpdateCancelButton(this UISearchBar uiSearchBar, ISearchBar searchBar) { uiSearchBar.ShowsCancelButton = searchBar.ShouldShowCancelButton(); // We can't cache the cancel button reference because iOS drops it when it's not displayed - // and creates a brand new one when necessary, so we have to look for it each time - var cancelButton = uiSearchBar.FindDescendantView(); + // and creates a brand new one when necessary, so we have to look for it each time. + // Exclude UIButton instances that are descendants of UITextField — those are the + // text-clear button inside the search field, not the cancel button outside it. + var cancelButton = uiSearchBar.FindDescendantView( + btn => btn.FindParent(v => v is UITextField) == null); if (cancelButton == null) + { + // Cancel button is hidden — remove any overlay we previously added + if (OperatingSystem.IsIOSVersionAtLeast(26)) + RemoveCancelButtonOverlay(uiSearchBar); return; + } if (searchBar.CancelButtonColor != null) { - cancelButton.SetTitleColor(searchBar.CancelButtonColor.ToPlatform(), UIControlState.Normal); - cancelButton.SetTitleColor(searchBar.CancelButtonColor.ToPlatform(), UIControlState.Highlighted); - cancelButton.SetTitleColor(searchBar.CancelButtonColor.ToPlatform(), UIControlState.Disabled); + var platformColor = searchBar.CancelButtonColor.ToPlatform(); + cancelButton.SetTitleColor(platformColor, UIControlState.Normal); + cancelButton.SetTitleColor(platformColor, UIControlState.Highlighted); + cancelButton.SetTitleColor(platformColor, UIControlState.Disabled); + + // On Mac, the cancel button is rendered as an icon (X mark) rather than text, + // so TintColor must be used to apply the color to the icon. if (cancelButton.TraitCollection.UserInterfaceIdiom == UIUserInterfaceIdiom.Mac) - cancelButton.TintColor = searchBar.CancelButtonColor.ToPlatform(); + { + cancelButton.TintColor = platformColor; + } + + // On iOS 26+, UIKit overrides TintColor/UIButtonConfiguration on every layout + // pass, making standard color APIs ineffective for the cancel button icon. + // Place a colored UIImageView sibling on top of the cancel button to apply + // the color outside UIButton's rendering pipeline. + // Defer via DispatchAsync so the cancel button frame is valid after layout. + // Capture the search bar (not the button) to always look up the current + // cancel button instance — iOS 26 may recreate it during theme transitions. + if (OperatingSystem.IsIOSVersionAtLeast(26)) + { + var capturedColor = platformColor; + var weakSearchBar = new WeakReference(uiSearchBar); + CoreFoundation.DispatchQueue.MainQueue.DispatchAsync(() => + { + if (!weakSearchBar.TryGetTarget(out var sb)) + return; + + var currentButton = sb.FindDescendantView( + btn => btn.FindParent(v => v is UITextField) == null); + if (currentButton != null) + ApplyCancelButtonOverlay(sb, currentButton, capturedColor); + }); + } + } + else if (OperatingSystem.IsIOSVersionAtLeast(26)) + { + // CancelButtonColor was cleared — remove any overlay we previously added + RemoveCancelButtonOverlay(uiSearchBar); + } + } + + // Schedules a deferred retry of ApplyCancelButtonOverlay on the main queue. + // Used when the cancel button is not ready for layout (detached or zero-frame). + static void ScheduleOverlayRetry(UISearchBar uiSearchBar, UIColor color, int retryCount) + { + var weakSB = new WeakReference(uiSearchBar); + CoreFoundation.DispatchQueue.MainQueue.DispatchAsync(() => + { + if (!weakSB.TryGetTarget(out var sb)) return; + var btn = sb.FindDescendantView( + b => b.FindParent(v => v is UITextField) == null); + if (btn != null) + ApplyCancelButtonOverlay(sb, btn, color, retryCount + 1); + }); + } + + static void ApplyCancelButtonOverlay(UISearchBar uiSearchBar, UIButton cancelButton, UIColor color, int retryCount = 0) + { + var parentView = cancelButton.Superview; + if (parentView == null) + { + // Button was detached by UIKit (e.g. mid-transition during a theme change). + // Retry with a fresh lookup so we always work with the current button instance. + if (retryCount < 2) + ScheduleOverlayRetry(uiSearchBar, color, retryCount); + return; + } + + // Remove any overlay from a previous call (e.g. color change or re-focus) + parentView.ViewWithTag(CancelButtonColorOverlayTag)?.RemoveFromSuperview(); + + // Find the UIImageView that UIButton uses to render the X icon. + // We need its frame in the parent view's coordinate space to position the overlay. + var iv = cancelButton.FindDescendantView(); + if (iv == null) + return; + + // Convert the icon's frame from its local coordinate space to the parent view. + var iconFrameInParent = parentView.ConvertRectFromView(iv.Frame, iv.Superview); + if (iconFrameInParent.Width <= 0 || iconFrameInParent.Height <= 0) + { + // The cancel button hasn't been laid out yet (e.g. on initial load when + // CancelButtonColor is set via AppThemeBinding before the view appears). + // Retry once after the next layout pass so we get a valid frame. + if (retryCount < 2) + ScheduleOverlayRetry(uiSearchBar, color, retryCount); + return; + } + + // Render the xmark icon in the requested color using CoreGraphics. + // AlwaysOriginal prevents UIKit from re-tinting the baked image. + var xmarkImage = UIImage.GetSystemImage("xmark"); + if (xmarkImage == null) + return; + + var imageSize = iconFrameInParent.Size; + var renderer = new UIGraphicsImageRenderer(imageSize, new UIGraphicsImageRendererFormat + { + Opaque = false, + Scale = 0, + }); + + var coloredImage = renderer.CreateImage(_ => + { + xmarkImage.Draw(new CGRect(CGPoint.Empty, imageSize)); + var ctx = UIGraphics.GetCurrentContext(); + if (ctx != null) + { + ctx.SetBlendMode(CGBlendMode.SourceIn); + ctx.SetFillColor(color.CGColor); + ctx.FillRect(new CGRect(CGPoint.Empty, imageSize)); + } + }).ImageWithRenderingMode(UIImageRenderingMode.AlwaysOriginal); + + // Add the overlay as the last (topmost) sibling of the cancel button so it + // renders on top of UIButton's layer-drawn X icon. + var overlay = new UIImageView(iconFrameInParent) + { + Image = coloredImage, + ContentMode = UIViewContentMode.ScaleAspectFit, + Tag = CancelButtonColorOverlayTag, + UserInteractionEnabled = false, + }; + + parentView.AddSubview(overlay); + } + + static void RemoveCancelButtonOverlay(UISearchBar uiSearchBar) + { + // Walk all descendants of the search bar to find and remove the overlay. + // The overlay is a direct sibling of the cancel button (child of cancel button's parent). + var cancelButtonParent = uiSearchBar.FindDescendantView( + btn => btn.FindParent(v => v is UITextField) == null)?.Superview; + cancelButtonParent?.ViewWithTag(CancelButtonColorOverlayTag)?.RemoveFromSuperview(); + + // Also check one level deeper in the hierarchy in case the search bar layout changes + foreach (var subview in uiSearchBar.Subviews) + { + foreach (var child in subview.Subviews) + child.ViewWithTag(CancelButtonColorOverlayTag)?.RemoveFromSuperview(); } } From 2f7168935fb6871a1c9c9a729271770a258be6cd Mon Sep 17 00:00:00 2001 From: Vignesh-SF3580 <102575140+Vignesh-SF3580@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:04:53 +0530 Subject: [PATCH 2/3] Addressed copilot comments. --- .../src/Platform/iOS/SearchBarExtensions.cs | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/Core/src/Platform/iOS/SearchBarExtensions.cs b/src/Core/src/Platform/iOS/SearchBarExtensions.cs index c975563e3c05..391ccf5d3f12 100644 --- a/src/Core/src/Platform/iOS/SearchBarExtensions.cs +++ b/src/Core/src/Platform/iOS/SearchBarExtensions.cs @@ -167,17 +167,35 @@ public static void UpdateCancelButton(this UISearchBar uiSearchBar, ISearchBar s // cancel button instance — iOS 26 may recreate it during theme transitions. if (OperatingSystem.IsIOSVersionAtLeast(26)) { - var capturedColor = platformColor; var weakSearchBar = new WeakReference(uiSearchBar); + var weakVirtualView = new WeakReference(searchBar); CoreFoundation.DispatchQueue.MainQueue.DispatchAsync(() => { if (!weakSearchBar.TryGetTarget(out var sb)) + { return; + } + + if (!weakVirtualView.TryGetTarget(out var virtualSearchBar)) + { + return; + } + + // Re-evaluate the desired cancel button color; it may have been + // changed or cleared since this callback was queued. + var currentColor = virtualSearchBar.CancelButtonColor; + if (currentColor is null) + { + RemoveCancelButtonOverlay(sb); + return; + } var currentButton = sb.FindDescendantView( btn => btn.FindParent(v => v is UITextField) == null); - if (currentButton != null) - ApplyCancelButtonOverlay(sb, currentButton, capturedColor); + if (currentButton is not null) + { + ApplyCancelButtonOverlay(sb, currentButton, currentColor.ToPlatform()); + } }); } } @@ -230,7 +248,7 @@ static void ApplyCancelButtonOverlay(UISearchBar uiSearchBar, UIButton cancelBut { // The cancel button hasn't been laid out yet (e.g. on initial load when // CancelButtonColor is set via AppThemeBinding before the view appears). - // Retry once after the next layout pass so we get a valid frame. + // Retry after the next layout pass (up to two times) so we get a valid frame. if (retryCount < 2) ScheduleOverlayRetry(uiSearchBar, color, retryCount); return; @@ -276,8 +294,8 @@ static void ApplyCancelButtonOverlay(UISearchBar uiSearchBar, UIButton cancelBut static void RemoveCancelButtonOverlay(UISearchBar uiSearchBar) { - // Walk all descendants of the search bar to find and remove the overlay. - // The overlay is a direct sibling of the cancel button (child of cancel button's parent). + // The overlay is added as a direct sibling of the cancel button (child of cancel button's parent). + // Find the cancel button's parent view and remove the overlay from it by tag. var cancelButtonParent = uiSearchBar.FindDescendantView( btn => btn.FindParent(v => v is UITextField) == null)?.Superview; cancelButtonParent?.ViewWithTag(CancelButtonColorOverlayTag)?.RemoveFromSuperview(); From 171c4526ef0a392b5c6361a3dbfcb5392e8693ff Mon Sep 17 00:00:00 2001 From: Vignesh-SF3580 <102575140+Vignesh-SF3580@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:45:40 +0530 Subject: [PATCH 3/3] Addressed review concerns. --- .../src/Platform/iOS/SearchBarExtensions.cs | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/Core/src/Platform/iOS/SearchBarExtensions.cs b/src/Core/src/Platform/iOS/SearchBarExtensions.cs index 391ccf5d3f12..eeb846cdd9d0 100644 --- a/src/Core/src/Platform/iOS/SearchBarExtensions.cs +++ b/src/Core/src/Platform/iOS/SearchBarExtensions.cs @@ -234,10 +234,10 @@ static void ApplyCancelButtonOverlay(UISearchBar uiSearchBar, UIButton cancelBut } // Remove any overlay from a previous call (e.g. color change or re-focus) - parentView.ViewWithTag(CancelButtonColorOverlayTag)?.RemoveFromSuperview(); + uiSearchBar.ViewWithTag(CancelButtonColorOverlayTag)?.RemoveFromSuperview(); // Find the UIImageView that UIButton uses to render the X icon. - // We need its frame in the parent view's coordinate space to position the overlay. + // We need its frame to determine the rendered image size. var iv = cancelButton.FindDescendantView(); if (iv == null) return; @@ -279,33 +279,34 @@ static void ApplyCancelButtonOverlay(UISearchBar uiSearchBar, UIButton cancelBut } }).ImageWithRenderingMode(UIImageRenderingMode.AlwaysOriginal); - // Add the overlay as the last (topmost) sibling of the cancel button so it - // renders on top of UIButton's layer-drawn X icon. - var overlay = new UIImageView(iconFrameInParent) + // Add the overlay as the last (topmost) sibling of the cancel button. + // Use Auto Layout constraints so the overlay stays centered over the X icon + // on rotation, multitasking split view, or dynamic type changes. + var overlay = new UIImageView { Image = coloredImage, ContentMode = UIViewContentMode.ScaleAspectFit, Tag = CancelButtonColorOverlayTag, UserInteractionEnabled = false, + TranslatesAutoresizingMaskIntoConstraints = false, }; parentView.AddSubview(overlay); + + NSLayoutConstraint.ActivateConstraints(new NSLayoutConstraint[] + { + overlay.CenterXAnchor.ConstraintEqualTo(cancelButton.CenterXAnchor), + overlay.CenterYAnchor.ConstraintEqualTo(cancelButton.CenterYAnchor), + overlay.WidthAnchor.ConstraintEqualTo(imageSize.Width), + overlay.HeightAnchor.ConstraintEqualTo(imageSize.Height), + }); } static void RemoveCancelButtonOverlay(UISearchBar uiSearchBar) { - // The overlay is added as a direct sibling of the cancel button (child of cancel button's parent). - // Find the cancel button's parent view and remove the overlay from it by tag. - var cancelButtonParent = uiSearchBar.FindDescendantView( - btn => btn.FindParent(v => v is UITextField) == null)?.Superview; - cancelButtonParent?.ViewWithTag(CancelButtonColorOverlayTag)?.RemoveFromSuperview(); - - // Also check one level deeper in the hierarchy in case the search bar layout changes - foreach (var subview in uiSearchBar.Subviews) - { - foreach (var child in subview.Subviews) - child.ViewWithTag(CancelButtonColorOverlayTag)?.RemoveFromSuperview(); - } + // UIView.ViewWithTag searches the entire subtree recursively, so it finds the overlay + // regardless of where it was placed in the search bar's view hierarchy. + uiSearchBar.ViewWithTag(CancelButtonColorOverlayTag)?.RemoveFromSuperview(); } internal static void UpdateSearchIcon(this UISearchBar uiSearchBar, ISearchBar searchBar)