Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the backport I added this class to minimize the amount of code being changed.

For .NET8 I've just modified GenericGlobalLayoutListener and updated everywhere that uses that code.

{
Action<GenericGlobalLayoutListenerImproved, AView?>? _callback;
WeakReference<AView>? _targetView;

public GenericGlobalLayoutListenerImproved(Action<GenericGlobalLayoutListenerImproved, AView?> callback, AView? targetView = null)
{
_callback = callback;

if (targetView?.ViewTreeObserver != null)
{
_targetView = new WeakReference<AView>(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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -213,7 +226,6 @@ public ModalContainer(
{
_windowMauiContext = windowMauiContext;
Modal = modal;

_backgroundView = new AView(_windowMauiContext.Context);
UpdateBackgroundColor();
AddView(_backgroundView);
Expand All @@ -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;
}
}
}

Expand All @@ -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);
Expand Down Expand Up @@ -314,6 +414,10 @@ public void Destroy()

Modal.Handler = null;

UpdateRootView(null);
_rootViewLayoutListener?.Invalidate();
_rootViewLayoutListener = null;

_fragmentManager
.BeginTransaction()
.Remove(_modalFragment)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
108 changes: 108 additions & 0 deletions src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.Android.cs
Original file line number Diff line number Diff line change
@@ -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<IWindowHandler>(window,
async (handler) =>
{
try
{
window.UpdateWindowSoftInputModeAdjust(panSize.ToPlatform());
VerticalStackLayout layout = new VerticalStackLayout();
List<Entry> entries = new List<Entry>();
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());
}
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ void SetupBuilder()

handlers.AddHandler(typeof(Controls.Shell), typeof(ShellHandler));
handlers.AddHandler<Layout, LayoutHandler>();
handlers.AddHandler<Entry, EntryHandler>();
handlers.AddHandler<Image, ImageHandler>();
handlers.AddHandler<Label, LabelHandler>();
handlers.AddHandler<Toolbar, ToolbarHandler>();
Expand Down
Loading