diff --git a/src/Compatibility/Core/src/Android/AppCompat/FormsAppCompatActivity.cs b/src/Compatibility/Core/src/Android/AppCompat/FormsAppCompatActivity.cs index 7a433c74536b..94fb9606c6bd 100644 --- a/src/Compatibility/Core/src/Android/AppCompat/FormsAppCompatActivity.cs +++ b/src/Compatibility/Core/src/Android/AppCompat/FormsAppCompatActivity.cs @@ -294,7 +294,7 @@ protected override void OnNewIntent(Intent intent) protected override void OnPause() { - _layout.HideKeyboard(true); + _layout.HideKeyboard(); // Stop animations or other ongoing actions that could consume CPU // Commit unsaved changes, build only if users expect such changes to be permanently saved when thy leave such as a draft email diff --git a/src/Compatibility/Core/src/iOS/Platform.cs b/src/Compatibility/Core/src/iOS/Platform.cs index 9df8c9b11ca2..37ccdfe94be4 100644 --- a/src/Compatibility/Core/src/iOS/Platform.cs +++ b/src/Compatibility/Core/src/iOS/Platform.cs @@ -543,7 +543,7 @@ async Task PresentModal(Page modal, bool animated) SetRenderer(modal, modalRenderer); } - var wrapper = new ModalWrapper(modalRenderer.Element.Handler as IPlatformViewHandler); + var wrapper = new ControlsModalWrapper(modalRenderer.Element.Handler as IPlatformViewHandler); if (_modals.Count > 1) { diff --git a/src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs index 789d88e6d0a6..9fa2e558b542 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/NavigationPage/iOS/NavigationRenderer.cs @@ -150,6 +150,8 @@ public override void ViewWillAppear(bool animated) public override void ViewDidDisappear(bool animated) { + CompletePendingNavigation(false); + base.ViewDidDisappear(animated); if (!_appeared || Element == null) @@ -379,9 +381,32 @@ void FindParentFlyoutPage() _parentFlyoutPage = flyoutDetail; } + TaskCompletionSource _pendingNavigationRequest; + ActionDisposable _removeLifecycleEvents; + + void CompletePendingNavigation(bool success) + { + if (_pendingNavigationRequest is null) + return; + + _removeLifecycleEvents?.Dispose(); + _removeLifecycleEvents = null; + + var pendingNavigationRequest = _pendingNavigationRequest; + _pendingNavigationRequest = null; + + BeginInvokeOnMainThread(() => + { + pendingNavigationRequest?.TrySetResult(success); + pendingNavigationRequest = null; + }); + } + Task GetAppearedOrDisappearedTask(Page page) { - var tcs = new TaskCompletionSource(); + CompletePendingNavigation(false); + + _pendingNavigationRequest = new TaskCompletionSource(); _ = page.ToPlatform(MauiContext); var renderer = (IPlatformViewHandler)page.Handler; @@ -392,24 +417,24 @@ Task GetAppearedOrDisappearedTask(Page page) EventHandler appearing = null, disappearing = null; appearing = (s, e) => { - parentViewController.Appearing -= appearing; - parentViewController.Disappearing -= disappearing; - - BeginInvokeOnMainThread(() => { tcs.SetResult(true); }); + CompletePendingNavigation(true); }; disappearing = (s, e) => + { + CompletePendingNavigation(false); + }; + + _removeLifecycleEvents = new ActionDisposable(() => { parentViewController.Appearing -= appearing; parentViewController.Disappearing -= disappearing; - - BeginInvokeOnMainThread(() => { tcs.SetResult(false); }); - }; + }); parentViewController.Appearing += appearing; parentViewController.Disappearing += disappearing; - return tcs.Task; + return _pendingNavigationRequest.Task; } void HandlePropertyChanged(object sender, PropertyChangedEventArgs e) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs index 688f9765618c..6b3da8d18eb1 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs @@ -156,6 +156,29 @@ internal bool SendPop() return false; } + public override void ViewDidDisappear(bool animated) + { + // If this page is removed from the View Hierarchy we need to resolve any + // pending navigation operations + var sourcesToComplete = new List>(); + + foreach (var item in _completionTasks.Values) + { + sourcesToComplete.Add(item); + } + + _completionTasks.Clear(); + + foreach (var source in sourcesToComplete) + source.TrySetResult(false); + + _popCompletionTask?.TrySetResult(false); + _popCompletionTask = null; + + + base.ViewDidDisappear(animated); + } + public override void ViewWillAppear(bool animated) { if (_disposed) diff --git a/src/Controls/src/Core/Compatibility/Handlers/iOS/DisposeHelpers.cs b/src/Controls/src/Core/Compatibility/Handlers/iOS/DisposeHelpers.cs index e54268bf2c45..157f8475b1be 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/iOS/DisposeHelpers.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/iOS/DisposeHelpers.cs @@ -26,7 +26,7 @@ internal static void DisposeModalAndChildHandlers(this Maui.IElement view) { if (renderer.ViewController != null) { - if (renderer.ViewController.ParentViewController is Platform.ModalWrapper modalWrapper) + if (renderer.ViewController.ParentViewController is Platform.ControlsModalWrapper modalWrapper) modalWrapper.Dispose(); } diff --git a/src/Controls/src/Core/HandlerImpl/Window/Window.Impl.cs b/src/Controls/src/Core/HandlerImpl/Window/Window.Impl.cs index 7444d8a533aa..305fef65c818 100644 --- a/src/Controls/src/Core/HandlerImpl/Window/Window.Impl.cs +++ b/src/Controls/src/Core/HandlerImpl/Window/Window.Impl.cs @@ -417,16 +417,19 @@ internal void FinishedAddingWindowToApplication(Application application) void SendWindowAppearing() { - Page?.SendAppearing(); + if (Navigation.ModalStack.Count == 0) + Page?.SendAppearing(); } void SendWindowDisppearing() { - Page?.SendDisappearing(); + if (Navigation.ModalStack.Count == 0) + Page?.SendDisappearing(); + IsActivated = false; } - void OnModalPopped(Page modalPage) + internal void OnModalPopped(Page modalPage) { int index = _visualChildren.IndexOf(modalPage); _visualChildren.Remove(modalPage); @@ -438,7 +441,7 @@ void OnModalPopped(Page modalPage) VisualDiagnostics.OnChildRemoved(this, modalPage, index); } - bool OnModalPopping(Page modalPage) + internal bool OnModalPopping(Page modalPage) { var args = new ModalPoppingEventArgs(modalPage); ModalPopping?.Invoke(this, args); @@ -446,7 +449,7 @@ bool OnModalPopping(Page modalPage) return args.Cancel; } - void OnModalPushed(Page modalPage) + internal void OnModalPushed(Page modalPage) { _visualChildren.Add(modalPage); var args = new ModalPushedEventArgs(modalPage); @@ -455,21 +458,25 @@ void OnModalPushed(Page modalPage) VisualDiagnostics.OnChildAdded(this, modalPage); } - void OnModalPushing(Page modalPage) + internal void OnModalPushing(Page modalPage) { var args = new ModalPushingEventArgs(modalPage); ModalPushing?.Invoke(this, args); Application?.NotifyOfWindowModalEvent(args); } - void OnPopCanceled() + internal void OnPopCanceled() { PopCanceled?.Invoke(this, EventArgs.Empty); } void IWindow.Created() { + if (IsCreated) + throw new InvalidOperationException("Window was already created"); + IsCreated = true; + IsDestroyed = false; Created?.Invoke(this, EventArgs.Empty); OnCreated(); @@ -478,6 +485,9 @@ void IWindow.Created() void IWindow.Activated() { + if (IsActivated) + throw new InvalidOperationException("Window was already activated"); + IsActivated = true; Activated?.Invoke(this, EventArgs.Empty); OnActivated(); @@ -485,6 +495,9 @@ void IWindow.Activated() void IWindow.Deactivated() { + if (!IsActivated) + throw new InvalidOperationException("Window was already deactivated"); + IsActivated = false; Deactivated?.Invoke(this, EventArgs.Empty); OnDeactivated(); @@ -499,7 +512,12 @@ void IWindow.Stopped() void IWindow.Destroying() { + if (IsDestroyed) + throw new InvalidOperationException("Window was already destroyed"); + IsDestroyed = true; + IsCreated = false; + SendWindowDisppearing(); Destroying?.Invoke(this, EventArgs.Empty); OnDestroying(); @@ -613,8 +631,6 @@ void OnPageChanged(Page? oldPage, Page? newPage) _menuBarTracker.Target = newPage; } - ModalNavigationManager.SettingNewPage(); - if (newPage != null) { newPage.HandlerChanged += OnPageHandlerChanged; @@ -676,58 +692,14 @@ protected override IReadOnlyList GetModalStack() return _owner.ModalNavigationManager.ModalStack; } - protected override async Task OnPopModal(bool animated) + protected override Task OnPopModal(bool animated) { - Page modal = _owner.ModalNavigationManager.ModalStack[_owner.ModalNavigationManager.ModalStack.Count - 1]; - if (_owner.OnModalPopping(modal)) - { - _owner.OnPopCanceled(); - return null; - } - - Page? nextPage; - if (modal.NavigationProxy.ModalStack.Count == 1) - { - nextPage = _owner.Page; - } - else - { - nextPage = _owner.ModalNavigationManager.ModalStack[_owner.ModalNavigationManager.ModalStack.Count - 2]; - } - - Page result = await _owner.ModalNavigationManager.PopModalAsync(animated); - result.Parent = null; - _owner.OnModalPopped(result); - - modal.SendNavigatedFrom(new NavigatedFromEventArgs(nextPage)); - nextPage?.SendNavigatedTo(new NavigatedToEventArgs(modal)); - - return result; + return _owner.ModalNavigationManager.PopModalAsync(animated); } - protected override async Task OnPushModal(Page modal, bool animated) + protected override Task OnPushModal(Page modal, bool animated) { - _owner.OnModalPushing(modal); - - modal.Parent = _owner; - - if (modal.NavigationProxy.ModalStack.Count == 0) - { - modal.NavigationProxy.Inner = this; - await _owner.ModalNavigationManager.PushModalAsync(modal, animated); - _owner.Page?.SendNavigatedFrom(new NavigatedFromEventArgs(modal)); - modal.SendNavigatedTo(new NavigatedToEventArgs(_owner.Page)); - } - else - { - var previousModalPage = modal.NavigationProxy.ModalStack[modal.NavigationProxy.ModalStack.Count - 1]; - await _owner.ModalNavigationManager.PushModalAsync(modal, animated); - modal.NavigationProxy.Inner = this; - previousModalPage.SendNavigatedFrom(new NavigatedFromEventArgs(modal)); - modal.SendNavigatedTo(new NavigatedToEventArgs(previousModalPage)); - } - - _owner.OnModalPushed(modal); + return _owner.ModalNavigationManager.PushModalAsync(modal, animated); } } } diff --git a/src/Controls/src/Core/Internals/MaybeNullWhenAttribute.cs b/src/Controls/src/Core/Internals/MaybeNullWhenAttribute.cs index a78352348b56..417e24cc8efe 100644 --- a/src/Controls/src/Core/Internals/MaybeNullWhenAttribute.cs +++ b/src/Controls/src/Core/Internals/MaybeNullWhenAttribute.cs @@ -8,7 +8,7 @@ namespace System.Diagnostics.CodeAnalysis { -#if !NETCOREAPP +#if !NETCOREAPP && !NETSTANDARD2_1_OR_GREATER /// Specifies that null is allowed as an input even if the corresponding type disallows it. [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] diff --git a/src/Controls/src/Core/NavigationProxy.cs b/src/Controls/src/Core/NavigationProxy.cs index b0f1f382a7ee..6a7a98382189 100644 --- a/src/Controls/src/Core/NavigationProxy.cs +++ b/src/Controls/src/Core/NavigationProxy.cs @@ -18,7 +18,7 @@ public interface INavigationProxy public class NavigationProxy : INavigation { INavigation _inner; - Lazy> _modalStack = new Lazy>(() => new List()); + Lazy _modalStack = new Lazy(() => new NavigatingStepRequestList()); Lazy> _pushStack = new Lazy>(() => new List()); @@ -37,11 +37,11 @@ public INavigation Inner if (ReferenceEquals(_inner, null)) { _pushStack = new Lazy>(() => new List()); - _modalStack = new Lazy>(() => new List()); + _modalStack = new Lazy(() => new NavigatingStepRequestList()); } else { - if (_pushStack != null && _pushStack.IsValueCreated) + if (_pushStack is not null && _pushStack.IsValueCreated) { foreach (Page page in _pushStack.Value) { @@ -49,11 +49,11 @@ public INavigation Inner } } - if (_modalStack != null && _modalStack.IsValueCreated) + if (_modalStack is not null && _modalStack.IsValueCreated) { - foreach (Page page in _modalStack.Value) + foreach (var request in _modalStack.Value) { - _inner.PushModalAsync(page); + _inner.PushModalAsync(request.Page, request.IsAnimated); } } @@ -126,7 +126,7 @@ public Task PushAsync(Page root) /// public Task PushAsync(Page root, bool animated) { - if (root.RealParent != null) + if (root.RealParent is not null) throw new InvalidOperationException("Page must not already have a parent."); return OnPushAsync(root, animated); } @@ -140,7 +140,7 @@ public Task PushModalAsync(Page modal) /// public Task PushModalAsync(Page modal, bool animated) { - if (modal.RealParent != null && modal.RealParent is not IWindow) + if (modal.RealParent is not null && modal.RealParent is not IWindow) throw new InvalidOperationException("Page must not already have a parent."); return OnPushModal(modal, animated); } @@ -154,19 +154,19 @@ public void RemovePage(Page page) protected virtual IReadOnlyList GetModalStack() { INavigation currentInner = Inner; - return currentInner == null ? _modalStack.Value : currentInner.ModalStack; + return currentInner is null ? _modalStack.Value.Pages : currentInner.ModalStack; } protected virtual IReadOnlyList GetNavigationStack() { INavigation currentInner = Inner; - return currentInner == null ? _pushStack.Value : currentInner.NavigationStack; + return currentInner is null ? _pushStack.Value : currentInner.NavigationStack; } protected virtual void OnInsertPageBefore(Page page, Page before) { INavigation currentInner = Inner; - if (currentInner == null) + if (currentInner is null) { int index = _pushStack.Value.IndexOf(before); if (index == -1) @@ -182,19 +182,19 @@ protected virtual void OnInsertPageBefore(Page page, Page before) protected virtual Task OnPopAsync(bool animated) { INavigation inner = Inner; - return inner == null ? Task.FromResult(Pop()) : inner.PopAsync(animated); + return inner is null ? Task.FromResult(Pop()) : inner.PopAsync(animated); } protected virtual Task OnPopModal(bool animated) { INavigation innerNav = Inner; - return innerNav == null ? Task.FromResult(PopModal()) : innerNav.PopModalAsync(animated); + return innerNav is null ? Task.FromResult(PopModal()) : innerNav.PopModalAsync(animated); } protected virtual Task OnPopToRootAsync(bool animated) { INavigation currentInner = Inner; - if (currentInner == null) + if (currentInner is null) { if (_pushStack.Value.Count == 0) return Task.FromResult(null); @@ -210,7 +210,7 @@ protected virtual Task OnPopToRootAsync(bool animated) protected virtual Task OnPushAsync(Page page, bool animated) { INavigation currentInner = Inner; - if (currentInner == null) + if (currentInner is null) { _pushStack.Value.Add(page); return Task.FromResult(page); @@ -221,9 +221,9 @@ protected virtual Task OnPushAsync(Page page, bool animated) protected virtual Task OnPushModal(Page modal, bool animated) { INavigation currentInner = Inner; - if (currentInner == null) + if (currentInner is null) { - _modalStack.Value.Add(modal); + _modalStack.Value.Add(new NavigationStepRequest(modal, true, animated)); return Task.FromResult(null); } return currentInner.PushModalAsync(modal, animated); @@ -232,7 +232,7 @@ protected virtual Task OnPushModal(Page modal, bool animated) protected virtual void OnRemovePage(Page page) { INavigation currentInner = Inner; - if (currentInner == null) + if (currentInner is null) { _pushStack.Value.Remove(page); } @@ -254,12 +254,12 @@ Page Pop() Page PopModal() { - List list = _modalStack.Value; + var list = _modalStack.Value; if (list.Count == 0) return null; - Page result = list[list.Count - 1]; + var result = list[list.Count - 1]; list.RemoveAt(list.Count - 1); - return result; + return result.Page; } } } \ No newline at end of file diff --git a/src/Controls/src/Core/NavigationStepRequest.cs b/src/Controls/src/Core/NavigationStepRequest.cs new file mode 100644 index 000000000000..287c2587419e --- /dev/null +++ b/src/Controls/src/Core/NavigationStepRequest.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Maui.Controls +{ + internal class NavigationStepRequest + { + public NavigationStepRequest(Page page, bool isModal, bool isAnimated) + { + IsModal = isModal; + IsAnimated = isAnimated; + Page = page; + } + + public bool IsModal { get; } + public bool IsAnimated { get; } + public Page Page { get; } + } + + class NavigatingStepRequestList : IList + { + List _innerList = new List(); + List _pages = new List(); + + public IReadOnlyList Pages => _pages; + public int Count => _pages.Count; + + public bool IsReadOnly => false; + + public NavigationStepRequest this[int index] + { + get => _innerList[index]; + set => _innerList.Insert(index, value); + } + + public void Add(NavigationStepRequest args) + { + _pages.Add(args.Page); + _innerList.Add(args); + } + + public void Remove(Page page) + { + if (TryGetValue(page, out var request)) + Remove(request); + } + + public void Remove(NavigationStepRequest args) + { + _innerList.Remove(args); + _pages.Remove(args.Page); + } + + public void Clear() + { + _innerList.Clear(); + _pages.Clear(); + } + + public int IndexOf(NavigationStepRequest item) + { + return _innerList.IndexOf(item); + } + + public void Insert(int index, NavigationStepRequest item) + { + _innerList.Insert(index, item); + _pages.Insert(index, item.Page); + } + + public void RemoveAt(int index) + { + _innerList.RemoveAt(index); + _pages.RemoveAt(index); + } + + public bool Contains(NavigationStepRequest item) + { + return _innerList.Contains(item); + } + + public void CopyTo(NavigationStepRequest[] array, int arrayIndex) + { + _innerList.CopyTo(array, arrayIndex); + + var pages = new Page[array.Length]; + + for (var i = 0; i < array.Length; i++) + { + NavigationStepRequest arg = array[i]; + pages[i] = arg.Page; + } + + _pages.CopyTo(pages, arrayIndex); + } + + bool ICollection.Remove(NavigationStepRequest item) + { + if (!_innerList.Contains(item)) + return false; + + Remove(item); + return true; + } + + public IEnumerator GetEnumerator() + { + return _innerList.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return _innerList.GetEnumerator(); + } + + internal bool TryGetValue(Page page, [NotNullWhen(true)] out NavigationStepRequest? request) + { + for (var i = 0; i < _innerList.Count; i++) + { + NavigationStepRequest? item = _innerList[i]; + + if (item.Page == page) + { + request = item; + return true; + } + } + + request = null; + + return false; + } + } +} \ 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 3b0c5771d928..7b29d8f61c54 100644 --- a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs +++ b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs @@ -19,6 +19,14 @@ namespace Microsoft.Maui.Controls.Platform internal partial class ModalNavigationManager { ViewGroup? _modalParentView; + bool _navAnimationInProgress; + internal const string CloseContextActionsSignalName = "Xamarin.CloseContextActions"; + + partial void InitializePlatform() + { + _window.Activated += (_, _) => SyncModalStackWhenPlatformIsReady(); + _window.Resumed += (_, _) => SyncModalStackWhenPlatformIsReady(); + } // This is only here for the device tests to use. // With the device tests we have a `FakeActivityRootView` and a `WindowTestFragment` @@ -38,9 +46,6 @@ ViewGroup GetModalParentView() throw new InvalidOperationException("Root View Needs to be set"); } - bool _navAnimationInProgress; - internal const string CloseContextActionsSignalName = "Xamarin.CloseContextActions"; - // AFAICT this is specific to ListView and Context Items internal bool NavAnimationInProgress { @@ -58,17 +63,16 @@ internal bool NavAnimationInProgress } } - public Task PopModalAsync(bool animated) + Task PopModalPlatformAsync(bool animated) { - Page modal = _navModel.PopModal(); + Page modal = CurrentPlatformModalPage; + _platformModalPages.Remove(modal); + var source = new TaskCompletionSource(); - var modalHandler = modal.Handler as IPlatformViewHandler; - if (modalHandler != null) + if (modal.Handler is IPlatformViewHandler modalHandler) { ModalContainer? modalContainer = null; - - for (int i = 0; i <= GetModalParentView().ChildCount; i++) { if (GetModalParentView().GetChildAt(i) is ModalContainer mc && @@ -116,20 +120,24 @@ AView GetCurrentRootView() throw new InvalidOperationException("Current Root View cannot be null"); } - public async Task PushModalAsync(Page modal, bool animated) + async Task PushModalPlatformAsync(Page modal, bool animated) { var viewToHide = GetCurrentRootView(); RemoveFocusability(viewToHide); - _navModel.PushModal(modal); + _platformModalPages.Add(modal); Task presentModal = PresentModal(modal, animated); await presentModal; - GetCurrentRootView() - .SendAccessibilityEvent(global::Android.Views.Accessibility.EventTypes.ViewFocused); + // The state of things might have changed after the modal view was pushed + if (IsModalReady) + { + GetCurrentRootView() + .SendAccessibilityEvent(global::Android.Views.Accessibility.EventTypes.ViewFocused); + } } Task PresentModal(Page modal, bool animated) @@ -187,17 +195,6 @@ void RemoveFocusability(AView platformView) vg.DescendantFocusability = DescendantFocusability.BlockDescendants; } - internal bool HandleBackPressed() - { - if (NavAnimationInProgress) - return true; - - Page root = _navModel.LastRoot; - bool handled = root?.SendBackButtonPressed() ?? false; - - return handled; - } - sealed class ModalContainer : ViewGroup { AView _backgroundView; @@ -277,11 +274,11 @@ void UpdateRootView(AView? rootView) // which sometimes causes the available window size to change void OnRootViewLayoutChanged(object? sender, LayoutChangeEventArgs e) { - if (Modal == null || sender is not AView view) + if (Modal is null || sender is not AView view) return; var modalStack = Modal?.Navigation?.ModalStack; - if (modalStack == null || + if (modalStack is null || modalStack.Count == 0 || modalStack[modalStack.Count - 1] != Modal) { @@ -289,7 +286,7 @@ void OnRootViewLayoutChanged(object? sender, LayoutChangeEventArgs e) } if ((_currentRootViewHeight != view.MeasuredHeight || _currentRootViewWidth != view.MeasuredWidth) - && this.ViewTreeObserver != null) + && this.ViewTreeObserver is not 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 @@ -308,7 +305,7 @@ void OnRootViewLayoutChanged(object? sender, LayoutChangeEventArgs e) _rootViewLayoutListener ??= new GenericGlobalLayoutListener((listener, view) => { - if (view != null && !this.IsInLayout) + if (view is not null && !this.IsInLayout) { listener.Invalidate(); _rootViewLayoutListener = null; @@ -325,10 +322,10 @@ void UpdateMargin() // ModalContainer takes into account the StatusBar or lack thereof var decorView = Context?.GetActivity()?.Window?.DecorView; - if (decorView != null && this.LayoutParameters is ViewGroup.MarginLayoutParams mlp) + if (decorView is not null && this.LayoutParameters is ViewGroup.MarginLayoutParams mlp) { var windowInsets = ViewCompat.GetRootWindowInsets(decorView); - if (windowInsets != null) + if (windowInsets is not null) { var barInsets = windowInsets.GetInsetsIgnoringVisibility(WindowInsetsCompat.Type.SystemBars()); @@ -355,7 +352,7 @@ public override bool OnTouchEvent(MotionEvent? e) protected override void OnMeasure(int widthMeasureSpec, int heightMeasureSpec) { - if (Context == null || NavigationRootManager?.RootView == null) + if (Context is null || NavigationRootManager?.RootView is null) { SetMeasuredDimension(0, 0); return; @@ -375,7 +372,7 @@ protected override void OnMeasure(int widthMeasureSpec, int heightMeasureSpec) protected override void OnLayout(bool changed, int l, int t, int r, int b) { - if (Context == null || NavigationRootManager?.RootView == null) + if (Context is null || NavigationRootManager?.RootView is null) return; NavigationRootManager @@ -393,11 +390,11 @@ void OnModalPagePropertyChanged(object? sender, PropertyChangedEventArgs e) void UpdateBackgroundColor() { - if (Modal == null) + if (Modal is null) return; Color modalBkgndColor = Modal.BackgroundColor; - if (modalBkgndColor == null) + if (modalBkgndColor is null) _backgroundView.SetWindowBackground(); else _backgroundView.SetBackgroundColor(modalBkgndColor.ToPlatform()); @@ -405,10 +402,10 @@ void UpdateBackgroundColor() public void Destroy() { - if (Modal == null || _windowMauiContext == null || _fragmentManager == null) + if (Modal is null || _windowMauiContext is null || _fragmentManager is null || !_fragmentManager.IsAlive() || _fragmentManager.IsDestroyed) return; - if (Modal.Toolbar?.Handler != null) + if (Modal.Toolbar?.Handler is not null) Modal.Toolbar.Handler = null; Modal.Handler = null; diff --git a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Standard.cs b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Standard.cs index 8d52e08a29c4..b04e70c83754 100644 --- a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Standard.cs +++ b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Standard.cs @@ -8,18 +8,22 @@ namespace Microsoft.Maui.Controls.Platform { internal partial class ModalNavigationManager { - public Task PopModalAsync(bool animated) + Task PopModalPlatformAsync(bool animated) { - if (ModalStack.Count == 0) - throw new InvalidOperationException(); - - return Task.FromResult(_navModel.PopModal()); + var currentPage = CurrentPlatformPage!; + _platformModalPages.Remove(currentPage); + return Task.FromResult(currentPage); } - public Task PushModalAsync(Page modal, bool animated) + Task PushModalPlatformAsync(Page modal, bool animated) { - _navModel.PushModal(modal); + _platformModalPages.Add(modal); return Task.CompletedTask; } + + Task SyncModalStackWhenPlatformIsReadyAsync() => + SyncPlatformModalStackAsync(); + + bool IsModalPlatformReady => true; } } diff --git a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Tizen.cs b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Tizen.cs index 9a422cc0d2ac..f94d7a93c1d0 100644 --- a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Tizen.cs +++ b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Tizen.cs @@ -8,20 +8,27 @@ namespace Microsoft.Maui.Controls.Platform internal partial class ModalNavigationManager { NavigationStack _modalStack => WindowMauiContext.GetModalStack(); - IPageController CurrentPageController => _navModel.CurrentPage; + IPageController CurrentPageController => CurrentPage!; + + Task SyncModalStackWhenPlatformIsReadyAsync() => + SyncPlatformModalStackAsync(); + + bool IsModalPlatformReady => true; partial void OnPageAttachedHandler() { WindowMauiContext.GetPlatformWindow().SetBackButtonPressedHandler(OnBackButtonPressed); } - public async Task PopModalAsync(bool animated) + async Task PopModalPlatformAsync(bool animated) { - Page modal = _navModel.PopModal(); + Page modal = CurrentPlatformModalPage; + _platformModalPages.Remove(modal); + ((IPageController)modal).SendDisappearing(); var modalRenderer = modal.Handler as IPlatformViewHandler; - if (modalRenderer != null) + if (modalRenderer is not null) { await _modalStack.Pop(animated); CurrentPageController?.SendAppearing(); @@ -30,24 +37,23 @@ public async Task PopModalAsync(bool animated) return modal; } - public async Task PushModalAsync(Page modal, bool animated) + async Task PushModalPlatformAsync(Page modal, bool animated) { CurrentPageController?.SendDisappearing(); - _navModel.PushModal(modal); + _platformModalPages.Add(modal); var nativeView = modal.ToPlatform(WindowMauiContext); await _modalStack.Push(nativeView, animated); // Verify that the modal is still on the stack - if (_navModel.CurrentPage == modal) + if (CurrentPage == modal) ((IPageController)modal).SendAppearing(); } bool OnBackButtonPressed() { - Page root = _navModel.LastRoot; - bool handled = root?.SendBackButtonPressed() ?? false; + bool handled = CurrentPage?.SendBackButtonPressed() ?? false; return handled; } diff --git a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Windows.cs b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Windows.cs index 5056bc1ecb34..9acdc0af86b7 100644 --- a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Windows.cs +++ b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Windows.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Maui.Graphics; @@ -10,29 +11,47 @@ internal partial class ModalNavigationManager _window.NativeWindow.Content as WindowRootViewContainer ?? throw new InvalidOperationException("Root container Panel not found"); - public Task PopModalAsync(bool animated) + bool _firstActivated; + + partial void InitializePlatform() + { + _window.Created += (_, _) => SyncModalStackWhenPlatformIsReady(); + _window.Destroying += (_, _) => _firstActivated = false; + _window.Activated += OnWindowActivated; + } + + void OnWindowActivated(object? sender, EventArgs e) + { + if (!_firstActivated) + { + _firstActivated = true; + SyncModalStackWhenPlatformIsReady(); + } + } + + Task PopModalPlatformAsync(bool animated) { var tcs = new TaskCompletionSource(); - var currentPage = _navModel.CurrentPage; - Page result = _navModel.PopModal(); - SetCurrent(_navModel.CurrentPage, currentPage, true, () => tcs.SetResult(result)); + var poppedPage = CurrentPlatformModalPage; + _platformModalPages.Remove(poppedPage); + SetCurrent(CurrentPlatformPage, poppedPage, true, () => tcs.SetResult(poppedPage)); return tcs.Task; } - public Task PushModalAsync(Page modal, bool animated) + Task PushModalPlatformAsync(Page modal, bool animated) { _ = modal ?? throw new ArgumentNullException(nameof(modal)); var tcs = new TaskCompletionSource(); - var currentPage = _navModel.CurrentPage; - _navModel.PushModal(modal); + var currentPage = CurrentPlatformPage; + _platformModalPages.Add(modal); SetCurrent(modal, currentPage, false, () => tcs.SetResult(true)); return tcs.Task; } - void RemovePage(Page page) + void RemovePage(Page page, bool popping) { - if (page == null) + if (page is null) return; var mauiContext = page.FindMauiContext() ?? @@ -40,36 +59,36 @@ void RemovePage(Page page) var windowManager = mauiContext.GetNavigationRootManager(); Container.RemovePage(windowManager.RootView); + + if (!popping) + return; + + page + .FindMauiContext() + ?.GetNavigationRootManager() + ?.Disconnect(); + + page.Handler?.DisconnectHandler(); } - void SetCurrent(Page newPage, Page previousPage, bool popping, Action? completedCallback = null) + void SetCurrent( + Page newPage, + Page previousPage, + bool popping, + Action? completedCallback = null) { try { if (popping) { - RemovePage(previousPage); + RemovePage(previousPage, popping); } else if (newPage.BackgroundColor.IsDefault() && newPage.Background.IsEmpty) { - RemovePage(previousPage); - } - - if (popping) - { - previousPage - .FindMauiContext() - ?.GetNavigationRootManager() - ?.Disconnect(); - - previousPage.Handler = null; - - // Un-parent the page; otherwise the Resources Changed Listeners won't be unhooked and the - // page will leak - previousPage.Parent = null; + RemovePage(previousPage, popping); } - if (Container == null || newPage == null) + if (Container is null || newPage is null) return; // pushing modal diff --git a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.cs b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.cs index 458036834bef..01b7acd4667a 100644 --- a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.cs +++ b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Text; using System.Threading.Tasks; using Microsoft.Maui.Controls.Internals; @@ -9,56 +10,387 @@ namespace Microsoft.Maui.Controls.Platform internal partial class ModalNavigationManager { Window _window; - public IReadOnlyList ModalStack => _navModel.Modals; + public IReadOnlyList ModalStack => _modalPages.Pages; IMauiContext WindowMauiContext => _window.MauiContext; - NavigationModel _navModel = new NavigationModel(); - NavigationModel? _previousNavModel = null; - Page? _previousPage; + + List _platformModalPages = new List(); + NavigatingStepRequestList _modalPages = new NavigatingStepRequestList(); + + Page? _currentPage; + + Page CurrentPlatformPage => + _platformModalPages.Count > 0 ? _platformModalPages[_platformModalPages.Count - 1] : (_window.Page ?? throw new InvalidOperationException("Current Window isn't loaded")); + + Page CurrentPlatformModalPage => + _platformModalPages.Count > 0 ? _platformModalPages[_platformModalPages.Count - 1] : throw new InvalidOperationException("Modal Stack is Empty"); + + Page? CurrentPage => + _modalPages.Count > 0 ? _modalPages[_modalPages.Count - 1].Page : _window.Page; + + // Shell takes care of firing its own Modal life cycle events + // With shell you cam remove / add multiple modals at once + bool FireLifeCycleEvents => _window?.Page is not Shell; + + partial void InitializePlatform(); public ModalNavigationManager(Window window) { _window = window; + _window.PropertyChanged += (_, args) => + { + if (args.Is(Window.PageProperty)) + SettingNewPage(); + }; + + InitializePlatform(); + + _window.HandlerChanging += OnWindowHandlerChanging; + _window.Destroying += (_, _) => + { + ClearModalPages(platform: true); + }; } - public Task PopModalAsync() + void OnWindowHandlerChanging(object? sender, HandlerChangingEventArgs e) { - return PopModalAsync(true); + // If the window handler is changing the activity is being recreated + // the window activated/resumed event will take care of syncing the platform modals + if (e.OldHandler is not null) + { + ClearModalPages(platform: true); + } } + public Task PopModalAsync() + { + return PopModalAsync(true); + } public Task PushModalAsync(Page modal) { return PushModalAsync(modal, true); } - internal void SettingNewPage() + bool syncing = false; + + bool IsModalReady + { + get + { + return + _window?.Page?.Handler is not null && + _window.Handler is not null + && IsModalPlatformReady; + } + } + + void SyncPlatformModalStack([CallerMemberName] string? callerName = null) + { + var logger = _window.FindMauiContext(true)?.Services?.CreateLogger(); + SyncPlatformModalStackAsync().FireAndForget(logger, callerName); + } + + void SyncModalStackWhenPlatformIsReady([CallerMemberName] string? callerName = null) + { + var logger = _window.FindMauiContext(true)?.Services?.CreateLogger(); + SyncModalStackWhenPlatformIsReadyAsync().FireAndForget(logger, callerName); + } + + + // This code only processes a single sync action per call. + // It recursively calls itself until no more sync actions are left to perform. + // + // A lot can change during the process of pushing/popping a page + // i.e. Users might change the root page during an appearing event. + // So, instead of just bull dozing through the whole sync we perform one + // sync step then recalculate the state of affairs and then perform another + // until no more sync operations are left. + // Typically it's always a good idea to re-evaluate after any async operation has completed + async Task SyncPlatformModalStackAsync() + { + if (!IsModalReady || syncing) + return; + + bool syncAgain = false; + + try + { + syncing = true; + + int popTo; + + for (popTo = 0; popTo < _platformModalPages.Count && popTo < _modalPages.Count; popTo++) + { + if (_platformModalPages[popTo] != _modalPages[popTo].Page) + { + break; + } + } + + // This means the modal stacks are already synced so we don't have to do anything + if (_platformModalPages.Count == _modalPages.Count && popTo == _platformModalPages.Count) + return; + + // This ensures that appearing has fired on the final page that will be visible after + // the sync has finished + CurrentPage?.SendAppearing(); + + // Pop platform modal pages until we get to the point where the xplat expectation + // matches the platform modals + if (_platformModalPages.Count > popTo && IsModalReady) + { + bool animated = false; + if (_modalPages.TryGetValue(CurrentPlatformModalPage, out var request)) + { + _modalPages.Remove(CurrentPlatformModalPage); + animated = request.IsAnimated; + } + + var page = await PopModalPlatformAsync(animated); + page.Parent = null; + syncAgain = true; + } + + if (!syncAgain) + { + //push any modals that need to be synced + var i = _platformModalPages.Count; + if (i < _modalPages.Count && IsModalReady) + { + var nextRequest = _modalPages[i]; + var nextPage = nextRequest.Page; + bool animated = nextRequest.IsAnimated; + + await PushModalPlatformAsync(nextPage, animated); + syncAgain = true; + } + } + } + finally + { + // Code has multiple exit points during the sync operation. + // So we're using a try/finally to ensure that syncing always + // gets transitioned to false. If more exit points are added at a later point + // we don't have to always worry about the exit point setting syncing to false. + syncing = false; + + // syncAgain is only set after a successful operation so we won't hit a case here + // where we hit an infinite loop of syncing. + if (syncAgain) + { + await SyncModalStackWhenPlatformIsReadyAsync().ConfigureAwait(false); + } + } + } + + public async Task PopModalAsync(bool animated) + { + if (_modalPages.Count <= 0) + throw new InvalidOperationException("PopModalAsync failed because modal stack is currently empty."); + + Page modal = _modalPages[_modalPages.Count - 1].Page; + + if (_window.OnModalPopping(modal)) + { + _window.OnPopCanceled(); + return null; + } + + _modalPages.Remove(modal); + + if (FireLifeCycleEvents) + { + modal.SendNavigatingFrom(new NavigatingFromEventArgs()); + modal.SendDisappearing(); + CurrentPage?.SendAppearing(); + } + + bool isPlatformReady = IsModalReady; + Task popTask = + (isPlatformReady && !syncing) ? PopModalPlatformAsync(animated) : Task.CompletedTask; + + await popTask; + modal.Parent = null; + _window.OnModalPopped(modal); + + if (FireLifeCycleEvents) + { + modal.SendNavigatedFrom(new NavigatedFromEventArgs(CurrentPage)); + CurrentPage?.SendNavigatedTo(new NavigatedToEventArgs(modal)); + } + + if (!isPlatformReady) + SyncModalStackWhenPlatformIsReady(); + + return modal; + } + + public async Task PushModalAsync(Page modal, bool animated) { - if (_previousPage != null) + _window.OnModalPushing(modal); + + var previousPage = CurrentPage; + _modalPages.Add(new NavigationStepRequest(modal, true, animated)); + modal.Parent = _window; + + if (FireLifeCycleEvents) + { + previousPage?.SendNavigatingFrom(new NavigatingFromEventArgs()); + previousPage?.SendDisappearing(); + CurrentPage?.SendAppearing(); + } + + bool isPlatformReady = IsModalReady; + if (isPlatformReady && !syncing) { - // if _previousNavModel has been set than _navModel has already been reinitialized - if (_previousNavModel != null) + if (ModalStack.Count == 0) { - _previousNavModel = null; - if (_navModel == null) - _navModel = new NavigationModel(); + modal.NavigationProxy.Inner = _window.Navigation; + await PushModalPlatformAsync(modal, animated); } else - _navModel = new NavigationModel(); + { + await PushModalPlatformAsync(modal, animated); + modal.NavigationProxy.Inner = _window.Navigation; + } } - if (_window.Page == null) + if (FireLifeCycleEvents) { - _previousPage = null; + previousPage?.SendNavigatedFrom(new NavigatedFromEventArgs(CurrentPage)); + CurrentPage?.SendNavigatedTo(new NavigatedToEventArgs(previousPage)); + } + + _window.OnModalPushed(modal); + if (!isPlatformReady) + SyncModalStackWhenPlatformIsReady(); + } + + void SettingNewPage() + { + if (_window.Page is null) + { + _currentPage = null; return; } - _navModel.Push(_window.Page, null); - _previousPage = _window.Page; + if (_currentPage != _window.Page) + { + var previousPage = _currentPage; + _currentPage = _window.Page; + + if (previousPage is not null) + { + previousPage.HandlerChanged -= OnCurrentPageHandlerChanged; + ClearModalPages(xplat: true); + } + + if (_currentPage is not null) + { + if (_currentPage.Handler is null) + { + _currentPage.HandlerChanged += OnCurrentPageHandlerChanged; + } + else + { + SyncModalStackWhenPlatformIsReady(); + } + } + } + } + + void OnCurrentPageHandlerChanged(object? sender, EventArgs e) + { + if (_currentPage is not null) + { + _currentPage.HandlerChanged -= OnCurrentPageHandlerChanged; + SyncModalStackWhenPlatformIsReady(); + } } partial void OnPageAttachedHandler(); public void PageAttachedHandler() => OnPageAttachedHandler(); + + void ClearModalPages(bool xplat = false, bool platform = false) + { + if (xplat) + _modalPages.Clear(); + + if (platform) + _platformModalPages.Clear(); + } + + // Windows and Android have basically the same requirement that + // we need to wait for the current page to finish loading before + // satsifying Modal requests. + // This will most likely change once we switch Android to using dialog fragments +#if WINDOWS || ANDROID + IDisposable? _platformPageWatchingForLoaded; + + async Task SyncModalStackWhenPlatformIsReadyAsync() + { + DisconnectPlatformPageWatchingForLoaded(); + + if (IsModalPlatformReady) + { + await SyncPlatformModalStackAsync().ConfigureAwait(false); + } + else if (_window.IsActivated && + _window?.Page?.Handler is not null) + { + if (CurrentPlatformPage.Handler is null) + { + CurrentPlatformPage.HandlerChanged += OnCurrentPlatformPageHandlerChanged; + ; + _platformPageWatchingForLoaded = new ActionDisposable(() => + { + CurrentPlatformPage.HandlerChanged -= OnCurrentPlatformPageHandlerChanged; + }); + } + else if (!CurrentPlatformPage.IsLoadedOnPlatform() && + CurrentPlatformPage.Handler is not null) + { + _platformPageWatchingForLoaded = + CurrentPlatformPage.OnLoaded(() => OnCurrentPlatformPageLoaded(_platformPageWatchingForLoaded, EventArgs.Empty)); + } + } + } + + void OnCurrentPlatformPageHandlerChanged(object? sender, EventArgs e) + { + DisconnectPlatformPageWatchingForLoaded(); + SyncModalStackWhenPlatformIsReady(); + } + + void DisconnectPlatformPageWatchingForLoaded() + { + _platformPageWatchingForLoaded?.Dispose(); + } + + void OnCurrentPlatformPageLoaded(object? sender, EventArgs e) + { + DisconnectPlatformPageWatchingForLoaded(); + SyncPlatformModalStack(); + } + + bool IsModalPlatformReady + { + get + { + bool result = + _window?.Page?.Handler is not null && + _window.IsActivated + && CurrentPlatformPage?.Handler is not null + && CurrentPlatformPage.IsLoadedOnPlatform(); + + if (result) + DisconnectPlatformPageWatchingForLoaded(); + + return result; + } + } +#endif } } diff --git a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.iOS.cs b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.iOS.cs index 2ff0a9bcd459..4ed90742c000 100644 --- a/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.iOS.cs +++ b/src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.iOS.cs @@ -12,12 +12,65 @@ namespace Microsoft.Maui.Controls.Platform { internal partial class ModalNavigationManager { + // We need to wait for the window to be activated the first time before + // we push any modal views. + // After it's been activated we can push modals if the app has been sent to + // the background so we don't care if it becomes inactive + bool _platformActivated; + bool _waitForModalToFinish; + + partial void InitializePlatform() + { + _window.Activated += OnWindowActivated; + _window.Resumed += (_, _) => SyncPlatformModalStack(); + _window.HandlerChanging += OnPlatformWindowHandlerChanging; + _window.Destroying += (_, _) => _platformActivated = false; + _window.PropertyChanging += OnWindowPropertyChanging; + _platformActivated = _window.IsActivated; + } + + void OnWindowPropertyChanging(object sender, PropertyChangingEventArgs e) + { + if (e.PropertyName != Window.PageProperty.PropertyName) + return; + + if (_currentPage is not null && + _currentPage.Handler is IPlatformViewHandler pvh && + pvh.ViewController?.PresentedViewController is ModalWrapper && + _window.Page != _currentPage) + { + ClearModalPages(xplat: true, platform: true); + + // Dismissing the root modal will dismiss everything + pvh.ViewController.DismissViewController(false, null); + } + } + + Task SyncModalStackWhenPlatformIsReadyAsync() => + SyncPlatformModalStackAsync(); + + bool IsModalPlatformReady => _platformActivated && !_waitForModalToFinish; + + void OnPlatformWindowHandlerChanging(object? sender, HandlerChangingEventArgs e) + { + _platformActivated = _window.IsActivated; + } + + void OnWindowActivated(object? sender, EventArgs e) + { + if (!_platformActivated) + { + _platformActivated = true; + SyncModalStackWhenPlatformIsReady(); + } + } + UIViewController? WindowViewController { get { if (_window?.Page?.Handler is IPlatformViewHandler pvh && - pvh.ViewController != null) + pvh.ViewController?.ViewIfLoaded?.Window is not null) { return pvh.ViewController; } @@ -28,80 +81,82 @@ internal partial class ModalNavigationManager } } - // do I really need this anymore? - static void HandleChildRemoved(object? sender, ElementEventArgs e) - { - var view = e.Element; - // TODO MAUI - //view?.DisposeModalAndChildRenderers(); - } - - public async Task PopModalAsync(bool animated) + async Task PopModalPlatformAsync(bool animated) { - var modal = _navModel.PopModal(); - modal.DescendantRemoved -= HandleChildRemoved; + var modal = CurrentPlatformModalPage; + _platformModalPages.Remove(modal); var controller = (modal.Handler as IPlatformViewHandler)?.ViewController; - if (ModalStack.Count >= 1 && controller != null) - await controller.DismissViewControllerAsync(animated); - else if (WindowViewController != null) - await WindowViewController.DismissViewControllerAsync(animated); - - // TODO MAUI - //modal.DisposeModalAndChildRenderers(); + if (controller?.ParentViewController is ModalWrapper modalWrapper && + modalWrapper.PresentingViewController is not null) + { + await modalWrapper.PresentingViewController.DismissViewControllerAsync(animated); + return modal; + } + // if the presnting VC is null that means the modal window was already dismissed + // this will usually happen as a result of swapping out the content on the window + // which is what was acting as the PresentingViewController return modal; } - public Task PushModalAsync(Page modal, bool animated) + Task PushModalPlatformAsync(Page modal, bool animated) { EndEditing(); - var elementConfiguration = modal as IElementConfiguration; + _platformModalPages.Add(modal); - var presentationStyle = - elementConfiguration? - .On()? - .ModalPresentationStyle() - .ToPlatformModalPresentationStyle(); - - _navModel.PushModal(modal); - - modal.DescendantRemoved += HandleChildRemoved; - - if (_window?.Page?.Handler != null) - return PresentModal(modal, animated && animated); + if (_window?.Page?.Handler is not null) + return PresentModal(modal, animated && _window.IsActivated); return Task.CompletedTask; } async Task PresentModal(Page modal, bool animated) { - modal.ToPlatform(WindowMauiContext); - var wrapper = new ModalWrapper(modal.Handler as IPlatformViewHandler); - - if (ModalStack.Count > 1) + bool failed = false; + try { - var topPage = ModalStack[ModalStack.Count - 2]; - var controller = (topPage?.Handler as IPlatformViewHandler)?.ViewController; - if (controller != null) + _waitForModalToFinish = true; + + var wrapper = new ControlsModalWrapper(modal.ToHandler(WindowMauiContext)); + + if (_platformModalPages.Count > 1) { - await controller.PresentViewControllerAsync(wrapper, animated); - await Task.Delay(5); - return; + var topPage = _platformModalPages[_platformModalPages.Count - 2]; + var controller = (topPage?.Handler as IPlatformViewHandler)?.ViewController; + if (controller is not null) + { + await controller.PresentViewControllerAsync(wrapper, animated); + await Task.Delay(5); + return; + } } - } - // One might wonder why these delays are here... well thats a great question. It turns out iOS will claim the - // presentation is complete before it really is. It does not however inform you when it is really done (and thus - // would be safe to dismiss the VC). Fortunately this is almost never an issue + // One might wonder why these delays are here... well thats a great question. It turns out iOS will claim the + // presentation is complete before it really is. It does not however inform you when it is really done (and thus + // would be safe to dismiss the VC). Fortunately this is almost never an issue - if (WindowViewController != null) + if (WindowViewController is not null) + { + await WindowViewController.PresentViewControllerAsync(wrapper, animated); + await Task.Delay(5); + } + } + catch { - await WindowViewController.PresentViewControllerAsync(wrapper, animated); - await Task.Delay(5); + failed = true; + throw; } + finally + { + _waitForModalToFinish = false; + + if (!failed) + SyncModalStackWhenPlatformIsReady(); + } + } void EndEditing() @@ -112,14 +167,13 @@ void EndEditing() // The topmost modal on the stack will have the Window; we can use that to end any current // editing that's going on - if (ModalStack.Count > 0) + if (_platformModalPages.Count > 0) { - var uiViewController = (ModalStack[ModalStack.Count - 1].Handler as IPlatformViewHandler)?.ViewController; + var uiViewController = (_platformModalPages[_platformModalPages.Count - 1].Handler as IPlatformViewHandler)?.ViewController; uiViewController?.View?.Window?.EndEditing(true); return; } - // TODO MAUI // If there aren't any modals, then the platform renderer will have the Window _window?.NativeWindow?.EndEditing(true); diff --git a/src/Controls/src/Core/Platform/iOS/ModalWrapper.cs b/src/Controls/src/Core/Platform/iOS/ControlsModalWrapper.cs similarity index 75% rename from src/Controls/src/Core/Platform/iOS/ModalWrapper.cs rename to src/Controls/src/Core/Platform/iOS/ControlsModalWrapper.cs index baddd904ca84..10806084a36a 100644 --- a/src/Controls/src/Core/Platform/iOS/ModalWrapper.cs +++ b/src/Controls/src/Core/Platform/iOS/ControlsModalWrapper.cs @@ -1,28 +1,26 @@ -#nullable disable -using System; +using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; -using System.Threading.Tasks; using Foundation; using Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific; using Microsoft.Maui.Graphics; -using ObjCRuntime; using UIKit; namespace Microsoft.Maui.Controls.Platform { - internal class ModalWrapper : UIViewController, IUIAdaptivePresentationControllerDelegate + internal class ControlsModalWrapper : ModalWrapper, IUIAdaptivePresentationControllerDelegate { - IPlatformViewHandler _modal; + IPlatformViewHandler? _modal; bool _isDisposed; + Page Page => ((Page?)_modal?.VirtualView) ?? throw new InvalidOperationException("Page cannot be null here"); - internal ModalWrapper(IPlatformViewHandler modal) + internal ControlsModalWrapper(IPlatformViewHandler modal) { _modal = modal; - var elementConfiguration = modal.VirtualView as IElementConfiguration; - if (elementConfiguration?.On()?.ModalPresentationStyle() is PlatformConfiguration.iOSSpecific.UIModalPresentationStyle style) + if (_modal.VirtualView is IElementConfiguration elementConfiguration && + elementConfiguration.On()?.ModalPresentationStyle() is PlatformConfiguration.iOSSpecific.UIModalPresentationStyle style) { var result = style.ToPlatformModalPresentationStyle(); @@ -43,23 +41,29 @@ internal ModalWrapper(IPlatformViewHandler modal) } UpdateBackgroundColor(); - View.AddSubview(modal.ViewController.View); + _ = modal?.ViewController?.View ?? throw new InvalidOperationException("View Controller Not Initialized on Modal Page"); + + View!.AddSubview(modal.ViewController.View); TransitioningDelegate = modal.ViewController.TransitioningDelegate; AddChildViewController(modal.ViewController); modal.ViewController.DidMoveToParentViewController(this); - if (OperatingSystem.IsIOSVersionAtLeast(13) || OperatingSystem.IsTvOSVersionAtLeast(13)) + if (PresentationController != null && + (OperatingSystem.IsIOSVersionAtLeast(13) || OperatingSystem.IsTvOSVersionAtLeast(13))) + { PresentationController.Delegate = this; + } - ((Page)modal.VirtualView).PropertyChanged += OnModalPagePropertyChanged; + if (modal.VirtualView is Page page) + page.PropertyChanged += OnModalPagePropertyChanged; } [Export("presentationControllerDidDismiss:")] [Microsoft.Maui.Controls.Internals.Preserve(Conditional = true)] public void DidDismiss(UIPresentationController _) { - var window = (_modal.VirtualView as Page)?.Window; + var window = (_modal?.VirtualView as Page)?.Window; if (window?.Page is Shell shell) { // The modal page might have a NavigationPage so it's not enough to just send @@ -78,10 +82,10 @@ public void DidDismiss(UIPresentationController _) shell.NavigationManager.GoToAsync(result).FireAndForget(); } else - ((Page)_modal.VirtualView).Navigation.PopModalAsync(false).FireAndForget(); + Page.Navigation.PopModalAsync(false).FireAndForget(); } - public override void DismissViewController(bool animated, Action completionHandler) + public override void DismissViewController(bool animated, Action? completionHandler) { base.DismissViewController(animated, completionHandler); } @@ -105,7 +109,7 @@ public override UIInterfaceOrientation PreferredInterfaceOrientationForPresentat return base.PreferredInterfaceOrientationForPresentation(); } - // TODO: [UnsupportedOSPlatform("ios6.0")] + // TODO: [UnsupportedOSPlatform("ios16.0")] #pragma warning disable CA1416, CA1422 public override bool ShouldAutorotate() { @@ -118,7 +122,6 @@ public override bool ShouldAutorotate() public override bool ShouldAutorotateToInterfaceOrientation(UIInterfaceOrientation toInterfaceOrientation) { - if ((ChildViewControllers != null) && (ChildViewControllers.Length > 0)) { return ChildViewControllers[0].ShouldAutorotateToInterfaceOrientation(toInterfaceOrientation); @@ -132,7 +135,7 @@ public override bool ShouldAutorotateToInterfaceOrientation(UIInterfaceOrientati public override void ViewDidLayoutSubviews() { base.ViewDidLayoutSubviews(); - _modal?.PlatformArrange(new Rect(0, 0, View.Bounds.Width, View.Bounds.Height)); + _modal?.PlatformArrange(new Rect(0, 0, View!.Bounds.Width, View.Bounds.Height)); } public override void ViewWillAppear(bool animated) @@ -172,10 +175,10 @@ public override void ViewDidLoad() public override UIViewController ChildViewControllerForStatusBarStyle() { - return ChildViewControllers?.LastOrDefault(); + return ChildViewControllers.Last(); } - void OnModalPagePropertyChanged(object sender, PropertyChangedEventArgs e) + void OnModalPagePropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == Page.BackgroundColorProperty.PropertyName) UpdateBackgroundColor(); @@ -188,12 +191,12 @@ void UpdateBackgroundColor() if (ModalPresentationStyle == UIKit.UIModalPresentationStyle.FullScreen) { - Color modalBkgndColor = ((Page)_modal.VirtualView).BackgroundColor; - View.BackgroundColor = modalBkgndColor?.ToPlatform() ?? Maui.Platform.ColorExtensions.BackgroundColor; + Color modalBkgndColor = Page.BackgroundColor; + View!.BackgroundColor = modalBkgndColor?.ToPlatform() ?? Maui.Platform.ColorExtensions.BackgroundColor; } else { - View.BackgroundColor = UIColor.Clear; + View!.BackgroundColor = UIColor.Clear; } } } diff --git a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt index 7243acbd3164..d23bf70aecce 100644 --- a/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -6,6 +6,7 @@ Microsoft.Maui.Controls.Shapes.Matrix.Equals(Microsoft.Maui.Controls.Shapes.Matr Microsoft.Maui.Controls.VisualElement.~VisualElement() -> void override Microsoft.Maui.Controls.LayoutOptions.GetHashCode() -> int override Microsoft.Maui.Controls.Platform.Compatibility.ShellPageRendererTracker.TitleViewContainer.LayoutSubviews() -> void +override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.ViewDidDisappear(bool animated) -> void override Microsoft.Maui.Controls.Platform.Compatibility.UIContainerView.SizeThatFits(CoreGraphics.CGSize size) -> CoreGraphics.CGSize *REMOVED*~override Microsoft.Maui.Controls.Platform.Compatibility.UIContainerView.WillMoveToSuperview(UIKit.UIView newSuper) -> void override Microsoft.Maui.Controls.Region.GetHashCode() -> int diff --git a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 211eb2a70c2a..557d3a37493e 100644 --- a/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -6,6 +6,7 @@ Microsoft.Maui.Controls.Shapes.Matrix.Equals(Microsoft.Maui.Controls.Shapes.Matr Microsoft.Maui.Controls.VisualElement.~VisualElement() -> void override Microsoft.Maui.Controls.LayoutOptions.GetHashCode() -> int override Microsoft.Maui.Controls.Platform.Compatibility.ShellPageRendererTracker.TitleViewContainer.LayoutSubviews() -> void +override Microsoft.Maui.Controls.Platform.Compatibility.ShellSectionRenderer.ViewDidDisappear(bool animated) -> void override Microsoft.Maui.Controls.Platform.Compatibility.UIContainerView.SizeThatFits(CoreGraphics.CGSize size) -> CoreGraphics.CGSize *REMOVED*~override Microsoft.Maui.Controls.Platform.Compatibility.UIContainerView.WillMoveToSuperview(UIKit.UIView newSuper) -> void override Microsoft.Maui.Controls.Region.GetHashCode() -> int diff --git a/src/Controls/src/Core/Shell/Shell.cs b/src/Controls/src/Core/Shell/Shell.cs index 6edd9aaf0bca..468978f9b701 100644 --- a/src/Controls/src/Core/Shell/Shell.cs +++ b/src/Controls/src/Core/Shell/Shell.cs @@ -1679,11 +1679,17 @@ protected override async Task OnPopModal(bool animated) protected override async Task OnPushModal(Page modal, bool animated) { + if (_shell.CurrentSection is null) + { + await base.OnPushModal(modal, animated); + return; + } + if (!_shell.NavigationManager.AccumulateNavigatedEvents) { // This will route the modal push through the shell section which is setup // to update the shell state after a modal push - await _shell.CurrentItem.CurrentItem.Navigation.PushModalAsync(modal, animated); + await _shell.CurrentSection.Navigation.PushModalAsync(modal, animated); return; } diff --git a/src/Controls/src/Core/Shell/ShellNavigatingEventArgs.cs b/src/Controls/src/Core/Shell/ShellNavigatingEventArgs.cs index adec2a55b768..efd84fb24157 100644 --- a/src/Controls/src/Core/Shell/ShellNavigatingEventArgs.cs +++ b/src/Controls/src/Core/Shell/ShellNavigatingEventArgs.cs @@ -5,6 +5,7 @@ namespace Microsoft.Maui.Controls { + /// public class ShellNavigatingEventArgs : EventArgs { diff --git a/src/Controls/src/Core/Shell/ShellSection.cs b/src/Controls/src/Core/Shell/ShellSection.cs index 292d1604969f..5a4f86ac4221 100644 --- a/src/Controls/src/Core/Shell/ShellSection.cs +++ b/src/Controls/src/Core/Shell/ShellSection.cs @@ -1120,7 +1120,8 @@ internal Task PopModalInnerAsync(bool animated) protected override async Task OnPushModal(Page modal, bool animated) { - if (_owner.Shell.NavigationManager.AccumulateNavigatedEvents) + if (_owner.Shell is null || + _owner.Shell.NavigationManager.AccumulateNavigatedEvents) { await base.OnPushModal(modal, animated); return; diff --git a/src/Controls/tests/Core.UnitTests/ApplicationTests.cs b/src/Controls/tests/Core.UnitTests/ApplicationTests.cs index 2e9c15269b25..674f36174edd 100644 --- a/src/Controls/tests/Core.UnitTests/ApplicationTests.cs +++ b/src/Controls/tests/Core.UnitTests/ApplicationTests.cs @@ -176,7 +176,7 @@ public void OnStartFiresOnceFromWindowCreated() Assert.Equal(0, app.OnStartCount); (window as IWindow).Created(); Assert.Equal(1, app.OnStartCount); - (window as IWindow).Created(); + Assert.Throws(() => (window as IWindow).Created()); Assert.Equal(1, app.OnStartCount); } diff --git a/src/Controls/tests/Core.UnitTests/NavigationModelTests.cs b/src/Controls/tests/Core.UnitTests/NavigationModelTests.cs index 1241f634d6c4..45a70e119052 100644 --- a/src/Controls/tests/Core.UnitTests/NavigationModelTests.cs +++ b/src/Controls/tests/Core.UnitTests/NavigationModelTests.cs @@ -11,6 +11,41 @@ namespace Microsoft.Maui.Controls.Core.UnitTests public class NavigationModelTests : BaseTestFixture { + [Fact] + public async Task ModalsWireUpInCorrectOrderWhenPushedBeforeWindowHasBeenCreated() + { + var mainPage = new ContentPage() { Title = "Main Page" }; + var modal1 = new ContentPage() { Title = "Modal 1" }; + var modal2 = new ContentPage() { Title = "Modal 2" }; + + await mainPage.Navigation.PushModalAsync(modal1); + await modal1.Navigation.PushModalAsync(modal2); + + TestWindow testWindow = new TestWindow(mainPage); + + Assert.Equal(modal1, testWindow.Navigation.ModalStack[0]); + Assert.Equal(modal2, testWindow.Navigation.ModalStack[1]); + } + + [Fact] + public async Task ModalsWireUpInCorrectOrderWhenShellIsBuiltAfterModalPush() + { + var shell = new Shell(); + var mainPage = new ContentPage() { Title = "Main Page 1" }; + var modal1 = new ContentPage() { Title = "Modal 1" }; + var modal2 = new ContentPage() { Title = "Modal 2" }; + + await mainPage.Navigation.PushModalAsync(modal1); + await modal1.Navigation.PushModalAsync(modal2); + + shell.Items.Add(new ShellContent { Content = mainPage }); + TestWindow testWindow = new TestWindow(shell); + + Assert.Equal(modal1, testWindow.Navigation.ModalStack[0]); + Assert.Equal(modal2, testWindow.Navigation.ModalStack[1]); + Assert.Equal(2, testWindow.Navigation.ModalStack.Count); + } + [Fact] public void CurrentNullWhenEmpty() { diff --git a/src/Controls/tests/Core.UnitTests/NavigationUnitTest.cs b/src/Controls/tests/Core.UnitTests/NavigationUnitTest.cs index c5e1d41a81bd..352615321321 100644 --- a/src/Controls/tests/Core.UnitTests/NavigationUnitTest.cs +++ b/src/Controls/tests/Core.UnitTests/NavigationUnitTest.cs @@ -829,6 +829,15 @@ public async Task TabBarSetsOnModalPageForSingleNavigationPage() Assert.NotNull(navigationPage.Toolbar); } + [Fact] + public async Task PopModalWithEmptyStackThrows() + { + var window = new TestWindow(new ContentPage()); + var contentPage1 = new ContentPage(); + var navigationPage = new TestNavigationPage(true, contentPage1); + Assert.ThrowsAsync(() => window.Navigation.PopModalAsync()); + } + [Fact] public async Task TabBarSetsOnFlyoutPageInsideModalPage() { diff --git a/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.cs b/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.cs index 2ed7283e10b6..81536319f4b5 100644 --- a/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.cs +++ b/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.cs @@ -310,7 +310,7 @@ protected Task OnLoadedAsync(VisualElement frameworkElement, TimeSpan? timeOut = frameworkElement.Loaded += loaded; } - return source.Task.WaitAsync(timeOut.Value); + return HandleLoadedUnloadedIssue(source.Task, timeOut.Value, () => frameworkElement.IsLoaded && frameworkElement.IsLoadedOnPlatform()); } protected Task OnUnloadedAsync(VisualElement frameworkElement, TimeSpan? timeOut = null) @@ -343,7 +343,27 @@ protected Task OnUnloadedAsync(VisualElement frameworkElement, TimeSpan? timeOut frameworkElement.Unloaded += unloaded; } - return source.Task.WaitAsync(timeOut.Value); + return HandleLoadedUnloadedIssue(source.Task, timeOut.Value, () => !frameworkElement.IsLoaded && !frameworkElement.IsLoadedOnPlatform()); + } + + // Modal Page's appear to currently not fire loaded/unloaded + async Task HandleLoadedUnloadedIssue(Task task, TimeSpan timeOut, Func isConditionValid) + { + try + { + await task.WaitAsync(timeOut); + } + catch (TimeoutException) + { + if (isConditionValid()) + { + return; + } + else + { + throw; + } + } } protected async Task OnNavigatedToAsync(Page page, TimeSpan? timeOut = null) diff --git a/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.iOS.cs b/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.iOS.cs index a89cbd3225bd..9c1a79b9d7ed 100644 --- a/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.iOS.cs +++ b/src/Controls/tests/DeviceTests/ControlsHandlerTestBase.iOS.cs @@ -30,6 +30,12 @@ Task SetupWindowForTests(IWindow window, Func runTests, IMauiCon } finally { + if (windowHandler is WindowHandlerStub windowHandlerStub) + { + if (windowHandlerStub.IsDisconnected) + await windowHandlerStub.FinishedDisconnecting; + } + if (windowHandler is not null) { if (window is Window controlsWindow && controlsWindow.Navigation.ModalStack.Count > 0) @@ -81,10 +87,10 @@ Task SetupWindowForTests(IWindow window, Func runTests, IMauiCon }); } - internal ModalWrapper GetModalWrapper(Page modalPage) + internal ControlsModalWrapper GetModalWrapper(Page modalPage) { var pageVC = (modalPage.Handler as IPlatformViewHandler).ViewController; - return (ModalWrapper)pageVC.ParentViewController; + return (ControlsModalWrapper)pageVC.ParentViewController; } protected bool IsBackButtonVisible(IElementHandler handler) diff --git a/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.Android.cs b/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.Android.cs index e62a0dd7ce05..120928717417 100644 --- a/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.Android.cs @@ -12,6 +12,58 @@ namespace Microsoft.Maui.DeviceTests { public partial class ModalTests : ControlsHandlerTestBase { + [Fact] + public async Task ChangeModalStackWhileDeactivated() + { + SetupBuilder(); + var page = new ContentPage(); + var modalPage = new ContentPage() + { + Content = new Label() + }; + + var window = new Window(page); + + await CreateHandlerAndAddToWindow(window, + async (_) => + { + IWindow iWindow = window; + await page.Navigation.PushModalAsync(new ContentPage()); + await page.Navigation.PushModalAsync(modalPage); + await page.Navigation.PushModalAsync(new ContentPage()); + await page.Navigation.PushModalAsync(new ContentPage()); + iWindow.Deactivated(); + await page.Navigation.PopModalAsync(); + await page.Navigation.PopModalAsync(); + iWindow.Activated(); + await OnLoadedAsync(modalPage); + }); + } + + [Fact] + public async Task DontPushModalPagesWhenWindowIsDeactivated() + { + SetupBuilder(); + var page = new ContentPage(); + var modalPage = new ContentPage() + { + Content = new Label() + }; + + var window = new Window(page); + + await CreateHandlerAndAddToWindow(window, + async (_) => + { + IWindow iWindow = window; + iWindow.Deactivated(); + await page.Navigation.PushModalAsync(modalPage); + Assert.False(modalPage.IsLoaded); + iWindow.Activated(); + await OnLoadedAsync(modalPage); + }); + } + [Theory] [InlineData(WindowSoftInputModeAdjust.Resize)] [InlineData(WindowSoftInputModeAdjust.Pan)] @@ -24,6 +76,7 @@ public async Task ModalPageMarginCorrectAfterKeyboardOpens(WindowSoftInputModeAd await CreateHandlerAndAddToWindow(window, async (handler) => { + Entry testEntry = null; try { window.UpdateWindowSoftInputModeAdjust(panSize.ToPlatform()); @@ -48,7 +101,7 @@ await CreateHandlerAndAddToWindow(window, // Locate the lowest visible entry var pageBoundingBox = modalPage.GetBoundingBox(); - Entry testEntry = entries[0]; + testEntry = entries[0]; foreach (var entry in entries) { var entryBox = entry.GetBoundingBox(); @@ -108,6 +161,9 @@ await CreateHandlerAndAddToWindow(window, finally { window.UpdateWindowSoftInputModeAdjust(WindowSoftInputModeAdjust.Resize.ToPlatform()); + + if (testEntry?.Handler is IPlatformViewHandler testEntryHandler) + KeyboardManager.HideKeyboard(testEntryHandler.PlatformView); } }); } diff --git a/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs b/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs index 64523b13a51e..f2efdfc8f52e 100644 --- a/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs @@ -8,6 +8,7 @@ using Microsoft.Maui.DeviceTests.Stubs; using Microsoft.Maui.Handlers; using Microsoft.Maui.Hosting; +using Microsoft.Maui.Platform; using Xunit; #if ANDROID || IOS || MACCATALYST @@ -56,6 +57,396 @@ void SetupBuilder() }); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task AppearingAndDisappearingFireOnWindowAndModal(bool useShell) + { + SetupBuilder(); + var windowPage = new ContentPage() + { + Content = new Label() { Text = "AppearingAndDisappearingFireOnWindowAndModal.Window" } + }; + + var modalPage = new ContentPage() + { + Content = new Label() { Text = "AppearingAndDisappearingFireOnWindowAndModal.Modal" } + }; + + Window window; + + if (useShell) + window = new Window(new Shell() { CurrentItem = windowPage }); + else + window = new Window(windowPage); + + int modalAppearing = 0; + int modalDisappearing = 0; + int windowAppearing = 0; + int windowDisappearing = 0; + + modalPage.Appearing += (_, _) => modalAppearing++; + modalPage.Disappearing += (_, _) => modalDisappearing++; + + windowPage.Appearing += (_, _) => windowAppearing++; + windowPage.Disappearing += (_, _) => windowDisappearing++; + + await CreateHandlerAndAddToWindow(window, + async (_) => + { + await windowPage.Navigation.PushModalAsync(modalPage); + await OnLoadedAsync(modalPage.Content); + await windowPage.Navigation.PopModalAsync(); + }); + + Assert.Equal(1, modalAppearing); + Assert.Equal(1, modalDisappearing); + Assert.Equal(2, windowAppearing); + Assert.Equal(2, windowDisappearing); + } + + [Theory] + // Currently broken on Shell + // Shane will fix on a separate PR + //[InlineData(true)] + [InlineData(false)] + public async Task AppearingAndDisappearingFireOnMultipleModals(bool useShell) + { + SetupBuilder(); + var windowPage = new ContentPage() + { + Content = new Label() { Text = "AppearingAndDisappearingFireOnWindowAndModal.Window" } + }; + + var modalPage1 = new ContentPage() + { + Content = new Label() { Text = "AppearingAndDisappearingFireOnWindowAndModal.Modal1" } + }; + + var modalPage2 = new ContentPage() + { + Content = new Label() { Text = "AppearingAndDisappearingFireOnWindowAndModal.Modal2" } + }; + + Window window; + + if (useShell) + window = new Window(new Shell() { CurrentItem = windowPage }); + else + window = new Window(windowPage); + + int modal1Appearing = 0; + int modal1Disappearing = 0; + int modal2Appearing = 0; + int modal2Disappearing = 0; + int windowAppearing = 0; + int windowDisappearing = 0; + + modalPage1.Appearing += (_, _) => modal1Appearing++; + modalPage1.Disappearing += (_, _) => modal1Disappearing++; + + modalPage2.Appearing += (_, _) => modal2Appearing++; + modalPage2.Disappearing += (_, _) => modal2Disappearing++; + + windowPage.Appearing += (_, _) => windowAppearing++; + windowPage.Disappearing += (_, _) => windowDisappearing++; + + await CreateHandlerAndAddToWindow(window, + async (_) => + { + await windowPage.Navigation.PushModalAsync(modalPage1); + await windowPage.Navigation.PushModalAsync(modalPage2); + await windowPage.Navigation.PopModalAsync(); + await windowPage.Navigation.PopModalAsync(); + }); + + Assert.Equal(2, modal1Appearing); + Assert.Equal(2, modal1Disappearing); + + Assert.Equal(1, modal2Appearing); + Assert.Equal(1, modal2Disappearing); + + Assert.Equal(2, windowAppearing); + Assert.Equal(2, windowDisappearing); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task LifeCycleEventsFireOnModalPagesPushedBeforeWindowHasLoaded(bool useShell) + { + SetupBuilder(); + var windowPage = new LifeCycleTrackingPage(); + var modalPage = new LifeCycleTrackingPage() + { + Content = new Label() + }; + + Window window; + + if (useShell) + window = new Window(new Shell() { CurrentItem = windowPage }); + else + window = new Window(windowPage); + + await windowPage.Navigation.PushModalAsync(modalPage); + + await CreateHandlerAndAddToWindow(window, + async (_) => + { + await OnNavigatedToAsync(modalPage); + await OnLoadedAsync(modalPage.Content); + + Assert.Equal(0, windowPage.AppearingCount); + + Assert.Equal(1, modalPage.AppearingCount); + Assert.Equal(1, modalPage.OnNavigatedToCount); + }); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task PushModalFromAppearing(bool useShell) + { + SetupBuilder(); + var windowPage = new ContentPage() + { + Content = new Label() + { + Text = "Root Page" + } + }; + + var modalPage = new ContentPage() + { + Content = new Label() + { + Text = "last modal page" + } + }; + + Window window; + + if (useShell) + window = new Window(new Shell() { CurrentItem = windowPage }); + else + window = new Window(new NavigationPage(windowPage)); + + + bool appearingFired = false; + await CreateHandlerAndAddToWindow(window, + async (handler) => + { + ContentPage contentPage = new ContentPage() + { + Content = new Label() + { + Text = "Second Page" + } + }; + + contentPage.Appearing += async (_, _) => + { + if (appearingFired) + return; + + appearingFired = true; + + await windowPage.Navigation.PushModalAsync(new ContentPage() + { + Content = new Label() + { + Text = "First modal page" + } + }); + + await windowPage.Navigation.PushModalAsync(modalPage); + }; + + await window.Page.Navigation.PushAsync(contentPage); + await OnLoadedAsync(modalPage); + await window.Navigation.PopModalAsync(); + await window.Navigation.PopModalAsync(); + await OnUnloadedAsync(modalPage); + await OnLoadedAsync(contentPage); + }); + + Assert.True(appearingFired); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task PushModalModalWithoutAwaiting(bool useShell) + { + SetupBuilder(); + var windowPage = new LifeCycleTrackingPage(); + var modalPage = new LifeCycleTrackingPage() + { + Content = new Label() + { + Text = "last page" + } + }; + + Window window; + + if (useShell) + window = new Window(new Shell() { CurrentItem = windowPage }); + else + window = new Window(windowPage); + + + await CreateHandlerAndAddToWindow(window, + async (handler) => + { + _ = windowPage.Navigation.PushModalAsync(new ContentPage() + { + Content = new Label() + { + Text = "First page" + } + }); + + _ = windowPage.Navigation.PushModalAsync(new ContentPage() + { + Content = new Label() + { + Text = "Second page" + } + }); + + _ = windowPage.Navigation.PushModalAsync(modalPage); + await OnLoadedAsync(modalPage); + }); + } + + [Fact] + public async Task LoadModalPagesBeforeWindowHasLoaded() + { + SetupBuilder(); + var page = new ContentPage(); + var modalPage = new ContentPage() + { + Content = new Label() + { + Text = "Modal Page" + } + }; + + var window = new Window(page); + await page.Navigation.PushModalAsync(modalPage); + + await CreateHandlerAndAddToWindow(window, + async (_) => + { + await OnNavigatedToAsync(modalPage); + await OnLoadedAsync(modalPage.Content); + }); + } + + [Fact] + public async Task SwapWindowPageDuringModalAppearing() + { + SetupBuilder(); + var page = new ContentPage(); + var newRootPage = new ContentPage() + { + Content = new Label() { Text = "New Root Page" } + }; + + var modalPage = new ContentPage() + { + Content = new Label() { Text = "SwapWindowPageDuringModalAppearing" } + }; + + var window = new Window(page); + + modalPage.Appearing += (_, _) => + { + window.Page = newRootPage; + }; + await CreateHandlerAndAddToWindow(window, + async (_) => + { + await page.Navigation.PushModalAsync(modalPage); + await OnLoadedAsync(newRootPage.Content); + }); + } + + [Fact] + public async Task ChangePageOnWindowRemovesModalStack() + { + SetupBuilder(); + var page = new ContentPage() + { + Content = new Label() + { + Background = SolidColorBrush.Purple, + Text = "Initial Page" + } + }; + + var modalPage = new ContentPage() + { + Content = new Label() { Text = "Modal Page" } + }; + + var window = new Window(page); + await page.Navigation.PushModalAsync(modalPage); + + await CreateHandlerAndAddToWindow(window, + async (_) => + { + await OnLoadedAsync(modalPage.Content); + var nextPage = new ContentPage() + { + Content = new Label() { Text = "Next Page" } + }; + + window.Page = nextPage; + await OnUnloadedAsync(modalPage.Content); + await OnLoadedAsync(nextPage.Content); + Assert.Equal(0, window.Navigation.ModalStack.Count); + }); + } + + [Fact] + public async Task RecreatingStackCorrectlyRecreatesModalStack() + { + SetupBuilder(); + + var page = new ContentPage(); + var modalPage = new ContentPage() + { + Content = new Label() + { + Text = "Hello from the modal page" + } + }; + + var window = new Window(page); + await page.Navigation.PushModalAsync(modalPage); + + var mauiContextStub1 = ContextStub.CreateNew(MauiContext); + + await CreateHandlerAndAddToWindow(window, async (handler) => + { + await OnNavigatedToAsync(modalPage); + await OnLoadedAsync(modalPage.Content); + + }, mauiContextStub1); + + var mauiContextStub2 = ContextStub.CreateNew(MauiContext); + await CreateHandlerAndAddToWindow(window, async (handler) => + { + await OnNavigatedToAsync(modalPage); + await OnLoadedAsync(modalPage.Content); + }, mauiContextStub2); + + } + [Theory] [ClassData(typeof(PageTypes))] public async Task BasicPushAndPop(Page rootPage, Page modalPage) diff --git a/src/Controls/tests/DeviceTests/Stubs/WindowHandlerStub.iOS.cs b/src/Controls/tests/DeviceTests/Stubs/WindowHandlerStub.iOS.cs index d3ea3b43df35..f11d704572b8 100644 --- a/src/Controls/tests/DeviceTests/Stubs/WindowHandlerStub.iOS.cs +++ b/src/Controls/tests/DeviceTests/Stubs/WindowHandlerStub.iOS.cs @@ -94,7 +94,7 @@ async void ReplaceCurrentView(IView view, UIWindow platformView, Action finished FireWindowEvent(virtualView, (window) => window.IsActivated, () => virtualView.Deactivated()); } - pvc.DismissViewController(false, + pvc.PresentingViewController.DismissViewController(false, () => { finishedClosing.Invoke(); @@ -115,7 +115,7 @@ async void ReplaceCurrentView(IView view, UIWindow platformView, Action finished platformView?.RootViewController?.PresentedViewController ?? vc.PresentedViewController; - if (presentedVC is Microsoft.Maui.Controls.Platform.ModalWrapper mw) + if (presentedVC is ModalWrapper mw) { await mw.PresentingViewController.DismissViewControllerAsync(false); } diff --git a/src/Core/src/Platform/Android/KeyboardManager.cs b/src/Core/src/Platform/Android/KeyboardManager.cs index 8d9e5b3903ef..977eae20a41b 100644 --- a/src/Core/src/Platform/Android/KeyboardManager.cs +++ b/src/Core/src/Platform/Android/KeyboardManager.cs @@ -1,91 +1,76 @@ using System; -using Android.App; using Android.Content; -using Android.OS; -using Android.Views; using Android.Views.InputMethods; using Android.Widget; +using AndroidX.AppCompat.App; using AndroidX.Core.View; using AView = Android.Views.View; +using SearchView = AndroidX.AppCompat.Widget.SearchView; namespace Microsoft.Maui.Platform { internal static class KeyboardManager { - internal static void HideKeyboard(this AView inputView, bool overrideValidation = false) + internal static bool HideKeyboard(this AView inputView) { - if (inputView?.Context == null) + if (inputView?.Context is null) throw new ArgumentNullException(nameof(inputView) + " must be set before the keyboard can be hidden."); - using (var inputMethodManager = (InputMethodManager)inputView.Context.GetSystemService(Context.InputMethodService)!) - { - if (!overrideValidation && !(inputView is EditText || inputView is TextView || inputView is SearchView)) - throw new ArgumentException("inputView should be of type EditText, SearchView, or TextView"); + var focusedView = inputView.Context?.GetActivity()?.Window?.CurrentFocus; + AView tokenView = focusedView ?? inputView; + var context = tokenView.Context; + + if (context is null) + return false; - var windowToken = inputView.WindowToken; - if (windowToken != null && inputMethodManager != null) - inputMethodManager.HideSoftInputFromWindow(windowToken, HideSoftInputFlags.None); + using (var inputMethodManager = context.GetSystemService(Context.InputMethodService) as InputMethodManager) + { + var windowToken = tokenView.WindowToken; + if (windowToken is not null && inputMethodManager is not null) + return inputMethodManager.HideSoftInputFromWindow(windowToken, HideSoftInputFlags.None); } + + return false; } - internal static void ShowKeyboard(this TextView inputView) + internal static bool ShowKeyboard(this TextView inputView) { - if (inputView?.Context == null) + if (inputView?.Context is null) throw new ArgumentNullException(nameof(inputView) + " must be set before the keyboard can be shown."); - using (var inputMethodManager = (InputMethodManager)inputView.Context.GetSystemService(Context.InputMethodService)!) + using (var inputMethodManager = inputView.Context.GetSystemService(Context.InputMethodService) as InputMethodManager) { // The zero value for the second parameter comes from // https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#showSoftInput(android.view.View,%20int) // Apparently there's no named value for zero in this case - inputMethodManager?.ShowSoftInput(inputView, 0); + return inputMethodManager?.ShowSoftInput(inputView, 0) == true; } } - internal static void ShowKeyboard(this SearchView searchView) + internal static bool ShowKeyboard(this SearchView searchView) { - if (searchView?.Context == null || searchView?.Resources == null) + if (searchView?.Context is null || searchView?.Resources is null) { throw new ArgumentNullException(nameof(searchView)); } - // Dig into the SearchView and find the actual TextView that we want to show keyboard input for - int searchViewTextViewId = searchView.Resources.GetIdentifier("android:id/search_src_text", null, null); - - if (searchViewTextViewId == 0) - { - // Cannot find the resource Id; nothing else to do - return; - } - - var textView = searchView.FindViewById(searchViewTextViewId); - - if (textView == null) - { - // Cannot find the TextView; nothing else to do - return; - } + var queryEditor = searchView.GetFirstChildOfType(); - using (var inputMethodManager = (InputMethodManager)searchView.Context.GetSystemService(Context.InputMethodService)!) - { - // The zero value for the second parameter comes from - // https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#showSoftInput(android.view.View,%20int) - // Apparently there's no named value for zero in this case - inputMethodManager?.ShowSoftInput(textView, 0); - } + return queryEditor?.ShowKeyboard() == true; + ; } - internal static void ShowKeyboard(this AView view) + internal static bool ShowKeyboard(this AView view) { switch (view) { case SearchView searchView: - searchView.ShowKeyboard(); - break; + return searchView.ShowKeyboard(); case TextView textView: - textView.ShowKeyboard(); - break; + return textView.ShowKeyboard(); } + + return false; } internal static void PostShowKeyboard(this AView view) @@ -107,12 +92,22 @@ void ShowKeyboard() public static bool IsSoftKeyboardVisible(this AView view) { - var insets = ViewCompat.GetRootWindowInsets(view); - if (insets == null) + var rootView = view.Context?.GetActivity()?.Window?.DecorView; + + if (rootView is null) + return false; + + var insets = ViewCompat.GetRootWindowInsets(rootView); + if (insets is null) return false; var result = insets.IsVisible(WindowInsetsCompat.Type.Ime()); - return result; + var size = insets.GetInsets(WindowInsetsCompat.Type.Ime()); + + if (!result && size.Top == 0 && size.Bottom == 0) + return false; + + return result && (size.Top > 0 || size.Bottom > 0); } } } \ No newline at end of file diff --git a/src/Core/src/Platform/Android/MauiContextExtensions.cs b/src/Core/src/Platform/Android/MauiContextExtensions.cs index b591cd4be9f6..38c51d28624b 100644 --- a/src/Core/src/Platform/Android/MauiContextExtensions.cs +++ b/src/Core/src/Platform/Android/MauiContextExtensions.cs @@ -79,7 +79,7 @@ internal static View ToPlatform( { if (view.Handler?.MauiContext is MauiContext scopedMauiContext) { - // If this handler becomes to a different activity then we need to + // If this handler belongs to a different activity then we need to // recreate the view. // If it's the same activity we just update the layout inflater // and the fragment manager so that the platform view doesn't recreate diff --git a/src/Core/src/Platform/ViewExtensions.cs b/src/Core/src/Platform/ViewExtensions.cs index 171e3b1c6cff..80a6019c6f3a 100644 --- a/src/Core/src/Platform/ViewExtensions.cs +++ b/src/Core/src/Platform/ViewExtensions.cs @@ -100,6 +100,36 @@ public static IPlatformViewHandler ToHandler(this IView view, IMauiContext conte } #endif + internal static IDisposable OnUnloaded(this IElement element, Action action) + { +#if PLATFORM + if (element.Handler is IPlatformViewHandler platformViewHandler && + platformViewHandler.PlatformView != null) + { + return platformViewHandler.PlatformView.OnUnloaded(action); + } + + throw new InvalidOperationException("Handler is not set on element"); +#else + throw new NotImplementedException(); +#endif + } + + internal static IDisposable OnLoaded(this IElement element, Action action) + { +#if PLATFORM + if (element.Handler is IPlatformViewHandler platformViewHandler && + platformViewHandler.PlatformView != null) + { + return platformViewHandler.PlatformView.OnLoaded(action); + } + + throw new InvalidOperationException("Handler is not set on element"); +#else + throw new NotImplementedException(); +#endif + } + #if PLATFORM internal static Task OnUnloadedAsync(this PlatformView platformView, TimeSpan? timeOut = null) { @@ -117,5 +147,17 @@ internal static Task OnLoadedAsync(this PlatformView platformView, TimeSpan? tim return taskCompletionSource.Task.WaitAsync(timeOut.Value); } #endif + internal static bool IsLoadedOnPlatform(this IElement element) + { + if (element.Handler is not IPlatformViewHandler pvh) + return false; + +#if PLATFORM + return pvh.PlatformView?.IsLoaded() == true; +#else + return true; +#endif + + } } } diff --git a/src/Core/src/Platform/Windows/MauiContextExtensions.cs b/src/Core/src/Platform/Windows/MauiContextExtensions.cs index 1940ecc59c59..e5a0b6862b69 100644 --- a/src/Core/src/Platform/Windows/MauiContextExtensions.cs +++ b/src/Core/src/Platform/Windows/MauiContextExtensions.cs @@ -28,7 +28,7 @@ public static IMauiContext MakeScoped(this IMauiContext mauiContext, bool regist if (registerNewNavigationRoot) { - scopedContext.AddWeakSpecific(new NavigationRootManager(scopedContext.GetPlatformWindow())); + scopedContext.AddSpecific(new NavigationRootManager(scopedContext.GetPlatformWindow())); } return scopedContext; diff --git a/src/Core/src/Platform/Windows/NavigationRootManager.cs b/src/Core/src/Platform/Windows/NavigationRootManager.cs index b6e617f6fb60..ce8270c58bf2 100644 --- a/src/Core/src/Platform/Windows/NavigationRootManager.cs +++ b/src/Core/src/Platform/Windows/NavigationRootManager.cs @@ -12,6 +12,7 @@ public partial class NavigationRootManager WindowRootView _rootView; bool _disconnected = true; bool _isActiveRootManager; + bool _applyTemplateFinished; public NavigationRootManager(Window platformWindow) { @@ -40,6 +41,8 @@ void OnApplyTemplateFinished(object? sender, EventArgs e) _platformWindow.ExtendsContentIntoTitleBar = true; UpdateAppTitleBar(true); } + + _applyTemplateFinished = true; } void OnAppTitleBarChanged(object? sender, EventArgs e) @@ -97,6 +100,9 @@ public virtual void Connect(UIElement platformView) } _disconnected = false; + + if (_applyTemplateFinished) + OnApplyTemplateFinished(_rootView, EventArgs.Empty); } public virtual void Disconnect() diff --git a/src/Core/src/Platform/Windows/StackNavigationManager.cs b/src/Core/src/Platform/Windows/StackNavigationManager.cs index 72d4513ae2ad..04266c94d843 100644 --- a/src/Core/src/Platform/Windows/StackNavigationManager.cs +++ b/src/Core/src/Platform/Windows/StackNavigationManager.cs @@ -17,6 +17,7 @@ public class StackNavigationManager IMauiContext _mauiContext; Frame? _navigationFrame; Action? _pendingNavigationFinished; + bool _connected; protected NavigationRootManager WindowManager => _mauiContext.GetNavigationRootManager(); internal IStackNavigation? NavigationView { get; private set; } @@ -34,6 +35,7 @@ public StackNavigationManager(IMauiContext mauiContext) public virtual void Connect(IStackNavigation navigationView, Frame navigationFrame) { + _connected = true; if (_navigationFrame != null) _navigationFrame.Navigated -= OnNavigated; @@ -49,6 +51,7 @@ public virtual void Connect(IStackNavigation navigationView, Frame navigationFra public virtual void Disconnect(IStackNavigation navigationView, Frame navigationFrame) { + _connected = false; if (_navigationFrame != null) _navigationFrame.Navigated -= OnNavigated; @@ -196,7 +199,7 @@ void OnNavigated(object sender, UI.Xaml.Navigation.NavigationEventArgs e) pc.OnLoaded(FireNavigationFinished); } - if (NavigationView is IView view) + if (NavigationView is IView view && _connected) { view.Arrange(fe); } diff --git a/src/Core/src/Platform/iOS/MauiContextExtensions.cs b/src/Core/src/Platform/iOS/MauiContextExtensions.cs index 6b01e5270332..6ab12f4abd1f 100644 --- a/src/Core/src/Platform/iOS/MauiContextExtensions.cs +++ b/src/Core/src/Platform/iOS/MauiContextExtensions.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.DependencyInjection; +using System; +using Microsoft.Extensions.DependencyInjection; using ObjCRuntime; using UIKit; @@ -8,5 +9,11 @@ internal static partial class MauiContextExtensions { public static UIWindow GetPlatformWindow(this IMauiContext mauiContext) => mauiContext.Services.GetRequiredService(); + + public static IServiceProvider GetApplicationServices(this IMauiContext mauiContext) + { + return MauiUIApplicationDelegate.Current.Services ?? + throw new InvalidOperationException("Unable to find Application Services"); + } } } \ No newline at end of file diff --git a/src/Core/src/Platform/iOS/ModalWrapper.cs b/src/Core/src/Platform/iOS/ModalWrapper.cs new file mode 100644 index 000000000000..c4380c46da70 --- /dev/null +++ b/src/Core/src/Platform/iOS/ModalWrapper.cs @@ -0,0 +1,8 @@ +using UIKit; + +namespace Microsoft.Maui.Platform +{ + internal class ModalWrapper : UIViewController + { + } +} \ No newline at end of file diff --git a/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs b/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs index bc17ebb1f4a4..6c4eebb482d4 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.Android.cs @@ -14,6 +14,7 @@ using Xunit.Sdk; using AColor = Android.Graphics.Color; using AView = Android.Views.View; +using SearchView = AndroidX.AppCompat.Widget.SearchView; namespace Microsoft.Maui.DeviceTests { @@ -47,26 +48,57 @@ public static async Task SendKeyboardReturnType(this AView view, ReturnType retu await Task.Delay(10); } - public static async Task WaitForFocused(this AView view, int timeout = 1000) + public static async Task WaitForFocused(this AView view, int timeout = 1000, string message = "") { - if (!view.IsFocused) + try { - TaskCompletionSource focusSource = new TaskCompletionSource(); - view.FocusChange += OnFocused; - await focusSource.Task.WaitAsync(TimeSpan.FromMilliseconds(timeout)); + if (view is SearchView searchView) + { + var queryEditor = searchView.GetFirstChildOfType(); - // Even thuogh the event fires focus hasn't fully been achieved - await Task.Delay(10); + if (queryEditor is null) + throw new Exception("Unable to locate EditText on SearchView"); + + view = queryEditor; + } - void OnFocused(object? sender, AView.FocusChangeEventArgs e) + if (!view.IsFocused) { - if (!e.HasFocus) - return; + TaskCompletionSource focusSource = new TaskCompletionSource(); + view.FocusChange += OnFocused; - view.FocusChange -= OnFocused; - focusSource.SetResult(); + try + { + await focusSource.Task.WaitAsync(TimeSpan.FromMilliseconds(timeout)); + } + catch + { + view.FocusChange -= OnFocused; + + if (!view.IsFocused) + throw; + } + + // Even though the event fires focus hasn't fully been achieved + await Task.Yield(); + + void OnFocused(object? sender, AView.FocusChangeEventArgs e) + { + if (!e.HasFocus) + return; + + view.FocusChange -= OnFocused; + focusSource.SetResult(); + } } } + catch (Exception ex) + { + if (!string.IsNullOrEmpty(message)) + throw new Exception(message, ex); + else + throw; + } } public static async Task WaitForUnFocused(this AView view, int timeout = 1000) @@ -75,6 +107,19 @@ public static async Task WaitForUnFocused(this AView view, int timeout = 1000) { TaskCompletionSource focusSource = new TaskCompletionSource(); view.FocusChange += OnUnFocused; + + try + { + await focusSource.Task.WaitAsync(TimeSpan.FromMilliseconds(timeout)); + } + catch + { + view.FocusChange -= OnUnFocused; + + if (view.IsFocused) + throw; + } + await focusSource.Task.WaitAsync(TimeSpan.FromMilliseconds(timeout)); // Even though the event fires unfocus hasn't fully been achieved @@ -102,31 +147,77 @@ public static Task FocusView(this AView view, int timeout = 1000) return Task.CompletedTask; } - public static async Task ShowKeyboardForView(this AView view, int timeout = 1000) + public static async Task ShowKeyboardForView(this AView view, int timeout = 1000, string message = "") { - await view.FocusView(timeout); - KeyboardManager.ShowKeyboard(view); - await view.WaitForKeyboardToShow(timeout); + if (KeyboardManager.IsSoftKeyboardVisible(view)) + return; + + try + { + await view.FocusView(timeout); + if (!KeyboardManager.ShowKeyboard(view)) + throw new Exception("ShowKeyboard operation failed"); + + await view.WaitForKeyboardToShow(timeout); + } + catch (Exception ex) + { + if (!string.IsNullOrEmpty(message)) + throw new Exception(message, ex); + else + throw; + } } - public static async Task HideKeyboardForView(this AView view, int timeout = 1000) + public static async Task HideKeyboardForView(this AView view, int timeout = 1000, string? message = null) { - await view.FocusView(timeout); - KeyboardManager.HideKeyboard(view); - await view.WaitForKeyboardToHide(timeout); + if (!KeyboardManager.IsSoftKeyboardVisible(view)) + return; + + try + { + if (!KeyboardManager.HideKeyboard(view)) + throw new Exception("HideKeyboard operation failed"); + + await view.WaitForKeyboardToHide(timeout); + } + catch (Exception ex) + { + if (!string.IsNullOrEmpty(message)) + throw new Exception(message, ex); + else + throw; + } } - public static async Task WaitForKeyboardToShow(this AView view, int timeout = 1000) + public static async Task WaitForKeyboardToShow(this AView view, int timeout = 1000, string message = "") { - var result = await Wait(() => KeyboardManager.IsSoftKeyboardVisible(view), timeout); - Assert.True(result); + try + { + var result = await Wait(() => KeyboardManager.IsSoftKeyboardVisible(view), timeout); + Assert.True(result); + // Even if the OS is reporting that the keyboard has opened it seems like the animation hasn't quite finished + // If you try to call hide too quickly after showing, sometimes it will just show and then pop back down. + await Task.Delay(100); + } + catch (Exception ex) + { + if (!string.IsNullOrEmpty(message)) + throw new Exception(message, ex); + else + throw; + } } public static async Task WaitForKeyboardToHide(this AView view, int timeout = 1000) { var result = await Wait(() => !KeyboardManager.IsSoftKeyboardVisible(view), timeout); Assert.True(result); + + // Even if the OS is reporting that the keyboard has closed it seems like the animation hasn't quite finished + // If you try to call hide too quickly after showing, sometimes it will just hide and then pop back up. + await Task.Delay(100); } public static Task WaitForLayout(AView view, int timeout = 1000) diff --git a/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs b/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs index f377e6749418..7ae046bfe918 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs @@ -144,6 +144,14 @@ public static UIViewController FindContentViewController() throw new InvalidOperationException("Could not attach view - unable to find RootViewController"); } + while (viewController.PresentedViewController is not null) + { + if (viewController is ModalWrapper || viewController.PresentedViewController is ModalWrapper) + throw new InvalidOperationException("Modal Window Is Still Present"); + + viewController = viewController.PresentedViewController; + } + if (viewController == null) { throw new InvalidOperationException("Could not attach view - unable to find presented ViewController");