Skip to content

Commit 881947b

Browse files
RedthCopilot
andcommitted
Add public modal navigation extensibility seam for external backends
External platform backends (for example a community Tizen or GTK backend) could not render modal push/pop without forking. `Window` constructed the internal `ModalNavigationManager` directly, `NavigationImpl` routed `PushModalAsync`/`PopModalAsync` through it, and the only way to supply platform presentation was an internal partial class completion compiled into `Microsoft.Maui.Controls` itself. This adds a minimal, additive seam in `Microsoft.Maui.Controls.Platform` that follows the existing `IAlertManager` and `IGesturePlatformManagerFactory` extensibility patterns: - `IModalNavigationPlatform` — presents and dismisses a single modal (`IsReady`, `PushModalAsync`, `PopModalAsync`, `PageAttached`, `Dispose`). - `IModalNavigationPlatformFactory` — application-wide DI factory that creates one platform per `Window`. Returning `null` keeps the built-in implementation. - `IModalNavigationHost` — the framework state a platform needs (`Window`, `MauiContext`, `PlatformModalStack`, `CurrentPage`, `CurrentPlatformPage`, `IsModalReady`, `IsBatchPopping`, `RequestSync`). `ModalNavigationManager` now implements `IModalNavigationHost` and resolves the factory lazily from the window handler's `IMauiContext.Services`, which is the per-window service scope, so each window gets its own isolated platform instance. The framework keeps ownership of the cross-platform modal stack, page lifecycle events, `Window` modal events and the push/pop reconciliation loop; only visual presentation is delegated. When no factory is registered, behavior is unchanged: the existing Android/iOS/MacCatalyst/Windows/Tizen/Standard partials are kept as the fallback implementation and are only renamed to `*Core` so the shared file can route between them and an override. Adds unit tests covering DI selection, per-window isolation, push/pop ordering, platform stack visibility during presentation, animation flag propagation, deferred readiness via `RequestSync`, pop cancellation, factory-returns-null fallback, push failure rollback, disposal on window destroy and handler change, and the no-registration path; plus device tests that exercise the seam with real handlers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent bedd1b1 commit 881947b

18 files changed

