在 .NET MAUI 中如何更好地自定义控件

今天,我想谈谈并向您展示在.NET MAUI中完全自定义控件的方法。在查看 .NET MAUI 之前,让我们回到几年前,回到 Xamarin.Forms 时代。那时,我们有很多自定义控件的方法, 比如当您不需要访问平台特有的 API 来自定义控件时,可以使用Behaviors ; 如果您需要访问平台特有的 API,可以使用 Effects。.

让我们稍微关注一下Effects API。它是由于 Xamarin 缺乏多目标体系结构而创建的。这意味着我们无法在共享级别(在 .NET 标准 csproj 中)访问特定于平台的代码。它工作得很好,可以让您免于创建自定义渲染器。

今天,在 .NET MAUI 中,我们可以利用多目标架构的强大功能,并在我们的共享项目中访问特定于平台的 API。那么我们还需要 Effects 吗?不需要了,因为我们可以访问我们所需要的所有平台的所有代码和 API。

那么让我们谈谈在 .NET MAUI 中自定义一个控件的所有可能性以及在此过程中您可以遇到的一些障碍。为此,我们将自定义 Image 控件,添加对呈现的图像进行着色的功能。

注意:如果您想使用 Effects ,.NET MAUI仍然支持,但不建议使用

源代码参考来自 .NET MAUI Community Toolkit 的IconTintColor。

自定义现有控件 

要向现有控件添加额外的功能,需要我们对其进行扩展并添加所需的功能。

让我们创建一个新控件,class ImageTintColor : Image 并添加一个新的 BindableProperty,我们将利用它来更改 Image 的色调颜色。

