.NET MAUIのコントロールを自作しよう

.NET MAUI (.NET Multi-platform App UI)は .NET 技術をベースとしてネイティブアプリを開発するためのクロスプラットフォームフレームワークです。本記事では .NET MAUI におけるカスタムコントロールの作り方を紹介します。


はじめに

2024年5月に .NET MAUI の前身である Xamarin は EOL を迎えます。

情報システム部ではいくつか Xamarin.Forms を使用した社内向けアプリを配信しており、 .NET MAUI への移行は急務です。

本記事は Xamarin.Forms で開発したクロスプラットフォームアプリを .NET MAUI に移植するにあたりカスタムコントロールの実装方法を調査したため、メモとしてまとめたものとなります。

公式のドキュメント・サンプルはこちらです。

learn.microsoft.com

カスタムコントロールのアーキテクチャー

.NET MAUI において、コントロールのレンダリングは各プラットフォームのネイティブコントロールを利用して行われています。

Xamarin.Forms 時代は Xamarin.Forms のコントロールとネイティブのコントロールとは Renderer で紐付けられていましたが、 .NET MAUI では Handler を使用します。

次の図は、 .NET MAUI のコントロールがネイティブのコントロールに紐付けられるまでを表しています。

.NET MAUI コントロールのアーキテクチャー
.NET MAUI ハンドラーを使用してカスタム コントロールを作成する - .NET MAUI | Microsoft Learn より

