diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellContentFragment.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellContentFragment.cs index 5bee0ab220dc..ad0f11feeada 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellContentFragment.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellContentFragment.cs @@ -9,6 +9,7 @@ using AndroidX.Core.View; using AndroidX.Fragment.App; using Google.Android.Material.AppBar; +using Microsoft.Maui.Platform; using AndroidAnimation = Android.Views.Animations.Animation; using AnimationSet = Android.Views.Animations.AnimationSet; using AToolbar = AndroidX.AppCompat.Widget.Toolbar; @@ -66,7 +67,7 @@ void IAppearanceObserver.OnAppearanceChanged(ShellAppearance appearance) IShellToolbarAppearanceTracker _appearanceTracker; Page _page; IPlatformViewHandler _viewhandler; - AView _root; + CoordinatorLayout _root; ShellPageContainer _shellPageContainer; ShellContent _shellContent; AToolbar _toolbar; @@ -135,6 +136,8 @@ public override AView OnCreateView(LayoutInflater inflater, ViewGroup container, _root = inflater.Inflate(Controls.Resource.Layout.shellcontent, null).JavaCast(); + MauiWindowInsetListener.SetupViewWithLocalListener(_root); + var shellContentMauiContext = _shellContext.Shell.Handler.MauiContext.MakeScoped(layoutInflater: inflater, fragmentManager: ChildFragmentManager); Maui.IElement parentElement = (_shellContent as Maui.IElement) ?? _page; @@ -143,9 +146,6 @@ public override AView OnCreateView(LayoutInflater inflater, ViewGroup container, _toolbar = (AToolbar)shellToolbar.ToPlatform(shellContentMauiContext); var appBar = _root.FindViewById(Resource.Id.shellcontent_appbar); - - GlobalWindowInsetListenerExtensions.TrySetGlobalWindowInsetListener(_root, this.Context); - appBar.AddView(_toolbar); _viewhandler = _page.ToHandler(shellContentMauiContext); @@ -183,6 +183,12 @@ void Destroy() // to avoid the navigation `TaskCompletionSource` to be stuck forever. AnimationFinished?.Invoke(this, EventArgs.Empty); + // Clean up the coordinator layout and local listener first + if (_root is not null) + { + MauiWindowInsetListener.RemoveViewWithLocalListener(_root); + } + (_shellContext?.Shell as IShellController)?.RemoveAppearanceObserver(this); if (_shellContent != null) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutTemplatedContentRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutTemplatedContentRenderer.cs index 6baa58817a06..4ee900175195 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutTemplatedContentRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutTemplatedContentRenderer.cs @@ -85,7 +85,7 @@ void OnFlyoutStateChanging(object sender, AndroidX.DrawerLayout.Widget.DrawerLay // - Keep this minimal. // - Will be replaced by the planned comprehensive window insets solution. // - Do not extend; add new logic to the forthcoming implementation instead. - internal class WindowsListener : Java.Lang.Object, IOnApplyWindowInsetsListener + internal class WindowsListener : MauiWindowInsetListener, IOnApplyWindowInsetsListener { private WeakReference _bgImageRef; private WeakReference _flyoutViewRef; @@ -100,10 +100,10 @@ public AView FlyoutView return null; } - set - { + set + { _flyoutViewRef = new WeakReference(value); - } + } } public AView FooterView { @@ -114,10 +114,10 @@ public AView FooterView return null; } - set - { + set + { _footerViewRef = new WeakReference(value); - } + } } public WindowsListener(ImageView bgImage) @@ -125,51 +125,57 @@ public WindowsListener(ImageView bgImage) _bgImageRef = new WeakReference(bgImage); } - public WindowInsetsCompat OnApplyWindowInsets(AView v, WindowInsetsCompat insets) + public override WindowInsetsCompat OnApplyWindowInsets(AView v, WindowInsetsCompat insets) { if (insets == null || v == null) return insets; - // The flyout overlaps the status bar so we don't really care about insetting it - var systemBars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars()); - var displayCutout = insets.GetInsets(WindowInsetsCompat.Type.DisplayCutout()); - var topInset = Math.Max(systemBars?.Top ?? 0, displayCutout?.Top ?? 0); - var bottomInset = Math.Max(systemBars?.Bottom ?? 0, displayCutout?.Bottom ?? 0); - var appbarLayout = v.FindDescendantView((v) => true); + if (v is CoordinatorLayout) + { + // The flyout overlaps the status bar so we don't really care about insetting it + var systemBars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars()); + var displayCutout = insets.GetInsets(WindowInsetsCompat.Type.DisplayCutout()); + var topInset = Math.Max(systemBars?.Top ?? 0, displayCutout?.Top ?? 0); + var bottomInset = Math.Max(systemBars?.Bottom ?? 0, displayCutout?.Bottom ?? 0); + var appbarLayout = v.FindDescendantView((v) => true); - int flyoutViewBottomInset = 0; + int flyoutViewBottomInset = 0; - if (FooterView is not null) - { - v.SetPadding(0, 0, 0, bottomInset); - flyoutViewBottomInset = 0; - } - else - { - flyoutViewBottomInset = bottomInset; - v.SetPadding(0, 0, 0, 0); - } + if (FooterView is not null) + { + v.SetPadding(0, 0, 0, bottomInset); + flyoutViewBottomInset = 0; + } + else + { + flyoutViewBottomInset = bottomInset; + v.SetPadding(0, 0, 0, 0); + } - if (appbarLayout.MeasuredHeight > 0) - { - FlyoutView?.SetPadding(0, 0, 0, flyoutViewBottomInset); - appbarLayout?.SetPadding(0, topInset, 0, 0); - } - else - { - FlyoutView?.SetPadding(0, topInset, 0, flyoutViewBottomInset); - appbarLayout?.SetPadding(0, 0, 0, 0); - } + if (appbarLayout.MeasuredHeight > 0) + { + FlyoutView?.SetPadding(0, 0, 0, flyoutViewBottomInset); + appbarLayout?.SetPadding(0, topInset, 0, 0); + } + else + { + FlyoutView?.SetPadding(0, topInset, 0, flyoutViewBottomInset); + appbarLayout?.SetPadding(0, 0, 0, 0); + } - if (_bgImageRef != null && _bgImageRef.TryGetTarget(out var bgImage) && bgImage != null) - { - bgImage.SetPadding(0, topInset, 0, bottomInset); + if (_bgImageRef != null && _bgImageRef.TryGetTarget(out var bgImage) && bgImage != null) + { + bgImage.SetPadding(0, topInset, 0, bottomInset); + } + + return WindowInsetsCompat.Consumed; } - return WindowInsetsCompat.Consumed; + + return base.OnApplyWindowInsets(v, insets); } } - + protected virtual void LoadView(IShellContext shellContext) { var context = shellContext.AndroidContext; @@ -202,7 +208,7 @@ protected virtual void LoadView(IShellContext shellContext) }; _windowsListener = new WindowsListener(_bgImage); - ViewCompat.SetOnApplyWindowInsetsListener(coordinator, _windowsListener); + MauiWindowInsetListener.SetupViewWithLocalListener(coordinator, _windowsListener); UpdateFlyoutHeaderBehavior(); _shellContext.Shell.PropertyChanged += OnShellPropertyChanged; @@ -718,6 +724,12 @@ public void OnOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) internal void Disconnect() { + + if (_rootView is CoordinatorLayout coordinator) + { + MauiWindowInsetListener.RemoveViewWithLocalListener(coordinator); + } + if (_shellContext?.Shell != null) _shellContext.Shell.PropertyChanged -= OnShellPropertyChanged; @@ -726,14 +738,12 @@ internal void Disconnect() _flyoutHeader = null; - if (_footerView != null) - _footerView.View = null; + _footerView?.View = null; _headerView?.Disconnect(); DisconnectRecyclerView(); - if (_contentView != null) - _contentView.View = null; + _contentView?.View = null; } protected override void Dispose(bool disposing) @@ -764,8 +774,7 @@ protected override void Dispose(bool disposing) if (_headerView != null) _headerView.LayoutChange -= OnHeaderViewLayoutChange; - if (_contentView != null) - _contentView.View = null; + _contentView?.View = null; _flyoutContentView?.Dispose(); _headerView?.Dispose(); diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs index 83310a7c5575..a3f4513ccaf2 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellSectionRenderer.cs @@ -13,8 +13,10 @@ using AndroidX.Fragment.App; using AndroidX.ViewPager.Widget; using AndroidX.ViewPager2.Widget; +using Google.Android.Material.AppBar; using Google.Android.Material.Tabs; using Microsoft.Extensions.Logging; +using Microsoft.Maui.Platform; using AToolbar = AndroidX.AppCompat.Widget.Toolbar; using AView = Android.Views.View; @@ -66,7 +68,7 @@ void AView.IOnClickListener.OnClick(AView v) #endregion IOnClickListener readonly IShellContext _shellContext; - AView _rootView; + CoordinatorLayout _rootView; bool _selecting; TabLayout _tablayout; IShellTabLayoutAppearanceTracker _tabLayoutAppearanceTracker; @@ -101,7 +103,9 @@ public override AView OnCreateView(LayoutInflater inflater, ViewGroup container, var context = Context; var root = PlatformInterop.CreateShellCoordinatorLayout(context); var appbar = PlatformInterop.CreateShellAppBar(context, Resource.Attribute.appBarLayoutStyle, root); - GlobalWindowInsetListenerExtensions.TrySetGlobalWindowInsetListener(root, this.Context); + + MauiWindowInsetListener.SetupViewWithLocalListener(root); + int actionBarHeight = context.GetActionBarHeight(); var shellToolbar = new Toolbar(shellSection); @@ -194,6 +198,12 @@ void Destroy() { if (_rootView != null) { + // Clean up the coordinator layout and local listener first + if (_rootView is not null) + { + MauiWindowInsetListener.RemoveViewWithLocalListener(_rootView); + } + UnhookEvents(); _shellContext?.Shell?.Toolbar?.Handler?.DisconnectHandler(); 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 f9b25a873bfa..202234f0c388 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs @@ -474,8 +474,7 @@ protected virtual async void UpdateLeftBarButtonItem(Context context, AToolbar t defaultDrawerArrowDrawable = true; } - if (icon != null) - icon.Progress = (CanNavigateBack) ? 1 : 0; + icon?.Progress = (CanNavigateBack) ? 1 : 0; if (command != null || CanNavigateBack) { diff --git a/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs b/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs index 0910d9a7d450..bfd7e5281b82 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/MauiRecyclerView.cs @@ -15,7 +15,7 @@ namespace Microsoft.Maui.Controls.Handlers.Items { - public class MauiRecyclerView : RecyclerView, IMauiRecyclerView + public class MauiRecyclerView : RecyclerView, IMauiRecyclerView, IMauiRecyclerView where TItemsView : ItemsView where TAdapter : ItemsViewAdapter where TItemsViewSource : IItemsViewSource diff --git a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs index cc2c6ef23aa6..e847e73e8952 100644 --- a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs +++ b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs @@ -8,12 +8,12 @@ using Android.Views; using Android.Views.Animations; using AndroidX.Activity; +using AndroidX.Core.View; using AndroidX.Fragment.App; using Microsoft.Maui.LifecycleEvents; using AAnimation = Android.Views.Animations.Animation; using AColor = Android.Graphics.Color; using AView = Android.Views.View; -using AndroidX.Core.View; namespace Microsoft.Maui.Controls.Platform { @@ -206,7 +206,6 @@ internal class ModalFragment : DialogFragment Page _modal; IMauiContext _mauiWindowContext; NavigationRootManager? _navigationRootManager; - GlobalWindowInsetListener? _modalInsetListener; static readonly ColorDrawable TransparentColorDrawable = new(AColor.Transparent); bool _pendingAnimation = true; @@ -320,15 +319,6 @@ public override AView OnCreateView(LayoutInflater inflater, ViewGroup? container var rootView = _navigationRootManager?.RootView ?? throw new InvalidOperationException("Root view not initialized"); - var context = rootView.Context ?? inflater.Context; - if (context is not null) - { - // Modal pages get their own separate GlobalWindowInsetListener instance - // This prevents cross-contamination with the main window's inset tracking - _modalInsetListener = new GlobalWindowInsetListener(); - ViewCompat.SetOnApplyWindowInsetsListener(rootView, _modalInsetListener); - } - if (IsAnimated) { _ = new GenericGlobalLayoutListener((listener, view) => @@ -383,20 +373,6 @@ public override void OnDismiss(IDialogInterface dialog) _modal.Toolbar.Handler = null; } - // Clean up the modal's separate GlobalWindowInsetListener - if (_modalInsetListener is not null) - { - _modalInsetListener.ResetAllViews(); - _modalInsetListener.Dispose(); - _modalInsetListener = null; - } - - var rootView = _navigationRootManager?.RootView; - if (rootView is not null) - { - ViewCompat.SetOnApplyWindowInsetsListener(rootView, null); - } - _modal.Handler = null; _modal = null!; _mauiWindowContext = null!; diff --git a/src/Controls/tests/DeviceTests/Elements/ScrollView/ScrollViewTests.cs b/src/Controls/tests/DeviceTests/Elements/ScrollView/ScrollViewTests.cs index 2a93836d775f..25f0cffd9b21 100644 --- a/src/Controls/tests/DeviceTests/Elements/ScrollView/ScrollViewTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/ScrollView/ScrollViewTests.cs @@ -174,9 +174,9 @@ public async Task ShouldGrow() var screenHeightConstraint = 600; var label = new Label() { Text = "Text inside a ScrollView" }; - var childLayout = new VerticalStackLayout { label }; - var scrollView = new ScrollView() { VerticalOptions = LayoutOptions.Fill, Content = childLayout }; - var parentLayout = new Grid { scrollView }; + var childLayout = new VerticalStackLayout { SafeAreaEdges = SafeAreaEdges.None, Children = { label } }; + var scrollView = new ScrollView() { SafeAreaEdges = SafeAreaEdges.None, VerticalOptions = LayoutOptions.Fill, Content = childLayout }; + var parentLayout = new Grid { SafeAreaEdges = SafeAreaEdges.None, Children = { scrollView } }; var expectedHeight = 100; parentLayout.HeightRequest = expectedHeight; diff --git a/src/Controls/tests/TestCases.HostApp/Elements/CollectionView/HeaderFooterGalleries/HeaderFooterView.xaml b/src/Controls/tests/TestCases.HostApp/Elements/CollectionView/HeaderFooterGalleries/HeaderFooterView.xaml index 0bda91b2ddd1..e154a08b1141 100644 --- a/src/Controls/tests/TestCases.HostApp/Elements/CollectionView/HeaderFooterGalleries/HeaderFooterView.xaml +++ b/src/Controls/tests/TestCases.HostApp/Elements/CollectionView/HeaderFooterGalleries/HeaderFooterView.xaml @@ -4,6 +4,7 @@ xmlns:d="http://schemas.microsoft.com/dotnet/2021/maui/design" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" + SafeAreaEdges="Container" x:Class=" Maui.Controls.Sample.CollectionViewGalleries.HeaderFooterGalleries.HeaderFooterView"> diff --git a/src/Core/src/Core/IMauiRecyclerView.cs b/src/Core/src/Core/IMauiRecyclerView.cs new file mode 100644 index 000000000000..7fc4632a4326 --- /dev/null +++ b/src/Core/src/Core/IMauiRecyclerView.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace Microsoft.Maui +{ + internal interface IMauiRecyclerView + { + } +} diff --git a/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs b/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs index 751cb714daa2..fac7d6e86772 100644 --- a/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs +++ b/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs @@ -4,6 +4,7 @@ using Android.Runtime; using Android.Views; using AndroidX.AppCompat.Widget; +using AndroidX.CoordinatorLayout.Widget; using AndroidX.Core.View; using AndroidX.DrawerLayout.Widget; using AndroidX.Fragment.App; @@ -32,8 +33,6 @@ protected override View CreatePlatformView() _navigationRoot = li.Inflate(Resource.Layout.navigationlayout, null) ?? throw new InvalidOperationException($"Resource.Layout.navigationlayout missing"); - GlobalWindowInsetListenerExtensions.TrySetGlobalWindowInsetListener(_navigationRoot, this.Context); - _navigationRoot.Id = View.GenerateViewId(); return dl; } @@ -286,6 +285,13 @@ void UpdateFlyoutBehavior() protected override void ConnectHandler(View platformView) { + MauiWindowInsetListener.RegisterParentForChildViews(platformView); + + if (_navigationRoot is CoordinatorLayout cl) + { + MauiWindowInsetListener.SetupViewWithLocalListener(cl); + } + if (platformView is DrawerLayout dl) { dl.DrawerStateChanged += OnDrawerStateChanged; @@ -295,6 +301,13 @@ protected override void ConnectHandler(View platformView) protected override void DisconnectHandler(View platformView) { + MauiWindowInsetListener.UnregisterView(platformView); + if (_navigationRoot is CoordinatorLayout cl) + { + MauiWindowInsetListener.UnregisterView(cl); + _navigationRoot = null; + } + if (platformView is DrawerLayout dl) { dl.DrawerStateChanged -= OnDrawerStateChanged; diff --git a/src/Core/src/Handlers/Toolbar/ToolbarHandler.Android.cs b/src/Core/src/Handlers/Toolbar/ToolbarHandler.Android.cs index 40ad092fd518..5cb268ecb5d8 100644 --- a/src/Core/src/Handlers/Toolbar/ToolbarHandler.Android.cs +++ b/src/Core/src/Handlers/Toolbar/ToolbarHandler.Android.cs @@ -42,14 +42,14 @@ void OnViewDetachedFromWindow(object? sender, View.ViewDetachedFromWindowEventAr { if (sender is MaterialToolbar mt && mt.IsAlive() && mt.Context is not null) { - GlobalWindowInsetListenerExtensions.RemoveGlobalWindowInsetListener(mt, mt.Context); + MauiWindowInsetListenerExtensions.RemoveMauiWindowInsetListener(mt, mt.Context); } } void OnViewAttachedToWindow(object? sender, View.ViewAttachedToWindowEventArgs e) { var context = MauiContext?.Context ?? throw new InvalidOperationException("Context cannot be null"); - GlobalWindowInsetListenerExtensions.TrySetGlobalWindowInsetListener(PlatformView, context); + MauiWindowInsetListenerExtensions.TrySetMauiWindowInsetListener(PlatformView, context); } private protected override void OnDisconnectHandler(object platformView) diff --git a/src/Core/src/Handlers/View/ViewHandler.Android.cs b/src/Core/src/Handlers/View/ViewHandler.Android.cs index 48898719e0b2..52efc78feb14 100644 --- a/src/Core/src/Handlers/View/ViewHandler.Android.cs +++ b/src/Core/src/Handlers/View/ViewHandler.Android.cs @@ -1,6 +1,7 @@ using System; using Android.Views; using AndroidX.Core.View; +using Microsoft.Maui.Platform; using PlatformView = Android.Views.View; namespace Microsoft.Maui.Handlers @@ -260,27 +261,32 @@ internal static void MapSafeAreaEdges(IViewHandler handler, IView view) { return; } - - if (handler.MauiContext?.Context is null || handler.PlatformView is not PlatformView platformView) + + if (handler.MauiContext?.Context is null || handler.PlatformView is not View platformView) { return; } - switch (platformView) + // Use our static registry approach to find and reset the appropriate listener + var listener = MauiWindowInsetListener.FindListenerForView(platformView); + + // Check for specific view group types that handle safe area + if (handler.PlatformView is ContentViewGroup cvg) + { + listener?.ResetAppliedSafeAreas(cvg); + cvg.MarkSafeAreaEdgeConfigurationChanged(); + } + else if (handler.PlatformView is LayoutViewGroup lvg) { - case ContentViewGroup cvg: - handler.MauiContext.Context.GetGlobalWindowInsetListener()?.ResetAppliedSafeAreas(cvg); - cvg.MarkSafeAreaEdgeConfigurationChanged(); - break; - case LayoutViewGroup lvg: - handler.MauiContext.Context.GetGlobalWindowInsetListener()?.ResetAppliedSafeAreas(lvg); - lvg.MarkSafeAreaEdgeConfigurationChanged(); - break; - case MauiScrollView msv: - handler.MauiContext.Context.GetGlobalWindowInsetListener()?.ResetAppliedSafeAreas(msv); - msv.MarkSafeAreaEdgeConfigurationChanged(); - break; + listener?.ResetAppliedSafeAreas(lvg); + lvg.MarkSafeAreaEdgeConfigurationChanged(); } + else if (handler.PlatformView is MauiScrollView msv) + { + listener?.ResetAppliedSafeAreas(msv); + msv.MarkSafeAreaEdgeConfigurationChanged(); + } + view.InvalidateMeasure(); } } diff --git a/src/Core/src/Handlers/Window/WindowHandler.Android.cs b/src/Core/src/Handlers/Window/WindowHandler.Android.cs index df7177d0c140..dd7739a14d69 100644 --- a/src/Core/src/Handlers/Window/WindowHandler.Android.cs +++ b/src/Core/src/Handlers/Window/WindowHandler.Android.cs @@ -1,13 +1,14 @@ using System; using Android.App; +using Android.Content.Res; using Android.Views; using AndroidX.Core.Graphics; using AndroidX.Core.View; using AndroidX.Window.Layout; using Google.Android.Material.AppBar; -using AView = Android.Views.View; +using Microsoft.Maui.Platform; using AColor = Android.Graphics.Color; -using Android.Content.Res; +using AView = Android.Views.View; namespace Microsoft.Maui.Handlers { @@ -77,6 +78,10 @@ private protected override void OnDisconnectHandler(object platformView) if (_rootManager != null) _rootManager.RootViewChanged -= OnRootViewChanged; + + // The MauiCoordinatorLayout will automatically unregister from the static registry + // when it's detached from the window, but we can ensure cleanup here as well + _rootManager = null; } void OnRootViewChanged(object? sender, EventArgs e) @@ -103,7 +108,12 @@ internal static void DisconnectHandler(NavigationRootManager? navigationRootMana var rootManager = handler.MauiContext.GetNavigationRootManager(); rootManager.Connect(window.Content); - return rootManager.RootView; + + // The NavigationRootManager creates a MauiCoordinatorLayout which automatically + // registers its MauiWindowInsetListener in the static registry for child views to use + var rootView = rootManager.RootView; + + return rootView; } void UpdateVirtualViewFrame(Activity activity) diff --git a/src/Core/src/Platform/Android/ContentViewGroup.cs b/src/Core/src/Platform/Android/ContentViewGroup.cs index e95cab7d67dd..fa5348fbb0f9 100644 --- a/src/Core/src/Platform/Android/ContentViewGroup.cs +++ b/src/Core/src/Platform/Android/ContentViewGroup.cs @@ -54,7 +54,7 @@ protected override void OnAttachedToWindow() // ScrollViews handle their own insets if (Parent is not MauiScrollView) { - _isInsetListenerSet = GlobalWindowInsetListenerExtensions.TrySetGlobalWindowInsetListener(this, _context); + _isInsetListenerSet = MauiWindowInsetListenerExtensions.TrySetMauiWindowInsetListener(this, _context); } } @@ -62,7 +62,7 @@ protected override void OnDetachedFromWindow() { base.OnDetachedFromWindow(); if (_isInsetListenerSet) - GlobalWindowInsetListenerExtensions.RemoveGlobalWindowInsetListener(this, _context); + MauiWindowInsetListenerExtensions.RemoveMauiWindowInsetListener(this, _context); _didSafeAreaEdgeConfigurationChange = true; _isInsetListenerSet = false; } @@ -155,7 +155,7 @@ protected override void OnConfigurationChanged(Configuration? newConfig) { base.OnConfigurationChanged(newConfig); - Context?.GetGlobalWindowInsetListener()?.ResetView(this); + MauiWindowInsetListener.FindListenerForView(this)?.ResetView(this); _didSafeAreaEdgeConfigurationChange = true; } @@ -231,7 +231,7 @@ internal IBorderStroke? Clip { _originalPadding = (PaddingLeft, PaddingTop, PaddingRight, PaddingBottom); _hasStoredOriginalPadding = true; - } + } return SafeAreaExtensions.ApplyAdjustedSafeAreaInsetsPx(insets, CrossPlatformLayout, _context, view); } diff --git a/src/Core/src/Platform/Android/EditTextExtensions.cs b/src/Core/src/Platform/Android/EditTextExtensions.cs index 55c51b78f57d..bbb99c029ab4 100644 --- a/src/Core/src/Platform/Android/EditTextExtensions.cs +++ b/src/Core/src/Platform/Android/EditTextExtensions.cs @@ -414,7 +414,7 @@ internal static bool HandleClearButtonTouched(this EditText? platformView, Touch // Android.Graphics.Rect has a Containts(x,y) method, but it only takes `int` and the coordinates from // the motion event are `float`. The we use GetX() and GetY() so our coordinates are relative to the // bounds of the EditText. - static bool RectContainsMotionEvent(global::Android.Graphics.Rect rect, MotionEvent motionEvent) + static bool RectContainsMotionEvent(global::Android.Graphics.Rect rect, MotionEvent motionEvent) { var x = motionEvent.GetX(); diff --git a/src/Core/src/Platform/Android/GlobalWindowInsetListener.cs b/src/Core/src/Platform/Android/GlobalWindowInsetListener.cs deleted file mode 100644 index 36e0e65028ff..000000000000 --- a/src/Core/src/Platform/Android/GlobalWindowInsetListener.cs +++ /dev/null @@ -1,348 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Android.Content; -using Android.Views; -using AndroidX.Core.Graphics; -using AndroidX.Core.View; -using AndroidX.Core.Widget; -using Google.Android.Material.AppBar; -using AView = Android.Views.View; - -namespace Microsoft.Maui.Platform -{ - internal class GlobalWindowInsetListener : WindowInsetsAnimationCompat.Callback, IOnApplyWindowInsetsListener - { - readonly HashSet _trackedViews = []; - bool IsImeAnimating { get; set; } - - AView? _pendingView; - - public GlobalWindowInsetListener() : base(DispatchModeStop) - { - } - - public WindowInsetsCompat? OnApplyWindowInsets(AView? v, WindowInsetsCompat? insets) - { - if (insets is null || !insets.HasInsets || v is null || IsImeAnimating) - { - if (IsImeAnimating) - _pendingView = v; - - return insets; - } - - _pendingView = null; - - // Handle custom inset views first - if (v is IHandleWindowInsets customHandler) - { - return customHandler.HandleWindowInsets(v, insets); - } - - // Apply default window insets for standard views - return ApplyDefaultWindowInsets(v, insets); - } - - static WindowInsetsCompat? ApplyDefaultWindowInsets(AView v, WindowInsetsCompat insets) - { - var systemBars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars()); - var displayCutout = insets.GetInsets(WindowInsetsCompat.Type.DisplayCutout()); - - var leftInset = Math.Max(systemBars?.Left ?? 0, displayCutout?.Left ?? 0); - var topInset = Math.Max(systemBars?.Top ?? 0, displayCutout?.Top ?? 0); - var rightInset = Math.Max(systemBars?.Right ?? 0, displayCutout?.Right ?? 0); - var bottomInset = Math.Max(systemBars?.Bottom ?? 0, displayCutout?.Bottom ?? 0); - - if (v is MaterialToolbar) - { - v.SetPadding(displayCutout?.Left ?? 0, 0, displayCutout?.Right ?? 0, 0); - return WindowInsetsCompat.Consumed; - } - - // Handle special cases - var appBarLayout = v.FindViewById(Resource.Id.navigationlayout_appbar); - - if (appBarLayout is null && v is ViewGroup group) - { - if (group.ChildCount > 0 && group.GetChildAt(0) is AppBarLayout firstChildAppBar) - { - appBarLayout = firstChildAppBar; - } - else if (group.ChildCount > 1 && group.GetChildAt(1) is AppBarLayout secondChildAppBar) - { - appBarLayout = secondChildAppBar; - } - } - - bool appBarLayoutContainsSomething = appBarLayout?.MeasuredHeight > 0; - - for (int i = 0; i < (appBarLayout?.ChildCount ?? 0) && !appBarLayoutContainsSomething; i++) - { - var child = appBarLayout?.GetChildAt(i); - if (child is not null && child.MeasuredHeight > 0) - { - appBarLayoutContainsSomething = true; - break; - } - } - - if (appBarLayout is not null) - { - if (appBarLayoutContainsSomething) - { - // Pad the AppBarLayout to avoid the navigation bar in landscape orientation and system UI in portrait. - // In landscape, the navigation bar is on the left or right edge; in portrait, we account for the status bar and display cutouts. - // Without this padding, the AppBarLayout would extend behind these system UI elements and be partially hidden or non-interactive. - appBarLayout.SetPadding(systemBars?.Left ?? 0, topInset, systemBars?.Right ?? 0, 0); - } - else - { - appBarLayout.SetPadding(0, 0, 0, 0); - } - } - - - var bottomNavigation = v.FindViewById(Resource.Id.navigationlayout_bottomtabs)?.MeasuredHeight > 0; - - if (bottomNavigation) - { - v.SetPadding(0, 0, 0, bottomInset); - } - else - { - v.SetPadding(0, 0, 0, 0); - } - - // Create new insets with consumed values - var newSystemBars = Insets.Of( - systemBars?.Left ?? 0, - appBarLayoutContainsSomething ? 0 : systemBars?.Top ?? 0, - systemBars?.Right ?? 0, - bottomNavigation ? 0 : systemBars?.Bottom ?? 0 - ) ?? Insets.None; - - var newDisplayCutout = Insets.Of( - displayCutout?.Left ?? 0, - appBarLayoutContainsSomething ? 0 : displayCutout?.Top ?? 0, - displayCutout?.Right ?? 0, - bottomNavigation ? 0 : displayCutout?.Bottom ?? 0 - ) ?? Insets.None; - - return new WindowInsetsCompat.Builder(insets) - ?.SetInsets(WindowInsetsCompat.Type.SystemBars(), newSystemBars) - ?.SetInsets(WindowInsetsCompat.Type.DisplayCutout(), newDisplayCutout) - ?.Build() ?? insets; - } - - public void TrackView(AView view) - { - _trackedViews.Add(view); - } - - public bool HasTrackedView => _trackedViews.Count > 0; - - public void ResetView(AView view) - { - if (view is IHandleWindowInsets customHandler) - { - customHandler.ResetWindowInsets(view); - } - - _trackedViews.Remove(view); - } - - public void ResetAllViews() - { - var viewsToReset = new List(_trackedViews); // Create a copy to avoid modification during enumeration - foreach (var view in viewsToReset) - { - ResetView(view); - } - } - - /// - /// Resets all tracked descendant views of the specified parent view to their original padding. - /// This should be called before applying new insets when SafeArea settings change. - /// - /// The parent view whose descendants should be reset - public void ResetAppliedSafeAreas(AView view) - { - ResetView(view); - - // Find all tracked views that are descendants of the parent view and reset them - foreach (var trackedView in _trackedViews.ToArray()) // Use ToArray to avoid modification during enumeration - { - if (IsDescendantOf(trackedView, view)) - { - ResetView(trackedView); - } - } - } - - /// - /// Checks if a view is a descendant of a parent view - /// - static bool IsDescendantOf(AView? child, AView parent) - { - if (child is null || parent is null) - { - return false; - } - - var currentParent = child.Parent; - while (currentParent is not null) - { - if (currentParent == parent) - { - return true; - } - - currentParent = currentParent.Parent; - } - return false; - } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - ResetAllViews(); - } - base.Dispose(disposing); - } - - public override void OnPrepare(WindowInsetsAnimationCompat? animation) - { - base.OnPrepare(animation); - - if (animation is null) - return; - - // Check if this is an IME animation - if ((animation.TypeMask & WindowInsetsCompat.Type.Ime()) != 0) - { - IsImeAnimating = true; - } - } - - public override WindowInsetsAnimationCompat.BoundsCompat? OnStart(WindowInsetsAnimationCompat? animation, WindowInsetsAnimationCompat.BoundsCompat? bounds) - { - if (animation is null) - return bounds; - - if ((animation.TypeMask & WindowInsetsCompat.Type.Ime()) != 0) - { - IsImeAnimating = true; - } - return bounds; - } - - public override WindowInsetsCompat? OnProgress(WindowInsetsCompat? insets, IList? runningAnimations) - { - if (insets != null && runningAnimations != null) - { - // Check for IME animations - foreach (var animation in runningAnimations) - { - if ((animation.TypeMask & WindowInsetsCompat.Type.Ime()) != 0) - { - var imeInsets = insets.GetInsets(WindowInsetsCompat.Type.Ime()); - var imeHeight = imeInsets?.Bottom ?? 0; - // IME height during animation: imeHeight - } - } - } - return insets; - } - - public override void OnEnd(WindowInsetsAnimationCompat? animation) - { - base.OnEnd(animation); - - if (animation is null) - return; - - // Check if this was an IME animation - if ((animation.TypeMask & WindowInsetsCompat.Type.Ime()) != 0) - { - - if (_pendingView is AView view) - { - _pendingView = null; - view.Post(() => - { - IsImeAnimating = false; - ViewCompat.RequestApplyInsets(view); - }); - } - else - { - IsImeAnimating = false; - } - } - } - } -} - -/// -/// Extension methods to access the shared GlobalWindowInsetListener instance. -/// -internal static class GlobalWindowInsetListenerExtensions -{ - /// - /// Gets the shared GlobalWindowInsetListener instance from the current MauiAppCompatActivity. - /// - /// The Android context - /// The shared GlobalWindowInsetListener instance, or null if not available - public static GlobalWindowInsetListener? GetGlobalWindowInsetListener(this Context context) - { - return context.GetActivity() as MauiAppCompatActivity is MauiAppCompatActivity activity - ? activity.GlobalWindowInsetListener - : null; - } - - /// - /// Sets the shared GlobalWindowInsetListener on the specified view. - /// This ensures all views use the same listener instance for coordinated inset management. - /// - /// The Android view to set the listener on - /// The Android context to get the listener from - public static bool TrySetGlobalWindowInsetListener(this View view, Context context) - { - if (view is not MaterialToolbar && view.FindParent( - (parent) => - parent is NestedScrollView || - parent is AppBarLayout || - parent is MauiScrollView) - is not null) - { - // Don't set the listener on views inside a NestedScrollView or AppBarLayout - return false; - } - - var listener = context.GetGlobalWindowInsetListener(); - if (listener is not null) - { - ViewCompat.SetOnApplyWindowInsetsListener(view, listener); - ViewCompat.SetWindowInsetsAnimationCallback(view, listener); - } - - return true; - } - - /// - /// Removes the GlobalWindowInsetListener from the specified view and resets its tracked state. - /// This should be called when a view is being detached to ensure proper cleanup. - /// - /// The Android view to remove the listener from - /// The Android context to get the listener from - public static void RemoveGlobalWindowInsetListener(this View view, Context context) - { - var listener = context.GetGlobalWindowInsetListener(); - listener?.ResetView(view); - ViewCompat.SetOnApplyWindowInsetsListener(view, null); - ViewCompat.SetWindowInsetsAnimationCallback(view, null); - } -} \ No newline at end of file diff --git a/src/Core/src/Platform/Android/IHandleWindowInsets.cs b/src/Core/src/Platform/Android/IHandleWindowInsets.cs index 9e567df545f3..9639bc1b6a82 100644 --- a/src/Core/src/Platform/Android/IHandleWindowInsets.cs +++ b/src/Core/src/Platform/Android/IHandleWindowInsets.cs @@ -3,23 +3,23 @@ namespace Microsoft.Maui.Platform { - /// - /// Interface for views that need to handle their own window insets behavior - /// - internal interface IHandleWindowInsets - { - /// - /// Handles window insets for this view - /// - /// The view receiving the insets - /// The window insets - /// The processed window insets - WindowInsetsCompat? HandleWindowInsets(AView view, WindowInsetsCompat insets); + /// + /// Interface for views that need to handle their own window insets behavior + /// + internal interface IHandleWindowInsets + { + /// + /// Handles window insets for this view + /// + /// The view receiving the insets + /// The window insets + /// The processed window insets + WindowInsetsCompat? HandleWindowInsets(AView view, WindowInsetsCompat insets); - /// - /// Resets any previously applied insets on this view - /// - /// The view to reset - void ResetWindowInsets(AView view); - } + /// + /// Resets any previously applied insets on this view + /// + /// The view to reset + void ResetWindowInsets(AView view); + } } \ No newline at end of file diff --git a/src/Core/src/Platform/Android/LayoutViewGroup.cs b/src/Core/src/Platform/Android/LayoutViewGroup.cs index b951c2543dba..6a3c8fb519f8 100644 --- a/src/Core/src/Platform/Android/LayoutViewGroup.cs +++ b/src/Core/src/Platform/Android/LayoutViewGroup.cs @@ -52,15 +52,15 @@ public LayoutViewGroup(Context context, IAttributeSet attrs, int defStyleAttr, i protected override void OnAttachedToWindow() { base.OnAttachedToWindow(); - _isInsetListenerSet = GlobalWindowInsetListenerExtensions.TrySetGlobalWindowInsetListener(this, _context); + _isInsetListenerSet = MauiWindowInsetListenerExtensions.TrySetMauiWindowInsetListener(this, _context); } protected override void OnDetachedFromWindow() { base.OnDetachedFromWindow(); if (_isInsetListenerSet) - GlobalWindowInsetListenerExtensions.RemoveGlobalWindowInsetListener(this, _context); - + MauiWindowInsetListenerExtensions.RemoveMauiWindowInsetListener(this, _context); + _didSafeAreaEdgeConfigurationChange = true; _isInsetListenerSet = false; } @@ -172,7 +172,7 @@ protected override void OnConfigurationChanged(Configuration? newConfig) { base.OnConfigurationChanged(newConfig); - Context?.GetGlobalWindowInsetListener()?.ResetView(this); + MauiWindowInsetListener.FindListenerForView(this)?.ResetView(this); _didSafeAreaEdgeConfigurationChange = true; } diff --git a/src/Core/src/Platform/Android/MauiAppCompatActivity.cs b/src/Core/src/Platform/Android/MauiAppCompatActivity.cs index 5b0788767b3e..839daa8cd0d5 100644 --- a/src/Core/src/Platform/Android/MauiAppCompatActivity.cs +++ b/src/Core/src/Platform/Android/MauiAppCompatActivity.cs @@ -11,16 +11,6 @@ namespace Microsoft.Maui { public partial class MauiAppCompatActivity : AppCompatActivity { - - GlobalWindowInsetListener? _globalWindowInsetListener; - - /// - /// Gets the shared GlobalWindowInsetListener instance for this activity. - /// This ensures all views use the same listener instance for coordinated inset management. - /// - internal GlobalWindowInsetListener GlobalWindowInsetListener => - _globalWindowInsetListener ??= new GlobalWindowInsetListener(); - // Override this if you want to handle the default Android behavior of restoring fragments on an application restart protected virtual bool AllowFragmentRestore => false; @@ -44,8 +34,6 @@ protected override void OnCreate(Bundle? savedInstanceState) protected override void OnDestroy() { - _globalWindowInsetListener?.Dispose(); - _globalWindowInsetListener = null; base.OnDestroy(); } diff --git a/src/Core/src/Platform/Android/MauiPageControl.cs b/src/Core/src/Platform/Android/MauiPageControl.cs index d14295352959..27dc9d4d4165 100644 --- a/src/Core/src/Platform/Android/MauiPageControl.cs +++ b/src/Core/src/Platform/Android/MauiPageControl.cs @@ -154,9 +154,7 @@ void UpdateShapes() shape.SetIntrinsicHeight((int)Context.ToPixels(indicatorSize)); shape.SetIntrinsicWidth((int)Context.ToPixels(indicatorSize)); - if (shape.Paint != null) -#pragma warning disable CA1416 // https://github.com/xamarin/xamarin-android/issues/6962 - shape.Paint.Color = color; + shape.Paint?.Color = color; #pragma warning restore CA1416 return shape; diff --git a/src/Core/src/Platform/Android/MauiScrollView.cs b/src/Core/src/Platform/Android/MauiScrollView.cs index 1653344a6ee0..61e9af6a0a24 100644 --- a/src/Core/src/Platform/Android/MauiScrollView.cs +++ b/src/Core/src/Platform/Android/MauiScrollView.cs @@ -61,14 +61,14 @@ public ICrossPlatformLayout? CrossPlatformLayout public override void OnAttachedToWindow() { base.OnAttachedToWindow(); - _isInsetListenerSet = GlobalWindowInsetListenerExtensions.TrySetGlobalWindowInsetListener(this, _context); + _isInsetListenerSet = MauiWindowInsetListenerExtensions.TrySetMauiWindowInsetListener(this, _context); } protected override void OnDetachedFromWindow() { base.OnDetachedFromWindow(); if (_isInsetListenerSet) - GlobalWindowInsetListenerExtensions.RemoveGlobalWindowInsetListener(this, _context); + MauiWindowInsetListenerExtensions.RemoveMauiWindowInsetListener(this, _context); _isInsetListenerSet = false; _didSafeAreaEdgeConfigurationChange = true; @@ -309,7 +309,7 @@ protected override void OnConfigurationChanged(Configuration? newConfig) { base.OnConfigurationChanged(newConfig); - Context?.GetGlobalWindowInsetListener()?.ResetView(this); + MauiWindowInsetListener.FindListenerForView(this)?.ResetView(this); _didSafeAreaEdgeConfigurationChange = true; } diff --git a/src/Core/src/Platform/Android/MauiSwipeView.cs b/src/Core/src/Platform/Android/MauiSwipeView.cs index 0c28a0373930..f0fc0ac8507e 100644 --- a/src/Core/src/Platform/Android/MauiSwipeView.cs +++ b/src/Core/src/Platform/Android/MauiSwipeView.cs @@ -1320,8 +1320,7 @@ static void ExecuteSwipeItem(ISwipeItem item) void EnableParentGesture(bool isGestureEnabled) { - if (_viewPagerParent != null) - _viewPagerParent.EnableGesture = isGestureEnabled; + _viewPagerParent?.EnableGesture = isGestureEnabled; } internal void OnOpenRequested(SwipeViewOpenRequest e) diff --git a/src/Core/src/Platform/Android/MauiWebView.cs b/src/Core/src/Platform/Android/MauiWebView.cs index 03a3d80fe5d1..a2ca3839b084 100644 --- a/src/Core/src/Platform/Android/MauiWebView.cs +++ b/src/Core/src/Platform/Android/MauiWebView.cs @@ -17,8 +17,7 @@ public MauiWebView(WebViewHandler handler, Context context) : base(context) void IWebViewDelegate.LoadHtml(string? html, string? baseUrl) { - if (_handler != null) - _handler.CurrentNavigationEvent = WebNavigationEvent.NewPage; + _handler?.CurrentNavigationEvent = WebNavigationEvent.NewPage; LoadDataWithBaseURL(baseUrl ?? AssetBaseUrl, html ?? string.Empty, "text/html", "UTF-8", null); } @@ -27,10 +26,7 @@ void IWebViewDelegate.LoadUrl(string? url) { if (!_handler.NavigatingCanceled(url)) { - if (_handler != null) - { - _handler.CurrentNavigationEvent = WebNavigationEvent.NewPage; - } + _handler?.CurrentNavigationEvent = WebNavigationEvent.NewPage; if (url is not null && !url.StartsWith('/') && !Uri.TryCreate(url, UriKind.Absolute, out _)) { diff --git a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs new file mode 100644 index 000000000000..3e1d2509d4c5 --- /dev/null +++ b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs @@ -0,0 +1,490 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Android.Content; +using Android.Views; +using AndroidX.Core.Graphics; +using AndroidX.Core.View; +using AndroidX.Core.Widget; +using AndroidX.RecyclerView.Widget; +using Google.Android.Material.AppBar; +using AView = Android.Views.View; + +namespace Microsoft.Maui.Platform +{ + /// + /// Registry entry for tracking view instances and their associated listeners. + /// Uses WeakReference to avoid memory leaks when views are disposed. + /// + internal record ViewEntry(WeakReference View, MauiWindowInsetListener Listener); + + /// + /// Manages window insets and safe area handling for Android views. + /// This class can be used as a global listener (one per activity) or as local listeners + /// attached to specific views for better isolation in complex navigation scenarios. + /// + /// Thread Safety: All public methods should be called on the UI thread. + /// Android view operations are not thread-safe and must execute on the main thread. + /// + internal class MauiWindowInsetListener : WindowInsetsAnimationCompat.Callback, IOnApplyWindowInsetsListener + { + readonly HashSet _trackedViews = []; + bool IsImeAnimating { get; set; } + + AView? _pendingView; + + // Static tracking for views that have local inset listeners. + // This registry allows child views to find their appropriate listener without + // relying on a global activity-level listener. + // Thread Safety: All access must be on UI thread (enforced by Android's threading model). + static readonly List _registeredViews = new(); + + /// + /// Registers a view to use this local listener instead of the global one. + /// This enables per-view inset management for better isolation in complex scenarios. + /// Must be called on UI thread. + /// + /// The view to register + internal void RegisterView(AView view) + { + // Clean up dead references and check for existing registration + for (int i = _registeredViews.Count - 1; i >= 0; i--) + { + var entry = _registeredViews[i]; + if (!entry.View.TryGetTarget(out var existingView)) + { + _registeredViews.RemoveAt(i); + } + else if (existingView == view) + { + // Already registered, no need to add again + return; + } + } + + // Add this view to the registry + _registeredViews.Add(new ViewEntry(new WeakReference(view), this)); + } + + /// + /// Unregisters a view from using this local listener. + /// Must be called on UI thread. + /// + /// The view to unregister + internal static MauiWindowInsetListener? UnregisterView(AView view) + { + for (int i = _registeredViews.Count - 1; i >= 0; i--) + { + if (_registeredViews[i].View.TryGetTarget(out var registeredView) && registeredView == view) + { + var listener = _registeredViews[i].Listener; + _registeredViews.RemoveAt(i); + return listener; + } + } + return null; + } + + /// + /// Finds the appropriate MauiWindowInsetListener for a given view by walking + /// up the view hierarchy until a registered view is found. + /// Must be called on UI thread. + /// + /// The view to find a listener for + /// The local listener if view is in a registered view hierarchy, null otherwise + internal static MauiWindowInsetListener? FindListenerForView(AView view) + { + // Walk up the view hierarchy looking for a registered view + var parent = view.Parent; + while (parent is not null) + { + // Skip setting listener on views inside nested scroll containers or AppBarLayout (except MaterialToolbar) + // We want the layout listener logic to get applied to the MaterialToolbar itself + // But we don't want any layout listeners to get applied to the children of MaterialToolbar (like the TitleView) + if (view is not MaterialToolbar && + (parent is AppBarLayout || parent is MauiScrollView || parent is IMauiRecyclerView)) + { + return null; + } + + if (parent is AView parentView) + { + // Check if this parent view is registered + // Clean up dead references while searching + for (int i = _registeredViews.Count - 1; i >= 0; i--) + { + var entry = _registeredViews[i]; + if (!entry.View.TryGetTarget(out var registeredView)) + { + _registeredViews.RemoveAt(i); + } + else if (ReferenceEquals(registeredView, parentView)) + { + return entry.Listener; + } + } + } + + parent = parent.Parent; + } + + return null; + } + + /// + /// Sets up a view to use this listener for inset handling. + /// This method registers the view and attaches the listener. + /// Must be called on UI thread. + /// + /// The view to set up + /// The same view for method chaining + internal static AView SetupViewWithLocalListener(AView view, MauiWindowInsetListener? listener = null) + { + listener ??= new MauiWindowInsetListener(); + ViewCompat.SetOnApplyWindowInsetsListener(view, listener); + ViewCompat.SetWindowInsetsAnimationCallback(view, listener); + + listener.RegisterView(view); + + return view; + } + + /// + /// Registers a parent view so its children can find an inset listener, without attaching + /// the listener to the parent itself. This is useful when you want child views to handle + /// insets but don't want the parent view to consume them. + /// Must be called on UI thread. + /// + /// The parent view to register + /// Optional listener to use. If null, a new one is created. + /// The listener that was registered + internal static MauiWindowInsetListener RegisterParentForChildViews(AView parentView, MauiWindowInsetListener? listener = null) + { + listener ??= new MauiWindowInsetListener(); + listener.RegisterView(parentView); + return listener; + } + + /// + /// Removes the local listener from a view and properly cleans up. + /// This resets all tracked views and unregisters the view. + /// Must be called on UI thread. + /// + /// The view to clean up + internal static void RemoveViewWithLocalListener(AView view) + { + // Remove the listener from the view + ViewCompat.SetOnApplyWindowInsetsListener(view, null); + ViewCompat.SetWindowInsetsAnimationCallback(view, null); + + // Reset any tracked views within this view + UnregisterView(view)?.ResetAppliedSafeAreas(view); + } + + public MauiWindowInsetListener() : base(DispatchModeStop) + { + } + + public virtual WindowInsetsCompat? OnApplyWindowInsets(AView? v, WindowInsetsCompat? insets) + { + if (insets is null || !insets.HasInsets || v is null || IsImeAnimating) + { + if (IsImeAnimating) + { + _pendingView = v; + } + + return insets; + } + + _pendingView = null; + + // Handle custom inset views first + if (v is IHandleWindowInsets customHandler) + { + return customHandler.HandleWindowInsets(v, insets); + } + + // Apply default window insets for standard views + return ApplyDefaultWindowInsets(v, insets); + } + + static WindowInsetsCompat? ApplyDefaultWindowInsets(AView v, WindowInsetsCompat insets) + { + var systemBars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars()); + var displayCutout = insets.GetInsets(WindowInsetsCompat.Type.DisplayCutout()); + + // Handle MaterialToolbar special case early + if (v is MaterialToolbar) + { + v.SetPadding(displayCutout?.Left ?? 0, 0, displayCutout?.Right ?? 0, 0); + return WindowInsetsCompat.Consumed; + } + + // Find AppBarLayout - check direct child first, then first two children + var appBarLayout = v.FindViewById(Resource.Id.navigationlayout_appbar); + if (appBarLayout is null && v is ViewGroup group) + { + if (group.ChildCount > 0 && group.GetChildAt(0) is AppBarLayout firstChild) + { + appBarLayout = firstChild; + } + else if (group.ChildCount > 1 && group.GetChildAt(1) is AppBarLayout secondChild) + { + appBarLayout = secondChild; + } + } + + // Check if AppBarLayout has meaningful content + bool appBarHasContent = appBarLayout?.MeasuredHeight > 0; + if (!appBarHasContent && appBarLayout is not null) + { + for (int i = 0; i < appBarLayout.ChildCount; i++) + { + var child = appBarLayout.GetChildAt(i); + if (child?.MeasuredHeight > 0) + { + appBarHasContent = true; + break; + } + } + } + + // Apply padding to AppBarLayout based on content and system insets + if (appBarLayout is not null) + { + if (appBarHasContent) + { + var topInset = Math.Max(systemBars?.Top ?? 0, displayCutout?.Top ?? 0); + appBarLayout.SetPadding(systemBars?.Left ?? 0, topInset, systemBars?.Right ?? 0, 0); + } + else + { + appBarLayout.SetPadding(0, 0, 0, 0); + } + } + + // Handle bottom navigation + var hasBottomNav = v.FindViewById(Resource.Id.navigationlayout_bottomtabs)?.MeasuredHeight > 0; + if (hasBottomNav) + { + var bottomInset = Math.Max(systemBars?.Bottom ?? 0, displayCutout?.Bottom ?? 0); + v.SetPadding(0, 0, 0, bottomInset); + } + else + { + v.SetPadding(0, 0, 0, 0); + } + + // Create new insets with consumed values + var newSystemBars = Insets.Of( + systemBars?.Left ?? 0, + appBarHasContent ? 0 : systemBars?.Top ?? 0, + systemBars?.Right ?? 0, + hasBottomNav ? 0 : systemBars?.Bottom ?? 0 + ) ?? Insets.None; + + var newDisplayCutout = Insets.Of( + displayCutout?.Left ?? 0, + appBarHasContent ? 0 : displayCutout?.Top ?? 0, + displayCutout?.Right ?? 0, + hasBottomNav ? 0 : displayCutout?.Bottom ?? 0 + ) ?? Insets.None; + + return new WindowInsetsCompat.Builder(insets) + ?.SetInsets(WindowInsetsCompat.Type.SystemBars(), newSystemBars) + ?.SetInsets(WindowInsetsCompat.Type.DisplayCutout(), newDisplayCutout) + ?.Build() ?? insets; + } + + public void TrackView(AView view) + { + _trackedViews.Add(view); + } + + public bool HasTrackedView => _trackedViews.Count > 0; + + public void ResetView(AView view) + { + if (view is IHandleWindowInsets customHandler) + { + customHandler.ResetWindowInsets(view); + } + + _trackedViews.Remove(view); + } + + public void ResetAllViews() + { + // Create a copy to avoid modification during enumeration + var viewsToReset = _trackedViews.ToArray(); + foreach (var view in viewsToReset) + { + ResetView(view); + } + } + + /// + /// Resets all tracked descendant views of the specified parent view to their original padding. + /// This should be called before applying new insets when SafeArea settings change. + /// + /// The parent view whose descendants should be reset + public void ResetAppliedSafeAreas(AView view) + { + ResetView(view); + + // Find all tracked views that are descendants of the parent view and reset them + foreach (var trackedView in _trackedViews.ToArray()) // Use ToArray to avoid modification during enumeration + { + if (IsDescendantOf(trackedView, view)) + { + ResetView(trackedView); + } + } + } + + /// + /// Checks if a view is a descendant of a parent view + /// + static bool IsDescendantOf(AView? child, AView parent) + { + if (child is null) + { + return false; + } + + var currentParent = child.Parent; + while (currentParent is not null) + { + if (currentParent == parent) + { + return true; + } + + currentParent = currentParent.Parent; + } + return false; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + ResetAllViews(); + } + base.Dispose(disposing); + } + + public override void OnPrepare(WindowInsetsAnimationCompat? animation) + { + base.OnPrepare(animation); + if (IsImeAnimation(animation)) + { + IsImeAnimating = true; + } + } + + public override WindowInsetsAnimationCompat.BoundsCompat? OnStart(WindowInsetsAnimationCompat? animation, WindowInsetsAnimationCompat.BoundsCompat? bounds) + { + if (IsImeAnimation(animation)) + { + IsImeAnimating = true; + } + + return bounds; + } + + public override WindowInsetsCompat? OnProgress(WindowInsetsCompat? insets, IList? runningAnimations) + { + if (insets is null || runningAnimations is null) + { + return insets; + } + + // Process any IME animations + foreach (var animation in runningAnimations) + { + if (IsImeAnimation(animation)) + { + var imeInsets = insets.GetInsets(WindowInsetsCompat.Type.Ime()); + // IME height available as: imeInsets?.Bottom ?? 0 + break; // Only need to process one IME animation + } + } + return insets; + } + + public override void OnEnd(WindowInsetsAnimationCompat? animation) + { + base.OnEnd(animation); + + if (IsImeAnimation(animation)) + { + if (_pendingView is AView view) + { + _pendingView = null; + view.Post(() => + { + IsImeAnimating = false; + ViewCompat.RequestApplyInsets(view); + }); + } + else + { + IsImeAnimating = false; + } + } + } + + /// + /// Helper method to check if an animation involves the IME + /// + static bool IsImeAnimation(WindowInsetsAnimationCompat? animation) => + animation is not null && (animation.TypeMask & WindowInsetsCompat.Type.Ime()) != 0; + } +} + +/// +/// Extension methods to access WindowInsetListener instances. +/// These methods support both the legacy global listener pattern and the new +/// per-view local listener pattern. +/// +internal static class MauiWindowInsetListenerExtensions +{ + /// + /// Sets the appropriate MauiWindowInsetListener on the specified view. + /// This prioritizes local view listeners over global ones. + /// + /// The Android view to set the listener on + /// The Android context to get the listener from + public static bool TrySetMauiWindowInsetListener(this View view, Context context) + { + // Check if this view is contained within a registered view first + if (MauiWindowInsetListener.FindListenerForView(view) is MauiWindowInsetListener localListener) + { + ViewCompat.SetOnApplyWindowInsetsListener(view, localListener); + ViewCompat.SetWindowInsetsAnimationCallback(view, localListener); + return true; + } + + // If no listener available, this is likely a configuration issue but not critical + return false; + } + + /// + /// Removes the MauiWindowInsetListener from the specified view and resets its tracked state. + /// This should be called when a view is being detached to ensure proper cleanup. + /// + /// The Android view to remove the listener from + /// The Android context to get the listener from + public static void RemoveMauiWindowInsetListener(this View view, Context context) + { + // Clear the listeners first + ViewCompat.SetOnApplyWindowInsetsListener(view, null); + ViewCompat.SetWindowInsetsAnimationCallback(view, null); + + // Reset view state - prefer local listener if available, otherwise use global + var listener = MauiWindowInsetListener.FindListenerForView(view); + listener?.ResetView(view); + } +} \ No newline at end of file diff --git a/src/Core/src/Platform/Android/Navigation/NavigationRootManager.cs b/src/Core/src/Platform/Android/Navigation/NavigationRootManager.cs index 37fa641fc12b..a8fa541dc466 100644 --- a/src/Core/src/Platform/Android/Navigation/NavigationRootManager.cs +++ b/src/Core/src/Platform/Android/Navigation/NavigationRootManager.cs @@ -1,4 +1,5 @@ using System; +using Android.Content; using Android.OS; using Android.Runtime; using Android.Views; @@ -17,6 +18,7 @@ public class NavigationRootManager AView? _rootView; ScopedFragment? _viewFragment; IToolbarElement? _toolbarElement; + CoordinatorLayout? _managedCoordinatorLayout; // TODO MAUI: temporary event to alert when rootview is ready // handlers and various bits use this to start interacting with rootview @@ -68,19 +70,21 @@ internal void Connect(IView? view, IMauiContext? mauiContext = null) } else { - navigationLayout ??= + navigationLayout = LayoutInflater .Inflate(Resource.Layout.navigationlayout, null) .JavaCast(); - _rootView = navigationLayout; - } - - if (navigationLayout is CoordinatorLayout && mauiContext.Context is not null) - { - GlobalWindowInsetListenerExtensions.TrySetGlobalWindowInsetListener(navigationLayout, mauiContext.Context); - } + // Set up the CoordinatorLayout with a local inset listener + if (navigationLayout is not null) + { + _managedCoordinatorLayout = navigationLayout; + MauiWindowInsetListener.SetupViewWithLocalListener(navigationLayout); + } + _rootView = navigationLayout; + } + // if the incoming view is a Drawer Layout then the Drawer Layout // will be the root view and internally handle all if its view management // this is mainly used for FlyoutView @@ -114,6 +118,12 @@ void OnWindowContentPlatformViewCreated() public virtual void Disconnect() { + // Clean up the coordinator layout and local listener first + if (_managedCoordinatorLayout is not null) + { + MauiWindowInsetListener.RemoveViewWithLocalListener(_managedCoordinatorLayout); + } + ClearPlatformParts(); SetContentView(null); } @@ -125,6 +135,7 @@ void ClearPlatformParts() DrawerLayout = null; _rootView = null; _toolbarElement = null; + _managedCoordinatorLayout = null; } IDisposable? _pendingFragment; diff --git a/src/Core/src/Platform/Android/PickerExtensions.cs b/src/Core/src/Platform/Android/PickerExtensions.cs index e9c5c485d828..b802dad11151 100644 --- a/src/Core/src/Platform/Android/PickerExtensions.cs +++ b/src/Core/src/Platform/Android/PickerExtensions.cs @@ -57,8 +57,7 @@ internal static void UpdateFlowDirection(this AppCompatAlertDialog alertDialog, // Propagate the MauiPicker LayoutDirection to the AlertDialog var dv = alertDialog.Window?.DecorView; - if (dv is not null) - dv.LayoutDirection = platformLayoutDirection; + dv?.LayoutDirection = platformLayoutDirection; var lv = alertDialog?.ListView; diff --git a/src/Core/src/Platform/Android/SafeAreaExtensions.cs b/src/Core/src/Platform/Android/SafeAreaExtensions.cs index 5c029196975a..d6bb7d9db0f1 100644 --- a/src/Core/src/Platform/Android/SafeAreaExtensions.cs +++ b/src/Core/src/Platform/Android/SafeAreaExtensions.cs @@ -8,267 +8,267 @@ namespace Microsoft.Maui.Platform; internal static class SafeAreaExtensions { - internal static ISafeAreaView2? GetSafeAreaView2(object? layout) => - layout switch - { - ISafeAreaView2 sav2 => sav2, - IElementHandler { VirtualView: ISafeAreaView2 virtualSav2 } => virtualSav2, - _ => null - }; - - internal static ISafeAreaView? GetSafeAreaView(object? layout) => - layout switch - { - ISafeAreaView sav => sav, - IElementHandler { VirtualView: ISafeAreaView virtualSav } => virtualSav, - _ => null - }; - - - internal static SafeAreaRegions GetSafeAreaRegionForEdge(int edge, ICrossPlatformLayout crossPlatformLayout) - { - var layout = crossPlatformLayout; - var safeAreaView2 = GetSafeAreaView2(layout); - - if (safeAreaView2 is not null) - { - return safeAreaView2.GetSafeAreaRegionsForEdge(edge); - } - - var safeAreaView = GetSafeAreaView(layout); - return safeAreaView?.IgnoreSafeArea == false ? SafeAreaRegions.Container : SafeAreaRegions.None; - } - - internal static WindowInsetsCompat? ApplyAdjustedSafeAreaInsetsPx( - WindowInsetsCompat windowInsets, - ICrossPlatformLayout crossPlatformLayout, - Context context, - View view) - { - WindowInsetsCompat? newWindowInsets; - var baseSafeArea = windowInsets.ToSafeAreaInsetsPx(context); - var keyboardInsets = windowInsets.GetKeyboardInsetsPx(context); - var isKeyboardShowing = !keyboardInsets.IsEmpty; - - var layout = crossPlatformLayout; - var safeAreaView2 = GetSafeAreaView2(layout); - var margins = (safeAreaView2 as IView)?.Margin ?? Thickness.Zero; - - if (safeAreaView2 is not null) - { - // Apply safe area selectively per edge based on SafeAreaRegions - var left = GetSafeAreaForEdge(GetSafeAreaRegionForEdge(0, layout), baseSafeArea.Left, 0, isKeyboardShowing, keyboardInsets); - var top = GetSafeAreaForEdge(GetSafeAreaRegionForEdge(1, layout), baseSafeArea.Top, 1, isKeyboardShowing, keyboardInsets); - var right = GetSafeAreaForEdge(GetSafeAreaRegionForEdge(2, layout), baseSafeArea.Right, 2, isKeyboardShowing, keyboardInsets); - var bottom = GetSafeAreaForEdge(GetSafeAreaRegionForEdge(3, layout), baseSafeArea.Bottom, 3, isKeyboardShowing, keyboardInsets); - - if (isKeyboardShowing && - context.GetActivity()?.Window is Window window && - window?.Attributes is WindowManagerLayoutParams attr) - { - // If the window is panned from the keyboard being open - // and there isn't a bottom inset to apply then just don't touch anything - var softInputMode = attr.SoftInputMode; - if (softInputMode == SoftInput.AdjustPan - && bottom == 0 - ) - { - return WindowInsetsCompat.Consumed; - } - } - - var globalWindowInsetsListener = context.GetGlobalWindowInsetListener(); - bool hasTrackedViews = globalWindowInsetsListener?.HasTrackedView == true; - - // Check intersection with view bounds to determine which edges actually need padding - // If we don't have any tracked views yet we will find the first view to pad - // in order to limit duplicate measures - var viewWidth = view.Width > 0 ? view.Width : view.MeasuredWidth; - var viewHeight = view.Height > 0 ? view.Height : view.MeasuredHeight; - - if ((viewHeight > 0 && viewWidth > 0) || !hasTrackedViews) - { - if (left == 0 && right == 0 && top == 0 && bottom == 0) - { - view.SetPadding(0, 0, 0, 0); - return windowInsets; - } - - // Get view's position on screen - var viewLocation = new int[2]; - view.GetLocationOnScreen(viewLocation); - var viewLeft = viewLocation[0]; - var viewTop = viewLocation[1]; - var viewRight = viewLeft + viewWidth; - var viewBottom = viewTop + viewHeight; - - // Adjust for view's position relative to parent (including margins) to calculate - // safe area insets relative to the parent's position, not the view's visual position. - // This ensures margins and safe area insets are additive rather than overlapping. - // For example: 20px margin + 30px safe area = 50px total offset - // We only take the margins into account if the Width and Height are set - // If the Width and Height aren't set it means the layout pass hasn't happen yet - if (view.Width > 0 && view.Height > 0) - { - viewTop = Math.Max(0, viewTop - (int)context.ToPixels(margins.Top)); - viewLeft = Math.Max(0, viewLeft - (int)context.ToPixels(margins.Left)); - viewRight += (int)context.ToPixels(margins.Right); - viewBottom += (int)context.ToPixels(margins.Bottom); - } - - // Get actual screen dimensions (including system UI) - var windowManager = context.GetSystemService(Context.WindowService) as IWindowManager; - if (windowManager?.DefaultDisplay is not null) - { - var realMetrics = new global::Android.Util.DisplayMetrics(); - windowManager.DefaultDisplay.GetRealMetrics(realMetrics); - var screenWidth = realMetrics.WidthPixels; - var screenHeight = realMetrics.HeightPixels; - - // Calculate actual overlap for each edge - // Top: how much the view extends into the top safe area - // If the viewTop is < 0 that means that it's most likely - // panned off the top of the screen so we don't want to apply any top inset - if (top > 0 && viewTop < top && viewTop >= 0) - { - // Calculate the actual overlap amount - top = Math.Min(top - viewTop, top); - } - else - { - if (viewHeight > 0 || hasTrackedViews) - top = 0; - } - - // Bottom: how much the view extends into the bottom safe area - if (bottom > 0 && viewBottom > (screenHeight - bottom)) - { - // Calculate the actual overlap amount - var bottomEdge = screenHeight - bottom; - bottom = Math.Min(viewBottom - bottomEdge, bottom); - } - else - { - // if the view height is zero because it hasn't done the first pass - // and we don't have any tracked views yet then we will apply the bottom inset - if (viewHeight > 0 || hasTrackedViews) - bottom = 0; - } - - // Left: how much the view extends into the left safe area - if (left > 0 && viewLeft < left) - { - // Calculate the actual overlap amount - left = Math.Min(left - viewLeft, left); - } - else - { - if (viewWidth > 0 || hasTrackedViews) - left = 0; - } - - // Right: how much the view extends into the right safe area - if (right > 0 && viewRight > (screenWidth - right)) - { - // Calculate the actual overlap amount - var rightEdge = screenWidth - right; - right = Math.Min(viewRight - rightEdge, right); - } - else - { - if (viewWidth > 0 || hasTrackedViews) - right = 0; - } - } - - // Build new window insets with unconsumed values - var builder = new WindowInsetsCompat.Builder(windowInsets); - - // Get original insets for each type - var systemBars = windowInsets.GetInsets(WindowInsetsCompat.Type.SystemBars()); - var displayCutout = windowInsets.GetInsets(WindowInsetsCompat.Type.DisplayCutout()); - var ime = windowInsets.GetInsets(WindowInsetsCompat.Type.Ime()); - - // Calculate what's left after consumption - // For system bars and display cutout, only consume what we're using - if (systemBars is not null) - { - var newSystemBarsLeft = left > 0 ? 0 : systemBars.Left; - var newSystemBarsTop = top > 0 ? 0 : systemBars.Top; - var newSystemBarsRight = right > 0 ? 0 : systemBars.Right; - var newSystemBarsBottom = (bottom > 0 || isKeyboardShowing) ? 0 : systemBars.Bottom; - - builder.SetInsets(WindowInsetsCompat.Type.SystemBars(), - AndroidX.Core.Graphics.Insets.Of(newSystemBarsLeft, newSystemBarsTop, newSystemBarsRight, newSystemBarsBottom)); - } - - if (displayCutout is not null) - { - var newCutoutLeft = left > 0 ? 0 : displayCutout.Left; - var newCutoutTop = top > 0 ? 0 : displayCutout.Top; - var newCutoutRight = right > 0 ? 0 : displayCutout.Right; - var newCutoutBottom = (bottom > 0 || isKeyboardShowing) ? 0 : displayCutout.Bottom; - - builder.SetInsets(WindowInsetsCompat.Type.DisplayCutout(), - AndroidX.Core.Graphics.Insets.Of(newCutoutLeft, newCutoutTop, newCutoutRight, newCutoutBottom)); - } - - // For keyboard (IME), only consume if we're handling it - if (ime is not null && isKeyboardShowing) - { - var newImeBottom = (bottom > 0 && bottom >= keyboardInsets.Bottom) ? 0 : ime.Bottom; - builder.SetInsets(WindowInsetsCompat.Type.Ime(), - AndroidX.Core.Graphics.Insets.Of(0, 0, 0, newImeBottom)); - } - - newWindowInsets = builder.Build(); - - // Apply all insets to content view group - view.SetPadding((int)left, (int)top, (int)right, (int)bottom); - if (left > 0 || right > 0 || top > 0 || bottom > 0) - { - globalWindowInsetsListener?.TrackView(view); - } - } - else - { - newWindowInsets = windowInsets; - } - } - else - { - newWindowInsets = windowInsets; - } - - // Fallback: return the base safe area for legacy views - return newWindowInsets; - } - - internal static double GetSafeAreaForEdge(SafeAreaRegions safeAreaRegion, double originalSafeArea, int edge, bool isKeyboardShowing, SafeAreaPadding keyBoardInsets) - { - // Edge-to-edge content - no safe area padding - if (safeAreaRegion == SafeAreaRegions.None) - { - return 0; - } - - // Handle SoftInput specifically - only apply keyboard insets for bottom edge when keyboard is showing - if (isKeyboardShowing && edge == 3) - { - if (SafeAreaEdges.IsSoftInput(safeAreaRegion)) - return keyBoardInsets.Bottom; - - // if they keyboard is showing then we will just return 0 for the bottom inset - // because that part of the view is covered by the keyboard so we don't want to pad the view - return 0; - } - - // All other regions respect safe area in some form - // This includes: - // - Default: Platform default behavior - // - All: Obey all safe area insets - // - Container: Content flows under keyboard but stays out of bars/notch - // - Any combination of the above flags - return originalSafeArea; - } + internal static ISafeAreaView2? GetSafeAreaView2(object? layout) => + layout switch + { + ISafeAreaView2 sav2 => sav2, + IElementHandler { VirtualView: ISafeAreaView2 virtualSav2 } => virtualSav2, + _ => null + }; + + internal static ISafeAreaView? GetSafeAreaView(object? layout) => + layout switch + { + ISafeAreaView sav => sav, + IElementHandler { VirtualView: ISafeAreaView virtualSav } => virtualSav, + _ => null + }; + + + internal static SafeAreaRegions GetSafeAreaRegionForEdge(int edge, ICrossPlatformLayout crossPlatformLayout) + { + var layout = crossPlatformLayout; + var safeAreaView2 = GetSafeAreaView2(layout); + + if (safeAreaView2 is not null) + { + return safeAreaView2.GetSafeAreaRegionsForEdge(edge); + } + + var safeAreaView = GetSafeAreaView(layout); + return safeAreaView?.IgnoreSafeArea == false ? SafeAreaRegions.Container : SafeAreaRegions.None; + } + + internal static WindowInsetsCompat? ApplyAdjustedSafeAreaInsetsPx( + WindowInsetsCompat windowInsets, + ICrossPlatformLayout crossPlatformLayout, + Context context, + View view) + { + WindowInsetsCompat? newWindowInsets; + var baseSafeArea = windowInsets.ToSafeAreaInsetsPx(context); + var keyboardInsets = windowInsets.GetKeyboardInsetsPx(context); + var isKeyboardShowing = !keyboardInsets.IsEmpty; + + var layout = crossPlatformLayout; + var safeAreaView2 = GetSafeAreaView2(layout); + var margins = (safeAreaView2 as IView)?.Margin ?? Thickness.Zero; + + if (safeAreaView2 is not null) + { + // Apply safe area selectively per edge based on SafeAreaRegions + var left = GetSafeAreaForEdge(GetSafeAreaRegionForEdge(0, layout), baseSafeArea.Left, 0, isKeyboardShowing, keyboardInsets); + var top = GetSafeAreaForEdge(GetSafeAreaRegionForEdge(1, layout), baseSafeArea.Top, 1, isKeyboardShowing, keyboardInsets); + var right = GetSafeAreaForEdge(GetSafeAreaRegionForEdge(2, layout), baseSafeArea.Right, 2, isKeyboardShowing, keyboardInsets); + var bottom = GetSafeAreaForEdge(GetSafeAreaRegionForEdge(3, layout), baseSafeArea.Bottom, 3, isKeyboardShowing, keyboardInsets); + + if (isKeyboardShowing && + context.GetActivity()?.Window is Window window && + window?.Attributes is WindowManagerLayoutParams attr) + { + // If the window is panned from the keyboard being open + // and there isn't a bottom inset to apply then just don't touch anything + var softInputMode = attr.SoftInputMode; + if (softInputMode == SoftInput.AdjustPan + && bottom == 0 + ) + { + return WindowInsetsCompat.Consumed; + } + } + + var globalWindowInsetsListener = MauiWindowInsetListener.FindListenerForView(view); + bool hasTrackedViews = globalWindowInsetsListener?.HasTrackedView == true; + + // Check intersection with view bounds to determine which edges actually need padding + // If we don't have any tracked views yet we will find the first view to pad + // in order to limit duplicate measures + var viewWidth = view.Width > 0 ? view.Width : view.MeasuredWidth; + var viewHeight = view.Height > 0 ? view.Height : view.MeasuredHeight; + + if ((viewHeight > 0 && viewWidth > 0) || !hasTrackedViews) + { + if (left == 0 && right == 0 && top == 0 && bottom == 0) + { + view.SetPadding(0, 0, 0, 0); + return windowInsets; + } + + // Get view's position on screen + var viewLocation = new int[2]; + view.GetLocationOnScreen(viewLocation); + var viewLeft = viewLocation[0]; + var viewTop = viewLocation[1]; + var viewRight = viewLeft + viewWidth; + var viewBottom = viewTop + viewHeight; + + // Adjust for view's position relative to parent (including margins) to calculate + // safe area insets relative to the parent's position, not the view's visual position. + // This ensures margins and safe area insets are additive rather than overlapping. + // For example: 20px margin + 30px safe area = 50px total offset + // We only take the margins into account if the Width and Height are set + // If the Width and Height aren't set it means the layout pass hasn't happen yet + if (view.Width > 0 && view.Height > 0) + { + viewTop = Math.Max(0, viewTop - (int)context.ToPixels(margins.Top)); + viewLeft = Math.Max(0, viewLeft - (int)context.ToPixels(margins.Left)); + viewRight += (int)context.ToPixels(margins.Right); + viewBottom += (int)context.ToPixels(margins.Bottom); + } + + // Get actual screen dimensions (including system UI) + var windowManager = context.GetSystemService(Context.WindowService) as IWindowManager; + if (windowManager?.DefaultDisplay is not null) + { + var realMetrics = new global::Android.Util.DisplayMetrics(); + windowManager.DefaultDisplay.GetRealMetrics(realMetrics); + var screenWidth = realMetrics.WidthPixels; + var screenHeight = realMetrics.HeightPixels; + + // Calculate actual overlap for each edge + // Top: how much the view extends into the top safe area + // If the viewTop is < 0 that means that it's most likely + // panned off the top of the screen so we don't want to apply any top inset + if (top > 0 && viewTop < top && viewTop >= 0) + { + // Calculate the actual overlap amount + top = Math.Min(top - viewTop, top); + } + else + { + if (viewHeight > 0 || hasTrackedViews) + top = 0; + } + + // Bottom: how much the view extends into the bottom safe area + if (bottom > 0 && viewBottom > (screenHeight - bottom)) + { + // Calculate the actual overlap amount + var bottomEdge = screenHeight - bottom; + bottom = Math.Min(viewBottom - bottomEdge, bottom); + } + else + { + // if the view height is zero because it hasn't done the first pass + // and we don't have any tracked views yet then we will apply the bottom inset + if (viewHeight > 0 || hasTrackedViews) + bottom = 0; + } + + // Left: how much the view extends into the left safe area + if (left > 0 && viewLeft < left) + { + // Calculate the actual overlap amount + left = Math.Min(left - viewLeft, left); + } + else + { + if (viewWidth > 0 || hasTrackedViews) + left = 0; + } + + // Right: how much the view extends into the right safe area + if (right > 0 && viewRight > (screenWidth - right)) + { + // Calculate the actual overlap amount + var rightEdge = screenWidth - right; + right = Math.Min(viewRight - rightEdge, right); + } + else + { + if (viewWidth > 0 || hasTrackedViews) + right = 0; + } + } + + // Build new window insets with unconsumed values + var builder = new WindowInsetsCompat.Builder(windowInsets); + + // Get original insets for each type + var systemBars = windowInsets.GetInsets(WindowInsetsCompat.Type.SystemBars()); + var displayCutout = windowInsets.GetInsets(WindowInsetsCompat.Type.DisplayCutout()); + var ime = windowInsets.GetInsets(WindowInsetsCompat.Type.Ime()); + + // Calculate what's left after consumption + // For system bars and display cutout, only consume what we're using + if (systemBars is not null) + { + var newSystemBarsLeft = left > 0 ? 0 : systemBars.Left; + var newSystemBarsTop = top > 0 ? 0 : systemBars.Top; + var newSystemBarsRight = right > 0 ? 0 : systemBars.Right; + var newSystemBarsBottom = (bottom > 0 || isKeyboardShowing) ? 0 : systemBars.Bottom; + + builder.SetInsets(WindowInsetsCompat.Type.SystemBars(), + AndroidX.Core.Graphics.Insets.Of(newSystemBarsLeft, newSystemBarsTop, newSystemBarsRight, newSystemBarsBottom)); + } + + if (displayCutout is not null) + { + var newCutoutLeft = left > 0 ? 0 : displayCutout.Left; + var newCutoutTop = top > 0 ? 0 : displayCutout.Top; + var newCutoutRight = right > 0 ? 0 : displayCutout.Right; + var newCutoutBottom = (bottom > 0 || isKeyboardShowing) ? 0 : displayCutout.Bottom; + + builder.SetInsets(WindowInsetsCompat.Type.DisplayCutout(), + AndroidX.Core.Graphics.Insets.Of(newCutoutLeft, newCutoutTop, newCutoutRight, newCutoutBottom)); + } + + // For keyboard (IME), only consume if we're handling it + if (ime is not null && isKeyboardShowing) + { + var newImeBottom = (bottom > 0 && bottom >= keyboardInsets.Bottom) ? 0 : ime.Bottom; + builder.SetInsets(WindowInsetsCompat.Type.Ime(), + AndroidX.Core.Graphics.Insets.Of(0, 0, 0, newImeBottom)); + } + + newWindowInsets = builder.Build(); + + // Apply all insets to content view group + view.SetPadding((int)left, (int)top, (int)right, (int)bottom); + if (left > 0 || right > 0 || top > 0 || bottom > 0) + { + globalWindowInsetsListener?.TrackView(view); + } + } + else + { + newWindowInsets = windowInsets; + } + } + else + { + newWindowInsets = windowInsets; + } + + // Fallback: return the base safe area for legacy views + return newWindowInsets; + } + + internal static double GetSafeAreaForEdge(SafeAreaRegions safeAreaRegion, double originalSafeArea, int edge, bool isKeyboardShowing, SafeAreaPadding keyBoardInsets) + { + // Edge-to-edge content - no safe area padding + if (safeAreaRegion == SafeAreaRegions.None) + { + return 0; + } + + // Handle SoftInput specifically - only apply keyboard insets for bottom edge when keyboard is showing + if (isKeyboardShowing && edge == 3) + { + if (SafeAreaEdges.IsSoftInput(safeAreaRegion)) + return keyBoardInsets.Bottom; + + // if they keyboard is showing then we will just return 0 for the bottom inset + // because that part of the view is covered by the keyboard so we don't want to pad the view + return 0; + } + + // All other regions respect safe area in some form + // This includes: + // - Default: Platform default behavior + // - All: Obey all safe area insets + // - Container: Content flows under keyboard but stays out of bars/notch + // - Any combination of the above flags + return originalSafeArea; + } } diff --git a/src/Core/src/Platform/Android/SearchViewExtensions.cs b/src/Core/src/Platform/Android/SearchViewExtensions.cs index 1a6e78424256..33d786aed442 100644 --- a/src/Core/src/Platform/Android/SearchViewExtensions.cs +++ b/src/Core/src/Platform/Android/SearchViewExtensions.cs @@ -8,8 +8,8 @@ using Android.Views.InputMethods; using Android.Widget; using static Android.Content.Res.Resources; -using SearchView = AndroidX.AppCompat.Widget.SearchView; using AAttribute = Android.Resource.Attribute; +using SearchView = AndroidX.AppCompat.Widget.SearchView; namespace Microsoft.Maui.Platform { @@ -194,10 +194,7 @@ public static void UpdateIsEnabled(this SearchView searchView, ISearchBar search if (editText == null) return; - if (editText != null) - { - editText.Enabled = searchBar.IsEnabled; - } + editText?.Enabled = searchBar.IsEnabled; } public static void UpdateKeyboard(this SearchView searchView, ISearchBar searchBar) diff --git a/src/Core/src/Platform/Android/StepperHandlerManager.cs b/src/Core/src/Platform/Android/StepperHandlerManager.cs index b8380077e9b2..7b263e10cdcc 100644 --- a/src/Core/src/Platform/Android/StepperHandlerManager.cs +++ b/src/Core/src/Platform/Android/StepperHandlerManager.cs @@ -64,11 +64,9 @@ public static void UpdateButtons(IStepper stepper, TButton? downButton, where TButton : AButton { // NOTE: a value of `null` means that we are forcing an update - if (downButton != null) - downButton.Enabled = stepper.IsEnabled && stepper.Value > stepper.Minimum; + downButton?.Enabled = stepper.IsEnabled && stepper.Value > stepper.Minimum; - if (upButton != null) - upButton.Enabled = stepper.IsEnabled && stepper.Value < stepper.Maximum; + upButton?.Enabled = stepper.IsEnabled && stepper.Value < stepper.Maximum; } class StepperListener : Java.Lang.Object, AView.IOnClickListener diff --git a/src/Core/src/Platform/Android/ViewExtensions.cs b/src/Core/src/Platform/Android/ViewExtensions.cs index 7bf2bdf09fd2..d65345bc4159 100644 --- a/src/Core/src/Platform/Android/ViewExtensions.cs +++ b/src/Core/src/Platform/Android/ViewExtensions.cs @@ -472,7 +472,7 @@ internal static Rect GetViewBounds(this View platformView) var location = new int[2]; platformView.GetLocationOnScreen(location); - + return new Rect( platformView.FromPixels(location[0]), platformView.FromPixels(location[1]),