.NET MAUI移行ガイド:XAML不要でフォームを作る4つの方法とWinFormsからの実践ステップ

既存のWinForms資産を抱えたまま、.NET MAUIへと舵を切ると最初に突き当たる壁が「XAMLを使わずに“フォーム”を作れるのか?」という疑問です。本稿では、その答えと実践方法を、純C#・CommunityToolkit.Maui.Markup・Comet(MVU)・Blazor Hybridの4アプローチで網羅的に解説。移行設計、落とし穴、実装テンプレートまで一気通貫で提供します。

目次

.NET MAUIは「フォーム」ではなく「ページ」——概念の読み替えから始める

.NET MAUIのUIはページ(Page)+レイアウト(Layout)+ビュー(View)の3層で構成されます。WinFormsの「フォーム+コントロール」思考とは近いようで異なり、画面遷移・状態管理・多プラットフォームの差異吸収を前提とした作りです。従来の「デザイナでフォームを置いてイベントを貼る」流儀の延長だけでは、せっかくのMAUIの恩恵(宣言的UI、ホットリロード、MVVM/MVUによる状態指向)を活かしきれません。

WinForms.NET MAUI補足
FormPage(例:ContentPage)画面の最小単位。Shell配下でルート化するのが一般的
Panel / TableLayoutPanelGrid / StackLayout / VerticalStackLayout柔軟なレイアウト。Gridを基本に設計するのが移行時は安全
TextBoxEntry / Editor単一行はEntry、複数行はEditor
DataGridView / ListViewCollectionView仮想化・テンプレートによる描画が前提
MenuStrip / ToolStripShell(Flyout/Tab)/ ToolbarItemナビゲーションはShellで。従来のメニュー欄はToolbarItemへ
MessageBoxDisplayAlert / DisplayActionSheet非同期APIによる対話

結論から言えば、XAMLなしでも画面は作れます。XAMLは公式推奨の表現手段ですが、以下のいずれの方法でも“コードだけ”でUIを構築可能です。

XAMLを使わない4つの実装パターン(比較表)

