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 520c0df76dac..97c71eca0f15 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutTemplatedContentRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellFlyoutTemplatedContentRenderer.cs @@ -123,21 +123,17 @@ protected virtual void LoadView(IShellContext shellContext) // so we want to delay loading to the latest possible point in time so // it doesn't delay initial startup. GenericGlobalLayoutListener ggll = null; - ggll = new GenericGlobalLayoutListener(InitialLoad); - sfl.ViewTreeObserver.AddOnGlobalLayoutListener(ggll); + ggll = new GenericGlobalLayoutListener(InitialLoad, sfl); - void InitialLoad() + void InitialLoad(GenericGlobalLayoutListener listener, AView view) { OnFlyoutViewLayoutChanging(); if (_flyoutContentView == null || ggll == null) return; - var listener = ggll; ggll = null; - - // Once initial load has finished let's just attach to Layout Changing - sfl.ViewTreeObserver.RemoveOnGlobalLayoutListener(listener); + listener.Invalidate(); sfl.LayoutChanging += OnFlyoutViewLayoutChanging; } } 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 c7c2ca1b730b..1607565a4bbc 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/Android/ShellToolbarTracker.cs @@ -77,8 +77,7 @@ public ShellToolbarTracker(IShellContext shellContext, AToolbar toolbar, DrawerL _drawerLayout = drawerLayout ?? throw new ArgumentNullException(nameof(drawerLayout)); _appBar = _platformToolbar.Parent.GetParentOfType(); - _globalLayoutListener = new GenericGlobalLayoutListener(() => UpdateNavBarHasShadow(Page)); - _appBar.ViewTreeObserver.AddOnGlobalLayoutListener(_globalLayoutListener); + _globalLayoutListener = new GenericGlobalLayoutListener((_,_) => UpdateNavBarHasShadow(Page), _appBar); _platformToolbar.SetNavigationOnClickListener(this); ((IShellController)ShellContext.Shell).AddFlyoutBehaviorObserver(this); ShellContext.Shell.Toolbar.PropertyChanged += OnToolbarPropertyChanged; @@ -175,9 +174,6 @@ protected override void Dispose(bool disposing) if (disposing) { - if (_appBar.IsAlive() && _appBar.ViewTreeObserver.IsAlive()) - _appBar.ViewTreeObserver.RemoveOnGlobalLayoutListener(_globalLayoutListener); - _globalLayoutListener.Invalidate(); if (_backButtonBehavior != null) diff --git a/src/Controls/src/Core/Platform/Android/GenericGlobalLayoutListener.cs b/src/Controls/src/Core/Platform/Android/GenericGlobalLayoutListener.cs index 0c6ba3610f77..0f56a66c306a 100644 --- a/src/Controls/src/Core/Platform/Android/GenericGlobalLayoutListener.cs +++ b/src/Controls/src/Core/Platform/Android/GenericGlobalLayoutListener.cs @@ -1,22 +1,31 @@ -#nullable disable using System; using Android.Views; +using AView = Android.Views.View; using Object = Java.Lang.Object; namespace Microsoft.Maui.Controls.Platform { internal class GenericGlobalLayoutListener : Object, ViewTreeObserver.IOnGlobalLayoutListener { - Action _callback; + Action? _callback; + WeakReference? _targetView; - public GenericGlobalLayoutListener(Action callback) + public GenericGlobalLayoutListener(Action callback, AView? targetView = null) { _callback = callback; + + if (targetView?.ViewTreeObserver != null) + { + _targetView = new WeakReference(targetView); + targetView.ViewTreeObserver.AddOnGlobalLayoutListener(this); + } } public void OnGlobalLayout() { - _callback?.Invoke(); + AView? targetView = null; + _targetView?.TryGetTarget(out targetView); + _callback?.Invoke(this, targetView); } protected override void Dispose(bool disposing) @@ -30,6 +39,16 @@ protected override void Dispose(bool disposing) internal void Invalidate() { _callback = null; + + if (_targetView != null && + _targetView.TryGetTarget(out var targetView) && + targetView.IsAlive() && + targetView.ViewTreeObserver != null) + { + targetView.ViewTreeObserver.RemoveOnGlobalLayoutListener(this); + } + + _targetView = null; } } } \ No newline at end of file diff --git a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs index f21db00369c1..a94344e99137 100644 --- a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs +++ b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs @@ -7,30 +7,39 @@ using Android.Views.Animations; using AndroidX.Activity; using AndroidX.AppCompat.App; +using AndroidX.AppCompat.Widget; +using AndroidX.Core.View; using AndroidX.Fragment.App; using Microsoft.Maui.Graphics; +using Microsoft.Maui.Platform; using AView = Android.Views.View; namespace Microsoft.Maui.Controls.Platform { internal partial class ModalNavigationManager { - ViewGroup GetModalParentView() + ViewGroup? _modalParentView; + + // This is only here for the device tests to use. + // With the device tests we have a `FakeActivityRootView` and a `WindowTestFragment` + // that we use to replicate the `DecorView` and `MainActivity` + // The tests will set this to the `FakeActivityRootView` so that the `modals` + // are part of the correct testing space. + // If/When we move to opening new activities we can remove this code. + internal void SetModalParentView(ViewGroup viewGroup) { - var currentRootView = GetCurrentRootView() as ViewGroup; - - if (_window?.PlatformActivity?.GetWindow() == _window) - { - currentRootView = _window?.PlatformActivity?.Window?.DecorView as ViewGroup; - } + _modalParentView = viewGroup; + } - return currentRootView ?? + ViewGroup GetModalParentView() + { + return _modalParentView ?? + _window?.PlatformActivity?.Window?.DecorView as ViewGroup ?? throw new InvalidOperationException("Root View Needs to be set"); } bool _navAnimationInProgress; internal const string CloseContextActionsSignalName = "Xamarin.CloseContextActions"; - Page CurrentPage => _navModel.CurrentPage; // AFAICT this is specific to ListView and Context Items internal bool NavAnimationInProgress @@ -197,6 +206,10 @@ sealed class ModalContainer : ViewGroup ModalFragment _modalFragment; FragmentManager? _fragmentManager; NavigationRootManager? NavigationRootManager => _modalFragment.NavigationRootManager; + int _currentRootViewHeight = 0; + int _currentRootViewWidth = 0; + GenericGlobalLayoutListener? _rootViewLayoutListener; + AView? _rootView; AView GetWindowRootView() => _windowMauiContext @@ -212,7 +225,6 @@ public ModalContainer( { _windowMauiContext = windowMauiContext; Modal = modal; - _backgroundView = new AView(_windowMauiContext.Context); UpdateBackgroundColor(); AddView(_backgroundView); @@ -228,22 +240,110 @@ public ModalContainer( .BeginTransaction() .Add(this.Id, _modalFragment) .Commit(); + } + protected override void OnAttachedToWindow() + { + base.OnAttachedToWindow(); UpdateMargin(); + UpdateRootView(GetWindowRootView()); + } + + protected override void OnDetachedFromWindow() + { + base.OnDetachedFromWindow(); + UpdateRootView(null); + } + + void UpdateRootView(AView? rootView) + { + if (_rootView.IsAlive() && _rootView != null) + { + _rootView.LayoutChange -= OnRootViewLayoutChanged; + _rootView = null; + } + + if (rootView.IsAlive() && rootView != null) + { + rootView.LayoutChange += OnRootViewLayoutChanged; + _rootView = rootView; + _currentRootViewHeight = _rootView.MeasuredHeight; + _currentRootViewWidth = _rootView.MeasuredWidth; + } + } + + // If the RootView changes sizes that means we also need to change sizes + // This will typically happen when the user is opening the soft keyboard + // which sometimes causes the available window size to change + void OnRootViewLayoutChanged(object? sender, LayoutChangeEventArgs e) + { + if (Modal == null || sender is not AView view) + return; + + var modalStack = Modal?.Navigation?.ModalStack; + if (modalStack == null || + modalStack.Count == 0 || + modalStack[modalStack.Count - 1] != Modal) + { + return; + } + + if ((_currentRootViewHeight != view.MeasuredHeight || _currentRootViewWidth != view.MeasuredWidth) + && this.ViewTreeObserver != null) + { + // When the keyboard closes Android calls layout but doesn't call remeasure. + // MY guess is that this is due to the modal not being part of the FitSystemWindowView + // The modal is added to the decor view so its dimensions don't get updated. + // So, here we are waiting for the layout pass to finish and then we remeasure the modal + // + // For .NET 8 we'll convert this all over to using a DialogFragment + // which means we can delete most of the awkward code here + _currentRootViewHeight = view.MeasuredHeight; + _currentRootViewWidth = view.MeasuredWidth; + if (!this.IsInLayout) + { + this.InvalidateMeasure(Modal); + return; + } + + _rootViewLayoutListener ??= new GenericGlobalLayoutListener((listener, view) => + { + if (view != null && !this.IsInLayout) + { + listener.Invalidate(); + _rootViewLayoutListener = null; + this.InvalidateMeasure(Modal); + } + }, this); + } } void UpdateMargin() { // This sets up the modal container to be offset from the top of window the same // amount as the view it's covering. This will make it so the - // ModalContainer takes into account the statusbar or lack thereof - var rootView = GetWindowRootView(); - int y = (int)rootView.GetLocationOnScreenPx().Y; + // ModalContainer takes into account the StatusBar or lack thereof + var decorView = Context?.GetActivity()?.Window?.DecorView; - if (this.LayoutParameters is ViewGroup.MarginLayoutParams mlp && - mlp.TopMargin != y) + if (decorView != null && this.LayoutParameters is ViewGroup.MarginLayoutParams mlp) { - mlp.TopMargin = y; + var windowInsets = ViewCompat.GetRootWindowInsets(decorView); + if (windowInsets != null) + { + var barInsets = windowInsets.GetInsetsIgnoringVisibility(WindowInsetsCompat.Type.SystemBars()); + + if (mlp.TopMargin != barInsets.Top) + mlp.TopMargin = barInsets.Top; + + if (mlp.LeftMargin != barInsets.Left) + mlp.LeftMargin = barInsets.Left; + + if (mlp.RightMargin != barInsets.Right) + mlp.RightMargin = barInsets.Right; + + if (mlp.BottomMargin != barInsets.Bottom) + mlp.BottomMargin = barInsets.Bottom; + } } } @@ -261,8 +361,8 @@ protected override void OnMeasure(int widthMeasureSpec, int heightMeasureSpec) return; } - var rootView = GetWindowRootView(); UpdateMargin(); + var rootView = GetWindowRootView(); widthMeasureSpec = MeasureSpecMode.Exactly.MakeMeasureSpec(rootView.MeasuredWidth); heightMeasureSpec = MeasureSpecMode.Exactly.MakeMeasureSpec(rootView.MeasuredHeight); @@ -313,6 +413,10 @@ public void Destroy() Modal.Handler = null; + UpdateRootView(null); + _rootViewLayoutListener?.Invalidate(); + _rootViewLayoutListener = null; + _fragmentManager .BeginTransaction() .Remove(_modalFragment) diff --git a/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.Android.cs b/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.Android.cs index 9b07a8065319..a809e6150e70 100644 --- a/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.Android.cs +++ b/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.Android.cs @@ -198,6 +198,11 @@ public override AView OnCreateView(ALayoutInflater inflater, AViewGroup containe FakeActivityRootView.AddView(handler.PlatformViewUnderTest); handler.PlatformViewUnderTest.LayoutParameters = new FitWindowsFrameLayout.LayoutParams(AViewGroup.LayoutParams.MatchParent, AViewGroup.LayoutParams.MatchParent); + if (_window is Window window) + { + window.ModalNavigationManager.SetModalParentView(FakeActivityRootView); + } + return FakeActivityRootView; } diff --git a/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.Android.cs new file mode 100644 index 000000000000..7c9f28ac6e73 --- /dev/null +++ b/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.Android.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Java.Lang; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Platform; +using Microsoft.Maui.Handlers; +using Microsoft.Maui.Platform; +using Xunit; +using WindowSoftInputModeAdjust = Microsoft.Maui.Controls.PlatformConfiguration.AndroidSpecific.WindowSoftInputModeAdjust; + +namespace Microsoft.Maui.DeviceTests +{ + public partial class ModalTests : ControlsHandlerTestBase + { + [Theory] + [InlineData(WindowSoftInputModeAdjust.Resize)] + [InlineData(WindowSoftInputModeAdjust.Pan)] + public async Task ModalPageMarginCorrectAfterKeyboardOpens(WindowSoftInputModeAdjust panSize) + { + SetupBuilder(); + + var navPage = new NavigationPage(new ContentPage()); + var window = new Window(navPage); + await CreateHandlerAndAddToWindow(window, + async (handler) => + { + try + { + window.UpdateWindowSoftInputModeAdjust(panSize.ToPlatform()); + VerticalStackLayout layout = new VerticalStackLayout(); + List entries = new List(); + ContentPage modalPage = new ContentPage() + { + Content = layout + }; + + for (int i = 0; i < 30; i++) + { + var entry = new Entry(); + entries.Add(entry); + layout.Add(entry); + } + + await navPage.CurrentPage.Navigation.PushModalAsync(modalPage); + await OnLoadedAsync(entries[0]); + + var pageBoundingBox = modalPage.GetBoundingBox(); + + Entry testEntry = entries[0]; + foreach (var entry in entries) + { + var entryBox = entry.GetBoundingBox(); + + // Locate the lowest visible entry + if ((entryBox.Y + (entryBox.Height * 2)) > pageBoundingBox.Height) + break; + + testEntry = entry; + } + + await AssertionExtensions.HideKeyboardForView(testEntry); + var rootPageOffsetY = navPage.CurrentPage.GetLocationOnScreen().Value.Y; + var modalOffsetY = modalPage.GetLocationOnScreen().Value.Y; + var originalModalPageSize = modalPage.GetBoundingBox(); + + await AssertionExtensions.ShowKeyboardForView(testEntry); + + // Type text into the entries + testEntry.Text = "Typing"; + + bool offsetMatchesWhenKeyboardOpened = await AssertionExtensions.Wait(() => + { + var keyboardOpenRootPageOffsetY = navPage.CurrentPage.GetLocationOnScreen().Value.Y; + var keyboardOpenModalOffsetY = modalPage.GetLocationOnScreen().Value.Y; + + var originalDiff = Math.Abs(rootPageOffsetY - modalOffsetY); + var openDiff = Math.Abs(keyboardOpenRootPageOffsetY - keyboardOpenModalOffsetY); + + + return Math.Abs(originalDiff - openDiff) <= 0.2; + }); + + Assert.True(offsetMatchesWhenKeyboardOpened, "Modal page has an invalid offset when open"); + + await AssertionExtensions.HideKeyboardForView(testEntry); + + bool offsetMatchesWhenKeyboardClosed = await AssertionExtensions.Wait(() => + { + var keyboardClosedRootPageOffsetY = navPage.CurrentPage.GetLocationOnScreen().Value.Y; + var keyboardClosedModalOffsetY = modalPage.GetLocationOnScreen().Value.Y; + + return rootPageOffsetY == keyboardClosedRootPageOffsetY && + modalOffsetY == keyboardClosedModalOffsetY; + }); + + Assert.True(offsetMatchesWhenKeyboardClosed, "Modal page failed to return to expected offset"); + + var finalModalPageSize = modalPage.GetBoundingBox(); + Assert.Equal(originalModalPageSize, finalModalPageSize); + } + finally + { + window.UpdateWindowSoftInputModeAdjust(WindowSoftInputModeAdjust.Resize.ToPlatform()); + } + }); + } + } +} diff --git a/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs b/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs index f5ce8d3e25dc..64523b13a51e 100644 --- a/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs @@ -43,6 +43,7 @@ void SetupBuilder() handlers.AddHandler(typeof(Controls.Shell), typeof(ShellHandler)); handlers.AddHandler(); + handlers.AddHandler(); handlers.AddHandler(); handlers.AddHandler(); handlers.AddHandler(); diff --git a/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs b/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs index d3824ae5ecd9..98a729d70a1c 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs @@ -108,6 +108,13 @@ public static async Task ShowKeyboardForView(this AView view, int timeout = 1000 await view.WaitForKeyboardToShow(timeout); } + public static async Task HideKeyboardForView(this AView view, int timeout = 1000) + { + await view.FocusView(timeout); + KeyboardManager.HideKeyboard(view); + await view.WaitForKeyboardToHide(timeout); + } + public static async Task WaitForKeyboardToShow(this AView view, int timeout = 1000) { var result = await Wait(() => KeyboardManager.IsSoftKeyboardVisible(view), timeout); diff --git a/src/TestUtils/src/DeviceTests/AssertionExtensions.Windows.cs b/src/TestUtils/src/DeviceTests/AssertionExtensions.Windows.cs index 738dc71687bf..a0edd2a4d57f 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.Windows.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.Windows.cs @@ -60,6 +60,11 @@ public static Task ShowKeyboardForView(this FrameworkElement view, int timeout = throw new NotImplementedException(); } + public static Task HideKeyboardForView(this FrameworkElement view, int timeout = 1000) + { + throw new NotImplementedException(); + } + public static Task CreateColorAtPointError(this CanvasBitmap bitmap, WColor expectedColor, int x, int y) => CreateColorError(bitmap, $"Expected {expectedColor} at point {x},{y} in renderered view."); diff --git a/src/TestUtils/src/DeviceTests/AssertionExtensions.cs b/src/TestUtils/src/DeviceTests/AssertionExtensions.cs index 16a285ecef80..69522425f9e8 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.cs @@ -110,12 +110,13 @@ public static Task WaitForKeyboardToHide(this IView view, int timeout = 1000) => public static Task SendValueToKeyboard(this IView view, char value, int timeout = 1000) => view.ToPlatform().SendValueToKeyboard(value, timeout); - public static Task SendKeyboardReturnType(this IView view, ReturnType returnType, int timeout = 1000) => view.ToPlatform().SendKeyboardReturnType(returnType, timeout); public static Task ShowKeyboardForView(this IView view, int timeout = 1000) => view.ToPlatform().ShowKeyboardForView(timeout); + public static Task HideKeyboardForView(this IView view, int timeout = 1000) => + view.ToPlatform().HideKeyboardForView(timeout); public static Task WaitForUnFocused(this IView view, int timeout = 1000) => view.ToPlatform().WaitForUnFocused(timeout); diff --git a/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs b/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs index 3ef0fdc9f164..f34df8bd97f1 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs @@ -65,6 +65,11 @@ public static Task ShowKeyboardForView(this UIView view, int timeout = 1000) throw new NotImplementedException(); } + public static Task HideKeyboardForView(this UIView view, int timeout = 1000) + { + throw new NotImplementedException(); + } + public static string CreateColorAtPointError(this UIImage bitmap, UIColor expectedColor, int x, int y) => CreateColorError(bitmap, $"Expected {expectedColor} at point {x},{y} in renderered view.");