📱

Entry→ネイティブコントロールの流れを.NET MAUIのソースコードで確認してみた

に公開

はじめに

.NET MAUI は、C# で書かれた共通コード(Virtual View)が各プラットフォームのネイティブ UI(Native View)を操作する。(Flutterのような独自描画ではない)
MAUI の UI は VirtualView / Handler / Native View の3層構造になっており、Handler の PlatformView プロパティがネイティブビューを保持するようになっている。
本記事では、Entry コントロールを例に、C# 側で設定した Entry.Text が iOS のネイティブ UITextField.Text にどう届くかを.NET MAUIフレームワークのソースコードで見ていく。

※本記事に掲載する.NET MAUIのソースコードは2025/09/01時点のmainブランチから必要な部分を抜粋している。

https://github.com/dotnet/maui


1. MAUIのUIについて

MAUI の UI は大きく3層で構成されている。

  • VirtualView
    • C# 側で定義された抽象ビューのインターフェース (IEntry, IButton など)
    • 開発者が直接操作するコントロールクラス(Entry, Button
    • 開発者はこのレイヤーを操作
  • Handler
    • VirtualView とNativeViewを仲介するレイヤ。
    • PropertyMapperCommandMapper でプロパティ・イベントを同期
    • 各Handlerはネイティブビューを生成・保持し、PlatformViewプロパティ経由でアクセス
  • Native View
    • プラットフォーム固有のネイティブコントロール
    • iOS の場合、EntryUIKit.UITextField にマップされる

ソースコード上では Handler の PlatformView プロパティにこの Native View インスタンス が格納される。

参考

https://learn.microsoft.com/ja-jp/dotnet/maui/user-interface/handlers/?view=net-maui-9.0

https://learn.microsoft.com/ja-jp/dotnet/maui/user-interface/handlers/customize?view=net-maui-9.0


2. Entry 関連のMAUIソースコード

MAUIのフレームワークのソースコードを順番に見ていく。

VirtualView (Entry コントロール)

MAUI の Entry クラスは InputView を継承し、文字列入力の基本 API を提供する。
主な実装点は以下の通り

  • Text プロパティ: public string Text { get; set; } で、InputView.TextProperty (双方向バインディング)に紐づく。
    値が変わると TextChanged イベントが発生。

  • Completed イベント: ユーザーが改行キーを押したときに発火する public event EventHandler Completed; が定義されている。

  • CursorPosition, SelectionLength など、カーソル位置に関するプロパティも持っている。

  • これらはすべて BindableProperty として実装され、データバインディングやスタイルの対象になる。

https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.maui.controls.entry?view=net-maui-9.0

src/Controls/src/Core/InputView/InputView.cs
public partial class InputView : View, IPlaceholderElement, ITextElement, ITextInput, IFontElement
{
    public static readonly BindableProperty TextProperty = BindableProperty.Create(nameof(Text), typeof(string), typeof(InputView), defaultBindingMode: BindingMode.TwoWay,
        propertyChanged: (bindable, oldValue, newValue) => ((InputView)bindable).OnTextChanged((string)oldValue, (string)newValue));

    public string Text
    {
        get => (string)GetValue(TextProperty);
        set => SetValue(TextProperty, value);
    }
}
src/Controls/src/Core/Entry/Entry.cs
public partial class Entry : InputView, ITextAlignmentElement, IEntryController, IElementConfiguration<Entry>, IEntry
{
    public new static readonly BindableProperty TextProperty = InputView.TextProperty;
}
src/Controls/src/Core/Entry/Entry.iOS.cs
#nullable disable
namespace Microsoft.Maui.Controls
{
	public partial class Entry
	{
		public static void MapText(IEntryHandler handler, Entry entry)
		{
			Platform.TextExtensions.UpdateText(handler.PlatformView, entry);
			EntryHandler.MapFormatting(handler, entry);
		}

		public static void MapText(EntryHandler handler, Entry entry) =>
			MapText((IEntryHandler)handler, entry);
	}
}

Handler

Entry の Handler(EntryHandler)は、IEntry(Virtual View)とネイティブビューをつなぐ役割を担う。
EntryHandler は共通ハンドラーとして IPropertyMapper<IEntry, IEntryHandler> を持ち、IEntry.Text プロパティの変更時に MapText メソッドを呼び出すよう設定されている。
具体的には、プロパティマッパー中の "Text" キーに MapText が紐付けられており、EntryHandler.MapText が呼ばれる。

iOS 向け実装(EntryHandler.iOS.cs)では、EntryHandlerViewHandler<IEntry, MauiTextField> を継承し、以下のようにネイティブビューを生成・接続。

  • CreatePlatformView: new MauiTextField { BorderStyle = RoundedRect, ClipsToBounds = true } としてネイティブのテキストフィールド(UITextField 相当)を生成。生成されたビューは PlatformView プロパティに格納される。

  • ConnectHandler/DisconnectHandler: ConnectHandler でネイティブイベント (ShouldReturn, EditingChanged など) をフックし、DisconnectHandler でそれらを解除。これにより、ユーザー操作を仮想ビュー側に伝搬させる。

  • MapText: public static void MapText(IEntryHandler handler, IEntry entry) は、handler.PlatformView?.UpdateText(entry) を呼び出してネイティブビューにテキストを設定。必要に応じて書式(MapFormatting)も更新。

src/Core/src/Handlers/Entry/IEntryHandler.cs
#if __IOS__ || MACCATALYST
using PlatformView = Microsoft.Maui.Platform.MauiTextField;
#elif MONOANDROID
using PlatformView = AndroidX.AppCompat.Widget.AppCompatEditText;
#elif WINDOWS
using PlatformView = Microsoft.UI.Xaml.Controls.TextBox;
#elif TIZEN
using PlatformView = Tizen.UIExtensions.NUI.Entry;
#elif (NETSTANDARD || !PLATFORM) || (NET6_0_OR_GREATER && !IOS && !ANDROID && !TIZEN)
using PlatformView = System.Object;
#endif

namespace Microsoft.Maui.Handlers
{
	public partial interface IEntryHandler : IViewHandler
	{
		new IEntry VirtualView { get; }
		new PlatformView PlatformView { get; }
	}
}
src/Core/src/Handlers/Entry/EntryHandler.cs
#nullable enable
#if __IOS__ || MACCATALYST
using PlatformView = Microsoft.Maui.Platform.MauiTextField;
#elif MONOANDROID
using PlatformView = AndroidX.AppCompat.Widget.AppCompatEditText;
#elif WINDOWS
using PlatformView = Microsoft.UI.Xaml.Controls.TextBox;
#elif TIZEN
using PlatformView = Tizen.UIExtensions.NUI.Entry;
#elif (NETSTANDARD || !PLATFORM) || (NET6_0_OR_GREATER && !IOS && !ANDROID && !TIZEN)
using PlatformView = System.Object;
#endif

using System;

namespace Microsoft.Maui.Handlers
{
	public partial class EntryHandler : IEntryHandler
	{
		public static IPropertyMapper<IEntry, IEntryHandler> Mapper = new PropertyMapper<IEntry, IEntryHandler>(ViewHandler.ViewMapper)
		{
			[nameof(IEntry.Text)] = MapText,
		};

		IEntry IEntryHandler.VirtualView => VirtualView;

		PlatformView IEntryHandler.PlatformView => PlatformView;
	}
}
src/Core/src/Handlers/Entry/EntryHandler.iOS.cs
using System;
using Foundation;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Platform;
using ObjCRuntime;
using UIKit;

namespace Microsoft.Maui.Handlers
{
	public partial class EntryHandler : ViewHandler<IEntry, MauiTextField>
	{
		readonly MauiTextFieldProxy _proxy = new();

		protected override MauiTextField CreatePlatformView() =>
			new MauiTextField
			{
				BorderStyle = UITextBorderStyle.RoundedRect,
				ClipsToBounds = true
			};

		public override void SetVirtualView(IView view)
		{
			base.SetVirtualView(view);

			_proxy.SetVirtualView(PlatformView);
		}

		protected override void ConnectHandler(MauiTextField platformView)
		{
			_proxy.Connect(VirtualView, platformView);
		}

		protected override void DisconnectHandler(MauiTextField platformView)
		{
			_proxy.Disconnect(platformView);
		}

		public static void MapText(IEntryHandler handler, IEntry entry)
		{
			handler.PlatformView?.UpdateText(entry);

			// Any text update requires that we update any attributed string formatting
			MapFormatting(handler, entry);
		}

		class MauiTextFieldProxy
		{
			bool _set;
			WeakReference<IEntry>? _virtualView;

			IEntry? VirtualView => _virtualView is not null && _virtualView.TryGetTarget(out var v) ? v : null;

			public void Connect(IEntry virtualView, MauiTextField platformView)
			{
				_virtualView = new(virtualView);

				platformView.TextPropertySet += OnTextPropertySet;
			}

			public void Disconnect(MauiTextField platformView)
			{
				_virtualView = null;

				platformView.TextPropertySet -= OnTextPropertySet;

				_set = false;
			}

			public void SetVirtualView(MauiTextField platformView)
			{
				if (!_set)
					platformView.SelectionChanged += OnSelectionChanged;
				_set = true;
			}

			void OnTextPropertySet(object? sender, EventArgs e)
			{
				if (sender is MauiTextField platformView)
				{
					VirtualView?.UpdateText(platformView.Text);
				}
			}
		}
	}
}
src/Core/src/Platform/iOS/TextFieldExtensions.cs
namespace Microsoft.Maui.Platform
{
	public static class TextFieldExtensions
	{
		public static void UpdateText(this UITextField textField, IEntry entry)
		{
			textField.Text = entry.Text;
		}
    }
}

ネイティブビュー

MauiTextField(生成されたUITextField のラッパ)に対して、先ほどの MapText 内で UpdateText(entry) が呼ばれる。

src/Core/src/Platform/iOS/MauiTextField.cs
public class MauiTextField : UITextField, IUIViewLifeCycleEvents
{
    public override string? Text
    {
        get => base.Text;
        set
        {
            var old = base.Text;

            base.Text = value;

            if (old != value)
                TextPropertySet?.Invoke(this, EventArgs.Empty);
        }
    }

    [UnconditionalSuppressMessage("Memory", "MEM0001", Justification = "Proven safe in test: MemoryTests.HandlerDoesNotLeak")]
    public event EventHandler? TextPropertySet;
}

UITextField

  • iOS UIKitの一行テキスト入力コンポーネント。text/placeholder などのプロパティと、編集イベントを持つ。
  • .NET からは Microsoft.iOS(Xamarin.iOS 後継)のバインディングで UIKit.UITextField クラスとして利用される。setter では内部的に ObjCRuntime.Messaging.objc_msgSend 経由でネイティブ -[UITextField setText:] が呼ばれる。
  • MAUI の iOS 実装では、UITextField を継承した MauiTextField を PlatformView として使い、MAUI 向けのフック(TextPropertySet など)を追加している。

https://developer.apple.com/documentation/uikit/uitextfield

以下、Microsoft.ios.dllをILSpyでデコンパイルして確認

namespace UIKit
{
    [Register("UITextField", true)]
    [SupportedOSPlatform("maccatalyst")]
    [SupportedOSPlatform("ios")]
    [SupportedOSPlatform("tvos")]
    public class UITextField : UIControl, IUITextInputTraits, INativeObject, IDisposable, IUIContentSizeCategoryAdjusting, IUIKeyInput, IUILetterformAwareAdjusting, IUIPasteConfigurationSupporting, IUITextDraggable, IUITextInput, IUITextDroppable, IUITextPasteConfigurationSupporting
    {
        [BindingImpl(BindingImplOptions.GeneratedCode | BindingImplOptions.Optimizable)]
        public virtual string? Text
        {
            [Export("text", ArgumentSemantic.Copy)]
            get
            {
                UIApplication.EnsureUIThread();
                if (base.IsDirectBinding)
                {
                    return CFString.FromHandle(Messaging.NativeHandle_objc_msgSend(base.Handle, Selector.GetHandle("text")), releaseHandle: false);
                }
                return CFString.FromHandle(Messaging.NativeHandle_objc_msgSendSuper(base.SuperHandle, Selector.GetHandle("text")), releaseHandle: false);
            }
            [Export("setText:", ArgumentSemantic.Copy)]
            set
            {
                UIApplication.EnsureUIThread();
                NativeHandle nsvalue = CFString.CreateNative(value);
                if (base.IsDirectBinding)
                {
                    Messaging.void_objc_msgSend_NativeHandle(base.Handle, Selector.GetHandle("setText:"), nsvalue);
                }
                else
                {
                    Messaging.void_objc_msgSendSuper_NativeHandle(base.SuperHandle, Selector.GetHandle("setText:"), nsvalue);
                }
                CFString.ReleaseNative(nsvalue);
            }
        }
    }
}
internal static class Messaging
{
    [DllImport("/usr/lib/libobjc.dylib", EntryPoint = "objc_msgSend")]
    public static extern void void_objc_msgSend_NativeHandle(IntPtr receiver, IntPtr selector, NativeHandle arg1);
}

3. 動作確認(デバッグなし最小検証)

Entry.Text の更新後、MapTextPlatformView.Text に値が届くことを ログだけで確認する。

MauiProgram.cs
#if DEBUG
    // IEntry.Text マッピングのあとにフックを追加
    EntryHandler.Mapper.AppendToMapping(nameof(IEntry.Text), (handler, view) =>
    {
        var tf = handler.PlatformView; // = MauiTextField (UITextField)
        System.Diagnostics.Debug.WriteLine($"[MapText後] Virtual='{view.Text}' Native='{tf?.Text}'");
    });
#endif

ログ

// コードから Entry.Text = Guid.NewGuid().ToString("N");
// ※毎回違う値を設定するためGUIDを使用
[0:] [MapText後] Virtual='0f0a50ac3fff43a39e4326c192899023' Native='0f0a50ac3fff43a39e4326c192899023'

// シミュレータのUIでテキストを編集
[0:] [MapText後] Virtual='0f0a50ac3fff43a39e4326c192899023A' Native='0f0a50ac3fff43a39e4326c192899023A'

4. まとめ

以下のような流れでMAUIのコントロールとネイティブコントロールがつながっていることが分かった。

  • Entry.Text → Handler → MauiTextField.Text → 画面更新
Entry.Text(Controls)
  └▶ IEntry(Core)
      └▶ EntryHandler.Mapper["Text"](Core)
          └▶ EntryHandler.MapText(...)(Core)
              └▶ TextFieldExtensions.UpdateText(...)(Core / iOS)
                  └▶ MauiTextField.Text = entry.Text(Core / iOS)
                      └▶ ※内部で UITextField#setText: が呼ばれ画面更新

Discussion