方法特徴導入ポイント向いているケース
純C#(コードビハインド)Page/Layout/Viewをnewし、プロパティ設定・イベント/Commandを記述理解コストが低い。動的UIに強いが、肥大化しがち小〜中規模画面、プロトタイプ、ランタイム生成
CommunityToolkit.Maui.Markup拡張メソッド/添字で宣言的に近いC#記法。Binding記述が簡潔記述量削減。XAMLライクな読みやすさをC#で確保中規模以上、MVVM徹底、保守性重視
Comet(MVU)状態(Model)に応じてUIを関数的に記述。すべてC#の宣言的UI学習コストあり。設計がハマると高速開発状態遷移が多い、MVU嗜好のチーム
Blazor HybridRazor(HTML+C#)で記述したWebコンポーネントをネイティブに埋め込みWeb資産の再利用。ブラウザではなくネイティブWebViewWebとネイティブの併用、既存Blazor/JS資産活用

注意:WinFormsに近いドラッグ&ドロップのWYSIWYGデザイナ(C#専用)は現状ありません。XAMLのホットリロード・Live Visual Treeなどを使い、「コード or XAMLを編集 → 即時反映で微調整」という開発ループに慣れるのが生産性向上の近道です。

純C#だけで“フォーム”を作る:最低限からMVVM対応まで

最小構成(イベント駆動)




// LoginPage.cs(XAMLなし)
public sealed class LoginPage : ContentPage
{
readonly Entry _user = new() { Placeholder = "ユーザー名" };
readonly Entry _pass = new() { Placeholder = "パスワード", IsPassword = true };
readonly Label _error = new() { TextColor = Colors.Red, IsVisible = false };


public LoginPage()
{
    var button = new Button { Text = "ログイン" };
    button.Clicked += OnLoginClicked;

    Content = new VerticalStackLayout
    {
        Padding = 24,
        Children =
        {
            new Label { Text = "サインイン", FontSize = 24, HorizontalOptions = LayoutOptions.Center },
            _user,
            _pass,
            button,
            _error
        }
    };
}

async void OnLoginClicked(object? sender, EventArgs e)
{
    if (string.IsNullOrWhiteSpace(_user.Text) || string.IsNullOrWhiteSpace(_pass.Text))
    {
        _error.Text = "ユーザー名とパスワードを入力してください。";
        _error.IsVisible = true;
        return;
    }
    _error.IsVisible = false;
    await DisplayAlert("確認", $"ようこそ {_user.Text} さん", "OK");
}


} 

設計的には「動くが太る」典型です。イベントが増えるほどページが肥大化します。長期運用を見据えるなら、次のようにMVVM+Commandへ寄せます。

MVVM+Command(XAMLなしでBinding)




// ViewModel(INotifyPropertyChangedの最小実装)
public class LoginViewModel : INotifyPropertyChanged
{
string _user = string.Empty;
string _pass = string.Empty;
string _error = string.Empty;
bool _hasError;


public string User { get => _user; set { _user = value; OnPropertyChanged(); } }
public string Pass { get => _pass; set { _pass = value; OnPropertyChanged(); } }
public string Error { get => _error; set { _error = value; OnPropertyChanged(); } }
public bool HasError { get => _hasError; set { _hasError = value; OnPropertyChanged(); } }

public ICommand LoginCommand { get; }

public LoginViewModel()
{
    LoginCommand = new Command(async () =>
    {
        if (string.IsNullOrWhiteSpace(User) || string.IsNullOrWhiteSpace(Pass))
        {
            Error = "必須項目を入力してください。";
            HasError = true;
            return;
        }
        HasError = false;
        // 認証ロジックをDIサービスへ委譲するのがベター
        await Application.Current!.MainPage!.DisplayAlert("確認", $"ようこそ {User} さん", "OK");
    });
}

public event PropertyChangedEventHandler? PropertyChanged;
void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string? name = null)
    => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));


}

// LoginPage.cs(C#だけでBinding)
public sealed class LoginPage : ContentPage
{
public LoginPage(LoginViewModel vm)
{
BindingContext = vm;


    var title = new Label { Text = "サインイン", FontSize = 24, HorizontalOptions = LayoutOptions.Center };

    var user = new Entry { Placeholder = "ユーザー名" };
    user.SetBinding(Entry.TextProperty, nameof(LoginViewModel.User));

    var pass = new Entry { Placeholder = "パスワード", IsPassword = true };
    pass.SetBinding(Entry.TextProperty, nameof(LoginViewModel.Pass));

    var error = new Label { TextColor = Colors.Red };
    error.SetBinding(Label.TextProperty, nameof(LoginViewModel.Error));
    error.SetBinding(IsVisibleProperty, nameof(LoginViewModel.HasError));

    var button = new Button { Text = "ログイン" };
    button.SetBinding(Button.CommandProperty, nameof(LoginViewModel.LoginCommand));

    Content = new VerticalStackLayout
    {
        Padding = 24,
        Children = { title, user, pass, button, error }
    };
}


} 

DI登録の例(MauiProgram)




builder.Services.AddSingleton();
builder.Services.AddTransient(); 

この形にすると、Viewは薄く、テスト対象はViewModel側に寄ります。XAMLを使わずとも、Bindingで「イベント密集の肥大化」を回避できます。

CommunityToolkit.Maui.Markupで“宣言的C#”に寄せる

CommunityToolkit.Maui.Markupは、C#だけでXAMLライクな宣言性・簡潔さを得るための拡張メソッド群です。.Bind()や添字記法、レイアウトの流れるような組み立てで、読みやすさが劇的に向上します。




// using CommunityToolkit.Maui.Markup;

