diff --git a/src/Controls/samples/Controls.Sample.UITests/Issues/Issue11501.cs b/src/Controls/samples/Controls.Sample.UITests/Issues/Issue11501.cs new file mode 100644 index 000000000000..ae68e4fe475e --- /dev/null +++ b/src/Controls/samples/Controls.Sample.UITests/Issues/Issue11501.cs @@ -0,0 +1,183 @@ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Maui.Controls; + +namespace Maui.Controls.Sample.Issues +{ + [Issue(IssueTracker.Github, 11501, "Making Fragment Changes While App is Backgrounded Fails", PlatformAffected.Android)] + public class Issue11501 : TestContentPage + { + Func _currentTest; + Page _mainPage; + List _modalStack; + Window _window; + public Issue11501() + { + Loaded += OnLoaded; + } + + private void OnLoaded(object sender, EventArgs e) + { + _window = Window; + _mainPage = Application.Current.MainPage; + _modalStack = Navigation.ModalStack.ToList(); + } + + private async void OnWindowActivated(object sender, EventArgs e) + { + DisconnectFromWindow(); + if (_currentTest is not null) + { + await Task.Yield(); + await _currentTest(); + _currentTest = null; + } + } + + void ConnectToWindow() + { + _window.Stopped -= OnWindowActivated; + _window.Stopped += OnWindowActivated; + } + + void DisconnectFromWindow() + { + _window.Stopped -= OnWindowActivated; + } + + protected override void Init() + { + Content = new VerticalStackLayout() + { + new Button() + { + Text = "Swap Main Page", + AutomationId = "SwapMainPage", + Command = new Command( () => + { + Application.Current.MainPage = + new ContentPage() { Title = "Test", Content = new Label() { AutomationId = "BackgroundMe", Text = "Background/Minimize the app" } }; + + ConnectToWindow(); + _currentTest = () => + { + Application.Current.MainPage = CreateDestinationPage(); + return Task.CompletedTask; + }; + }) + }, + new Button() + { + Text = "Changing Details/Flyout on FlyoutPage in Background", + AutomationId = "SwapFlyoutPage", + Command = new Command(()=> + { + var flyoutPage = new FlyoutPage() + { + Flyout = new ContentPage() { Title = "Test", Content = new Label(){Text = "Background/Minimize the app" } }, + Detail = new NavigationPage(new ContentPage(){ Title = "Test", Content = new Label() { AutomationId = "BackgroundMe", Text = "Background/Minimize the app" } }) + { + Title = "Test" + }, + }; + + Application.Current.MainPage = flyoutPage; + ConnectToWindow(); + + _currentTest = () => + { + flyoutPage.Flyout = CreateDestinationPage(); + flyoutPage.Detail = new NavigationPage(CreateDestinationPage()); + return Task.CompletedTask; + }; + }) + }, + + new Button() + { + Text = "Swap Tabbed Page", + AutomationId = "SwapTabbedPage", + Command = new Command( () => + { + Application.Current.MainPage = new ContentPage() { Title = "Test", Content = new Label() {AutomationId = "BackgroundMe", Text = "Background/Minimize the app" } }; + ConnectToWindow(); + _currentTest = () => + { + Application.Current.MainPage = new TabbedPage() + { + Children = + { + new NavigationPage(CreateDestinationPage()) + { + Title = "Test" + }, + new ContentPage() { Title = "Test", Content = new Label(){Text = "Second Page" } }, + } + }; + return Task.CompletedTask; + }; + }) + }, + new Button() + { + Text = "Removing and Changing Tabs", + AutomationId = "RemoveAddTabs", + Command = new Command(() => + { + var tabbedPage = new TabbedPage() + { + Children = + { + new ContentPage() { Title = "Test", Content = new Label() { AutomationId = "BackgroundMe", Text = "Background/Minimize the app" } }, + new NavigationPage(CreateDestinationPage()) + { + Title = "Test" + } + } + }; + + Application.Current.MainPage = tabbedPage; + ConnectToWindow(); + + _currentTest = () => + { + tabbedPage.Children.RemoveAt(0); + tabbedPage.Children.Add(CreateDestinationPage()); + return Task.CompletedTask; + }; + }) + }, + }; + } + + ContentPage CreateDestinationPage() + { + return new ContentPage() + { + Title = "Test", + Content = new VerticalStackLayout() + { + new Button() + { + AutomationId = "Restore", + Text = "Restore", + Command = new Command(async ()=> + { + Application.Current.MainPage = _mainPage; + + await Task.Yield(); + + foreach(var page in _modalStack) + { + await _mainPage.Navigation.PushModalAsync(page); + } + }) + } + } + }; + } + } +} diff --git a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs index f1cb3725b180..734304b4c29e 100644 --- a/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs +++ b/src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs @@ -84,10 +84,9 @@ public static void UpdateBackButton(this AToolbar nativeToolbar, Toolbar toolbar nativeToolbar.Context ?? toolbar.Handler?.MauiContext?.Context; - nativeToolbar.NavigationIcon ??= new DrawerArrowDrawable(context!) - { - Progress = 1 - }; + nativeToolbar.NavigationIcon ??= new DrawerArrowDrawable(context!); + if (nativeToolbar.NavigationIcon is DrawerArrowDrawable iconDrawable) + iconDrawable.Progress = 1; var backButtonTitle = toolbar.BackButtonTitle; ImageSource image = toolbar.TitleIcon; @@ -110,6 +109,9 @@ public static void UpdateBackButton(this AToolbar nativeToolbar, Toolbar toolbar } else { + if (nativeToolbar.NavigationIcon is DrawerArrowDrawable iconDrawable) + iconDrawable.Progress = 0; + nativeToolbar.SetNavigationContentDescription(Resource.String.nav_app_bar_open_drawer_description); } } diff --git a/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs b/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs index f65a368bed79..a8d6b9d8fcd8 100644 --- a/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs +++ b/src/Controls/src/Core/Platform/Android/TabbedPageManager.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; -using Android.App.Roles; using Android.Content; using Android.Content.Res; using Android.Graphics; @@ -11,7 +10,6 @@ using Android.Views; using AndroidX.CoordinatorLayout.Widget; using AndroidX.Fragment.App; -using AndroidX.ViewPager.Widget; using AndroidX.ViewPager2.Widget; using Google.Android.Material.AppBar; using Google.Android.Material.BottomNavigation; @@ -60,13 +58,11 @@ internal class TabbedPageManager ColorStateList _currentBarTextColorStateList; bool _tabItemStyleLoaded; TabLayoutMediator _tabLayoutMediator; - - NavigationRootManager NavigationRootManager { get; } + IDisposable _pendingFragment; public TabbedPageManager(IMauiContext context) { _context = context; - NavigationRootManager = _context.GetNavigationRootManager(); _listeners = new Listeners(this); _viewPager = new ViewPager2(context.Context) { @@ -175,6 +171,9 @@ void OnLayoutChanged(object sender, AView.LayoutChangeEventArgs e) void RemoveTabs() { + _pendingFragment?.Dispose(); + _pendingFragment = null; + if (_tabLayoutFragment != null) { var fragment = _tabLayoutFragment; @@ -189,13 +188,19 @@ void RemoveTabs() { SetContentBottomMargin(0); - _ = _context - .GetNavigationRootManager() - .FragmentManager - .BeginTransaction() - .Remove(fragment) - .SetReorderingAllowed(true) - .Commit(); + if (_context?.Context is Context c) + { + _pendingFragment = + fragmentManager + .RunOrWaitForResume(c, fm => + { + fm + .BeginTransaction() + .Remove(fragment) + .SetReorderingAllowed(true) + .Commit(); + }); + } } _tabplacementId = 0; @@ -223,6 +228,9 @@ void RootViewChanged(object sender, EventArgs e) internal void SetTabLayout() { + _pendingFragment?.Dispose(); + _pendingFragment = null; + int id; var rootManager = _context.GetNavigationRootManager(); @@ -231,7 +239,6 @@ internal void SetTabLayout() if (rootManager.RootView == null) { rootManager.RootViewChanged += RootViewChanged; - return; } @@ -241,7 +248,6 @@ internal void SetTabLayout() if (_tabplacementId == id) return; - _tabLayoutFragment = new ViewFragment(BottomNavigationView); SetContentBottomMargin(_context.Context.Resources.GetDimensionPixelSize(Resource.Dimension.design_bottom_navigation_height)); } else @@ -250,17 +256,34 @@ internal void SetTabLayout() if (_tabplacementId == id) return; - _tabLayoutFragment = new ViewFragment(TabLayout); SetContentBottomMargin(0); } - _tabplacementId = id; - _ = rootManager - .FragmentManager - .BeginTransaction() - .Replace(id, _tabLayoutFragment) - .SetReorderingAllowed(true) - .Commit(); + if (_context?.Context is Context c) + { + _pendingFragment = + rootManager + .FragmentManager + .RunOrWaitForResume(c, fm => + { + if (IsBottomTabPlacement) + { + _tabLayoutFragment = new ViewFragment(BottomNavigationView); + } + else + { + _tabLayoutFragment = new ViewFragment(TabLayout); + } + + _tabplacementId = id; + + fm + .BeginTransactionEx() + .ReplaceEx(id, _tabLayoutFragment) + .SetReorderingAllowed(true) + .Commit(); + }); + } } void SetContentBottomMargin(int bottomMargin) diff --git a/src/Controls/tests/UITests/Tests/Issues/Issue11501.cs b/src/Controls/tests/UITests/Tests/Issues/Issue11501.cs new file mode 100644 index 000000000000..9999d9c3038f --- /dev/null +++ b/src/Controls/tests/UITests/Tests/Issues/Issue11501.cs @@ -0,0 +1,45 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.AppiumTests.Issues +{ + public class Issue11501 : _IssuesUITest + { + public Issue11501(TestDevice device) : base(device) + { + } + + public override string Issue => "Making Fragment Changes While App is Backgrounded Fails"; + + [TestCase("SwapMainPage")] + [TestCase("SwapFlyoutPage")] + [TestCase("SwapTabbedPage")] + [TestCase("RemoveAddTabs")] + public async Task MakingFragmentRelatedChangesWhileAppIsBackgroundedFails(string scenario) + { + this.IgnoreIfPlatforms(new TestDevice[] { TestDevice.Mac, TestDevice.Windows }); + + try + { + App.WaitForElement(scenario); + App.Click(scenario); + App.BackgroundApp(); + + // Wait for app to finish backgrounding + await Task.Yield(); + App.WaitForNoElement("BackgroundMe"); + App.ForegroundApp(); + App.WaitForElement("Restore"); + App.Click("Restore"); + } + catch + { + // Just in case these tests leave the app in an unreliable state + App.ResetApp(); + FixtureSetup(); + throw; + } + } + } +} diff --git a/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs b/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs index 83134d66d728..63ef0303a7e5 100644 --- a/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs +++ b/src/Core/src/Handlers/FlyoutView/FlyoutViewHandler.Android.cs @@ -10,7 +10,6 @@ namespace Microsoft.Maui.Handlers { - public partial class FlyoutViewHandler : ViewHandler { View? _flyoutView; @@ -50,8 +49,12 @@ double FlyoutWidth } } + IDisposable? _pendingFragment; void UpdateDetailsFragmentView() { + _pendingFragment?.Dispose(); + _pendingFragment = null; + _ = MauiContext ?? throw new InvalidOperationException($"{nameof(MauiContext)} should have been set by base class."); if (_detailViewFragment is not null && @@ -61,31 +64,49 @@ void UpdateDetailsFragmentView() return; } + var context = MauiContext.Context; + if (context is null) + return; + if (VirtualView.Detail?.Handler is IPlatformViewHandler pvh) pvh.DisconnectHandler(); + var fragmentManager = MauiContext.GetFragmentManager(); + if (VirtualView.Detail is null) { if (_detailViewFragment is not null) { - MauiContext - .GetFragmentManager() - .BeginTransaction() - .Remove(_detailViewFragment) - .SetReorderingAllowed(true) - .Commit(); + _pendingFragment = + fragmentManager + .RunOrWaitForResume(context, (fm) => + { + if (_detailViewFragment is null) + { + return; + } + + fm + .BeginTransactionEx() + .RemoveEx(_detailViewFragment) + .SetReorderingAllowed(true) + .Commit(); + }); } } else { - _detailViewFragment = new ScopedFragment(VirtualView.Detail, MauiContext); - - MauiContext - .GetFragmentManager() - .BeginTransaction() - .Replace(Resource.Id.navigationlayout_content, _detailViewFragment) - .SetReorderingAllowed(true) - .Commit(); + _pendingFragment = + fragmentManager + .RunOrWaitForResume(context, (fm) => + { + _detailViewFragment = new ScopedFragment(VirtualView.Detail, MauiContext); + fm + .BeginTransaction() + .Replace(Resource.Id.navigationlayout_content, _detailViewFragment) + .SetReorderingAllowed(true) + .Commit(); + }); } } diff --git a/src/Core/src/Platform/Android/FragmentManagerExtensions.cs b/src/Core/src/Platform/Android/FragmentManagerExtensions.cs index 0528ae143c50..e8ce5d70496e 100644 --- a/src/Core/src/Platform/Android/FragmentManagerExtensions.cs +++ b/src/Core/src/Platform/Android/FragmentManagerExtensions.cs @@ -70,5 +70,71 @@ public static bool IsDestroyed(this FragmentManager? obj, Context? context) return context.IsDestroyed(); } + + public static IDisposable? RunOrWaitForResume(this FragmentManager obj, Context context, Action onResume) + { + if (obj.IsDestroyed(context)) + return null; + + if (obj.IsStateSaved) + { + var callback = new CallBacks(context, onResume, obj); + obj.RegisterFragmentLifecycleCallbacks(callback, false); + return new ActionDisposable(() => + { + callback?.Disconnect(); + callback = null; + }); + } + + onResume.Invoke(obj); + return null; + } + + class CallBacks : FragmentManager.FragmentLifecycleCallbacks + { + FragmentManager? _fragmentManager; + Action? _onResume; + Context? _context; + + public CallBacks( + Context context, + Action onResume, + FragmentManager fragmentManager) + { + _fragmentManager = fragmentManager; + _fragmentManager.RegisterFragmentLifecycleCallbacks(this, false); + _onResume = onResume; + _context = context; + } + + public override void OnFragmentDestroyed(FragmentManager fm, Fragment f) + { + base.OnFragmentDestroyed(fm, f); + Disconnect(); + } + + public override void OnFragmentResumed(FragmentManager fm, Fragment f) + { + base.OnFragmentResumed(fm, f); + var resume = _onResume; + Disconnect(); + resume?.Invoke(fm); + } + + public void Disconnect() + { + if (_fragmentManager is not null && + !_fragmentManager.IsDestroyed(_context)) + { + _fragmentManager.UnregisterFragmentLifecycleCallbacks(this); + } + + _fragmentManager = null; + _onResume = null; + _context = null; + } + } + } } \ No newline at end of file diff --git a/src/Core/src/Platform/Android/Navigation/NavigationRootManager.cs b/src/Core/src/Platform/Android/Navigation/NavigationRootManager.cs index 41d5def909ca..8d14bed8cca4 100644 --- a/src/Core/src/Platform/Android/Navigation/NavigationRootManager.cs +++ b/src/Core/src/Platform/Android/Navigation/NavigationRootManager.cs @@ -15,7 +15,7 @@ public class NavigationRootManager { IMauiContext _mauiContext; AView? _rootView; - Fragment? _viewFragment; + ScopedFragment? _viewFragment; IToolbarElement? _toolbarElement; // TODO MAUI: temporary event to alert when rootview is ready @@ -115,39 +115,66 @@ public virtual void Disconnect() void ClearPlatformParts() { + _pendingFragment?.Dispose(); + _pendingFragment = null; DrawerLayout = null; _rootView = null; _toolbarElement = null; } + IDisposable? _pendingFragment; void SetContentView(IView? view) { - if (view == null) + _pendingFragment?.Dispose(); + _pendingFragment = null; + + var context = _mauiContext.Context; + if (context is null) + return; + + if (view is null) { - if (_viewFragment != null && !FragmentManager.IsDestroyed(_mauiContext.Context)) + if (_viewFragment is not null && !FragmentManager.IsDestroyed(context)) { - FragmentManager - .BeginTransaction() - .Remove(_viewFragment) - .SetReorderingAllowed(true) - .Commit(); + _pendingFragment = + FragmentManager + .RunOrWaitForResume(context, fm => + { + if (_viewFragment is null) + return; + + fm + .BeginTransaction() + .Remove(_viewFragment) + .SetReorderingAllowed(true) + .Commit(); + + _viewFragment = null; + }); } - _viewFragment = null; + if (FragmentManager.IsDestroyed(context)) + _viewFragment = null; } else { - _viewFragment = - new ElementBasedFragment( - view, - _mauiContext!, - OnWindowContentPlatformViewCreated); - - FragmentManager - .BeginTransaction() - .Replace(Resource.Id.navigationlayout_content, _viewFragment) - .SetReorderingAllowed(true) - .Commit(); + + _pendingFragment = + FragmentManager + .RunOrWaitForResume(context, fm => + { + _viewFragment = + new ElementBasedFragment( + view, + _mauiContext, + OnWindowContentPlatformViewCreated); + + fm + .BeginTransactionEx() + .ReplaceEx(Resource.Id.navigationlayout_content, _viewFragment) + .SetReorderingAllowed(true) + .Commit(); + }); } } diff --git a/src/Core/src/Platform/Android/Navigation/StackNavigationManager.cs b/src/Core/src/Platform/Android/Navigation/StackNavigationManager.cs index 7e1548b616fa..4dda5e3828a0 100644 --- a/src/Core/src/Platform/Android/Navigation/StackNavigationManager.cs +++ b/src/Core/src/Platform/Android/Navigation/StackNavigationManager.cs @@ -31,6 +31,7 @@ public class StackNavigationManager internal bool? IsPopping { get; private set; } internal bool IsAnimated { get; set; } = true; internal NavigationRequest? ActiveRequestedArgs { get; private set; } + internal NavigationRequest? OnResumeRequestedArgs { get; private set; } public IReadOnlyList NavigationStack { get; private set; } = new List(); internal NavHostFragment NavHost => @@ -79,7 +80,7 @@ public StackNavigationManager(IMauiContext mauiContext) * */ void ApplyNavigationRequest(NavigationRequest args) { - if (IsNavigating) + if (IsNavigating && OnResumeRequestedArgs is null) { // This should really never fire for the developer. Our xplat code should be handling waiting for navigation to // complete before requesting another navigation from Core @@ -93,6 +94,15 @@ void ApplyNavigationRequest(NavigationRequest args) } ActiveRequestedArgs = args; + + if (_fragmentManager?.IsStateSaved == true) + { + OnResumeRequestedArgs = args; + return; + } + + OnResumeRequestedArgs = null; + IReadOnlyList newPageStack = args.NavigationStack; bool animated = args.Animated; var navController = NavController; @@ -530,6 +540,12 @@ public override void OnFragmentResumed(AndroidX.Fragment.App.FragmentManager fm, if (_stackNavigationManager?.VirtualView == null) return; + if (_stackNavigationManager.OnResumeRequestedArgs is not null) + { + _stackNavigationManager.ApplyNavigationRequest(_stackNavigationManager.OnResumeRequestedArgs); + return; + } + if (f is NavigationViewFragment pf) _stackNavigationManager.OnNavigationViewFragmentResumed(fm, pf);