既存の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 | 補足 |
|---|---|---|
| Form | Page(例:ContentPage) | 画面の最小単位。Shell配下でルート化するのが一般的 |
| Panel / TableLayoutPanel | Grid / StackLayout / VerticalStackLayout | 柔軟なレイアウト。Gridを基本に設計するのが移行時は安全 |
| TextBox | Entry / Editor | 単一行はEntry、複数行はEditor |
| DataGridView / ListView | CollectionView | 仮想化・テンプレートによる描画が前提 |
| MenuStrip / ToolStrip | Shell(Flyout/Tab)/ ToolbarItem | ナビゲーションはShellで。従来のメニュー欄はToolbarItemへ |
| MessageBox | DisplayAlert / 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 Hybrid | Razor(HTML+C#)で記述したWebコンポーネントをネイティブに埋め込み | Web資産の再利用。ブラウザではなくネイティブWebView | Webとネイティブの併用、既存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に置き換え、ロジックを疎結合化
- 画面単位の読み替え:Form → Page、コントロールはEntry/Button/CollectionView等へマッピング。
- MVVM/MVUの採用:イベントロジックをViewModelまたはUpdate関数に集約。
- DIでサービス分離:認証、データアクセス、バリデーションをサービス化しUIから切り離す。
- 一部C#→安定後にXAML化:プロトタイプはC#で素早く。仕様が固まればXAMLへ移しデザイナビリティを高める手も。
- 段階移行:新規画面は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.Load | OnAppearing(Pageのライフサイクル) | 非同期初期化はOnAppearingでawait |
| DialogResult/ShowDialog | Navigation.PushModalAsync / DisplayAlert | 非同期の戻り値で分岐。戻る操作も非同期 |
| Control.Enabled/Visible | BindingでIsEnabled/IsVisible | 状態はViewModel側に集約 |
| DataSourceにDataTable | ObservableCollection+DataTemplate | テンプレートで自由度を確保しつつ仮想化 |
| Application.DoEvents | await/非同期タスクで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流のピクセル想定 | 小画面/回転で崩れる | 余白・比率・最小サイズでルール化しレスポンシブ前提に |
ケーススタディ:段階移行のすすめ(実施順序の一例)
- 現行フォームの棚卸し:共通レイアウト・入力項目・検証ルールを抽出しカタログ化。
- 核となる2〜3画面を選定:純C#(またはMarkup)で高速プロトタイプを作成して合意形成。
- サービス境界の確立:DB・API・設定・ファイル入出力をインターフェース化しUIから切断。
- Navigation/Shell構成を固定:URLルーティング・タブ・権限別表示を固める。
- C#→XAMLの必要性を再判断:保守性・読みやすさ重視の画面をXAMLへ、動的・複雑ロジックはC#継続。
- 自動テスト整備: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に寄せる——この段階戦略が、最短で“動く”クロスプラットフォームを手に入れるコツです。

コメント