public sealed class SignupPage : ContentPage
{
public SignupPage(SignupViewModel vm)
{
BindingContext = vm;


    Content = new Grid
    {
        RowDefinitions = Rows.Define(
            (Row.Title, Star),
            (Row.Form,  Auto),
            (Row.Footer,Auto)
        ),
        ColumnDefinitions = Columns.Define(Star)
    }
    .Children(
        new Label()
            .Text("アカウント作成")
            .Font(size:24)
            .CenterHorizontal()
            .Row(Row.Title),

        new VerticalStackLayout
        {
            Spacing = 12
        }
        .Children(
            new Entry().Placeholder("メールアドレス").Bind(Entry.TextProperty, "Email"),
            new Entry { IsPassword = true }.Placeholder("パスワード").Bind(Entry.TextProperty, "Password"),
            new Button().Text("登録").BindCommand("SignupCommand")
        )
        .Margin(24)
        .Row(Row.Form),

        new Label()
            .Text("登録で利用規約に同意したものとみなします。")
            .Opacity(0.7)
            .Margin(24,12)
            .Row(Row.Footer)
    );
}

enum Row { Title, Form, Footer }


} 

従来のSetBindingよりも視線移動が少なく、「レイアウト構造とバインディング意図」が一望できます。C#で統一したいチームに特に有効です。

Comet(MVU)で“状態からUIを生やす”

MVU(Model-View-Update)パターンでは、UIは状態(Model)の純粋関数として表現されます。イベントで状態が変わるとUIが再計算され、差分だけが反映されます。C#だけで宣言的UIを書きたい・関数型指向が好みという場合は選択肢になります。




// Pseudo code style(概念例)
public class CounterView : View
{
State count = 0;


[Body]
View Build() =>
    new VStack
    {
        new Text(() => $"Count: {count.Value}"),
        new Button("+", () => count.Value++)
    }
    .Padding(24);


} 

MVUはコンポーネントの副作用管理・状態遷移が明確で、複雑なワークフローを扱う際に威力を発揮します。一方、既存WinFormsの命令的コードを直貼りしにくいため、ロジックの再設計が必要です。

Blazor HybridでRazorコンポーネントを“フォーム”として使う

Webで育てたRazor/Blazor資産をそのまま使いたいならBlazor Hybridが強力です。Razorでフォームを作り、BlazorWebViewに埋め込んでMAUIページとして表示できます。

MauiProgram登録例




builder.Services.AddMauiBlazorWebView();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
#endif 

ページ側の埋め込み




public sealed class MainPage : ContentPage
{
public MainPage()
{
Content = new BlazorWebView
{
HostPage = "wwwroot/index.html",
RootComponents =
{
new RootComponent { Selector = "#app", ComponentType = typeof(Pages.Counter) }
}
};
}
} 

HTML/CSSの表現力を保ちつつネイティブ機能にもアクセスできます。JavaScript資産の再利用やWebとネイティブのコード共有を重視する現場に向きます。

「WinFormsデザイナの代替は?」への現実解

  • WYSIWYGのC#デザイナはなし:ピクセル配置に頼る発想から脱却し、GridやStackでレスポンシブに組み立てる。
  • ホットリロード前提:コードまたはXAMLを編集して即実機/エミュレータで反映。試行錯誤を短サイクルで回す。
  • 宣言的UI+データバインディング:イベントべったりではなく、Command/Bindingへ寄せる。

移行アーキテクチャ:FormをPageに置き換え、ロジックを疎結合化

  1. 画面単位の読み替え:Form → Page、コントロールはEntry/Button/CollectionView等へマッピング。
  2. MVVM/MVUの採用:イベントロジックをViewModelまたはUpdate関数に集約。
  3. DIでサービス分離:認証、データアクセス、バリデーションをサービス化しUIから切り離す。
  4. 一部C#→安定後にXAML化:プロトタイプはC#で素早く。仕様が固まればXAMLへ移しデザイナビリティを高める手も。
  5. 段階移行:新規画面はMAUI、既存はWinFormsを保守しつつ、機能単位で順次差し替え。

プラットフォーム固有処理:条件コンパイル・部分クラス・Handler

条件コンパイルの例


