Skip to content

Commit 0f8671c

Browse files
RedthCopilot
andcommitted
Add Tizen modal page navigation, aligned with dotnet/maui#37853
Modal page navigation was the one area of this slice that could not be built on the shipped .NET MAUI 11 surface. ModalNavigationManager.Tizen.cs upstream is an internal partial-class completion compiled into Microsoft.Maui.Controls, and the neutral Standard partial only updates logical state, so an out-of-tree backend gets no rendering and has no DI, factory or provider hook. dotnet/maui#37853 adds that seam, following the shape already used by IAlertManager (#36633) and IGesturePlatformManagerFactory (#36655). It is still OPEN, so its interfaces are not in the 11.0.0-preview.7 package this repository builds against. Provisional alignment --------------------- Core/Platform/Modal/ProvisionalModalNavigationContracts.cs carries copies of IModalNavigationPlatform, IModalNavigationPlatformFactory and IModalNavigationHost with member shapes taken verbatim from the PR. Adopting the real interfaces is then a namespace change on two types plus deleting that file. The copies live in Microsoft.Maui.Platforms.Tizen, NOT Microsoft.Maui.Controls.Platform: re-declaring a MAUI type name in a MAUI namespace would collide (CS0433) for consumers that also reference MAUI's own build once the PR lands. ProvisionalModalNavigationContractTests keeps the copies honest. It asserts the member shape of each interface, asserts the namespace rule, and fails outright once Microsoft.Maui.Controls.Platform.IModalNavigationPlatform appears in the referenced assembly, with instructions to delete the provisional file. Implementation -------------- TizenModalNavigationPlatform ports ModalNavigationManager.Tizen.cs onto the seam. SendDisappearing/SendAppearing and the manual _platformModalPages bookkeeping are deliberately absent: under the seam the framework owns the platform stack and raises the page lifecycle events, so keeping them would fire those events twice. Batch pops suppress animation so the intermediate modals of a Shell pop-to-root do not flash, and the back-button handler resolves the current page on every press rather than capturing it. TizenModalPageRealizer replaces modal.ToPlatform(context), which is compiled per platform and has no Tizen build now that Tizen left the MAUI repository. It does the same work through public, platform-neutral handler APIs, which also makes page realization testable on the host. Dialog coordination is now neutral too -------------------------------------- NuiModalHost is replaced by TizenModalHost, which drives the new Tizen-owned ITizenNavigationStack instead of NUI directly. Placeholder balance - the failure mode that wedges every subsequent modal in the app - is therefore verified by host-side tests rather than only on device. NuiNavigationStack is now the single NUI-aware piece of modal coordination. Window-scoped services ---------------------- ITizenNavigationStack and ITizenWindowBackButton wrap objects the window owns, but registration happens before any window exists, so both are registered scoped as holders that the window handler fills in via AttachTizenWindow. They fail differently on purpose: an unattached navigation stack throws, because a modal that reports success without appearing is worse than a clear failure, while an unattached back button records and replays the handler, because PageAttached can run before the window handler does and a missing back button is not fatal. No back-button implementation is supplied here. Upstream that registry lives in Microsoft.Maui.Platform.WindowExtensions and is consumed by MauiApplication, both of which belong to the Tizen Core layer; duplicating it would create a second, competing source of truth for back-button routing. AttachTizenWindow takes the Core layer's implementation as an optional argument instead. Testing ------- 150 host-side tests, up from 101. New coverage: modal push/pop ordering, animation-flag propagation, batch-pop suppression, back-button routing and disposal, factory per-window isolation and null-return fallback, dialog placeholder balance including the fault and buried-placeholder paths, window-scoped holder semantics, and the provisional contract guards. eng/verify-nui-sources.sh now also type-checks the modal sources. It caught the back-button boundary problem: NuiWindowBackButton was calling SetBackButtonPressedHandler, which is a MAUI Core Tizen extension rather than a NUI or Tizen.UIExtensions API and does not exist here. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 6bf1bb4 commit 0f8671c

17 files changed

Lines changed: 1887 additions & 95 deletions