図をまとめると大体このようなことが分かります。

  1. .NET MAUI のクロスプラットフォーム部分で扱う仮想のクロスプラットフォームコントロールがある(図では Video
  2. 仮想コントロールをレンダリングするネイティブのコントロールがある(図では MauiVideoPlayer とその下のコントロール群)
  3. 仮想コントロールとネイティブコントロールとは Handler で紐付けられている

カスタムコントロールを作成する

アーキテクチャーの雰囲気を理解したところで、実際にカスタムコントロールを作成してみます。

今回はシンプルにな Web 表示を行うことができる WebView を実装してみます。ただし全ての環境に対して実装すると分量が大変多くなってしまうため、 Windows 環境に対してのみ実装します。

試した環境は以下となっています。

要素 バージョン
OS Windows 11 22H2
Visual Studio 2022 (17.9.1)
.NET SDK 8.0.201
.NET Workload (maui-windows) 8.0.6 / 8.0.100

最終的なソースコードは以下のレポジトリーに保存しています。

github.com

空のカスタムコントロールの作成

まずは、特に何も中身が無い、空の状態のコントロールを実装します。

仮想コントロール実装

仮想コントロールの CustomWebView を作成します。

仮想コントロールは Microsoft.Maui.Controls.View を継承したクラスとします。

また、大体どのサンプルでも仮想コントロールに対してインタフェースが定義されているため、本記事でもインターフェースを作成しています。

// Controls/ICustomWebView.cs

namespace SampleLib.Controls;

/// <summary>
/// カスタムWebViewのインターフェース
/// </summary>
public interface ICustomWebView
{
}
// Controls/CustomWebView.cs

namespace SampleLib.Controls;

/// <summary>
/// カスタムWebViewの仮想コントロール
/// </summary>
public class CustomWebView : View, ICustomWebView
{
}
ネイティブコントロール実装

仮想コントロールに対応するネイティブのコントロールを作成します。

継承元は Microsoft.UI.Xaml.FrameworkElement とします。 また、コンストラクターで仮想コントロールのインスタンスを受け取っておきます。

複数プラットフォームを利用する場合は、これをプラットフォーム分だけ作成します。

// Platforms/Windows/Controls/PlatformCustomWebView.cs

using Microsoft.UI.Xaml;

namespace SampleLib.Controls;

/// <summary>
/// Windows環境でCustomWebViewをレンダリングするコントロール
/// </summary>
public class PlatformCustomWebView : FrameworkElement
{
    private readonly CustomWebView _virtualView;

    public PlatformCustomWebView(CustomWebView virtualView)
    {
        this._virtualView = virtualView;
    }
}
ハンドラー実装

仮想コントロールとネイティブコントロールとを紐付けるハンドラーを実装します。

このハンドラーはプラットフォーム依存と非依存のハイブリッドのため、 partial で2ファイル作成し、プラットフォーム側で Microsoft.Maui.Handlers.ViewHandler<TVirtualView, TPlatformView> を継承させます。

複数プラットフォームを利用する場合はハンドラークラスもプラットフォーム分だけ作成します。

// Platforms/Windows/Handlers/CustomWebViewHandler.cs

using Microsoft.Maui.Handlers;
using SampleLib.Controls;

namespace SampleLib.Handlers;

/// <summary>
/// Windows環境でCustomWebViewの仮想コントロールとネイティブコントロールとを紐付けるハンドラー
/// </summary>
public partial class CustomWebViewHandler : ViewHandler<CustomWebView, PlatformCustomWebView>
{
    protected override PlatformCustomWebView CreatePlatformView()
    {
        // ネイティブコントロールのインスタンスを作成する
        return new PlatformCustomWebView(this.VirtualView);
    }

    protected override void ConnectHandler(PlatformCustomWebView platformView)
    {
        base.ConnectHandler(platformView);

        // ネイティブコントロールのセットアップ処理を行う
    }

    protected override void DisconnectHandler(PlatformCustomWebView platformView)
    {
        base.DisconnectHandler(platformView);

        // ネイティブコントロールのクリーンアップ処理を行う
    }
}
// Handlers/CustomWebViewHandler.cs

using Microsoft.Maui.Handlers;
using SampleLib.Controls;

namespace SampleLib.Handlers;

/// <summary>
/// CustomWebViewの仮想コントロールとネイティブコントロールとを紐付けるハンドラー
/// </summary>
public partial class CustomWebViewHandler
{
    /// <summary>
    /// プロパティのマッピング
    /// </summary>
    public static readonly PropertyMapper<CustomWebView, CustomWebViewHandler> PropertyMapper = new(ViewHandler.ViewMapper);

    /// <summary>
    /// コマンドのマッピング
    /// </summary>
    public static readonly CommandMapper<CustomWebView, CustomWebViewHandler> CommandMapper = new(ViewHandler.ViewCommandMapper);

    public CustomWebViewHandler()
        : base(PropertyMapper, CommandMapper)
    {
    }
}

ハンドラークラスのプラットフォーム依存部分には以下の3つを実装します。

  • ネイティブコントロールのインスタンス化( CreatePlatformView
  • ネイティブコントロールのセットアップ処理( ConnectHandler
  • ネイティブコントロールのクリーンアップ処理( DisconnectHandler

共通部分にはプロパティとコマンドのマッピング定義を実装します。ただし今は空のコントロールを作成しているので両方とも空の状態となります。

ちなみに DisconnectHandler は MAUI では自動で呼び出されませんので、手動で呼び出す必要があるそうです。実装漏れが発生する可能性が高いため十分に注意してください。

ハンドラー登録

ここまでで仮想コントロール、ネイティブコントロール、そしてハンドラーが作成されたため、最後に仮想コントロールと対応するハンドラーを登録します。

// MauiAppBuilderExtensions.cs

using SampleLib.Controls;
using SampleLib.Handlers;

namespace SampleLib;

public static class MauiAppBuilderExtensions
{
    /// <summary>
    /// MAUIアプリにSampleLibsを登録します。
    /// </summary>
    public static MauiAppBuilder UseSampleLib(this MauiAppBuilder builder)
    {
        // カスタムコントロールとハンドラーの登録
        builder.ConfigureMauiHandlers(handlers =>
        {
            handlers.AddHandler<CustomWebView, CustomWebViewHandler>();
        });

        return builder;
    }

}

あとは MAUI アプリのスタートアップで UseSampleLib を呼び出します。

// MauiProgram.cs

var builder = MauiApp.CreateBuilder();
builder
    .UseMauiApp<App>()
    .UseSampleLib()  // 追加
    .ConfigureFonts(fonts =>
    {
        fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
        fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
    });

ハンドラーの登録まで完了したら XAML に CustomWebView を記述することで何にも表示されない空の画面が描画されます。

お疲れ様でした!これでようやくカスタムコントロール実装の下地が整いました!!

空のコントロールを表示した状態

ネイティブコントロールの差し替え

空のコントロールが動作することが分かりましたので、ネイティブコントロールを実際のコントロールに変更します。

今回は WebView の作成を目的としているため、環境毎にネイティブコントロールPlatformCustomWebView の継承元をこれらのクラスとします。

環境 継承元クラス
Android Android.Webkit.WebView
iOS WebKit.WKWebView
MacOS WebKit.WKWebView
Windows Microsoft.UI.Xaml.Controls.WebView2

プロパティの実装

ここまでで、空のコントロールを作成して WebView に差し替えました。

続いて、仮想コントロールからネイティブコントロールに対してプロパティ値を渡してみます。

今のままでは何も表示されない WebView ですので、プロパティ値として表示する URL を設定できるようにしましょう。

仮想コントロールの変更

.NET MAUI の仮想コントロールにプロパティを追加するには、追加するプロパティと対応するBindableProperty を実装します。定型文みたいなものですね。

// Controls/CustomWebView.cs

namespace SampleLib.Controls;

/// <summary>
/// カスタムWebViewの仮想コントロール
/// </summary>
public class CustomWebView : View, ICustomWebView
{
    public static readonly BindableProperty UrlProperty = BindableProperty.Create(
        propertyName: nameof(Url),
        returnType: typeof(string),
        declaringType: typeof(CustomWebView),
        defaultValue: null
    );

    /// <summary>
    /// 表示URL
    /// </summary>
    public string? Url
    {
        get => (string?) this.GetValue(UrlProperty);
        set => this.SetValue(UrlProperty, value);
    }
}
ネイティブコントロールへの処理

続いて表示する URL が指定された際のネイティブコントロールに対する処理をハンドラーで実装します。

シンプルな処理であればハンドラーに実装するだけで十分ですが、複雑な処理の場合、ネイティブコントロール側にも何らかの実装が必要になるかもしれません。

// Platforms/Windows/Handlers/CustomWebViewHandler.cs

namespace SampleLib.Handlers;

/// <summary>
/// Windows環境でCustomWebViewの仮想コントロールとネイティブコントロールとを紐付けるハンドラー
/// </summary>
public partial class CustomWebViewHandler : ViewHandler<CustomWebView, PlatformCustomWebView>
{
    // ...

    /// <summary>
    /// URLプロパティを処理します。
    /// </summary>
    public static void MapUrl(CustomWebViewHandler handler, CustomWebView view)
    {
        if (view.Url != null)
        {
            handler.PlatformView.Source = new Uri(view.Url);
        }
    }
}

プロパティに対応する処理を第1引数がハンドラークラス、第2引数が仮想コントロールクラスの静的メソッドとして実装します。

メソッド名は何でも良いのですが、 Map プレフィックスを付けておくと、マッピングメソッドだと分かりやすいでしょう。

マッピングの登録

プロパティ値を処理するメソッドを実装したら、最後に BindableProperty とのマッピングをハンドラーの PropertyMapper に登録します。

// Handlers/CustomWebViewHandler.cs

namespace SampleLib.Handlers;

/// <summary>
/// CustomWebViewの仮想コントロールとネイティブコントロールとを紐付けるハンドラー
/// </summary>
public partial class CustomWebViewHandler
{
    /// <summary>
    /// プロパティのマッピング
    /// </summary>
    public static readonly PropertyMapper<CustomWebView, CustomWebViewHandler> PropertyMapper = new(ViewHandler.ViewMapper)
    {
        [nameof(CustomWebView.Url)] = MapUrl
    };

    // ...
}

マッピングまで登録すると、 URL を指定することで WebView で表示ができるようになります。

URL プロパティがマッピングされて動作した WebView

コマンドの実装

ここまででプロパティ値を仮想コントロールからネイティブコントロールに渡せるようになりました。

続いてはコマンド=アクション実行です。

WebView のアクションといえば「戻る」「進む」「再読み込み」だと思いますので、この3つを実装してみましょう。

仮想コントロールの変更

まずは、仮想コントロールにコマンドを呼び出すためのメソッドとイベントを実装します。

// Controls/CustomWebView.cs

namespace SampleLib.Controls;

/// <summary>
/// カスタムWebViewの仮想コントロール
/// </summary>
public class CustomWebView : View, ICustomWebView
{
    // ...

    /// <summary>
    /// 前のページに戻るイベント
    /// </summary>
    public event EventHandler? GoBackRequested;

    /// <summary>
    /// 次のページに進むイベント
    /// </summary>
    public event EventHandler? GoForwardRequested;

    /// <summary>
    /// ページをリロードするイベント
    /// </summary>
    public event EventHandler? ReloadRequested;


    /// <summary>
    /// 前のページに戻ります。
    /// </summary>
    public void GoBack()
    {
        this.Handler?.Invoke(nameof(GoBackRequested), null);
        this.GoBackRequested?.Invoke(this, EventArgs.Empty);
    }

    /// <summary>
    /// 次のページに進みます。
    /// </summary>
    public void GoForward()
    {
        this.Handler?.Invoke(nameof(GoForwardRequested), null);
        this.GoForwardRequested?.Invoke(this, EventArgs.Empty);
    }

    /// <summary>
    /// ページを再読み込みします。
    /// </summary>
    public void Reload()
    {
        this.Handler?.Invoke(nameof(ReloadRequested), null);
        this.ReloadRequested?.Invoke(this, EventArgs.Empty);
    }
}

コマンドメソッドで各イベントとハンドラーの両方をトリガーすることに注意してください。

前者は仮想コントロールのイベントを購読している要素に対して、後者はネイティブコントロールに対して通知されます。

ネイティブコントロールへの処理

続いて、ネイティブコントロールに対するコマンドの処理をハンドラーに実装します。

こちらもプロパティの場合と同様に、シンプルな処理であればハンドラーに実装するだけで十分ですが、複雑な処理の場合、ネイティブコントロール側にも何らかの実装が必要になるかもしれません。

// Platforms/Windows/Handlers/CustomWebViewHandler.cs

namespace SampleLib.Handlers;

/// <summary>
/// Windows環境でCustomWebViewの仮想コントロールとネイティブコントロールとを紐付けるハンドラー
/// </summary>
public partial class CustomWebViewHandler : ViewHandler<CustomWebView, PlatformCustomWebView>
{
    // ...

    /// <summary>
    /// 前のページに戻るコマンドを処理します。
    /// </summary>
    public static void MapGoBackRequested(CustomWebViewHandler handler, CustomWebView view, object? args)
    {
        handler.PlatformView.GoBack();
    }

    /// <summary>
    /// 前のページに戻るコマンドを処理します。
    /// </summary>
    public static void MapGoForwardRequested(CustomWebViewHandler handler, CustomWebView view, object? args)
    {
        handler.PlatformView.GoForward();
    }

    /// <summary>
    /// ページの再読み込みコマンドを処理します。
    /// </summary>
    public static void MapReloadRequested(CustomWebViewHandler handler, CustomWebView view, object? args)
    {
        handler.PlatformView.GoBack();
    }
}

コマンドに対応する処理を、第1引数がハンドラークラス、第2引数が仮想コントロールクラス、第3引数がイベント引数の静的メソッドとして実装します。

プロパティの場合と同様にメソッド名は何でも良いのですが、 Map プレフィックスを付けておくとマッピングメソッドだと分かりやすいでしょう。

マッピングの登録

コマンドを処理するメソッドを実装したら、最後にイベントとのマッピングをハンドラーの CommandMapper に登録します。

// Handlers/CustomWebViewHandler.cs

namespace SampleLib.Handlers;

/// <summary>
/// CustomWebViewの仮想コントロールとネイティブコントロールとを紐付けるハンドラー
/// </summary>
public partial class CustomWebViewHandler
{
    // ...

    /// <summary>
    /// コマンドのマッピング
    /// </summary>
    public static readonly CommandMapper<CustomWebView, CustomWebViewHandler> CommandMapper = new(ViewHandler.ViewCommandMapper)
    {
        [nameof(CustomWebView.GoBackRequested)] = MapGoBackRequested,
        [nameof(CustomWebView.GoForwardRequested)] = MapGoForwardRequested,
        [nameof(CustomWebView.ReloadRequested)] = MapReloadRequested
    };

    // ...
}

以上を実装することで、仮想コントロールからネイティブコントロールに対してコマンド(アクション)を送信することができるようになります。

イベントの実装

これまでは、仮想コントロールからネイティブコントロールに対しての処理を実装してきましたが、最後にネイティブコントロールから仮想コントロールへのイベント通知を実装してみます。

とは言ってもイベント通知はとてもシンプルで、ハンドラーやネイティブコントロールから仮想コントロールに通知を出すだけなので、プロパティ・コマンドとは異なりマッピングは必要ありません。

今回はナビゲーションの終了イベントを通知します。

仮想コントロールの変更

まずは、仮想コントロールに対してイベントとイベントを送信するためのメソッドを追加します。

// Controls/CustomWebView.cs

using System.ComponentModel;

namespace SampleLib.Controls;

/// <summary>
/// カスタムWebViewの仮想コントロール
/// </summary>
public class CustomWebView : View, ICustomWebView
{
    // ...

    /// <summary>
    /// ナビゲーションの終了イベント
    /// </summary>
    public event EventHandler? NavigationEnd;

    /// <summary>
    /// ナビゲーションの終了イベントを送信します。
    /// </summary>
    [EditorBrowsable(EditorBrowsableState.Never)]
    public void SendNavigationEnd()
    {
        this.NavigationEnd?.Invoke(this, EventArgs.Empty);
    }
}
ネイティブコントロールからの処理

続いて、ネイティブコントロールから先に追加した仮想コントロールの、イベント送信用メソッドを呼び出します。

// Platforms/Windows/Handlers/CustomWebViewHandler.cs

namespace SampleLib.Handlers;

/// <summary>
/// Windows環境でCustomWebViewの仮想コントロールとネイティブコントロールとを紐付けるハンドラー
/// </summary>
public partial class CustomWebViewHandler : ViewHandler<CustomWebView, PlatformCustomWebView>
{
    private WebView2? _webView;

    protected override void ConnectHandler(PlatformCustomWebView platformView)
    {
        base.ConnectHandler(platformView);

        // ネイティブコントロールのセットアップ処理を行う
        platformView.CoreWebView2Initialized += OnCoreWebView2Initialized;
    }

    protected override void DisconnectHandler(PlatformCustomWebView platformView)
    {
        base.DisconnectHandler(platformView);

        // ネイティブコントロールのクリーンアップ処理を行う
        platformView.CoreWebView2Initialized -= OnCoreWebView2Initialized;
        if (_webView != null)
        {
            _webView.NavigationCompleted -= OnWebView2NavigationCompleted;
            _webView = null;
        }
    }

    /// <summary>
    /// CoreWebView2の初期化済イベントハンドリング
    /// </summary>
    private void OnCoreWebView2Initialized(WebView2 sender, CoreWebView2InitializedEventArgs args)
    {
        _webView = sender;
        sender.NavigationCompleted += OnWebView2NavigationCompleted;
    }

    /// <summary>
    /// CoreWebView2のナビゲーション完了イベントハンドリング
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="args"></param>
    private void OnWebView2NavigationCompleted(WebView2 sender, CoreWebView2NavigationCompletedEventArgs args)
    {
        // 仮想コントロールのイベント発火用メソッド呼び出し
        this.VirtualView.SendNavigationEnd();
    }

    // ...
}

WebView2 のイベントを購読するためにイベント送信以外の処理も含まれていますが、雰囲気は理解頂けるかなと思います。

おわりに

.NET MAUI でカスタムコントロールを実装するために必要なことを調べた内容をまとめてみました。

一度分かってしまえば難しくはないのですが、実装することが多く、順番を間違えるとコンパイルエラーが頻発するので多少の取っ付きづらさがあるように思えます。

ただ、それを加味しても、大量のプロジェクトを用意せねばならなかった Xamarin.Forms 時代と比べると、やりやすくなっていると感じました。

本記事が皆様の実装の手助けになれば幸いです。

執筆担当者プロフィール
中谷 大造

中谷 大造(日本ビジネスシステムズ株式会社)

情報システム部の中谷です。社内用スクラッチアプリの開発をしています。

担当記事一覧