Skip to content

Commit b33290c

Browse files
RedthCopilot
andcommitted
Add ViewHandler.SetContainerView for external platform backends
ViewHandler.SetupContainer() and RemoveContainer() are protected extensibility points, but ContainerView's setter is `private protected`. A handler deriving from ViewHandler<TVirtualView, TPlatformView> in another assembly - for example a platform backend that ships outside of dotnet/maui - therefore has no way to publish the wrapper it created, so it has to report NeedsContainer => false and loses gradient/image backgrounds, clip and shadow. Add a narrow `protected void SetContainerView(PlatformView?)` to ViewHandler instead of widening the property setter. It only records the container view: it never re-parents anything and it does not change HasContainer, so SetupContainer/RemoveContainer stay in charge of the lifecycle and all existing built-in handlers are untouched. Platform type safety is preserved through a `private protected virtual` ValidateContainerView hook. iOS/MacCatalyst and Tizen shadow ContainerView with a WrapperView-typed property that hard-casts the base value, so those overrides reject non-WrapperView containers with a clear ArgumentException instead of failing later with an InvalidCastException. Tests live in a new Core.ExternalBackend project whose assembly is deliberately not an InternalsVisibleTo friend of Microsoft.Maui, so the fact that it compiles proves the API is reachable externally. The new unit tests cover setup/remove re-parenting and ordering, NeedsContainer transitions driven through MapContainerView, repeated round trips, no-op HasContainer assignments, direct SetContainerView install/clear, and handler disconnect. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent bedd1b1 commit b33290c

21 files changed

Lines changed: 687 additions & 2 deletions