docs/tizen-modal-navigation.md

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# Tizen modal navigation
2+
3+
How modal pages and modal dialogs are presented on Tizen, and why the code is shaped the way it is.
4+
5+
Two different things are called "modal" here and they are handled separately:
6+
7+
| | Modal **pages** | Modal **dialogs** |
8+
|---|---|---|
9+
| Raised by | `Navigation.PushModalAsync` / `PopModalAsync` | `DisplayAlert`, `DisplayActionSheet`, `DisplayPromptAsync` |
10+
| Presented as | A page pushed onto the Tizen `NavigationStack` | A native NUI popup floating above the page |
11+
| Owned here by | `TizenModalNavigationPlatform` | `TizenAlertManagerSubscription` + `TizenModalHost` |
12+
13+
Both end up driving the same window-level `NavigationStack`, which is why both go through the
14+
single Tizen-owned `ITizenNavigationStack` contract.
15+
16+
---
17+
18+
## Modal pages
19+
20+
### The upstream problem
21+
22+
`ModalNavigationManager.Tizen.cs` in dotnet/maui is an **internal partial-class completion**. The
23+
framework's `ModalNavigationManager` declares `PushModalPlatformAsync` / `PopModalPlatformAsync` /
24+
`IsModalPlatformReady` and each platform supplies its half, compiled into
25+
`Microsoft.Maui.Controls` itself. The neutral `Standard` partial only updates logical state, so an
26+
out-of-tree backend gets no rendering at all and there is no DI, factory or provider hook to
27+
replace it.
28+
29+
That makes modal navigation the one area of this migration that could not be delivered against the
30+
shipped .NET MAUI 11 public surface, unlike alerts (`IAlertManager`, dotnet/maui#36633) and
31+
gestures (`IGesturePlatformManagerFactory`, dotnet/maui#36655).
32+
33+
### The seam
34+
35+
[dotnet/maui#37853](https://github.com/dotnet/maui/pull/37853) adds the missing extensibility
36+
point, following the shape already established by the alert and gesture seams:
37+
38+
```csharp
39+
public interface IModalNavigationPlatform : IDisposable
40+
{
41+
bool IsReady { get; }
42+
Task PushModalAsync(Page modal, bool animated);
43+
Task PopModalAsync(Page modal, bool animated);
44+
void PageAttached();
45+
}
46+
47+
public interface IModalNavigationPlatformFactory
48+
{
49+
IModalNavigationPlatform? CreateModalNavigationPlatform(IModalNavigationHost host);
50+
}
51+
```
52+
53+
The framework keeps ownership of everything shared and difficult — the cross-platform modal stack,
54+
`Appearing`/`Disappearing`/`NavigatedTo`/`NavigatedFrom`, `Window.ModalPushing`/`ModalPopped`,
55+
`Shell` batch-pop semantics, and the reconciliation loop. Only the visual presentation of a single
56+
push or pop is delegated.
57+
58+
### ⚠️ Provisional status
59+
60+
**dotnet/maui#37853 is still open.** Those interfaces are therefore not in the
61+
`11.0.0-preview.7` package this repository builds against and cannot be implemented yet.
62+
63+
`Core/Platform/Modal/ProvisionalModalNavigationContracts.cs` carries copies of the three
64+
interfaces, with member shapes taken verbatim from the PR, so the Tizen implementation is written
65+
against the final contract today. Adopting the real interfaces is then a namespace change on two
66+
types plus deleting that file — no logic moves.
67+
68+
The copies live in `Microsoft.Maui.Platforms.Tizen`, **not** `Microsoft.Maui.Controls.Platform`.
69+
Re-declaring a MAUI type name inside a MAUI namespace would collide (`CS0433`) for any consumer
70+
that also references MAUI's own build once the PR lands.
71+
72+
`ProvisionalModalNavigationContractTests` keeps this honest:
73+
74+
- the member shape of each provisional interface is asserted against the PR's shape, so a copy
75+
cannot silently drift;
76+
- the namespace rule is asserted, so a copy cannot silently become a collision;
77+
- `UpstreamHasNotShippedTheseTypesYet` **fails** the moment
78+
`Microsoft.Maui.Controls.Platform.IModalNavigationPlatform` appears in the referenced assembly,
79+
with instructions to delete the provisional file.
80+
81+
Until the PR merges, .NET MAUI will not resolve
82+
`IModalNavigationPlatformFactory` — the registration is real and tested, but it binds the
83+
provisional interface, not one the framework knows about.
84+
85+
### What the Tizen implementation does
86+
87+
`TizenModalNavigationPlatform` is the port of `ModalNavigationManager.Tizen.cs` reshaped onto the
88+
seam:
89+
90+
| Upstream member | Here | Note |
91+
|---|---|---|
92+
| `IsModalPlatformReady => true` | `IsReady => true` | Tizen has no deferred readiness, so `RequestSync` is never needed. |
93+
| `PushModalPlatformAsync` | `PushModalAsync` | Realize the page, push the native view. |
94+
| `PopModalPlatformAsync` | `PopModalAsync` | Pop the stack, release the page's handler. |
95+
| `OnPageAttachedHandler` | `PageAttached` | Install the back-button handler. |
96+
| `OnBackButtonPressed` | back-button delegate | Resolved through the host on every press. |
97+
98+
Deliberately **absent** from the port:
99+
100+
- `SendDisappearing()` / `SendAppearing()` — the framework raises these itself under the seam.
101+
Keeping them would fire the page lifecycle events twice.
102+
- `_platformModalPages.Add/Remove` — the framework owns the platform stack and updates it *before*
103+
awaiting the platform, which is why `PushModalAsync` receives a page that is already on
104+
`IModalNavigationHost.PlatformModalStack`.
105+
106+
Two behaviours worth calling out:
107+
108+
- **Batch pop.** `PopModalAsync` passes `animated && !host.IsBatchPopping`. A `Shell` pop-to-root
109+
dismisses several modals at once; animating the intermediate ones makes them flash on screen.
110+
- **Back button.** The handler resolves `host.CurrentPage` on every press rather than capturing it,
111+
because the current page changes as modals come and go.
112+
113+
### Realizing a page without `ToPlatform`
114+
115+
`ModalNavigationManager.Tizen.cs` called `modal.ToPlatform(WindowMauiContext)`.
116+
`Microsoft.Maui.Platform.ElementExtensions.ToPlatform` is compiled per platform and has **no Tizen
117+
build** now that Tizen left the MAUI repository.
118+
119+
`TizenModalPageRealizer` does the same work using only public, platform-neutral API — resolve a
120+
handler from `IMauiContext.Handlers`, give it the context and the virtual view, then take its
121+
container or platform view. A useful side effect is that modal page realization is unit testable on
122+
the host.
123+
124+
---
125+
126+
## Modal dialogs
127+
128+
Alert, action sheet and prompt dialogs are native NUI popups, not stack entries. The stack still
129+
has to know something modal is on screen so that back-button handling and page ordering stay
130+
correct, so a placeholder entry is pushed for the duration of the dialog.
131+
132+
This is the port of `NavigationStackExtensions.PushDummyPopupPage`, with one deliberate deviation:
133+
**exceptions are not swallowed**. The original swallowed everything, which was survivable only
134+
because it published the dialog result from inside that scope; a swallowed failure left the
135+
awaiting `DisplayAlertAsync` caller pending forever. The placeholder is still always popped, but
136+
the failure now propagates so `TizenAlertManagerSubscription` can fault the caller.
137+
138+
If the placeholder is no longer on top when the dialog closes — something else was pushed while it
139+
was open — it is removed by identity rather than popped, matching the original.
140+
141+
---
142+
143+
## Window-scoped services
144+
145+
`ITizenNavigationStack` and `ITizenWindowBackButton` wrap objects the window owns, but registration
146+
happens at host-build time, before any window exists. They are registered **scoped** as holders
147+
that the Tizen window handler fills in:
148+
149+
```csharp
150+
TizenNuiHostingExtensions.AttachTizenWindow(mauiContext, nativeWindow, navigationStack);
151+
```
152+
153+
The two holders behave differently on purpose:
154+
155+
- `TizenScopedNavigationStack` **throws** when used before attachment. A modal that reports success
156+
without appearing is worse than a clear failure.
157+
- `TizenScopedWindowBackButton` **records and replays** the handler. The modal platform installs
158+
its handler on `PageAttached`, which can run before the window handler attaches the native
159+
window, and a missing back button is not fatal — presses just fall through to the platform
160+
default.
161+
162+
### Back button ownership
163+
164+
No back-button implementation is supplied by this layer. Upstream, the handler registry lives in
165+
`Microsoft.Maui.Platform.WindowExtensions` and is consumed by `MauiApplication`, both of which
166+
belong to the Tizen **Core** layer rather than Controls. Duplicating that registry here would
167+
create a second, competing source of truth for back-button routing, so
168+
`AttachTizenWindow` takes the Core layer's implementation as an optional argument instead.
169+
170+
---
171+
172+
## Verification
173+
174+
| Layer | How it is verified |
175+
|---|---|
176+
| Modal page push/pop, animation flags, batch pop, back button, disposal | `tests/Controls.UnitTests/TizenModalNavigationPlatformTests.cs` |
177+
| Factory behaviour and per-window isolation | `TizenModalNavigationPlatformFactoryTests` |
178+
| Dialog placeholder balance, including the fault and buried-placeholder paths | `tests/Controls.UnitTests/TizenModalHostTests.cs` |
179+
| Window-scoped holder semantics | `TizenScopedWindowServiceTests` |
180+
| Provisional contract shape, namespace and expiry | `ProvisionalModalNavigationContractTests` |
181+
| DI registration and lifetimes | `tests/Controls.UnitTests/TizenServiceRegistrationTests.cs` |
182+
| `NuiNavigationStack` | Compile-checked by `eng/verify-nui-sources.sh`; behaviour needs a device |
183+
184+
Placeholder balance in particular used to be device-only. It now runs on the host, which matters
185+
because the failure mode — a stack left permanently unbalanced — wedges every subsequent modal in
186+
the app.

eng/verify-nui-sources.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ cat > "$PROJ_DIR/NuiCompileCheck.csproj" <<PROJ
124124
<ItemGroup>
125125
<Compile Include="\$(SourceRoot)Alerts/*.cs" />
126126
<Compile Include="\$(SourceRoot)Gestures/*.cs" />
127+
<Compile Include="\$(SourceRoot)Modal/*.cs" />
127128
<Compile Include="\$(SourceRoot)Nui/*.cs" />
128129
<Compile Include="\$(SourceRoot)TizenControlsServiceCollectionExtensions.cs" />
129130
</ItemGroup>
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
using System;
2+
using System.Threading.Tasks;
3+
using Microsoft.Maui.Controls;
4+
5+
namespace Microsoft.Maui.Platforms.Tizen
6+
{
7+
/// <summary>
8+
/// The Tizen navigation stack that modal pages and modal dialogs are presented on.
9+
/// </summary>
10+
/// <remarks>
11+
/// <para>
12+
/// This is a Tizen-owned contract over <c>Tizen.UIExtensions.NUI.NavigationStack</c>. Declaring
13+
/// it here keeps modal coordination - which entry is on top, when a placeholder is pushed and
14+
/// popped, how a push is unwound when it faults - independent of NUI and therefore testable
15+
/// off device.
16+
/// </para>
17+
/// <para>
18+
/// One stack exists per window. Register it in the window scope.
19+
/// </para>
20+
/// </remarks>
21+
public interface ITizenNavigationStack
22+
{
23+
/// <summary>Gets the number of entries on the stack.</summary>
24+
int Count { get; }
25+
26+
/// <summary>Gets the entry currently on top, or <see langword="null"/> when empty.</summary>
27+
object? Top { get; }
28+
29+
/// <summary>
30+
/// Gets or sets a value indicating whether the entry below the top one stays visible.
31+
/// </summary>
32+
/// <remarks>
33+
/// Used when pushing a placeholder for a dialog: the dialog floats above the page, so the
34+
/// page underneath must keep rendering.
35+
/// </remarks>
36+
bool ShownBehindPage { get; set; }
37+
38+
/// <summary>
39+
/// Creates an empty native view suitable for use as a placeholder entry.
40+
/// </summary>
41+
/// <remarks>
42+
/// Dialogs are native popups rather than stack entries, but the stack still has to know
43+
/// something modal is on screen so that back-button handling and page ordering stay
44+
/// correct. This lets that placeholder be created without a dependency on NUI.
45+
/// </remarks>
46+
object CreatePlaceholder();
47+
48+
/// <summary>Pushes <paramref name="platformView"/> onto the stack.</summary>
49+
/// <param name="platformView">The native view to present.</param>
50+
/// <param name="animated"><see langword="true"/> to animate the transition.</param>
51+
Task PushAsync(object platformView, bool animated);
52+
53+
/// <summary>Pops the top entry off the stack.</summary>
54+
/// <param name="animated"><see langword="true"/> to animate the transition.</param>
55+
Task PopAsync(bool animated);
56+
57+
/// <summary>
58+
/// Removes <paramref name="platformView"/> from anywhere in the stack.
59+
/// </summary>
60+
/// <param name="platformView">The native view to remove.</param>
61+
/// <remarks>
62+
/// Used to unwind a placeholder that is no longer on top because something else was pushed
63+
/// while a dialog was open.
64+
/// </remarks>
65+
void Remove(object platformView);
66+
}
67+
68+
/// <summary>
69+
/// Turns a modal <see cref="Page"/> into the native view that represents it, and releases it
70+
/// again once the modal is dismissed.
71+
/// </summary>
72+
public interface ITizenModalPageRealizer
73+
{
74+
/// <summary>
75+
/// Creates or returns the native view for <paramref name="page"/>.
76+
/// </summary>
77+
/// <param name="page">The page to realize.</param>
78+
/// <param name="mauiContext">The window-scoped context to realize it in.</param>
79+
object Realize(Page page, IMauiContext mauiContext);
80+
81+
/// <summary>
82+
/// Releases the platform view created for <paramref name="page"/>.
83+
/// </summary>
84+
/// <param name="page">The page whose handler should be disconnected.</param>
85+
void Release(Page page);
86+
}
87+
88+
/// <summary>
89+
/// Installs the handler invoked when the hardware or software back button is pressed.
90+
/// </summary>
91+
public interface ITizenWindowBackButton
92+
{
93+
/// <summary>
94+
/// Sets the back-button handler, or clears it when <paramref name="handler"/> is
95+
/// <see langword="null"/>.
96+
/// </summary>
97+
/// <param name="handler">
98+
/// Returns <see langword="true"/> when the press was handled and should not fall through to
99+
/// the platform's default behaviour.
100+
/// </param>
101+
void SetBackButtonPressedHandler(Func<bool>? handler);
102+
}
103+
}

0 commit comments

Comments
 (0)