public interface IAppInfoService { string GetAppId(); }

public class AppInfoService : IAppInfoService
{
public string GetAppId()
{
#if WINDOWS
return "com.example.win";
#elif ANDROID
return "com.example.android";
#elif IOS
return "com.example.ios";
#else
return "unknown";
#endif
}
} 

Handlerによるプラットフォームごとの見た目調整




// 例:Entryの罫線や余白をプラットフォーム別に調整
Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("Custom", (handler, view) =>
{
#if ANDROID
handler.PlatformView.SetPadding(24, handler.PlatformView.PaddingTop,
24, handler.PlatformView.PaddingBottom);
#elif IOS
handler.PlatformView.BorderStyle = UIKit.UITextBorderStyle.RoundedRect;
#elif WINDOWS
handler.PlatformView.BorderThickness = new Microsoft.UI.Xaml.Thickness(2);
#endif
}); 

Win32 APIやGDI+依存が残っている場合は、処理を抽象化してプラットフォーム別に実装する構成が安全です。

“業務フォーム”の実装テンプレート(C#のみ)

顧客登録の典型フォームを、検証・エラー表示・コマンドを含めてC#だけで実装する例です。


public sealed class CustomerViewModel : INotifyPropertyChanged
{
    string _name = string.Empty;
    string _email = string.Empty;
    string _phone = string.Empty;
    string _error = string.Empty;
    bool _hasError;


public string Name { get => _name; set { _name = value; OnChanged(); } }
public string Email { get => _email; set { _email = value; OnChanged(); } }
public string Phone { get => _phone; set { _phone = value; OnChanged(); } }

public string Error { get => _error; set { _error = value; OnChanged(); } }
public bool HasError { get => _hasError; set { _hasError = value; OnChanged(); } }

public ICommand SaveCommand { get; }

public CustomerViewModel()
{
    SaveCommand = new Command(async () =>
    {
        var (ok, msg) = Validate();
        if (!ok) { Error = msg; HasError = true; return; }
        HasError = false;
        await Task.Delay(100); // 保存処理の疑似
        await Application.Current!.MainPage!.DisplayAlert("完了", "顧客を保存しました。", "OK");
    });
}

(bool ok, string message) Validate()
{
    if (string.IsNullOrWhiteSpace(Name)) return (false, "氏名は必須です。");
    if (string.IsNullOrWhiteSpace(Email) || !Email.Contains("@")) return (false, "メールアドレスが不正です。");
    if (!string.IsNullOrWhiteSpace(Phone) && Phone.Length < 10) return (false, "電話番号の桁数を確認してください。");
    return (true, "");
}

public event PropertyChangedEventHandler? PropertyChanged;
void OnChanged([System.Runtime.CompilerServices.CallerMemberName] string? n = null)
    => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));


}