Microsoft.Maui-dev.sln

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestUtils", "src\TestUtils\
3737
EndProject
3838
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core.UnitTests", "src\Core\tests\UnitTests\Core.UnitTests.csproj", "{92644F6F-5946-48FC-A21A-A3D6EE24E8B3}"
3939
EndProject
40+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core.ExternalBackend", "src\Core\tests\ExternalBackend\Core.ExternalBackend.csproj", "{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41}"
41+
EndProject
4042
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E8AD265B-3C67-4640-AC58-A522F9FB3361}"
4143
EndProject
4244
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{C564DDD6-DE79-45CD-88EA-3F690481572A}"
@@ -309,6 +311,10 @@ Global
309311
{92644F6F-5946-48FC-A21A-A3D6EE24E8B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
310312
{92644F6F-5946-48FC-A21A-A3D6EE24E8B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
311313
{92644F6F-5946-48FC-A21A-A3D6EE24E8B3}.Release|Any CPU.Build.0 = Release|Any CPU
314+
{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
315+
{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41}.Debug|Any CPU.Build.0 = Debug|Any CPU
316+
{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41}.Release|Any CPU.ActiveCfg = Release|Any CPU
317+
{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41}.Release|Any CPU.Build.0 = Release|Any CPU
312318
{F7F2B379-52CE-4802-9EC9-0D7967B6BFB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
313319
{F7F2B379-52CE-4802-9EC9-0D7967B6BFB7}.Debug|Any CPU.Build.0 = Debug|Any CPU
314320
{F7F2B379-52CE-4802-9EC9-0D7967B6BFB7}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -679,6 +685,7 @@ Global
679685
{DAAC2822-63B6-4DE0-83AE-04873CD2F364} = {72397ADB-40A8-4B8E-8E08-2DBE2803C845}
680686
{FBB3270F-1924-4A72-845E-A6DF39C402F6} = {7AC28763-9C68-4BF9-A1BA-25CBFFD2D15C}
681687
{92644F6F-5946-48FC-A21A-A3D6EE24E8B3} = {C564DDD6-DE79-45CD-88EA-3F690481572A}
688+
{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41} = {C564DDD6-DE79-45CD-88EA-3F690481572A}
682689
{E8AD265B-3C67-4640-AC58-A522F9FB3361} = {09C264E9-E3F3-4586-9151-DCBB1F6DA7AB}
683690
{C564DDD6-DE79-45CD-88EA-3F690481572A} = {09C264E9-E3F3-4586-9151-DCBB1F6DA7AB}
684691
{50C758FE-4E10-409A-94F5-A75480960864} = {459BF674-83CB-46F6-881F-A2D2117DBF4D}

Microsoft.Maui-mac.slnf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"src\\Core\\tests\\Benchmarks\\Core.Benchmarks.csproj",
4040
"src\\Core\\tests\\DeviceTests.Shared\\Core.DeviceTests.Shared.csproj",
4141
"src\\Core\\tests\\DeviceTests\\Core.DeviceTests.csproj",
42+
"src\\Core\\tests\\ExternalBackend\\Core.ExternalBackend.csproj",
4243
"src\\Core\\tests\\UnitTests\\Core.UnitTests.csproj",
4344
"src\\Essentials\\samples\\Sample.Server.WebAuthenticator\\Essentials.Sample.Server.WebAuthenticator.csproj",
4445
"src\\Essentials\\samples\\Samples.Server.Passkeys\\Essentials.Samples.Server.Passkeys.csproj",

Microsoft.Maui-vscode.sln

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestUtils", "src\TestUtils\
3535
EndProject
3636
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core.UnitTests", "src\Core\tests\UnitTests\Core.UnitTests.csproj", "{92644F6F-5946-48FC-A21A-A3D6EE24E8B3}"
3737
EndProject
38+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core.ExternalBackend", "src\Core\tests\ExternalBackend\Core.ExternalBackend.csproj", "{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41}"
39+
EndProject
3840
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E8AD265B-3C67-4640-AC58-A522F9FB3361}"
3941
EndProject
4042
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{C564DDD6-DE79-45CD-88EA-3F690481572A}"
@@ -272,6 +274,10 @@ Global
272274
{92644F6F-5946-48FC-A21A-A3D6EE24E8B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
273275
{92644F6F-5946-48FC-A21A-A3D6EE24E8B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
274276
{92644F6F-5946-48FC-A21A-A3D6EE24E8B3}.Release|Any CPU.Build.0 = Release|Any CPU
277+
{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
278+
{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41}.Debug|Any CPU.Build.0 = Debug|Any CPU
279+
{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41}.Release|Any CPU.ActiveCfg = Release|Any CPU
280+
{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41}.Release|Any CPU.Build.0 = Release|Any CPU
275281
{F7F2B379-52CE-4802-9EC9-0D7967B6BFB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
276282
{F7F2B379-52CE-4802-9EC9-0D7967B6BFB7}.Debug|Any CPU.Build.0 = Debug|Any CPU
277283
{F7F2B379-52CE-4802-9EC9-0D7967B6BFB7}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -591,6 +597,7 @@ Global
591597
{DAAC2822-63B6-4DE0-83AE-04873CD2F364} = {72397ADB-40A8-4B8E-8E08-2DBE2803C845}
592598
{FBB3270F-1924-4A72-845E-A6DF39C402F6} = {7AC28763-9C68-4BF9-A1BA-25CBFFD2D15C}
593599
{92644F6F-5946-48FC-A21A-A3D6EE24E8B3} = {C564DDD6-DE79-45CD-88EA-3F690481572A}
600+
{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41} = {C564DDD6-DE79-45CD-88EA-3F690481572A}
594601
{E8AD265B-3C67-4640-AC58-A522F9FB3361} = {09C264E9-E3F3-4586-9151-DCBB1F6DA7AB}
595602
{C564DDD6-DE79-45CD-88EA-3F690481572A} = {09C264E9-E3F3-4586-9151-DCBB1F6DA7AB}
596603
{50C758FE-4E10-409A-94F5-A75480960864} = {459BF674-83CB-46F6-881F-A2D2117DBF4D}

Microsoft.Maui-windows.slnf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"src\\Core\\tests\\Benchmarks\\Core.Benchmarks.csproj",
4747
"src\\Core\\tests\\DeviceTests.Shared\\Core.DeviceTests.Shared.csproj",
4848
"src\\Core\\tests\\DeviceTests\\Core.DeviceTests.csproj",
49+
"src\\Core\\tests\\ExternalBackend\\Core.ExternalBackend.csproj",
4950
"src\\Core\\tests\\UnitTests\\Core.UnitTests.csproj",
5051
"src\\Essentials\\samples\\Sample.Server.WebAuthenticator\\Essentials.Sample.Server.WebAuthenticator.csproj",
5152
"src\\Essentials\\samples\\Samples.Server.Passkeys\\Essentials.Samples.Server.Passkeys.csproj",

Microsoft.Maui.sln

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestUtils", "src\TestUtils\
3737
EndProject
3838
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core.UnitTests", "src\Core\tests\UnitTests\Core.UnitTests.csproj", "{92644F6F-5946-48FC-A21A-A3D6EE24E8B3}"
3939
EndProject
40+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core.ExternalBackend", "src\Core\tests\ExternalBackend\Core.ExternalBackend.csproj", "{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41}"
41+
EndProject
4042
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E8AD265B-3C67-4640-AC58-A522F9FB3361}"
4143
EndProject
4244
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{C564DDD6-DE79-45CD-88EA-3F690481572A}"
@@ -298,6 +300,10 @@ Global
298300
{92644F6F-5946-48FC-A21A-A3D6EE24E8B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
299301
{92644F6F-5946-48FC-A21A-A3D6EE24E8B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
300302
{92644F6F-5946-48FC-A21A-A3D6EE24E8B3}.Release|Any CPU.Build.0 = Release|Any CPU
303+
{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
304+
{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41}.Debug|Any CPU.Build.0 = Debug|Any CPU
305+
{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41}.Release|Any CPU.ActiveCfg = Release|Any CPU
306+
{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41}.Release|Any CPU.Build.0 = Release|Any CPU
301307
{F7F2B379-52CE-4802-9EC9-0D7967B6BFB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
302308
{F7F2B379-52CE-4802-9EC9-0D7967B6BFB7}.Debug|Any CPU.Build.0 = Debug|Any CPU
303309
{F7F2B379-52CE-4802-9EC9-0D7967B6BFB7}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -660,6 +666,7 @@ Global
660666
{DAAC2822-63B6-4DE0-83AE-04873CD2F364} = {72397ADB-40A8-4B8E-8E08-2DBE2803C845}
661667
{FBB3270F-1924-4A72-845E-A6DF39C402F6} = {7AC28763-9C68-4BF9-A1BA-25CBFFD2D15C}
662668
{92644F6F-5946-48FC-A21A-A3D6EE24E8B3} = {C564DDD6-DE79-45CD-88EA-3F690481572A}
669+
{7A3D1D2E-6C9B-4C0E-8E2E-2E9F1D5B7C41} = {C564DDD6-DE79-45CD-88EA-3F690481572A}
663670
{E8AD265B-3C67-4640-AC58-A522F9FB3361} = {09C264E9-E3F3-4586-9151-DCBB1F6DA7AB}
664671
{C564DDD6-DE79-45CD-88EA-3F690481572A} = {09C264E9-E3F3-4586-9151-DCBB1F6DA7AB}
665672
{50C758FE-4E10-409A-94F5-A75480960864} = {459BF674-83CB-46F6-881F-A2D2117DBF4D}

src/Core/src/Handlers/View/ViewHandler.cs

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,13 +144,15 @@ public virtual bool NeedsContainer
144144
/// <summary>
145145
/// Constructs the <see cref="ContainerView"/> and adds <see cref="PlatformView"/> to a container.
146146
/// </summary>
147-
/// <remarks>This method is called when <see cref="HasContainer"/> is set to <see langword="true"/>.</remarks>
147+
/// <remarks>This method is called when <see cref="HasContainer"/> is set to <see langword="true"/>.
148+
/// Overrides should call <see cref="SetContainerView(PlatformView?)"/> to publish the container they created.</remarks>
148149
protected abstract void SetupContainer();
149150

150151
/// <summary>
151152
/// Deconstructs the <see cref="ContainerView"/> and removes <see cref="PlatformView"/> from its container.
152153
/// </summary>
153-
/// <remarks>This method is called when <see cref="HasContainer"/> is set to <see langword="false"/>.</remarks>
154+
/// <remarks>This method is called when <see cref="HasContainer"/> is set to <see langword="false"/>.
155+
/// Overrides should call <see cref="SetContainerView(PlatformView?)"/> with <see langword="null"/> to clear the container they removed.</remarks>
154156
protected abstract void RemoveContainer();
155157

156158
/// <summary>
@@ -159,6 +161,83 @@ public virtual bool NeedsContainer
159161
/// <remarks>Note that this can be <see langword="null"/>. Especially when <see cref="HasContainer"/> is set to <see langword="false"/> this value might not be set.</remarks>
160162
public PlatformView? ContainerView { get; private protected set; }
161163

164+
/// <summary>
165+
/// Sets or clears the view returned by <see cref="ContainerView"/>.
166+
/// </summary>
167+
/// <param name="containerView">The platform view that wraps <see cref="PlatformView"/>, or <see langword="null"/> to clear the current container view.</param>
168+
/// <remarks>
169+
/// <para>This is the supported way for a handler that lives in another assembly - for example a platform backend that
170+
/// ships outside of .NET MAUI - to participate in the container view lifecycle. Call it from
171+
/// <see cref="SetupContainer"/> after the container has been created and the <see cref="PlatformView"/> has been
172+
/// re-parented into it, and call it with <see langword="null"/> from <see cref="RemoveContainer"/> after the
173+
/// <see cref="PlatformView"/> has been moved back to the original parent.</para>
174+
/// <para>This method only records the container view; it never re-parents views, and it does not change
175+
/// <see cref="HasContainer"/>. Attaching and detaching the platform views remains the responsibility of the
176+
/// <see cref="SetupContainer"/> and <see cref="RemoveContainer"/> overrides, which .NET MAUI invokes when
177+
/// <see cref="HasContainer"/> changes.</para>
178+
/// <example>
179+
/// A handler in an external backend assembly:
180+
/// <code language="csharp">
181+
/// public class MyBackendViewHandler&lt;TVirtualView, TPlatformView&gt; : ViewHandler&lt;TVirtualView, TPlatformView&gt;
182+
/// where TVirtualView : class, IView
183+
/// where TPlatformView : MyPlatformView
184+
/// {
185+
/// public override bool NeedsContainer =>
186+
/// VirtualView?.Background is not null ||
187+
/// VirtualView?.Clip is not null ||
188+
/// VirtualView?.Shadow is not null ||
189+
/// base.NeedsContainer;
190+
///
191+
/// protected override void SetupContainer()
192+
/// {
193+
/// if (PlatformView is null || ContainerView is not null)
194+
/// return;
195+
///
196+
/// var wrapper = new MyWrapperView();
197+
/// var parent = PlatformView.Parent;
198+
/// parent?.Remove(PlatformView);
199+
/// wrapper.Content = PlatformView;
200+
/// parent?.Add(wrapper);
201+
///
202+
/// SetContainerView(wrapper);
203+
/// }
204+
///
205+
/// protected override void RemoveContainer()
206+
/// {
207+
/// if (ContainerView is not MyWrapperView wrapper)
208+
/// {
209+
/// SetContainerView(null);
210+
/// return;
211+
/// }
212+
///
213+
/// var parent = wrapper.Parent;
214+
/// parent?.Remove(wrapper);
215+
/// wrapper.Content = null;
216+
/// parent?.Add(PlatformView);
217+
///
218+
/// SetContainerView(null);
219+
/// }
220+
/// }
221+
/// </code>
222+
/// </example>
223+
/// </remarks>
224+
/// <exception cref="System.ArgumentException">Thrown when <paramref name="containerView"/> is not a container type that this handler supports.</exception>
225+
protected void SetContainerView(PlatformView? containerView)
226+
{
227+
if (containerView is not null)
228+
{
229+
ValidateContainerView(containerView);
230+
}
231+
232+
ContainerView = containerView;
233+
}
234+
235+
// Lets a platform-specific handler reject container views that would break the strongly typed
236+
// ContainerView property it shadows (for example the WrapperView-typed property on iOS and Tizen).
237+
private protected virtual void ValidateContainerView(PlatformView containerView)
238+
{
239+
}
240+
162241
object? IViewHandler.ContainerView => ContainerView;
163242

164243
/// <summary>

src/Core/src/Handlers/View/ViewHandlerOfT.Tizen.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ public abstract partial class ViewHandler<TVirtualView, TPlatformView> : IPlatfo
2121
protected set => base.ContainerView = value;
2222
}
2323

24+
private protected override void ValidateContainerView(NView containerView)
25+
{
26+
if (containerView is not WrapperView)
27+
{
28+
throw new ArgumentException(
29+
$"The container view must be a {nameof(WrapperView)} because {GetType().Name} exposes {nameof(ContainerView)} as a {nameof(WrapperView)}.",
30+
nameof(containerView));
31+
}
32+
}
33+
2434
~ViewHandler()
2535
{
2636
Dispose(disposing: false);

src/Core/src/Handlers/View/ViewHandlerOfT.iOS.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.Maui.Graphics;
2+
using System;
23
using UIKit;
34

45
namespace Microsoft.Maui.Handlers
@@ -11,6 +12,16 @@ public partial class ViewHandler<TVirtualView, TPlatformView> : IPlatformViewHan
1112
protected set => base.ContainerView = value;
1213
}
1314

15+
private protected override void ValidateContainerView(UIView containerView)
16+
{
17+
if (containerView is not WrapperView)
18+
{
19+
throw new ArgumentException(
20+
$"The container view must be a {nameof(WrapperView)} because {GetType().Name} exposes {nameof(ContainerView)} as a {nameof(WrapperView)}.",
21+
nameof(containerView));
22+
}
23+
}
24+
1425
public UIViewController? ViewController { get; set; }
1526

1627
public override void PlatformArrange(Rect rect) =>

src/Core/src/PublicAPI/net-android/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,3 +363,4 @@ virtual Microsoft.Maui.Platform.StackNavigationManager.OnCreateNavigationAnimati
363363
Microsoft.Maui.ISwipeItemMenuItemIconColor
364364
Microsoft.Maui.ISwipeItemMenuItemIconColor.IconColor.get -> Microsoft.Maui.Graphics.Color?
365365
static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void
366+
Microsoft.Maui.Handlers.ViewHandler.SetContainerView(Android.Views.View? containerView) -> void

src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,4 @@ virtual Microsoft.Maui.Animations.PlatformTicker.Dispose(bool disposing) -> void
6363
Microsoft.Maui.ISwipeItemMenuItemIconColor
6464
Microsoft.Maui.ISwipeItemMenuItemIconColor.IconColor.get -> Microsoft.Maui.Graphics.Color?
6565
static Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.MapIconColor(Microsoft.Maui.Handlers.ISwipeItemMenuItemHandler! handler, Microsoft.Maui.ISwipeItemMenuItem! view) -> void
66+
Microsoft.Maui.Handlers.ViewHandler.SetContainerView(UIKit.UIView? containerView) -> void

0 commit comments

Comments
 (0)