.NET 8에 들어오면서, Windows Forms에서도 ICommand 기반의 MVVM 패턴을 완전히 구현할 수 있게 되었죠
그래서, Windows Forms에서도 Generic Host를 시작으로 Dependency Injection, MVVM, Command 패턴을 빠짐없이 사용할 수 있는지 살펴보았고, 코드 작성 난이도가 다소 높긴 하나 충분히 해볼만한 것 같습니다. 여기에 맞추어 샘플 코드를 만들어봤습니다.
관심있으신 분들께 참고가 될만한 레퍼런스 코드를 GitHub Gist로 공유해봅니다.
/*
<!-- Project Configuration -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<UseWPF>false</UseWPF>
<ImplicitUsings>disable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
</ItemGroup>
</Project>
*/
#nullable enable
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Windows.Input;
internal static class Program
{
private static async Task MainAsync(string[] args)
{
using var cancellationTokenSource = new CancellationTokenSource();
args ??= Environment.GetCommandLineArgs().Skip(1).ToArray();
var builder = Host.CreateApplicationBuilder(args);
builder.Configuration
.AddCommandLine(args)
.AddEnvironmentVariables()
.AddUserSecrets(typeof(Program).Assembly, true);
builder.Logging
.AddConsole()
.AddDebug();
builder.Services.AddSingleton<MainForm>(sp =>
{
var logger = sp.GetRequiredService<ILogger<MainForm>>();
var viewModel = sp.GetRequiredService<MainFormViewModel>();
return new MainForm(logger, viewModel)
{
Text = "Hello, MVVM with DI in WinForm 8",
};
});
builder.Services.AddSingleton<MainFormViewModel>(_ =>
{
return new MainFormViewModel()
{
Username = "James",
Age = 22,
Contact = "010-1234-1234",
Memo = "Hello!",
Active = false,
};
});
builder.Services.AddSingleton<ApplicationContext>(sp =>
{
var mainForm = sp.GetRequiredService<MainForm>();
return new ApplicationContext(mainForm);
});
builder.Services.AddHostedService<WindowsApp>();
var app = builder.Build();
await app.RunAsync(cancellationTokenSource.Token);
}
[STAThread]
private static int Main(string[] args)
{
try
{
MainAsync(args).GetAwaiter().GetResult();
}
catch (Exception ex)
{
Environment.ExitCode = 1;
Debug.WriteLine(ex.ToString());
Console.Error.WriteLine(ex.ToString());
}
return Environment.ExitCode;
}
}
public sealed class WindowsApp(
IHostApplicationLifetime HostAppLifetime,
ApplicationContext WinformAppContext
) : BackgroundService
{
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
return Task.Run(() =>
{
Application.OleRequired();
Application.EnableVisualStyles();
Application.SetHighDpiMode(HighDpiMode.SystemAware);
Application.Run(WinformAppContext);
}, stoppingToken)
.ContinueWith(_ =>
{
HostAppLifetime.StopApplication();
}, stoppingToken);
}
}
public sealed class MainForm : Form
{
public MainForm(
ILogger<MainForm> logger,
MainFormViewModel viewModel)
{
_logger = logger;
_viewModel = viewModel;
_viewModel.RequestClose += (_sender, _e) =>
{
Close();
};
}
private ILogger _logger;
private MainFormViewModel _viewModel;
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
SuspendLayout();
DoubleBuffered = true;
Size = new Size(640, 480);
Visible = true;
DataContext = _viewModel;
MinimumSize = new Size(640, 480);
MaximumSize = new Size(800, 600);
var tableLayout = new TableLayoutPanel()
{
Parent = this,
Dock = DockStyle.Fill,
Padding = new Padding(10),
};
// 표 레이아웃에 3행 추가
tableLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
tableLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
tableLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
tableLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize, 80f));
tableLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
tableLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
// 표 레이아웃에 2개 열 추가
tableLayout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize, 30f));
tableLayout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize, 70f));
// 이름 라벨 추가
var nameLabel = new Label()
{
Parent = tableLayout,
Text = "Name: ",
Dock = DockStyle.Fill,
TextAlign = ContentAlignment.MiddleRight,
};
tableLayout.SetCellPosition(nameLabel, new TableLayoutPanelCellPosition(row: 1, column: 1));
// 이름 텍스트박스 추가
var nameTextbox = new TextBox()
{
Parent = tableLayout,
Dock = DockStyle.Fill,
};
nameTextbox.DataBindings.Add(new Binding(
nameof(nameTextbox.Text),
DataContext,
nameof(_viewModel.Username),
false,
DataSourceUpdateMode.OnPropertyChanged
));
tableLayout.SetCellPosition(nameTextbox, new TableLayoutPanelCellPosition(row: 1, column: 2));
// 나이 라벨 추가
var ageLabel = new Label()
{
Parent = tableLayout,
Text = "Age: ",
Dock = DockStyle.Fill,
TextAlign = ContentAlignment.MiddleRight,
};
tableLayout.SetCellPosition(ageLabel, new TableLayoutPanelCellPosition(row: 2, column: 1));
// 나이 숫자 텍스트박스 추가
var ageUpDown = new NumericUpDown()
{
Parent = tableLayout,
Dock = DockStyle.Fill,
Minimum = 0m,
Maximum = 200m,
};
ageUpDown.DataBindings.Add(new Binding(
nameof(ageUpDown.Value),
DataContext,
nameof(_viewModel.Age),
false,
DataSourceUpdateMode.OnPropertyChanged
));
tableLayout.SetCellPosition(ageUpDown, new TableLayoutPanelCellPosition(row: 2, column: 2));
// 연락처 라벨 추가
var contactLabel = new Label()
{
Parent = tableLayout,
Text = "Contact: ",
Dock = DockStyle.Fill,
TextAlign = ContentAlignment.MiddleRight,
};
tableLayout.SetCellPosition(contactLabel, new TableLayoutPanelCellPosition(row: 3, column: 1));
// 연락처 마스크 텍스트 박스 추가
var contactTextbox = new MaskedTextBox()
{
Parent = tableLayout,
TextMaskFormat = MaskFormat.ExcludePromptAndLiterals,
Dock = DockStyle.Fill,
};
contactTextbox.DataBindings.Add(new Binding(
nameof(contactTextbox.Text),
DataContext,
nameof(_viewModel.Contact),
false,
DataSourceUpdateMode.OnPropertyChanged
));
tableLayout.SetCellPosition(contactTextbox, new TableLayoutPanelCellPosition(row: 3, column: 2));
// 메모 텍스트박스 추가 (여러 줄)
var memoTextbox = new TextBox()
{
Parent = tableLayout,
Dock = DockStyle.Fill,
Multiline = true,
Height = 120,
};
memoTextbox.DataBindings.Add(new Binding(
nameof(memoTextbox.Text),
DataContext,
nameof(_viewModel.Memo),
false,
DataSourceUpdateMode.OnPropertyChanged
));
tableLayout.SetCellPosition(memoTextbox, new TableLayoutPanelCellPosition(row: 4, column: 1));
tableLayout.SetColumnSpan(memoTextbox, 2);
// 활성 회원 체크박스 추가
var activeCheckbox = new CheckBox()
{
Parent = tableLayout,
Dock = DockStyle.Fill,
Text = "Active member",
AutoSize = true,
};
activeCheckbox.DataBindings.Add(new Binding(
nameof(activeCheckbox.Checked),
DataContext,
nameof(_viewModel.Active),
false,
DataSourceUpdateMode.OnPropertyChanged
));
tableLayout.SetCellPosition(activeCheckbox, new TableLayoutPanelCellPosition(row: 5, column: 1));
tableLayout.SetColumnSpan(activeCheckbox, 2);
// 버튼 패널 추가
var buttonPanel = new FlowLayoutPanel()
{
Parent = tableLayout,
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.RightToLeft,
AutoSize = true,
};
tableLayout.SetCellPosition(buttonPanel, new TableLayoutPanelCellPosition(row: 6, column: 1));
tableLayout.SetColumnSpan(buttonPanel, 2);
// 확인 버튼 추가
var okayButton = new Button()
{
Parent = buttonPanel,
Text = "OK",
DialogResult = DialogResult.OK,
AutoSize = true,
Command = _viewModel.AcceptCommand,
//CommandParameter = _viewModel,
};
this.AcceptButton = okayButton;
// 취소 버튼 추가
var cancelButton = new Button()
{
Parent = buttonPanel,
Text = "Cancel",
DialogResult = DialogResult.Cancel,
AutoSize = true,
Command = _viewModel.CancelCommand,
//CommandParameter = _viewModel,
};
// RightToLeft 레이아웃에서 취소 버튼이 오른쪽에 먼저 표시되도록 수정
cancelButton.BringToFront();
this.CancelButton = cancelButton;
ResumeLayout();
_logger.LogInformation("Application has been loaded.");
}
}
public sealed class MainFormViewModel : INotifyPropertyChanged
{
public MainFormViewModel()
{
_acceptCommand = new MainFormAcceptCommand(this);
_cancelCommand = new MainFormCancelCommand(this);
}
public ICommand AcceptCommand => _acceptCommand;
public ICommand CancelCommand => _cancelCommand;
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string propertyName = "")
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
_acceptCommand.RaiseCanExecuteChanged();
_cancelCommand.RaiseCanExecuteChanged();
}
private string _username = string.Empty;
private int _age = 21;
private string _contact = string.Empty;
private string _memo = string.Empty;
private bool _active = true;
private MainFormAcceptCommand _acceptCommand;
private MainFormCancelCommand _cancelCommand;
public string Username
{
get => _username;
set
{
if (_username != value)
{
_username = value;
OnPropertyChanged();
}
}
}
public int Age
{
get => _age;
set
{
if (_age != value)
{
_age = value;
OnPropertyChanged();
}
}
}
public string Contact
{
get => _contact;
set
{
if (_contact != value)
{
_contact = value;
OnPropertyChanged();
}
}
}
public string Memo
{
get => _memo;
set
{
if (_memo != value)
{
_memo = value;
OnPropertyChanged();
}
}
}
public bool Active
{
get => _active;
set
{
if (_active != value)
{
_active = value;
OnPropertyChanged();
}
}
}
public async Task SaveProfileDataAsync(string filePath)
{
await File.WriteAllTextAsync(
filePath,
this.ToString(),
new UTF8Encoding(false));
}
public void CloseMainWindow(object? parameter)
{
SendOrPostCallback callback = _ =>
{
RequestClose?.Invoke(this, EventArgs.Empty);
};
var context = SynchronizationContext.Current;
if (context != null) context.Send(callback, this);
else callback.Invoke(this);
}
public override string ToString()
=> $"Name: {_username}, Age: {_age}, Contact: {_contact}, Memo: {_memo}, Active: {_active}";
public event EventHandler? RequestClose;
}
public sealed class MainFormAcceptCommand(
MainFormViewModel ViewModel
) : ICommand
{
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter)
=> ViewModel.Active;
public void Execute(object? parameter)
=> _ = ExecuteAsync(parameter);
public async Task ExecuteAsync(object? parameter)
{
try
{
await ViewModel.SaveProfileDataAsync(
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),
"Sample.txt"));
ViewModel.CloseMainWindow(parameter);
}
catch (Exception) { }
}
public void RaiseCanExecuteChanged()
=> CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
public sealed class MainFormCancelCommand(
MainFormViewModel ViewModel
) : ICommand
{
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter)
=> true;
public void Execute(object? parameter)
=> ViewModel.CloseMainWindow(parameter);
public void RaiseCanExecuteChanged()
=> CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}