public sealed class CustomerPage : ContentPage
{
public CustomerPage(CustomerViewModel vm)
{
BindingContext = vm;


    var grid = new Grid
    {
        RowDefinitions = new RowDefinitionCollection
        {
            new RowDefinition(GridLength.Auto),
            new RowDefinition(GridLength.Auto),
            new RowDefinition(GridLength.Auto),
            new RowDefinition(GridLength.Auto),
            new RowDefinition(GridLength.Star),
        },
        ColumnDefinitions = new ColumnDefinitionCollection
        {
            new ColumnDefinition(GridLength.Auto),
            new ColumnDefinition(GridLength.Star)
        },
        Padding = 16
    };

    var nameLabel  = new Label { Text = "氏名:" , VerticalTextAlignment = TextAlignment.Center };
    var emailLabel = new Label { Text = "メール:", VerticalTextAlignment = TextAlignment.Center };
    var phoneLabel = new Label { Text = "電話:" , VerticalTextAlignment = TextAlignment.Center };

    var name  = new Entry { Placeholder = "山田 太郎" };
    var email = new Entry { Placeholder = "[email protected]", Keyboard = Keyboard.Email };
    var phone = new Entry { Placeholder = "08012345678", Keyboard = Keyboard.Telephone, MaxLength = 11 };

    name.SetBinding(Entry.TextProperty,  nameof(CustomerViewModel.Name), mode: BindingMode.TwoWay);
    email.SetBinding(Entry.TextProperty, nameof(CustomerViewModel.Email), mode: BindingMode.TwoWay);
    phone.SetBinding(Entry.TextProperty, nameof(CustomerViewModel.Phone), mode: BindingMode.TwoWay);

    var save = new Button { Text = "保存", HorizontalOptions = LayoutOptions.End };
    save.SetBinding(Button.CommandProperty, nameof(CustomerViewModel.SaveCommand));

    var error = new Label { TextColor = Colors.Red, Margin = new Thickness(0,8) };
    error.SetBinding(Label.TextProperty, nameof(CustomerViewModel.Error));
    error.SetBinding(IsVisibleProperty, nameof(CustomerViewModel.HasError));

    grid.Add(nameLabel,  0, 0); grid.Add(name,  1, 0);
    grid.Add(emailLabel, 0, 1); grid.Add(email, 1, 1);
    grid.Add(phoneLabel, 0, 2); grid.Add(phone, 1, 2);
    grid.Add(save,       1, 3);
    grid.Add(error,      1, 4);

    Content = new ScrollView { Content = grid };
    Title = "顧客登録";
}


} 

このテンプレートをベースに、検証ロジックの切り出しや入力制約(Behavior/Converter)を追加していくと、業務フォームに必要な堅牢性が得られます。

レイアウト指針:Gridを基本、Stackは浅く、ネイティブ差はHandlerで吸収

  • Grid基軸:列・行を明示して可読性と調整容易性を両立。特に複数列の入力フォームはGridが最適。
  • Stackは浅く:ネストが深いと計測コスト増。VerticalStackLayoutで縦並びしつつ、区切り単位でGridへ。
  • 共通余白:ページのPaddingを先に決め、各コントロールに過剰なMarginを足さない。
  • 文字サイズ・行間:プラットフォームごとに見え方が違うため、LineHeightやMinimumHeightRequestで微調整。
  • フォーカス移動:Enterで次Entryへフォーカス、完了でCommand実行など、キーボード中心の業務操作を意識。

よくある置き換え:フォームイベントからの発想転換

WinFormsのやり方MAUIでの代替ポイント
Form.LoadOnAppearing(Pageのライフサイクル)非同期初期化はOnAppearingでawait
DialogResult/ShowDialogNavigation.PushModalAsync / DisplayAlert非同期の戻り値で分岐。戻る操作も非同期
Control.Enabled/VisibleBindingでIsEnabled/IsVisible状態はViewModel側に集約
DataSourceにDataTableObservableCollection+DataTemplateテンプレートで自由度を確保しつつ仮想化
Application.DoEventsawait/非同期タスクでUIスレッドを塞がないプログレスはActivityIndicator/ProgressBarで表現

パフォーマンス要点:初期体験とスクロールの滑らかさに投資する

  • テンプレートの軽量化:CollectionViewのItemTemplateは簡潔に。不要な入れ子は避ける。
  • 画像の遅延読み込み:業務アプリでもアイコン/サムネイルを遅延・キャッシュする。
  • 非同期I/O:DB・APIアクセスはすべてawaitで。UIスレッドを塞がない。
  • 初期レンダリングの分割:最初に必要なパーツのみ描画し、詳細は遅延表示。
  • ハンドラ調整:プラットフォーム固有の余計なパディングやアニメーションを切ると体感が上がる。

ナビゲーション:Shellを使いこなし、URL思考で設計する

WinFormsの多くは親子フォームやMDIで管理していましたが、MAUIではShellを使って画面遷移・深いリンク・タブ/ドロワーを一元化します。XAMLを使わずともShellはC#から構築できます。


