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ブランチから必要な部分を抜粋している。
1. MAUIのUIについて
MAUI の UI は大きく3層で構成されている。
-
VirtualView
- C# 側で定義された抽象ビューのインターフェース (
IEntry
,IButton
など) - 開発者が直接操作するコントロールクラス(
Entry
,Button
) - 開発者はこのレイヤーを操作
- C# 側で定義された抽象ビューのインターフェース (
-
Handler
- VirtualView とNativeViewを仲介するレイヤ。
-
PropertyMapper
やCommandMapper
でプロパティ・イベントを同期 - 各Handlerはネイティブビューを生成・保持し、PlatformViewプロパティ経由でアクセス
-
Native View
- プラットフォーム固有のネイティブコントロール
- iOS の場合、
Entry
はUIKit.UITextField
にマップされる
ソースコード上では Handler の
PlatformView
プロパティにこの Native View インスタンス が格納される。
参考
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
として実装され、データバインディングやスタイルの対象になる。
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);
}
}
public partial class Entry : InputView, ITextAlignmentElement, IEntryController, IElementConfiguration<Entry>, IEntry
{
public new static readonly BindableProperty TextProperty = InputView.TextProperty;
}
#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)では、EntryHandler
は ViewHandler<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)も更新。
#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; }
}
}
#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;
}
}
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);
}
}
}
}
}
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)
が呼ばれる。
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 など)を追加している。
以下、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
の更新後、MapText
→ PlatformView.Text
に値が届くことを ログだけで確認する。
#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