diff --git a/src/Controls/src/Core/Platform/Android/GenericGlobalLayoutListenerImproved.cs b/src/Controls/src/Core/Platform/Android/GenericGlobalLayoutListenerImproved.cs new file mode 100644 index 000000000000..25b07f7e6bf4 --- /dev/null +++ b/src/Controls/src/Core/Platform/Android/GenericGlobalLayoutListenerImproved.cs @@ -0,0 +1,55 @@ +#nullable enable +using System; +using Android.Views; +using AView = Android.Views.View; +using Object = Java.Lang.Object; + +namespace Microsoft.Maui.Controls.Platform +{ + internal class GenericGlobalLayoutListenerImproved : Object, ViewTreeObserver.IOnGlobalLayoutListener + { + Action? _callback; + WeakReference? _targetView; + + public GenericGlobalLayoutListenerImproved(Action callback, AView? targetView = null) + { + _callback = callback; + + if (targetView?.ViewTreeObserver != null) + { + _targetView = new WeakReference(targetView); + targetView.ViewTreeObserver.AddOnGlobalLayoutListener(this); + } + } + + public void OnGlobalLayout() + { + AView? targetView = null; + _targetView?.TryGetTarget(out targetView); + _callback?.Invoke(this, targetView); + } + + protected override void Dispose(bool disposing) + { + Invalidate(); + base.Dispose(disposing); + } + + // I don't want our code to dispose of this class I'd rather just let the natural + // process manage the life cycle so we don't dispose of this too early + 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 dfa0e299401c..d718eb891246 100644 --- a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs +++ b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs @@ -8,30 +8,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 @@ -198,6 +207,10 @@ sealed class ModalContainer : ViewGroup ModalFragment _modalFragment; FragmentManager? _fragmentManager; NavigationRootManager? NavigationRootManager => _modalFragment.NavigationRootManager; + int _currentRootViewHeight = 0; + int _currentRootViewWidth = 0; + GenericGlobalLayoutListenerImproved? _rootViewLayoutListener; + AView? _rootView; AView GetWindowRootView() => _windowMauiContext @@ -213,7 +226,6 @@ public ModalContainer( { _windowMauiContext = windowMauiContext; Modal = modal; - _backgroundView = new AView(_windowMauiContext.Context); UpdateBackgroundColor(); AddView(_backgroundView); @@ -229,22 +241,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 GenericGlobalLayoutListenerImproved((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; + } } } @@ -262,8 +362,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); @@ -314,6 +414,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 e5962ac08a09..ee3337d18cab 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs @@ -83,6 +83,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 1ddfa0526900..538ea2e8e1b7 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.Windows.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.Windows.cs @@ -55,6 +55,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 05414bb8e232..6755d9a646dd 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 WaitForFocused(this IView view, int timeout = 1000) => view.ToPlatform().WaitForFocused(timeout); diff --git a/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs b/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs index e0661e399618..2c9395d4953d 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs @@ -47,6 +47,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.");