public sealed class AppShellCSharp : Shell
{
    public AppShellCSharp()
    {
        var login = new ShellContent { Title = "ログイン", ContentTemplate = new DataTemplate(typeof(LoginPage)) };
        var signup = new ShellContent { Title = "登録", ContentTemplate = new DataTemplate(typeof(SignupPage)) };


    var tab = new Tab { Title = "アカウント" };
    tab.Items.Add(login);
    tab.Items.Add(signup);

    Items.Add(tab);

    Routing.RegisterRoute("customer", typeof(CustomerPage));
}


} 

WinFormsのメニュー項目は、ShellのFlyout/TabとToolbarItemの組み合わせで置き換えるのが自然です。

テスト戦略:UIは薄く、ViewModelとサービスを徹底的に検証

  • ViewModel単体テスト:バリデーション、状態遷移、Command発火をテスト。
  • サービスのモック:認証・リポジトリ・設定保存をインターフェース化しDIで差し替え。
  • UIのスモークテスト:起動・遷移・主要操作が落ちないことをデバイス/エミュレータで確認。

移行の見積りポイント:手戻りを抑えるチェックリスト

  • Win32/GDI+依存の棚卸し:代替APIの有無、P/Invokeの必要性、または機能縮退の許容。
  • フォーム数/複雑度:Grid設計が必要な画面、可変行・動的追加が多い画面を優先的にリデザイン。
  • 共通コンポーネント化:バリデーション、ボタン行、検索パネルなどを再利用部品に昇格。
  • 入力デバイス考慮:タッチ/キーボード/マウスのバランス。フォーカス移動・ショートカット設計。
  • 段階デリバリ:ハイリスク画面は先にプロトタイプ(純C#)→確定後にXAML/Markupへ。

落とし穴と回避策

落とし穴症状対策
イベントだらけのページ肥大化・保守困難Command/Bindingへ寄せる。ViewModelへロジックを移す
Stackの深いネスト計測/配列コスト増で重いGrid基軸に設計する。レイアウトを段で分割
プラットフォーム差の放置見た目/操作感のムラHandler/条件コンパイルで差を吸収。最終確認は実機で
同期I/Oの混入UIフリーズすべて非同期化、進捗はIndicatorで可視化
WinForms流のピクセル想定小画面/回転で崩れる余白・比率・最小サイズでルール化しレスポンシブ前提に

ケーススタディ:段階移行のすすめ(実施順序の一例)

  1. 現行フォームの棚卸し:共通レイアウト・入力項目・検証ルールを抽出しカタログ化。
  2. 核となる2〜3画面を選定:純C#(またはMarkup)で高速プロトタイプを作成して合意形成。
  3. サービス境界の確立:DB・API・設定・ファイル入出力をインターフェース化しUIから切断。
  4. Navigation/Shell構成を固定:URLルーティング・タブ・権限別表示を固める。
  5. C#→XAMLの必要性を再判断:保守性・読みやすさ重視の画面をXAMLへ、動的・複雑ロジックはC#継続。
  6. 自動テスト整備:ViewModelテストを先に整え、後からUIテストを薄く乗せる。

まとめ:XAMLは“最適解の一つ”。C#だけでも十分戦える

.NET MAUIでは、XAMLが標準的な選択肢である一方、純C#・CommunityToolkit.Maui.Markup・Comet(MVU)・Blazor Hybridのいずれでもフォーム相当の画面を完全に構築可能です。WinFormsのドラッグ&ドロップ・デザイナ体験はありませんが、ホットリロードと宣言的UIの組み合わせで代替し、さらに設計段階で「Page/Layout」思考へ切り替えることで、クロスプラットフォームかつ保守しやすいUIへ着地できます。

移行では、Form→Pageの読み替え、ViewModel/サービス分離、Grid基軸レイアウト、プラットフォーム差はHandlerで吸収の4点を押さえておけば、XAMLに依存せずとも十分に高品質な体験を実現できます。まずはC#で素早く組み、固まったらXAMLやMarkupに寄せる——この段階戦略が、最短で“動く”クロスプラットフォームを手に入れるコツです。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次