diff --git a/.github/agent-pr-session/pr-31487.md b/.github/agent-pr-session/pr-31487.md new file mode 100644 index 000000000000..b15821b1c3a2 --- /dev/null +++ b/.github/agent-pr-session/pr-31487.md @@ -0,0 +1,261 @@ +# PR Review: #31487 - [Android] Fixed duplicate title icon when setting TitleIconImageSource Multiple times + +**Date:** 2026-01-08 | **Issue:** [#31445](https://github.com/dotnet/maui/issues/31445) | **PR:** [#31487](https://github.com/dotnet/maui/pull/31487) + +## ✅ Final Recommendation: APPROVE + +| Phase | Status | +|-------|--------| +| Pre-Flight | ✅ COMPLETE | +| 🧪 Tests | ✅ COMPLETE | +| 🚦 Gate | ✅ PASSED | +| 🔧 Fix | ✅ COMPLETE | +| 📋 Report | ✅ COMPLETE | + +--- + +
+📋 Issue Summary + +On Android, calling `NavigationPage.SetTitleIconImageSource(page, "image.png")` more than once for the same page results in the icon being rendered multiple times in the navigation bar. + +**Steps to Reproduce:** +1. Launch app on Android +2. Tap "Set TitleIconImageSource" once: icon appears +3. Tap it again: a second identical icon appears + +**Expected:** Single toolbar icon regardless of how many times SetTitleIconImageSource is called. + +**Actual:** Each repeated call adds an additional duplicate icon. + +**Platforms Affected:** +- [ ] iOS +- [x] Android +- [ ] Windows +- [ ] MacCatalyst + +**Version:** 9.0.100 SR10 + +
+ +
+📁 Files Changed + +| File | Type | Changes | +|------|------|---------| +| `src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs` | Fix | +17/-6 | +| `src/Controls/tests/TestCases.HostApp/Issues/Issue31445.cs` | Test | +38 | +| `src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue31445.cs` | Test | +23 | +| `snapshots/android/Issue31445DuplicateTitleIconDoesNotAppear.png` | Snapshot | binary | +| `snapshots/mac/Issue31445DuplicateTitleIconDoesNotAppear.png` | Snapshot | binary | +| `snapshots/windows/Issue31445DuplicateTitleIconDoesNotAppear.png` | Snapshot | binary | +| `snapshots/ios/Issue31445DuplicateTitleIconDoesNotAppear.png` | Snapshot | binary | + +
+ +
+💬 PR Discussion Summary + +**Key Comments:** +- Issue verified by LogishaSelvarajSF4525 on MAUI 9.0.0 & 9.0.100 +- PR triggered UI tests by jsuarezruiz +- PureWeen requested rebase + +**Reviewer Feedback:** +- Copilot review: Suggested testing with different image sources or rapid succession to validate fix better + +**Disagreements to Investigate:** +| File:Line | Reviewer Says | Author Says | Status | +|-----------|---------------|-------------|--------| +| Issue31445.cs:31 | Test with different images or rapid calls | N/A | ⚠️ INVESTIGATE | + +**Author Uncertainty:** +- None noted + +
+ +
+🧪 Tests + +**Status**: ✅ COMPLETE + +- [x] PR includes UI tests +- [x] Tests reproduce the issue +- [x] Tests follow naming convention (`Issue31445`) + +**Test Files:** +- HostApp: `src/Controls/tests/TestCases.HostApp/Issues/Issue31445.cs` +- NUnit: `src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue31445.cs` + +**Test Behavior:** +- Uses snapshot verification (`VerifyScreenshot()`) +- Navigates to test page, taps button to trigger duplicate icon scenario +- Verified to compile successfully + +
+ +
+🚦 Gate - Test Verification + +**Status**: ✅ PASSED + +- [x] Tests FAIL without fix (bug reproduced - duplicate icons appeared) +- [x] Tests PASS with fix (single icon as expected) + +**Result:** PASSED ✅ + +**Verification Details:** +- Platform: Android (emulator-5554) +- Without fix: Test FAILED (screenshot mismatch - duplicate icons) +- With fix: Test PASSED (single icon verified) + +
+ +
+🔧 Fix Candidates + +**Status**: ✅ COMPLETE + +| # | Source | Approach | Test Result | Files Changed | Model | Notes | +|---|--------|----------|-------------|---------------|-------|-------| +| 1 | try-fix | Check for existing icon view at position 0, reuse if exists, only create new if needed | ✅ PASS | `ToolbarExtensions.cs` (+7) | Opus 4.5 | Works! Independently arrived at same solution logic as PR | +| 2 | try-fix | Dedupe defensively by scanning all toolbar children, keep first `ToolbarTitleIconImageView`, remove extras; then reuse/create | ✅ PASS | `ToolbarExtensions.cs` (+22/-5) | GPT 5.2 | More robust if child ordering changes or duplicates already exist | +| 3 | try-fix | Use `FindViewWithTag` to uniquely identify/retrieve the MAUI title icon | ✅ PASS | `ToolbarExtensions.cs` (+20/-6) | Gemini 2.0 Flash | Explicit identification; avoids index assumptions and iteration; most robust against external view insertions | +| PR | PR #31487 | Check for existing ToolbarTitleIconImageView before adding new one | ✅ PASS (Gate) | `ToolbarExtensions.cs` (+17/-6) | Author | Original PR - validated by Gate | + +**Exhausted:** Yes (3 passing alternatives found) + +**Selected Fix:** PR's fix - It’s simplest and sufficient. +- #3 (Tag) is the most "correct" for robustness but adds Tag management overhead. +- #2 (Dedupe) is good for cleanup. +- PR/#1 (Index 0) are standard for this codebase's patterns. + +**Comparison Notes:** +- PR/try-fix #1 rely on `GetChildAt(0)` being the title icon view when present +- try-fix #2 is more defensive: it collapses existing duplicates regardless of child index and then reuses/creates as needed +- try-fix #3 uses explicit tagging: precise but introduces new state (Tag) to manage + +
+ +--- + +**Next Step:** Propose Alternative Fix #2 (Dedupe & Scan) to Author for Discussion + +--- + +## 💬 Draft Comment for Author + +Hi @PureWeen, + +Reviewing the fix in this PR, it works correctly for the reported issue and tests pass. + +I explored a couple of alternative approaches and found one that might offer slightly better robustness against edge cases, which I wanted to run by you: + +**Alternative: Dedupe & Scan** +Instead of just checking index 0, we could scan all children of the toolbar to find any `ToolbarTitleIconImageView` instances. + +```csharp +// Scan all children to find existing title icons +ToolbarTitleIconImageView? titleIcon = null; +for (int i = 0; i < nativeToolbar.ChildCount; i++) +{ + var child = nativeToolbar.GetChildAt(i); + if (child is ToolbarTitleIconImageView icon) + { + if (titleIcon == null) + titleIcon = icon; // Keep the first one found + else + nativeToolbar.RemoveView(icon); // Remove any extras (self-healing) + } +} +``` + +**Why consider this?** +1. **Robustness against Injection:** If another library inserts a view at index 0 (e.g., search bar), the current PR fix (checking only index 0) would fail to see the existing icon and create a duplicate. +2. **Self-Healing:** If the toolbar is already in a bad state (multiple icons from previous bugs), this approach cleans them up. + +**Trade-off:** +It involves a loop, so O(N) instead of O(1), but for a toolbar with very few items, this is negligible. + +Do you think the added robustness is worth the change, or should we stick to the simpler Index 0 check (current PR) which matches the existing removal logic? + +--- + +## 📋 Final Report + +### Summary + +PR #31487 correctly fixes the duplicate title icon issue on Android. The fix checks for an existing `ToolbarTitleIconImageView` at position 0 before creating a new one, preventing duplicate icons when `SetTitleIconImageSource` is called multiple times. + +### Root Cause + +The original `UpdateTitleIcon` method always created a new `ToolbarTitleIconImageView` and added it to position 0, without checking if one already existed. This caused duplicate icons when the method was called repeatedly. + +### Validation + +| Check | Result | +|-------|--------| +| Tests reproduce bug | ✅ Test fails without fix (duplicate icons) | +| Tests pass with fix | ✅ Test passes with fix (single icon) | +| Independent fix analysis | ✅ try-fix arrived at same solution | +| Code quality | ✅ Clean, minimal change | + +### Regression Analysis + +
+📜 Git History Analysis + +**Original Implementation:** `e2f3aaa222` (Oct 2021) by Shane Neuville +- Part of "[Android] ToolbarHandler and fixes for various page nesting scenarios (#2781)" +- The bug has existed since the original implementation - it was never designed to handle repeated calls + +**Key Finding:** The original code had a check for removing an existing icon when source is null/empty: +```csharp +if (nativeToolbar.GetChildAt(0) is ToolbarTitleIconImageView existingImageView) + nativeToolbar.RemoveView(existingImageView); +``` +But this check was **only in the removal path**, not in the creation path. The fix extends this pattern to also check before adding. + +**Related Toolbar Issues in This File:** +| Commit | Issue | Description | +|--------|-------|-------------| +| `a93e88c3de` | #7823 | Fix toolbar item icon not removed when navigating | +| `c04b7d79cc` | #19673 | Fixed android toolbar icon change | +| `158ed8b4f1` | #28767 | Removing outdated menu items after activity switch | + +**Pattern:** Multiple fixes in this file address issues where Android toolbar state isn't properly cleaned up or reused. This PR follows the same pattern. + +
+ +
+🔄 Platform Comparison + +| Platform | TitleIcon Implementation | Duplicate Prevention | +|----------|-------------------------|---------------------| +| **Android** | Creates `ToolbarTitleIconImageView`, adds to position 0 | ❌ Was missing (now fixed by PR) | +| **Windows** | Sets `TitleIconImageSource` property directly | ✅ Property-based, no duplicates possible | +| **iOS** | Uses `NavigationRenderer` with property binding | ✅ Property-based approach | + +**Why Android was vulnerable:** Android uses a view-based approach (adding/removing child views) while other platforms use property-based approaches. View management requires explicit duplicate checks. + +
+ +
+⚠️ Risk Assessment + +**Regression Risk: LOW** + +1. **Minimal change** - Only modifies the creation logic, doesn't change removal +2. **Consistent pattern** - Uses same `GetChildAt(0)` check that already existed for removal +3. **Well-tested** - UI test verifies the specific scenario +4. **No side effects** - Reusing existing view is safe; `SetImageDrawable` handles updates + +**Potential Edge Cases (from Copilot review suggestion):** +- Setting different image sources rapidly → Should work fine, image is updated on existing view +- Setting same source multiple times → Explicitly tested, works correctly + +
+ +### Recommendation + +**✅ APPROVE** - The PR's approach is correct and validated by independent analysis. The fix is minimal, focused, and addresses the root cause. diff --git a/.gitignore b/.gitignore index 598def77e641..daafde085962 100644 --- a/.gitignore +++ b/.gitignore @@ -388,3 +388,6 @@ temp # TypeScript source map files (generated artifacts) # Note: CSS map files in templates (e.g., bootstrap) are intentionally tracked *.js.map + +# Gradle build reports +src/Core/AndroidNative/build/reports/ diff --git a/eng/pipelines/ci-uitests.yml b/eng/pipelines/ci-uitests.yml index b7c924cbb623..c3b6ace38f43 100644 --- a/eng/pipelines/ci-uitests.yml +++ b/eng/pipelines/ci-uitests.yml @@ -166,12 +166,12 @@ stages: # BuildNativeAOT is false by default, but true in devdiv environment BuildNativeAOT: ${{ or(parameters.BuildNativeAOT, and(ne(variables['Build.Reason'], 'PullRequest'), eq(variables['System.TeamProject'], 'devdiv'))) }} RunNativeAOT: ${{ parameters.RunNativeAOT }} - ${{ if or(parameters.BuildEverything, and(ne(variables['Build.Reason'], 'PullRequest'), eq(variables['System.TeamProject'], 'devdiv'))) }}: + ${{ if or(parameters.BuildEverything, ne(variables['Build.Reason'], 'PullRequest')) }}: androidApiLevels: [ 30 ] - iosVersions: [ '18.4' ] + iosVersions: [ '18.5', 'latest' ] ${{ else }}: androidApiLevels: [ 30 ] - iosVersions: [ '18.4' ] + iosVersions: [ 'latest' ] projects: - name: controls desc: Controls diff --git a/eng/pipelines/common/ui-tests.yml b/eng/pipelines/common/ui-tests.yml index 88bee2ce7160..fe1231c2292c 100644 --- a/eng/pipelines/common/ui-tests.yml +++ b/eng/pipelines/common/ui-tests.yml @@ -9,6 +9,7 @@ parameters: androidApiLevelsExtended: [ 36 ] # API 36 for Material3 tests with Pixel 3 XL iosVersions: [ 'latest' ] provisionatorChannel: 'latest' + defaultiOSVersion: '26.0' timeoutInMinutes: 180 skipProvisioning: true BuildNativeAOT: false # Parameter to control whether NativeAOT artifacts should be built @@ -295,7 +296,7 @@ stages: parameters: platform: ios ${{ if eq(version, 'latest') }}: - version: 18.5 + version: ${{ parameters.defaultiOSVersion }} ${{ if ne(version, 'latest') }}: version: ${{ version }} path: ${{ project.ios }} @@ -338,7 +339,7 @@ stages: parameters: platform: ios ${{ if eq(version, 'latest') }}: - version: 18.5 + version: ${{ parameters.defaultiOSVersion }} ${{ if ne(version, 'latest') }}: version: ${{ version }} path: ${{ project.ios }} @@ -382,7 +383,7 @@ stages: parameters: platform: ios ${{ if eq(version, 'latest') }}: - version: 18.5 + version: ${{ parameters.defaultiOSVersion }} ${{ if ne(version, 'latest') }}: version: ${{ version }} path: ${{ project.ios }} @@ -432,7 +433,7 @@ stages: parameters: platform: ios ${{ if eq(version, 'latest') }}: - version: 18.5 + version: ${{ parameters.defaultiOSVersion }} ${{ if ne(version, 'latest') }}: version: ${{ version }} path: ${{ project.ios }} diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs index 9a2c22d36fca..feda1cdbb481 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs @@ -233,6 +233,10 @@ protected async virtual void OnNavigateBack() { try { + // Call OnBackButtonPressed to allow the page to intercept navigation + if (Page?.SendBackButtonPressed() == true) + return; + await Page.Navigation.PopAsync(); } catch (Exception exc) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs index 8826058cee0f..025ae0e77993 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs @@ -7,6 +7,7 @@ using System.Windows.Input; using CoreGraphics; using Foundation; +using Microsoft.Maui.Graphics; using Microsoft.Maui.Graphics.Platform; using UIKit; using static Microsoft.Maui.Controls.Compatibility.Platform.iOS.AccessibilityExtensions; @@ -498,13 +499,13 @@ void UpdateLeftToolbarItems() UIImage? icon = null; + var foregroundColor = _context?.Shell.CurrentPage?.GetValue(Shell.ForegroundColorProperty) as Color ?? + _context?.Shell.GetValue(Shell.ForegroundColorProperty) as Color; + if (image is not null) { icon = result?.Value; - var foregroundColor = _context?.Shell.CurrentPage?.GetValue(Shell.ForegroundColorProperty) ?? - _context?.Shell.GetValue(Shell.ForegroundColorProperty); - if (foregroundColor is null) { icon = icon?.ImageWithRenderingMode(UIImageRenderingMode.AlwaysOriginal); @@ -542,6 +543,16 @@ void UpdateLeftToolbarItems() { NavigationItem.LeftBarButtonItem = new UIBarButtonItem(icon, UIBarButtonItemStyle.Plain, (s, e) => LeftBarButtonItemHandler(ViewController, IsRootPage)) { Enabled = enabled }; + + // For iOS 26+, explicitly set the tint color on the bar button item + // because the navigation bar's tint color is not automatically inherited + if (OperatingSystem.IsIOSVersionAtLeast(26) || OperatingSystem.IsMacCatalystVersionAtLeast(26)) + { + if (foregroundColor is not null) + { + NavigationItem.LeftBarButtonItem.TintColor = foregroundColor.ToPlatform(); + } + } } else { diff --git a/src/Controls/src/Core/Editor/Editor.Mapper.cs b/src/Controls/src/Core/Editor/Editor.Mapper.cs index 01ce7ee9e79e..d2dc2b69a706 100644 --- a/src/Controls/src/Core/Editor/Editor.Mapper.cs +++ b/src/Controls/src/Core/Editor/Editor.Mapper.cs @@ -17,6 +17,7 @@ public partial class Editor #if IOS || ANDROID EditorHandler.Mapper.AppendToMapping(nameof(VisualElement.IsFocused), InputView.MapIsFocused); + EditorHandler.Mapper.AppendToMapping(nameof(VisualElement.IsVisible), InputView.MapIsVisible); #endif #if ANDROID diff --git a/src/Controls/src/Core/Entry/Entry.Mapper.cs b/src/Controls/src/Core/Entry/Entry.Mapper.cs index 413f01b94078..9acb2b4127b6 100644 --- a/src/Controls/src/Core/Entry/Entry.Mapper.cs +++ b/src/Controls/src/Core/Entry/Entry.Mapper.cs @@ -22,6 +22,7 @@ public partial class Entry #if IOS || ANDROID EntryHandler.Mapper.AppendToMapping(nameof(VisualElement.IsFocused), InputView.MapIsFocused); + EntryHandler.Mapper.AppendToMapping(nameof(VisualElement.IsVisible), InputView.MapIsVisible); #endif #if ANDROID diff --git a/src/Controls/src/Core/Handlers/Items/Android/ItemsSources/ObservableGroupedSource.cs b/src/Controls/src/Core/Handlers/Items/Android/ItemsSources/ObservableGroupedSource.cs index f2b7565c4ef4..596f1224c28a 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/ItemsSources/ObservableGroupedSource.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/ItemsSources/ObservableGroupedSource.cs @@ -226,10 +226,13 @@ void UpdateGroupTracking() for (int n = 0; n < _groupSource.Count; n++) { - var source = ItemsSourceFactory.Create(_groupSource[n] as IEnumerable, _groupableItemsView, this); - source.HasFooter = _hasGroupFooters; - source.HasHeader = _hasGroupHeaders; - _groups.Add(source); + if (_groupSource[n] is IEnumerable list) + { + var source = ItemsSourceFactory.Create(list, _groupableItemsView, this); + source.HasFooter = _hasGroupFooters; + source.HasHeader = _hasGroupHeaders; + _groups.Add(source); + } } } diff --git a/src/Controls/src/Core/Handlers/Items/Android/MauiCarouselRecyclerView.cs b/src/Controls/src/Core/Handlers/Items/Android/MauiCarouselRecyclerView.cs index 279b965728dc..2917b6060ed7 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/MauiCarouselRecyclerView.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/MauiCarouselRecyclerView.cs @@ -489,7 +489,7 @@ void UpdatePosition(int position) void SetCurrentItem(int carouselPosition) { - if (ItemsViewAdapter?.ItemsSource?.Count == 0) + if (ItemsViewAdapter?.ItemsSource?.Count == 0 || carouselPosition < 0) return; var item = ItemsViewAdapter.ItemsSource.GetItem(carouselPosition); diff --git a/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs b/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs index adebfc16e999..2c2a75a63074 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs @@ -425,9 +425,9 @@ protected virtual int DetermineTargetPosition(ScrollToRequestEventArgs args) if (args.Mode == ScrollToMode.Position) { // Do not use `IGroupableItemsViewSource` since `UngroupedItemsSource` also implements that interface - if (ItemsViewAdapter.ItemsSource is UngroupedItemsSource) + if (ItemsViewAdapter.ItemsSource is UngroupedItemsSource ungroupedSource) { - return args.Index; + return ungroupedSource.HasHeader ? args.Index + 1 : args.Index; } else if (ItemsViewAdapter.ItemsSource is IGroupableItemsViewSource groupItemSource) { diff --git a/src/Controls/src/Core/Handlers/Items/Android/RecyclerViewScrollListener.cs b/src/Controls/src/Core/Handlers/Items/Android/RecyclerViewScrollListener.cs index b5d7d3896df2..1464f866368f 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/RecyclerViewScrollListener.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/RecyclerViewScrollListener.cs @@ -34,12 +34,9 @@ public override void OnScrolled(RecyclerView recyclerView, int dx, int dy) { base.OnScrolled(recyclerView, dx, dy); - // TODO: These offsets will be incorrect upon row size or count change. - // They are currently provided in place of LayoutManager's default offset calculation - // because it does not report accurate values in the presence of uneven rows. - // See https://stackoverflow.com/questions/27507715/android-how-to-get-the-current-x-offset-of-recyclerview - _horizontalOffset += dx; - _verticalOffset += dy; + var itemCount = recyclerView.GetAdapter()?.ItemCount ?? 0; + _horizontalOffset = itemCount == 0 ? 0 : _horizontalOffset + dx; + _verticalOffset = itemCount == 0 ? 0 : _verticalOffset + dy; var (First, Center, Last) = GetVisibleItemsIndex(recyclerView); var itemsViewScrolledEventArgs = new ItemsViewScrolledEventArgs diff --git a/src/Controls/src/Core/Handlers/Items/Android/SimpleItemTouchHelperCallback.cs b/src/Controls/src/Core/Handlers/Items/Android/SimpleItemTouchHelperCallback.cs index f250d39ff73b..865027324f70 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/SimpleItemTouchHelperCallback.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/SimpleItemTouchHelperCallback.cs @@ -28,7 +28,12 @@ public override int GetMovementFlags(RecyclerView recyclerView, RecyclerView.Vie public override bool OnMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) { - if (viewHolder.ItemViewType != target.ItemViewType) + // Block reordering onto structural elements (Header, Footer, GroupHeader, GroupFooter). + // Dragging FROM structural elements is already prevented by GetMovementFlags returning 0. + // All other items (including those with different DataTemplateSelector view types) can be freely reordered. + var targetViewType = target.ItemViewType; + if (targetViewType == ItemViewType.Header || targetViewType == ItemViewType.Footer + || targetViewType == ItemViewType.GroupHeader || targetViewType == ItemViewType.GroupFooter) { return false; } diff --git a/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Android.cs b/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Android.cs index 6fa39583b485..567bb5a30ba9 100644 --- a/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Android.cs +++ b/src/Controls/src/Core/Handlers/Items/CarouselViewHandler.Android.cs @@ -45,6 +45,10 @@ public static void MapPeekAreaInsets(CarouselViewHandler handler, CarouselView c public static void MapPosition(CarouselViewHandler handler, CarouselView carouselView) { + if (carouselView.Position < 0) + { + return; + } (handler.PlatformView as IMauiCarouselRecyclerView).UpdateFromPosition(); } diff --git a/src/Controls/src/Core/InputView/InputView.Platform.cs b/src/Controls/src/Core/InputView/InputView.Platform.cs index 31717388ee4c..d71f0c7506f4 100644 --- a/src/Controls/src/Core/InputView/InputView.Platform.cs +++ b/src/Controls/src/Core/InputView/InputView.Platform.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; namespace Microsoft.Maui.Controls { @@ -14,6 +15,21 @@ internal static void MapIsFocused(IViewHandler handler, IView view) ?.UpdateFocusForView(iv); } } + + internal static void MapIsVisible(IViewHandler handler, IView view) + { + if (view is not InputView inputView || handler?.PlatformView == null) + { + return; + } + + // Prevent input queuing when InputView is hidden + // Dismiss soft keyboard on Android/iOS to stop background input processing + if (!inputView.IsVisible && inputView.IsSoftInputShowing()) + { + inputView.HideSoftInputAsync(CancellationToken.None); + } + } #endif } } diff --git a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs index eb8e5dd7a673..b10250198432 100644 --- a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs +++ b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs @@ -56,17 +56,38 @@ public static void UpdateTitleIcon(this AToolbar nativeToolbar, Toolbar toolbar) ImageSource source = toolbar.TitleIcon; - if (source == null || source.IsEmpty) + ToolbarTitleIconImageView? iconView = null; + for (int childIndex = 0; childIndex < nativeToolbar.ChildCount; childIndex++) { - if (nativeToolbar.GetChildAt(0) is ToolbarTitleIconImageView existingImageView) - nativeToolbar.RemoveView(existingImageView); + var child = nativeToolbar.GetChildAt(childIndex); + if (child is ToolbarTitleIconImageView icon) + { + if (iconView is null) + { + iconView = icon; // Keep the first one found + } + else + { + nativeToolbar.RemoveView(icon); // Remove any extras (self-healing) + } + } + } + if (source is null || source.IsEmpty) + { + if (iconView is not null) + { + nativeToolbar.RemoveView(iconView); + } return; } - var iconView = new ToolbarTitleIconImageView(nativeToolbar.Context); - nativeToolbar.AddView(iconView, 0); - iconView.SetImageResource(global::Android.Resource.Color.Transparent); + if (iconView is null) + { + iconView = new ToolbarTitleIconImageView(nativeToolbar.Context); + nativeToolbar.AddView(iconView, 0); + iconView.SetImageResource(global::Android.Resource.Color.Transparent); + } source.LoadImage(toolbar.Handler.MauiContext, (result) => { diff --git a/src/Controls/src/Core/Platform/Android/TapAndPanGestureDetector.cs b/src/Controls/src/Core/Platform/Android/TapAndPanGestureDetector.cs index 03bb05bc1fbc..5aa602ac6d0c 100644 --- a/src/Controls/src/Core/Platform/Android/TapAndPanGestureDetector.cs +++ b/src/Controls/src/Core/Platform/Android/TapAndPanGestureDetector.cs @@ -40,19 +40,20 @@ public void UpdateLongPressSettings() public override bool OnTouchEvent(MotionEvent ev) { - if (base.OnTouchEvent(ev)) - return true; + bool baseHandled = base.OnTouchEvent(ev); + bool pointerHandled = false; if (_pointerGestureHandler != null && ev?.Action is - MotionEventActions.Up or MotionEventActions.Down or MotionEventActions.Cancel) + MotionEventActions.Up or MotionEventActions.Down or MotionEventActions.Move or MotionEventActions.Cancel) { _pointerGestureHandler.OnTouch(ev); + pointerHandled = _pointerGestureHandler.HasAnyPointerGestures(); } if (_listener != null && ev?.Action == MotionEventActions.Up) _listener.EndScrolling(); - return false; + return baseHandled || pointerHandled; } protected override void Dispose(bool disposing) diff --git a/src/Controls/src/Core/Shell/ShellNavigationManager.cs b/src/Controls/src/Core/Shell/ShellNavigationManager.cs index 009851a7afa6..dbe205c51e56 100644 --- a/src/Controls/src/Core/Shell/ShellNavigationManager.cs +++ b/src/Controls/src/Core/Shell/ShellNavigationManager.cs @@ -575,6 +575,24 @@ public static ShellNavigationState GetNavigationState(ShellItem shellItem, Shell } } +#if IOS || MACCATALYST + if (Shell.Current?.CurrentState?.Location is not null) + { + var currentRoute = Shell.Current?.CurrentState?.Location?.ToString(); + if (!string.IsNullOrEmpty(currentRoute)) + { + var currentPaths = new List(currentRoute.Split('/')); + // Indices 0 and 1 of both routeStack and currentPaths are dummy/empty values + // The first meaningful route segment is at index 2. + if (currentPaths.Count == routeStack.Count && currentPaths.Count > 3 && currentPaths[2] == routeStack[2]) + { + // Current route is same as the new route, so remove the last elements of the routeStack + routeStack.RemoveRange(3, routeStack.Count - 3); + } + } + } +#endif + if (routeStack.Count > 0) routeStack.Insert(0, "/"); diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DarkTheme_VerifyVisualState.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DarkTheme_VerifyVisualState.png index 20fbfc484f84..4c9f347061c0 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DarkTheme_VerifyVisualState.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/DarkTheme_VerifyVisualState.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ImageShouldScaleProperly.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ImageShouldScaleProperly.png new file mode 100644 index 000000000000..4bfb644edb04 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ImageShouldScaleProperly.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue18242Test.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue18242Test.png index 334c07d5c133..1eeafdcbc965 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue18242Test.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue18242Test.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue25558VerifyImageButtonAspects.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue25558VerifyImageButtonAspects.png new file mode 100644 index 000000000000..6cf9fd77ff83 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue25558VerifyImageButtonAspects.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue31445DuplicateTitleIconDoesNotAppear.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue31445DuplicateTitleIconDoesNotAppear.png new file mode 100644 index 000000000000..8e2548fc7aa2 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/Issue31445DuplicateTitleIconDoesNotAppear.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/LightTheme_VerifyVisualState.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/LightTheme_VerifyVisualState.png index 4bed392556db..da580192eefe 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/LightTheme_VerifyVisualState.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/LightTheme_VerifyVisualState.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/PropertiesShouldBeCorrectlyApplied.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/PropertiesShouldBeCorrectlyApplied.png index ab0b8ced767a..fc61de114d0e 100644 Binary files a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/PropertiesShouldBeCorrectlyApplied.png and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/PropertiesShouldBeCorrectlyApplied.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ShellFlyoutIconShouldNotBeBlack.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ShellFlyoutIconShouldNotBeBlack.png new file mode 100644 index 000000000000..faf8fd4ddeb2 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/ShellFlyoutIconShouldNotBeBlack.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorVerticalTextAlignmentWhenVisibilityToggled.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorVerticalTextAlignmentWhenVisibilityToggled.png new file mode 100644 index 000000000000..85ac665f9189 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorVerticalTextAlignmentWhenVisibilityToggled.png differ diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorsNotVisible.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorsNotVisible.png new file mode 100644 index 000000000000..b4277a769623 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/VerifyEditorsNotVisible.png differ diff --git a/src/Controls/tests/TestCases.HostApp/Elements/StepperCoreGalleryPage.cs b/src/Controls/tests/TestCases.HostApp/Elements/StepperCoreGalleryPage.cs index 4f9222513d13..cfc056fa1e2b 100644 --- a/src/Controls/tests/TestCases.HostApp/Elements/StepperCoreGalleryPage.cs +++ b/src/Controls/tests/TestCases.HostApp/Elements/StepperCoreGalleryPage.cs @@ -12,6 +12,10 @@ public StepperCoreGalleryPage() // Default var defaultLabel = new Label { AutomationId = "DefaultLabel", Text = "Default" }; var defaultStepper = new Stepper { AutomationId = "DefaultStepper" }; + if (OperatingSystem.IsIOSVersionAtLeast(26)) + { + defaultStepper.HorizontalOptions = LayoutOptions.Center; + } var defaultLabelValue = new Label { AutomationId = "DefaultLabelValue", diff --git a/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Editor/EditorControlPage.xaml b/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Editor/EditorControlPage.xaml index 9bd65608d445..58c428a45721 100644 --- a/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Editor/EditorControlPage.xaml +++ b/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Editor/EditorControlPage.xaml @@ -8,7 +8,12 @@ - +