Lines changed: 1245 additions & 21 deletions
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#nullable enable
2+
3+
using System.Collections.Generic;
4+
5+
namespace Microsoft.Maui.Controls.Platform
6+
{
7+
/// <summary>
8+
/// Exposes the cross-platform modal navigation state that a <see cref="IModalNavigationPlatform"/>
9+
/// needs in order to present and dismiss modal pages.
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>
13+
/// The framework implements this interface and supplies an instance to
14+
/// <see cref="IModalNavigationPlatformFactory.CreateModalNavigationPlatform(IModalNavigationHost)"/>.
15+
/// One host exists per <see cref="Controls.Window"/>, so an implementation must never be shared
16+
/// between windows.
17+
/// </para>
18+
/// <para>
19+
/// The framework owns the cross-platform modal stack, the page lifecycle events
20+
/// (<c>Appearing</c>/<c>Disappearing</c>, <c>NavigatedTo</c>/<c>NavigatedFrom</c>), the
21+
/// <see cref="Controls.Window.ModalPushing"/>/<see cref="Controls.Window.ModalPopped"/> events and the
22+
/// reconciliation loop that keeps the platform stack in sync with the requested stack. A platform
23+
/// implementation is only responsible for the visual presentation of a single push or pop.
24+
/// </para>
25+
/// </remarks>
26+
public interface IModalNavigationHost
27+
{
28+
/// <summary>
29+
/// Gets the window that owns this modal navigation host.
30+
/// </summary>
31+
Window Window { get; }
32+
33+
/// <summary>
34+
/// Gets the <see cref="IMauiContext"/> scoped to <see cref="Window"/>.
35+
/// </summary>
36+
/// <exception cref="System.InvalidOperationException">
37+
/// Thrown when the window does not currently have a handler and therefore has no context.
38+
/// </exception>
39+
IMauiContext MauiContext { get; }
40+
41+
/// <summary>
42+
/// Gets the modal pages that the platform has actually presented, in push order.
43+
/// The last entry is the modal currently on screen.
44+
/// </summary>
45+
/// <remarks>
46+
/// This is maintained by the framework. It is updated before
47+
/// <see cref="IModalNavigationPlatform.PushModalAsync(Page, bool)"/> is awaited and before
48+
/// <see cref="IModalNavigationPlatform.PopModalAsync(Page, bool)"/> is awaited, so during those
49+
/// calls it already reflects the requested end state.
50+
/// </remarks>
51+
IReadOnlyList<Page> PlatformModalStack { get; }
52+
53+
/// <summary>
54+
/// Gets the page the user is expected to be looking at once the cross-platform modal stack is
55+
/// fully applied. This is the topmost requested modal, or the window's page when no modal is
56+
/// requested, with <see cref="Shell"/> unwrapped to its current page.
57+
/// </summary>
58+
Page? CurrentPage { get; }
59+
60+
/// <summary>
61+
/// Gets the page that is currently hosting content on the platform: the topmost entry of
62+
/// <see cref="PlatformModalStack"/>, or the window's page when no modal has been presented.
63+
/// </summary>
64+
/// <exception cref="System.InvalidOperationException">
65+
/// Thrown when there is no presented modal and the window has no page.
66+
/// </exception>
67+
Page CurrentPlatformPage { get; }
68+
69+
/// <summary>
70+
/// Gets a value indicating whether the framework considers the window ready to present modals
71+
/// (the window and its page both have handlers, and <see cref="IModalNavigationPlatform.IsReady"/>
72+
/// returned <see langword="true"/>).
73+
/// </summary>
74+
bool IsModalReady { get; }
75+
76+
/// <summary>
77+
/// Gets a value indicating whether several modals are being dismissed as a single batch, for
78+
/// example during a <see cref="Shell"/> pop-to-root. Implementations can use this to dismiss
79+
/// without animation or intermediate layout so that the modals in between do not flash on screen.
80+
/// </summary>
81+
bool IsBatchPopping { get; }
82+
83+
/// <summary>
84+
/// Asks the framework to re-run the reconciliation loop that compares the requested modal stack
85+
/// with <see cref="PlatformModalStack"/> and issues any push or pop that is still outstanding.
86+
/// </summary>
87+
/// <remarks>
88+
/// Call this when <see cref="IModalNavigationPlatform.IsReady"/> transitions from
89+
/// <see langword="false"/> to <see langword="true"/>. The framework does not poll
90+
/// <see cref="IModalNavigationPlatform.IsReady"/>, so a platform that defers readiness must
91+
/// notify the framework through this method or queued modals will never be presented.
92+
/// The call is safe to make from any thread and does not block.
93+
/// </remarks>
94+
void RequestSync();
95+
}
96+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
#nullable enable
2+
3+
using System;
4+
using System.Threading.Tasks;
5+
6+
namespace Microsoft.Maui.Controls.Platform
7+
{
8+
/// <summary>
9+
/// Presents and dismisses modal pages for a single <see cref="Window"/>.
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>
13+
/// Register an <see cref="IModalNavigationPlatformFactory"/> in the application's service collection
14+
/// to supply an implementation. This is the extensibility point that lets an alternative platform
15+
/// backend render modal navigation without forking the framework: the framework keeps ownership of
16+
/// the cross-platform modal stack, page lifecycle events and the push/pop reconciliation loop, and
17+
/// only the visual presentation is delegated here.
18+
/// </para>
19+
/// <para>
20+
/// The framework creates one instance per window and disposes it when the window is destroyed or
21+
/// when the window's handler changes. Implementations must therefore tolerate multiple
22+
/// create/dispose cycles and must not be shared between windows.
23+
/// </para>
24+
/// <para>
25+
/// All members are invoked on the UI thread.
26+
/// </para>
27+
/// </remarks>
28+
public interface IModalNavigationPlatform : IDisposable
29+
{
30+
/// <summary>
31+
/// Gets a value indicating whether the backend can present or dismiss a modal right now.
32+
/// </summary>
33+
/// <remarks>
34+
/// While this returns <see langword="false"/> the framework still records pushes and pops on the
35+
/// cross-platform modal stack and still raises the corresponding page lifecycle events, but it
36+
/// does not call <see cref="PushModalAsync(Page, bool)"/> or <see cref="PopModalAsync(Page, bool)"/>.
37+
/// When the backend becomes ready it must call
38+
/// <see cref="IModalNavigationHost.RequestSync"/> so the queued operations are applied.
39+
/// Return <see langword="true"/> unconditionally if the backend has no readiness requirement.
40+
/// </remarks>
41+
bool IsReady { get; }
42+
43+
/// <summary>
44+
/// Presents <paramref name="modal"/> on top of
45+
/// <see cref="IModalNavigationHost.CurrentPlatformPage"/>.
46+
/// </summary>
47+
/// <param name="modal">The page to present. It has already been added to
48+
/// <see cref="IModalNavigationHost.PlatformModalStack"/> when this method is called.</param>
49+
/// <param name="animated"><see langword="true"/> to animate the transition.</param>
50+
/// <returns>
51+
/// A task that completes once the modal is on screen and safe to dismiss. The framework awaits
52+
/// this task before applying any further modal operation, so completing early can allow a
53+
/// subsequent pop to race the presentation.
54+
/// </returns>
55+
/// <remarks>
56+
/// If this task faults, the framework removes <paramref name="modal"/> from
57+
/// <see cref="IModalNavigationHost.PlatformModalStack"/> again and rethrows to the caller of
58+
/// <c>PushModalAsync</c>.
59+
/// </remarks>
60+
Task PushModalAsync(Page modal, bool animated);
61+
62+
/// <summary>
63+
/// Dismisses <paramref name="modal"/>, revealing
64+
/// <see cref="IModalNavigationHost.CurrentPlatformPage"/>.
65+
/// </summary>
66+
/// <param name="modal">The page to dismiss. It has already been removed from
67+
/// <see cref="IModalNavigationHost.PlatformModalStack"/> when this method is called, so
68+
/// <see cref="IModalNavigationHost.CurrentPlatformPage"/> already refers to the page that is
69+
/// about to be revealed.</param>
70+
/// <param name="animated"><see langword="true"/> to animate the transition. The framework passes
71+
/// the value that was supplied to the matching push when the pop is the result of stack
72+
/// reconciliation, and the value supplied to <c>PopModalAsync</c> otherwise.</param>
73+
/// <returns>A task that completes once the modal is off screen.</returns>
74+
/// <remarks>
75+
/// Implementations are responsible for releasing the platform views created for
76+
/// <paramref name="modal"/>. The framework detaches the page from its parent after this task
77+
/// completes.
78+
/// </remarks>
79+
Task PopModalAsync(Page modal, bool animated);
80+
81+
/// <summary>
82+
/// Called when the window's page gets a handler, including when the page is replaced.
83+
/// </summary>
84+
/// <remarks>
85+
/// Use this to attach platform hooks that depend on the window's content being realized, such as
86+
/// a hardware back button handler. This may be called multiple times for the same window.
87+
/// </remarks>
88+
void PageAttached();
89+
}
90+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#nullable enable
2+
3+
namespace Microsoft.Maui.Controls.Platform
4+
{
5+
/// <summary>
6+
/// Application-wide factory for creating <see cref="IModalNavigationPlatform"/> instances.
7+
/// Register an implementation in the application's
8+
/// <see cref="Microsoft.Extensions.DependencyInjection.IServiceCollection"/> to replace the built-in
9+
/// modal presentation for every window.
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>
13+
/// When an <see cref="IModalNavigationPlatformFactory"/> is present in
14+
/// <see cref="IMauiContext.Services"/> for a window, the framework calls
15+
/// <see cref="CreateModalNavigationPlatform(IModalNavigationHost)"/> once for that window and routes
16+
/// every modal push and pop to the returned instance instead of the built-in platform code. This
17+
/// lets alternative platform backends render modal navigation without reflection, partial classes or
18+
/// forking, while the framework keeps ownership of the cross-platform modal stack, the page
19+
/// lifecycle events and the push/pop reconciliation loop.
20+
/// </para>
21+
/// <para>
22+
/// The factory itself is resolved from the window's service scope, so it may be registered with any
23+
/// lifetime. The framework always calls it once per window and disposes the returned instance, so
24+
/// return a new <see cref="IModalNavigationPlatform"/> for each call.
25+
/// </para>
26+
/// <example>
27+
/// Registering a backend that presents modals with its own native window stack:
28+
/// <code lang="csharp">
29+
/// public sealed class MyModalNavigationPlatformFactory : IModalNavigationPlatformFactory
30+
/// {
31+
/// public IModalNavigationPlatform? CreateModalNavigationPlatform(IModalNavigationHost host)
32+
/// =&gt; new MyModalNavigationPlatform(host);
33+
/// }
34+
///
35+
/// public sealed class MyModalNavigationPlatform : IModalNavigationPlatform
36+
/// {
37+
/// readonly IModalNavigationHost _host;
38+
///
39+
/// public MyModalNavigationPlatform(IModalNavigationHost host) =&gt; _host = host;
40+
///
41+
/// public bool IsReady =&gt; true;
42+
///
43+
/// public async Task PushModalAsync(Page modal, bool animated)
44+
/// {
45+
/// var nativeView = modal.ToPlatform(_host.MauiContext);
46+
/// await MyNativeModalStack.For(_host.Window).PushAsync(nativeView, animated);
47+
/// }
48+
///
49+
/// public async Task PopModalAsync(Page modal, bool animated)
50+
/// {
51+
/// await MyNativeModalStack.For(_host.Window).PopAsync(animated &amp;&amp; !_host.IsBatchPopping);
52+
/// (modal.Handler as IPlatformViewHandler)?.Dispose();
53+
/// }
54+
///
55+
/// public void PageAttached()
56+
/// {
57+
/// MyNativeWindow.For(_host.Window).BackButtonPressed =
58+
/// () =&gt; _host.CurrentPage?.SendBackButtonPressed() ?? false;
59+
/// }
60+
///
61+
/// public void Dispose() =&gt; MyNativeModalStack.Release(_host.Window);
62+
/// }
63+
///
64+
/// // In MauiProgram:
65+
/// builder.Services.AddSingleton&lt;IModalNavigationPlatformFactory, MyModalNavigationPlatformFactory&gt;();
66+
/// </code>
67+
/// </example>
68+
/// </remarks>
69+
public interface IModalNavigationPlatformFactory
70+
{
71+
/// <summary>
72+
/// Creates the <see cref="IModalNavigationPlatform"/> for a window.
73+
/// </summary>
74+
/// <param name="host">
75+
/// The per-window host that exposes the framework's modal navigation state. Keep a reference to
76+
/// it; it stays valid for the lifetime of the returned instance.
77+
/// </param>
78+
/// <returns>
79+
/// A new <see cref="IModalNavigationPlatform"/> owned and disposed by the framework, or
80+
/// <see langword="null"/> to let the window keep the built-in platform implementation. Returning
81+
/// <see langword="null"/> is useful when a backend only wants to override modal navigation for
82+
/// certain windows.
83+
/// </returns>
84+
IModalNavigationPlatform? CreateModalNavigationPlatform(IModalNavigationHost host);
85+
}
86+
}

src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Android.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ ViewGroup GetModalParentView()
9292
throw new InvalidOperationException("Root View Needs to be set");
9393
}
9494

95-
Task<Page> PopModalPlatformAsync(bool animated)
95+
Task<Page> PopModalPlatformCoreAsync(bool animated)
9696
{
9797
Page modal = CurrentPlatformModalPage;
9898
_platformModalPages.Remove(modal);
@@ -161,7 +161,7 @@ AView GetCurrentRootView()
161161
throw new InvalidOperationException("Current Root View cannot be null");
162162
}
163163

164-
async Task PushModalPlatformAsync(Page modal, bool animated)
164+
async Task PushModalPlatformCoreAsync(Page modal, bool animated)
165165
{
166166
var viewToHide = GetCurrentRootView();
167167

src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Standard.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,22 @@ namespace Microsoft.Maui.Controls.Platform
88
{
99
internal partial class ModalNavigationManager
1010
{
11-
Task<Page> PopModalPlatformAsync(bool animated)
11+
Task<Page> PopModalPlatformCoreAsync(bool animated)
1212
{
1313
var currentPage = CurrentPlatformPage!;
1414
_platformModalPages.Remove(currentPage);
1515
return Task.FromResult(currentPage);
1616
}
1717

18-
Task PushModalPlatformAsync(Page modal, bool animated)
18+
Task PushModalPlatformCoreAsync(Page modal, bool animated)
1919
{
2020
_platformModalPages.Add(modal);
2121
return Task.CompletedTask;
2222
}
2323

24-
Task SyncModalStackWhenPlatformIsReadyAsync() =>
24+
Task SyncModalStackWhenPlatformIsReadyCoreAsync() =>
2525
SyncPlatformModalStackAsync();
2626

27-
bool IsModalPlatformReady => true;
27+
bool IsModalPlatformReadyCore => true;
2828
}
2929
}

src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Tizen.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,17 @@ internal partial class ModalNavigationManager
1010
NavigationStack _modalStack => WindowMauiContext.GetModalStack();
1111
IPageController CurrentPageController => CurrentPage!;
1212

13-
Task SyncModalStackWhenPlatformIsReadyAsync() =>
13+
Task SyncModalStackWhenPlatformIsReadyCoreAsync() =>
1414
SyncPlatformModalStackAsync();
1515

16-
bool IsModalPlatformReady => true;
16+
bool IsModalPlatformReadyCore => true;
1717

1818
partial void OnPageAttachedHandler()
1919
{
2020
WindowMauiContext.GetPlatformWindow().SetBackButtonPressedHandler(OnBackButtonPressed);
2121
}
2222

23-
async Task<Page> PopModalPlatformAsync(bool animated)
23+
async Task<Page> PopModalPlatformCoreAsync(bool animated)
2424
{
2525
Page modal = CurrentPlatformModalPage;
2626
_platformModalPages.Remove(modal);
@@ -37,7 +37,7 @@ async Task<Page> PopModalPlatformAsync(bool animated)
3737
return modal;
3838
}
3939

40-
async Task PushModalPlatformAsync(Page modal, bool animated)
40+
async Task PushModalPlatformCoreAsync(Page modal, bool animated)
4141
{
4242
CurrentPageController?.SendDisappearing();
4343
_platformModalPages.Add(modal);

src/Controls/src/Core/Platform/ModalNavigationManager/ModalNavigationManager.Windows.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ void OnWindowActivated(object? sender, EventArgs e)
2929
}
3030
}
3131

32-
Task<Page> PopModalPlatformAsync(bool animated)
32+
Task<Page> PopModalPlatformCoreAsync(bool animated)
3333
{
3434
var tcs = new TaskCompletionSource<Page>();
3535
var poppedPage = CurrentPlatformModalPage;
@@ -38,7 +38,7 @@ Task<Page> PopModalPlatformAsync(bool animated)
3838
return tcs.Task;
3939
}
4040

41-
Task PushModalPlatformAsync(Page modal, bool animated)
41+
Task PushModalPlatformCoreAsync(Page modal, bool animated)
4242
{
4343
_ = modal ?? throw new ArgumentNullException(nameof(modal));
4444

0 commit comments

Comments
 (0)