[.NET 8] Windows Forms (No Designer) + Generic Host + Dependency Injection + MVVM + Command 패턴을 하나의 소스 파일로 묶어봤습니다.

.NET 8에 들어오면서, Windows Forms에서도 ICommand 기반의 MVVM 패턴을 완전히 구현할 수 있게 되었죠

그래서, Windows Forms에서도 Generic Host를 시작으로 Dependency Injection, MVVM, Command 패턴을 빠짐없이 사용할 수 있는지 살펴보았고, 코드 작성 난이도가 다소 높긴 하나 충분히 해볼만한 것 같습니다. 여기에 맞추어 샘플 코드를 만들어봤습니다.

관심있으신 분들께 참고가 될만한 레퍼런스 코드를 GitHub Gist로 공유해봅니다. :smiley:

/*
<!-- 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);
}
9 Likes

확실히 Windows Forms는 오래된 코드베이스여서 그런지 이 예제의 테마에 맞추어 코드를 작성할 때 생각보다 타이핑해야 할 코드의 양이 제법 많은 편입니다.

그래서 코드를 간결하게 만들어줄 익스텐션 메서드가 잘 쓰일 필요가 있겠다는 생각도 듭니다.

6 Likes

기왕이면 Button에 Command를 직접 지정하는 것 보다 DataBindings에 Binding을 추가하시면 더욱 멋진 예제가 되지 않을까 싶습니다.

// 확인 버튼 추가
var okayButton = new Button()
{
    Parent = buttonPanel,
    Text = "OK",
    DialogResult = DialogResult.OK,
    AutoSize = true,
    //Command = _viewModel.AcceptCommand,
    //CommandParameter = _viewModel,
};
okayButton.DataBindings.Add(new Binding(
    nameof(okayButton.Command),
    DataContext,
    nameof(_viewModel.AcceptCommand),
    true,
    DataSourceUpdateMode.OnPropertyChanged
));
this.AcceptButton = okayButton;

// 취소 버튼 추가
var cancelButton = new Button()
{
    Parent = buttonPanel,
    Text = "Cancel",
    DialogResult = DialogResult.Cancel,
    AutoSize = true,
    //Command = _viewModel.CancelCommand,
    //CommandParameter = _viewModel,
};
cancelButton.DataBindings.Add(new Binding(
    nameof(cancelButton.Command),
    DataContext,
    nameof(_viewModel.CancelCommand),
    true,
    DataSourceUpdateMode.OnPropertyChanged
));

4 Likes