diff --git a/docs/design/HandlerResolution.md b/docs/design/HandlerResolution.md index 93a5e1db85a3..d9b30e2ed3e2 100644 --- a/docs/design/HandlerResolution.md +++ b/docs/design/HandlerResolution.md @@ -3,7 +3,20 @@ Handler Resolution # Introduction -Handlers are the platform components used to render a cross platform `View` on the screen. Every platform registers a handler against a .NET Maui type. +Handlers are the platform components used to render a cross-platform `View` on the screen. Each view type is associated with a handler that knows how to create and manage the corresponding platform-native control. + +## Declaring a Handler with `[ElementHandler]` + +Most built-in .NET MAUI views declare their handler using the `[ElementHandler]` attribute directly on the view class: + +```csharp +[ElementHandler(typeof(ButtonHandler))] +public partial class Button : View, IButton { ... } +``` + +This is the primary mechanism for associating views with handlers. It is trimmer-safe and AOT-friendly because the handler type is statically referenced. + +The attribute is declared with `Inherited = false`, so each view type must explicitly declare it. However, `MauiHandlersFactory` walks the type's base class hierarchy (`Type.BaseType`) when looking for the attribute, so a base class attribute acts as a fallback for derived types that don't declare their own. ## Registering a Handler in Code @@ -14,6 +27,31 @@ builder.ConfigureMauiHandlers(handlers => } ``` +DI registration should only be used to override an existing `[ElementHandler]` declaration or when the element type is an interface (e.g., `IScrollView`). DI-registered handlers take priority over `[ElementHandler]` attributes when the registered type is assignable from the requested element type. + +## Resolution Order + +Both `MauiHandlersFactory.GetHandler(Type)` and `MauiHandlersFactory.GetHandlerType(Type)` follow the same resolution order: + +1. **Exact DI registration** — checks if a handler was registered for this exact type via `AddHandler` +2. **Assignable DI registration** — uses `RegisteredHandlerServiceTypeSet` to find the best matching concrete or interface registration (e.g., a handler registered for `Button` matches a derived `FancyButton`, and a handler registered for `IScrollView` matches a `ScrollView` instance) +3. **`[ElementHandler]` attribute** — walks the type's base class hierarchy looking for the attribute +4. **`IContentView` fallback** — returns `ContentViewHandler` for any `IContentView` implementation +5. **`GetHandlerType` returns `null`** / **`GetHandler` throws `HandlerNotFoundException`** — if none of the above matched + +### Handler Instantiation + +How a handler instance is created depends on how it was resolved: + +- **DI-registered handlers** (steps 1 & 2): Instantiated through `MauiFactory.GetService()`, which uses `Activator.CreateInstance` on the registered `ImplementationType`, or invokes the `ImplementationFactory` delegate if one was provided. +- **`[ElementHandler]` attribute** (step 3): Instantiated directly via `Activator.CreateInstance` — no DI involvement. +- **Fallback in `ElementExtensions.ToHandler()`**: When `Activator.CreateInstance` fails with a `MissingMethodException` (e.g., the handler requires constructor parameters), `ActivatorUtilities.CreateInstance` is used instead, which supports constructor injection from the DI container. + +> **Note:** Handlers registered via `[ElementHandler]` must have a public parameterless constructor. +> They are instantiated with `Activator.CreateInstance()`, not through the DI container. +> The `ActivatorUtilities.CreateInstance()` fallback only applies to DI-registered handlers +> resolved through `ElementExtensions.ToHandler()`. + ## Types used in the resolution of Handlers to Views ### `MauiFactory` @@ -34,8 +72,7 @@ public class MauiHandlersFactory : MauiFactory, IMauiHandlersFactory - `MauiFactory` has support for `ctor` resolution but we currently have it disabled in all cases. - Handlers will currently attempt to instantiate through [Extensions.DependencyInjection.ActivatorUtilities.CreateInstance](https://github.com/dotnet/maui/blob/cc53f0979baf5d6bb8a5d6bf84b64f3cf591c56f/src/Core/src/Platform/ElementExtensions.cs#L34 ) if a default constructor hasn't been created. So the ctor resolution feature of `MauiFactory` probably doesn't have any currently useful purpose. - `MauiFactory` currently doesn't support Scoped Services which is the main reason why we switched to `Ms.Ext.DI` for our main implementation. .NET MAUI Blazor requires Scoped Services and we've started using Scoped Services as well for multi-window. -- `MauiFactory` retrieves all base types from the requested type and all implemented interfaces. It first iterates over base types and then if nothing is found it loops through the interfaces. The interface behavior currently leads to some odd behavior because everything implements `IView`. This means that if a handler isn't registered then `MauiFactory` just returns a random handler because technically every single handler is registered against a cross platform view that implements`IView`. https://github.com/dotnet/maui/issues/1298 - - We should probably remove the interface matching part of `MauiFactory` +- `MauiFactory` retrieves the handler type registered for the requested type. Interface-based registration matching is now handled by `RegisteredHandlerServiceTypeSet`, which finds the most specific matching interface to avoid ambiguity (the old behavior of matching any `IView`-implementing interface has been fixed — see https://github.com/dotnet/maui/issues/1298). ### IMauiHandlersFactory @@ -54,7 +91,6 @@ public interface IMauiHandlersFactory : IMauiFactory Type? GetHandlerType(Type iview); IElementHandler? GetHandler(Type type); IElementHandler? GetHandler() where T : IElement; - IMauiHandlersCollection GetCollection(); } ``` diff --git a/src/Controls/samples/Controls.Sample/Controls/BordelessEntry/BordelessEntryServiceBuilder.cs b/src/Controls/samples/Controls.Sample/Controls/BordelessEntry/BordelessEntryServiceBuilder.cs index 647bd41eafe8..7599234bf010 100644 --- a/src/Controls/samples/Controls.Sample/Controls/BordelessEntry/BordelessEntryServiceBuilder.cs +++ b/src/Controls/samples/Controls.Sample/Controls/BordelessEntry/BordelessEntryServiceBuilder.cs @@ -57,7 +57,9 @@ public void Initialize(IServiceProvider services) } } +#pragma warning disable CS0618 // Obsolete BordelessEntryServiceBuilder.HandlersCollection ??= services.GetRequiredService().GetCollection(); +#pragma warning restore CS0618 if (BordelessEntryServiceBuilder.PendingHandlers.Count > 0) { diff --git a/src/Controls/src/Core/ActivityIndicator/ActivityIndicator.cs b/src/Controls/src/Core/ActivityIndicator/ActivityIndicator.cs index 0498db4a2c80..b7b7d203e227 100644 --- a/src/Controls/src/Core/ActivityIndicator/ActivityIndicator.cs +++ b/src/Controls/src/Core/ActivityIndicator/ActivityIndicator.cs @@ -1,8 +1,10 @@ #nullable disable using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using Microsoft.Maui.Graphics; +using Microsoft.Maui.Handlers; namespace Microsoft.Maui.Controls { @@ -13,8 +15,27 @@ namespace Microsoft.Maui.Controls /// This control gives a visual clue to the user that something is happening, without information about its progress. /// [DebuggerDisplay("{GetDebuggerDisplay(), nq}")] +#if ANDROID + [ActivityIndicatorHandler] +#else + [ElementHandler(typeof(ActivityIndicatorHandler))] +#endif public partial class ActivityIndicator : View, IColorElement, IElementConfiguration, IActivityIndicator { +#if ANDROID + internal sealed class ActivityIndicatorHandlerAttribute : ElementHandlerAttribute + { + [return: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + public override Type GetHandlerType() + { + if (RuntimeFeature.IsMaterial3Enabled) + return typeof(ActivityIndicatorHandler2); + + return typeof(ActivityIndicatorHandler); + } + } +#endif + /// Bindable property for . public static readonly BindableProperty IsRunningProperty = BindableProperty.Create(nameof(IsRunning), typeof(bool), typeof(ActivityIndicator), default(bool)); @@ -63,4 +84,4 @@ private protected override string GetDebuggerDisplay() return $"{base.GetDebuggerDisplay()}, {debugText}"; } } -} \ No newline at end of file +} diff --git a/src/Controls/src/Core/Application/Application.Mapper.cs b/src/Controls/src/Core/Application/Application.Mapper.cs index e67bb20e1151..23da91a8023a 100644 --- a/src/Controls/src/Core/Application/Application.Mapper.cs +++ b/src/Controls/src/Core/Application/Application.Mapper.cs @@ -1,13 +1,20 @@ #nullable disable using System; +using System.Threading; using Microsoft.Maui.Controls.Compatibility; namespace Microsoft.Maui.Controls { public partial class Application { - internal static new void RemapForControls() + static int s_remappedForControls; + internal override void RemapForControls() { + if (Interlocked.CompareExchange(ref s_remappedForControls, 1, 0) != 0) + return; + + base.RemapForControls(); + // Adjust the mappings to preserve Controls.Application legacy behaviors #if ANDROID // There is also a mapper on Window for this property since this property is relevant at the window level for diff --git a/src/Controls/src/Core/Application/Application.cs b/src/Controls/src/Core/Application/Application.cs index 5e58016a9eb0..d30daae10be3 100644 --- a/src/Controls/src/Core/Application/Application.cs +++ b/src/Controls/src/Core/Application/Application.cs @@ -18,6 +18,7 @@ namespace Microsoft.Maui.Controls /// /// Represents the main application class that provides lifecycle management, resources, and theming. /// + [ElementHandler(typeof(ApplicationHandler))] public partial class Application : Element, IResourcesProvider, IApplicationController, IElementConfiguration, IVisualTreeElement, IApplication { readonly WeakEventManager _weakEventManager = new WeakEventManager(); diff --git a/src/Controls/src/Core/Border/Border.cs b/src/Controls/src/Core/Border/Border.cs index 6447de28da1a..3fc0fd2e7007 100644 --- a/src/Controls/src/Core/Border/Border.cs +++ b/src/Controls/src/Core/Border/Border.cs @@ -6,6 +6,7 @@ using Microsoft.Maui.Controls.Shapes; using Microsoft.Maui.Graphics; using Microsoft.Maui.Layouts; +using Microsoft.Maui.Handlers; namespace Microsoft.Maui.Controls { @@ -17,6 +18,7 @@ namespace Microsoft.Maui.Controls /// background, shape, padding, and more to create visually rich containers. /// [ContentProperty(nameof(Content))] + [ElementHandler(typeof(BorderHandler))] public class Border : View, IContentView, IBorderView, IPaddingElement, ISafeAreaElement, ISafeAreaView2 { float[]? _strokeDashPattern; diff --git a/src/Controls/src/Core/BoxView/BoxView.cs b/src/Controls/src/Core/BoxView/BoxView.cs index 2b97d0903e89..0243cffb1ea5 100644 --- a/src/Controls/src/Core/BoxView/BoxView.cs +++ b/src/Controls/src/Core/BoxView/BoxView.cs @@ -2,12 +2,14 @@ using System; using System.Runtime.CompilerServices; using Microsoft.Maui.Graphics; +using Microsoft.Maui.Controls.Handlers; namespace Microsoft.Maui.Controls { /// /// A used to draw a solid colored rectangle. /// + [ElementHandler(typeof(BoxViewHandler))] public partial class BoxView : View, IColorElement, ICornerElement, IElementConfiguration, IShapeView, IShape { WeakBrushChangedProxy _fillProxy = null; diff --git a/src/Controls/src/Core/Button/Button.Mapper.cs b/src/Controls/src/Core/Button/Button.Mapper.cs index 53bbdb65d5e9..6eedef6c93b9 100644 --- a/src/Controls/src/Core/Button/Button.Mapper.cs +++ b/src/Controls/src/Core/Button/Button.Mapper.cs @@ -1,6 +1,6 @@ #nullable disable using System; -using System.Collections.Generic; +using System.Threading; using System.Text; using Microsoft.Maui.Controls.Compatibility; using Microsoft.Maui.Controls.Platform; @@ -11,9 +11,14 @@ namespace Microsoft.Maui.Controls public partial class Button { // IButton does not include the ContentType property, so we map it here to handle Image Positioning - - internal new static void RemapForControls() + static int s_remappedForControls; + internal override void RemapForControls() { + if (Interlocked.CompareExchange(ref s_remappedForControls, 1, 0) != 0) + return; + + base.RemapForControls(); + ButtonHandler.Mapper.ReplaceMapping(nameof(ContentLayout), MapContentLayout); #if IOS ButtonHandler.Mapper.ReplaceMapping(nameof(Padding), MapPadding); diff --git a/src/Controls/src/Core/Button/Button.cs b/src/Controls/src/Core/Button/Button.cs index cc87be1f60c8..4696dea90fde 100644 --- a/src/Controls/src/Core/Button/Button.cs +++ b/src/Controls/src/Core/Button/Button.cs @@ -6,6 +6,7 @@ using System.Runtime.CompilerServices; using System.Windows.Input; using Microsoft.Maui.Controls.Internals; +using Microsoft.Maui.Handlers; using Microsoft.Maui.Graphics; @@ -15,6 +16,7 @@ namespace Microsoft.Maui.Controls /// A button that reacts to touch events. /// [DebuggerDisplay("{GetDebuggerDisplay(), nq}")] + [ElementHandler(typeof(ButtonHandler))] public partial class Button : View, IFontElement, ITextElement, IBorderElement, IButtonController, IElementConfiguration