public class ImageTintColor : Image{    public static readonly BindableProperty TintColorProperty =        BindableProperty.Create(nameof(TintColor), typeof(Color), typeof(ImageTintColor), propertyChanged: OnTintColorChanged);
    public Color? TintColor    {        get => (Color?)GetValue(TintColorProperty);        set => SetValue(TintColorProperty, value);    }
    static void OnTintColorChanged(BindableObject bindable, object oldValue, object newValue)    {        // ...    }}
熟悉 Xamarin.Forms 的人会认识到这一点;它与您将在 Xamarin.Forms 应用程序中编写的代码几乎相同。
.NET MAUI 平台特定的 API 工作将在 OnTintColorChanged 委托上进行。让我们来看看。
public class ImageTintColor : Image{    public static readonly BindableProperty TintColorProperty =        BindableProperty.Create(nameof(TintColor), typeof(Color), typeof(ImageTintColor), propertyChanged: OnTintColorChanged);
    public Color? TintColor    {        get => (Color?)GetValue(TintColorProperty);        set => SetValue(TintColorProperty, value);    }
    static void OnTintColorChanged(BindableObject bindable, object oldValue, object newValue)    {        var control = (ImageTintColor)bindable;        var tintColor = control.TintColor;
        if (control.Handler is null || control.Handler.PlatformView is null)        {            // 执行 Handler 且 PlatformView 为 null 时的解决方法            control.HandlerChanged += OnHandlerChanged;            return;        }
        if (tintColor is not null)        {#if ANDROID            // 注意 Android.Widget.ImageView 的使用,它是一个 Android 特定的 API            // 您可以在这里找到`ApplyColor`的Android实现:https://github.com/pictos/MFCC/blob/1ef490e507385e050b0cfb6e4f5d68f0cb0b2f60/MFCC/TintColorExtension.android.cs#L9-L12            ImageExtensions.ApplyColor((Android.Widget.ImageView)control.Handler.PlatformView, tintColor);#elif IOS            // 注意 UIKit.UIImage 的使用,它是一个 iOS 特定的 API            // 您可以在这里找到`ApplyColor`的iOS实现:https://github.com/pictos/MFCC/blob/1ef490e507385e050b0cfb6e4f5d68f0cb0b2f60/MFCC/TintColorExtension.ios.cs#L7-L11            ImageExtensions.ApplyColor((UIKit.UIImageView)control.Handler.PlatformView, tintColor);#endif        }        else        {#if ANDROID            // 注意 Android.Widget.ImageView 的使用,它是一个 Android 特定的 API            // 您可以在这里找到 `ClearColor` 的 Android 实现:https://github.com/pictos/MFCC/blob/1ef490e507385e050b0cfb6e4f5d68f0cb0b2f60/MFCC/TintColorExtension.android.cs#L14-L17            ImageExtensions.ClearColor((Android.Widget.ImageView)control.Handler.PlatformView);#elif IOS            // 注意 UIKit.UIImage 的使用,它是一个 iOS 特定的 API            // 您可以在这里找到`ClearColor`的iOS实现:https://github.com/pictos/MFCC/blob/1ef490e507385e050b0cfb6e4f5d68f0cb0b2f60/MFCC/TintColorExtension.ios.cs#L13-L16            ImageExtensions.ClearColor((UIKit.UIImageView)control.Handler.PlatformView);#endif        }
        void OnHandlerChanged(object s, EventArgs e)        {            OnTintColorChanged(control, oldValue, newValue);            control.HandlerChanged -= OnHandlerChanged;        }    }}
因为 .NET MAUI 使用多目标,我们可以访问平台的详细信息并按照我们想要的方式自定义控件。ImageExtensions.ApplyColor 和 ImageExtensions.ClearColor 方法是添加或删除图像色调的辅助方法。
您可能会注意到 Handler 和 PlatformView 的 null 检查。这可能是您在使用过程中遇到的第一个阻碍。在创建和实例化 Image 控件并调用 BindableProperty 的 PropertyChanged 委托时,Handler 可以为 null。因此,如果不进行 null 检查,代码将抛出 NullReferenceException。这听起来像一个bug,但它实际上是一个特性!这使 .NET MAUI 工程团队能够保持与 Xamarin.Forms 上的控件相同的生命周期,从而避免从 Forms 迁移到 .NET MAUI 的应用程序的一些重大更改。
现在我们已经完成了所有设置,可以在 ContentPage 中使用控件了。在下面的代码片段中,您可以看到如何在 XAML 中使用它:
<ContentPage x:Class="MyMauiApp.ImageControl"             xmlns="http://schemas.microsoft.com/dotnet/2021/maui"             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"             xmlns:local="clr-namespace:MyMauiApp"             Title="ImageControl"             BackgroundColor="White">
            <local:ImageTintColor x:Name="ImageTintColorControl"                                  Source="shield.png"                                  TintColor="Orange" /></ContentPage>

使用附加属性和 PropertyMapper 

自定义控件的另一种方法是使用 AttachedProperties,当您不需要将其绑定到特定的自定义控件时是 使用BindableProperty。

下面是我们如何为 TintColor 创建一个 AttachedProperty:

public static class TintColorMapper{    public static readonly BindableProperty TintColorProperty = BindableProperty.CreateAttached("TintColor", typeof(Color), typeof(Image), null);
    public static Color GetTintColor(BindableObject view) => (Color)view.GetValue(TintColorProperty);
    public static void SetTintColor(BindableObject view, Color? value) => view.SetValue(TintColorProperty, value);
    public static void ApplyTintColor()    {        // ...    }}

同样,我们在 Xamarin.Forms 上为 AttachedProperty 提供了样板,但如您所见,我们没有 PropertyChanged 委托。为了处理属性更改,我们将使用 ImageHandler 中的 Mapper。您可以在任何级别添加 Mapper,因为成员是静态的。我选择在 TintColorMapper 类中执行此操作,如下所示。

public static class TintColorMapper{     public static readonly BindableProperty TintColorProperty = BindableProperty.CreateAttached("TintColor", typeof(Color), typeof(Image), null);
    public static Color GetTintColor(BindableObject view) => (Color)view.GetValue(TintColorProperty);
    public static void SetTintColor(BindableObject view, Color? value) => view.SetValue(TintColorProperty, value);
    public static void ApplyTintColor()    {        ImageHandler.Mapper.Add("TintColor", (handler, view) =>        {            var tintColor = GetTintColor((Image)handler.VirtualView);
            if (tintColor is not null)            {#if ANDROID                // 注意 Android.Widget.ImageView 的使用,它是一个 Android 特定的 API                // 您可以在这里找到`ApplyColor`的Android实现:https://github.com/pictos/MFCC/blob/1ef490e507385e050b0cfb6e4f5d68f0cb0b2f60/MFCC/TintColorExtension.android.cs#L9-L12                ImageExtensions.ApplyColor((Android.Widget.ImageView)control.Handler.PlatformView, tintColor);#elif IOS                // 注意 UIKit.UIImage 的使用,它是一个 iOS 特定的 API                // 您可以在这里找到`ApplyColor`的iOS实现:https://github.com/pictos/MFCC/blob/1ef490e507385e050b0cfb6e4f5d68f0cb0b2f60/MFCC/TintColorExtension.ios.cs#L7-L11                ImageExtensions.ApplyColor((UIKit.UIImageView)handler.PlatformView, tintColor);#endif            }            else            {#if ANDROID                // 注意 Android.Widget.ImageView 的使用,它是一个 Android 特定的 API                // 您可以在这里找到 `ClearColor` 的 Android 实现:https://github.com/pictos/MFCC/blob/1ef490e507385e050b0cfb6e4f5d68f0cb0b2f60/MFCC/TintColorExtension.android.cs#L14-L17                ImageExtensions.ClearColor((Android.Widget.ImageView)handler.PlatformView);#elif IOS                // 注意 UIKit.UIImage 的使用,它是一个 iOS 特定的 API                // 您可以在这里找到`ClearColor`的iOS实现:https://github.com/pictos/MFCC/blob/1ef490e507385e050b0cfb6e4f5d68f0cb0b2f60/MFCC/TintColorExtension.ios.cs#L13-L16                ImageExtensions.ClearColor((UIKit.UIImageView)handler.PlatformView);#endif            }        });    }}

代码与之前显示的几乎相同,只是使用了另一个 API 实现,在本例中是 AppendToMapping 方法。如果您不想要这种行为,可以改用 CommandMapper,它将在属性更改或操作发生时触发。

请注意,当我们处理 Mapper 和 CommandMapper 时,我们将为项目中使用该处理程序的所有控件添加此行为。在这种情况下,所有Image控件都会触发此代码。在某些情况下这可能并不是您想要的,如果您需要更具体的方法, PlatformBehavior 方法将会非常适合。

现在我们已经设置好了所有内容,可以在页面中使用控件了,在下面的代码片段中,您可以看到如何在 XAML 中使用它。

<ContentPage x:Class="MyMauiApp.ImageControl"             xmlns="http://schemas.microsoft.com/dotnet/2021/maui"             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"             xmlns:local="clr-namespace:MyMauiApp"             Title="ImageControl"             BackgroundColor="White">
            <Image x:Name="Image"                   local:TintColorMapper.TintColor="Fuchsia"                   Source="shield.png" /></ContentPage>

使用PlatformBehavior

PlatformBehavior 是在 .NET MAUI 上创建的新 API,它让您在需要以安全的方式访问平台特有的 API 时,可以更轻松地自定义控件(这是安全的因为它确保 Handler 和 PlatformView 不为 null )。它有两种方法来重写:OnAttachedTo 和 OnDetachedFrom。此 API 用于替换 Xamarin.Forms 中的 Effect API 并利用多目标体系结构。

在此示例中,我们将使用部分类来实现特定于平台的 API:

//文件名 : ImageTintColorBehavior.cs
public partial class IconTintColorBehavior {    public static readonly BindableProperty TintColorProperty =        BindableProperty.Create(nameof(TintColor), typeof(Color), typeof(IconTintColorBehavior), propertyChanged: OnTintColorChanged);
    public Color? TintColor    {        get => (Color?)GetValue(TintColorProperty);        set => SetValue(TintColorProperty, value);    }}

上面的代码将被我们所针对的所有平台编译。

现在让我们看看 Android 平台的代码:

//文件名: ImageTintColorBehavior.android.cs
public partial class IconTintColorBehavior : PlatformBehavior<Image, ImageView> // 注意 ImageView 的使用,它是 Android 特定的 API{    protected override void OnAttachedTo(Image bindable, ImageView platformView) =>        ImageExtensions.ApplyColor(bindable, platformView); // 您可以在这里找到`ApplyColor`的Android实现:https://github.com/pictos/MFCC/blob/1ef490e507385e050b0cfb6e4f5d68f0cb0b2f60/MFCC/TintColorExtension.android.cs#L9-L12
    protected override void OnDetachedFrom(Image bindable, ImageView platformView) =>        ImageExtensions.ClearColor(platformView); // 您可以在这里找到 `ClearColor` 的 Android 实现:https://github.com/pictos/MFCC/blob/1ef490e507385e050b0cfb6e4f5d68f0cb0b2f60/MFCC/TintColorExtension.android.cs#L14-L17}

这是 iOS 平台的代码:

//文件名: ImageTintColorBehavior.ios.cs
public partial class IconTintColorBehavior : PlatformBehavior<Image, UIImageView> // 注意 UIImageView 的使用,它是一个 iOS 特定的 API{    protected override void OnAttachedTo(Image bindable, UIImageView platformView) =>         ImageExtensions.ApplyColor(bindable, platformView); // 你可以在这里找到`ApplyColor`的iOS实现:https://github.com/pictos/MFCC/blob/1ef490e507385e050b0cfb6e4f5d68f0cb0b2f60/MFCC/TintColorExtension.ios.cs#L7-L11
    protected override void OnDetachedFrom(Image bindable, UIImageView platformView) =>         ImageExtensions.ClearColor(platformView); // 你可以在这里找到`ClearColor`的iOS实现:https://github.com/pictos/MFCC/blob/1ef490e507385e050b0cfb6e4f5d68f0cb0b2f60/MFCC/TintColorExtension.ios.cs#L13-L16}

正如您所看到的,我们不需要关心是否 Handler 为 null ,因为 PlatformBehavior<T, U> 会为我们处理。

我们可以指定此行为涵盖的平台特有的 API 的类型。如果您想为多个类型应用控件,则无需指定平台视图的类型(例如,使用 PlatformBehavior<T> );您可能想在多个控件中应用您的行为,在这种情况下,platformView 将是 Android 上的 PlatformBehavior<View> 和 iOS 上的 PlatformBehavior<UIView>。

而且用法更好,您只需要调用 Behavior 即可:

<ContentPage x:Class="MyMauiApp.ImageControl"             xmlns="http://schemas.microsoft.com/dotnet/2021/maui"             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"             xmlns:local="clr-namespace:MyMauiApp"             Title="ImageControl"             BackgroundColor="White">
            <Image x:Name="Image"                   Source="shield.png">                <Image.Behaviors>                    <local:IconTintColorBehavior TintColor="Fuchsia">                </Image.Behaviors>            </Image></ContentPage>

注意:当 Handler 与 VirtualView 断开连接时,即触发 Unloaded 事件时,PlatformBehavior 将调用 OnDetachedFrom。Behavior API 不会自动调用 OnDetachedFrom 方法,作为开发者需要自己处理。

总结

在这篇文章中,我们讨论了自定义控件以及与平台特有的 API 交互的各种方式。没有正确或错误的方法,所有这些都是有效的解决方案,您只需要看看哪种方法更适合您的情况。我想说的是,在大多数情况下,您会想要使用 PlatformBehavior,因为它旨在使用多目标方法并确保在控件不再使用时清理资源。要了解更多信息,请查看有关自定义控件的文档。