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 @@
+ HorizontalOptions="Center"
+ AutomationId="EditorControlTitleLabel">
+
+
+
+
-
+
@@ -268,6 +270,7 @@
Minimum="1"
Maximum="100"
Increment="1"
+ HorizontalOptions="Start"
AutomationId="MaximumVisibleStepper"/>
diff --git a/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Stepper/StepperControlPage.xaml b/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Stepper/StepperControlPage.xaml
index fb58ba4e1fb9..b686c52f5a67 100644
--- a/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Stepper/StepperControlPage.xaml
+++ b/src/Controls/tests/TestCases.HostApp/FeatureMatrix/Stepper/StepperControlPage.xaml
@@ -31,7 +31,8 @@
IsEnabled="{Binding IsEnabled}"
IsVisible="{Binding IsVisible}"
FlowDirection="{Binding FlowDirection}"
- AutomationId="StepperControl"/>
+ AutomationId="StepperControl"
+ x:Name="StepperControl"/>
@@ -50,6 +52,7 @@
Minimum="0.5"
Increment="0.5"
ValueChanged="OnPane1LengthChanged"
+ HorizontalOptions="Start"
AutomationId="Pane1LengthStepper"/>