WinUI 3에서 모달 창 띄우기

WinUI 3에서는 Windows Forms나 WPF와는 다르게 ShowDialog 메서드가 없기 때문에 좀 복잡한 방법으로 모달 창을 띄워야 합니다. 아무래도 WinUI 3가 UWP에게서 큰 영향을 받았는데 UWP에는 다중 창이라는 개념이 없었다 보니까 이렇게 되지 않았나 싶습니다.

프로젝트 준비

가장 먼저 MainWindow에 모달 창을 띄우는 버튼을 하나 둡시다.

<Button HorizontalAlignment="Center" VerticalAlignment="Center" Click="TheButton_OnClick">모달 창 띄우기</Button>

그리고 TheButton_OnClick 메서드는 다음과 같이 구현해줍시다.

private void TheButton_OnClick(object sender, RoutedEventArgs e) {
    ModalWindow mw = new(this);

    // Activate() 대신 AppWindow.Show() 사용
    mw.AppWindow.Show();
}

여기서 특히 주의해야할 점은 창을 띄울 때 Window.Activate 메서드를 사용하면 안된다는 것입니다. AppWindow 클래스의 Show 메서드를 사용해야 하는데 이유는 잘 모르겠습니다.

프로젝트에 ModalWindow라는 이름의 창을 하나 추가하고 다음과 같이 작성합니다.

public sealed partial class ModalWindow {
    private readonly Window parentWindow;

    public ModalWindow(Window parent) {
        InitializeComponent();
        parentWindow = parent;
    }
}

이제 앱을 실행해보면 창 하나가 뜨고, 여기서 버튼을 누르면 창이 하나 더 뜰 것입니다.

아직까지는 처리를 하지 않았기 때문에 실제로 모달 창으로 작동하는 건 아닙니다.

PInvoke 함수 정의

이제 PInvoke 함수와 상수를 정의해야 합니다.

private const int GWLP_HWNDPARENT = -8;

[DllImport("user32.dll",
#if !WIN64
    EntryPoint = "SetWindowLongW",
#endif
    ExactSpelling = true, SetLastError = true)]
private static extern nint SetWindowLongPtrW(IntPtr hWnd, int index, nint newLong);

이 코드가 32비트에서 정상적으로 작동하려면 csproj 파일에 다음 내용을 추가해야 합니다. 왜냐하면 64비트에서는 SetWindowLongPtrW 함수를 사용해야 하지만 32비트에서는 SetWindowLongPtrW 함수가 없고 SetWindowLongW 함수를 사용해야 하기 때문에 그렇습니다. winuser.h 파일 살펴보신 분들은 아시겠지만 SetWindowLongPtrW는 32비트로 컴파일힐 때는 SetWindowLongW에 대한 매크로일 뿐입니다. 물론 64비트로만 빌드할거라면 아래 내용과 위의 #if 전처리기로 감싼 부분은 없어도 됩니다.

<PropertyGroup Condition="'$(Platform)' == 'x64' or '$(Platform)' == 'ARM64'">
  <DefineConstants>$(DefineConstants);WIN64</DefineConstants>
</PropertyGroup>

모달 창으로 만들기

이제 진짜 모달 장으로 만들어봅시다.

public sealed partial class ModalWindow {
    private const int GWLP_HWNDPARENT = -8;
    private readonly Window parentWindow;

    public ModalWindow(Window parent) {
        InitializeComponent();

        parentWindow = parent;

        // 모달 창으로 만들어주는 Presenter
        var presenter = OverlappedPresenter.CreateForDialog();
        presenter.IsModal = true;

        // 부모 창 설정
        SetWindowLongPtrW(WindowNative.GetWindowHandle(this), GWLP_HWNDPARENT, WindowNative.GetWindowHandle(parent));
        AppWindow.SetPresenter(presenter);

        Closed += ModalWindow_Closed;
    }

    [DllImport("user32.dll",
#if !WIN64
        EntryPoint = "SetWindowLongW",
#endif
        ExactSpelling = true, SetLastError = true)]
    private static extern nint SetWindowLongPtrW(IntPtr hWnd, int index, nint newLong);

    // 이거 안해주면 모달 창 닫을 때 부모 창이 뒤로 들어가버립니다.
    private void ModalWindow_Closed(object sender, WindowEventArgs e) => parentWindow.Activate();
}

이제 프로그램을 실행해 보면 모달 창으로 뜨는 것을 확인할 수 있습니다.

이전 내용. 이 방법으로 하시면 안됩니다. 맨 밑에서 볼 수 있듯이 몇가지 문제가 있습니다.

프로젝트 준비

가장 먼저 MainWindow에 모달 창을 띄우는 버튼을 하나 둡시다.

<Button HorizontalAlignment="Center" VerticalAlignment="Center" Click="TheButton_OnClick">모달 창 띄우기</Button>

그리고 TheButton_OnClick 메서드는 다음과 같이 구현해줍시다.

private void TheButton_OnClick(object sender, RoutedEventArgs e) {
    ModalWindow mw = new(this);

    mw.Activate();
}

프로젝트에 ModalWindow라는 이름의 창을 하나 추가하고 다음과 같이 작성합니다.

using Microsoft.UI.Xaml;
using WinRT.Interop;

namespace App1;

/// <summary>
/// An empty window that can be used on its own or navigated to within a Frame.
/// </summary>
public sealed partial class ModalWindow {
    private readonly IntPtr parentHwnd;

    public ModalWindow(Window parent) {
        InitializeComponent();
        parentHwnd = WindowNative.GetWindowHandle(parent);
    }
}

이제 앱을 실행해보면 창 하나가 뜨고, 여기서 버튼을 누르면 창이 하나 더 뜰 것입니다.

아직까지는 처리를 하지 않았기 때문에 실제로 모달 창으로 작동하는 건 아닙니다.

PInvoke 함수 및 WndProc 정의

이제 PInvoke 함수와 상수를 정의해야 합니다.

private const int GWLP_HWNDPARENT = -8;
private const int GWLP_WNDPROC = -4;
private const uint WM_DESTROY = 0x0002;

[DllImport("user32.dll", ExactSpelling = true, SetLastError = true)]
private static extern nint SetWindowLongPtrW(IntPtr hWnd, int index, nint newLong);

[DllImport("user32.dll", ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool EnableWindow(IntPtr hWnd, [MarshalAs(UnmanagedType.Bool)] bool enable);

[DllImport("user32.dll", ExactSpelling = true)]
private static extern nint CallWindowProcW(IntPtr prevWndProc, IntPtr hWnd, uint msg, nint wParam, nint lParam);

WndProc 대리자 타입을 정의합시다.

[UnmanagedFunctionPointer(CallingConvention.StdCall)]
internal delegate nint WndProc(IntPtr hWnd, uint uMsg, nint wParam, nint lParam);

모달 창으로 만들기

이제 진짜 모달 장으로 만들어봅시다.

public sealed partial class ModalWindow {
    private const int GWLP_HWNDPARENT = -8;
    private const int GWLP_WNDPROC = -4;
    private const uint WM_DESTROY = 0x0002;
    private readonly IntPtr parentHwnd;
    private readonly IntPtr originalWndProc;

    public ModalWindow(Window parent) {
        InitializeComponent();

        parentHwnd = WindowNative.GetWindowHandle(parent);
        var presenter = OverlappedPresenter.CreateForDialog();
        presenter.IsModal = true;

        SetWindowLongPtrW(WindowNative.GetWindowHandle(this), GWLP_HWNDPARENT, parentHwnd);
        AppWindow.SetPresenter(presenter);

        Activated += ModalWindow_Activated;

        originalWndProc = SetWindowLongPtrW(WindowNative.GetWindowHandle(this), GWLP_WNDPROC,
            Marshal.GetFunctionPointerForDelegate(new WndProc(MyWndProc)));
    }

    [DllImport("user32.dll", ExactSpelling = true, SetLastError = true)]
    private static extern nint SetWindowLongPtrW(IntPtr hWnd, int index, nint newLong);

    [DllImport("user32.dll", ExactSpelling = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool EnableWindow(IntPtr hWnd, [MarshalAs(UnmanagedType.Bool)] bool enable);

    [DllImport("user32.dll", ExactSpelling = true)]
    private static extern nint CallWindowProcW(IntPtr prevWndProc, IntPtr hWnd, uint msg, nint wParam, nint lParam);

    private nint MyWndProc(IntPtr hWnd, uint uMsg, nint wParam, nint lParam) {
        if (uMsg == WM_DESTROY) {
            EnableWindow(parentHwnd, true);
        }

        return CallWindowProcW(originalWndProc, hWnd, uMsg, wParam, lParam);
    }

    private void ModalWindow_Activated(object sender, WindowActivatedEventArgs args) => EnableWindow(parentHwnd, false);
}

이제 프로그램을 실행해 보면 모달 창으로 뜨는 것을 확인할 수 있습니다.

이 방법은 완벽한 방법이 아닙니다. 영상에 보이듯이 모달 창을 닫으면 원래 창이 뒤로 들어가 버립니다. 무엇보다도 가끔씩 비주얼 스튜디오 디버거에서도 안 잡히고 프로그램이 꺼지는 경우가 있습니다. 이벤트 뷰어를 보면 0xc000027b라고 잡히기는 하는데 검색해도 정확히 뭐가 문제인지는 안보이더라고요. 그래서 WinDbg로 디버깅을 시도했는데 WinDbg를 물렸을 때는 절대로 재현이 안되더군요(…). 그래서 잘 모르겠습니다.

6개의 좋아요

모달 창을 닫을 때 부모 창이 비활성화 되는 문제는 모달 윈도우가 닫힐 때 부모 창을 강제로 Activate 시키는 것으로 대처할 수 있을 것 같네요. 마침 부모 창 핸들을 얻기 위해서 ModalWindow의 생성자로 부모 창을 받으니 참조를 얻기 위해 별도의 노력이 필요하지도 않구요.

private readonly Window _parent;

public ModalWindow(Window parent)
{
    // 생략...
    _parent = parent;
    Closed += ModalWindow_Closed;
}

private void ModalWindow_Closed(object sender, WindowEventArgs args)
{
    _parent.Activate();
}
4개의 좋아요

혹시나 해서 WinUI 3 Gallery(MS스토어 링크)를 찾아보니 AppWindow 항목에 모달 윈도우 만드는 법이 나와 있네요.

OverlappedPresenter.IsModal 속성에 입력을 차단하는 기능이 이미 포함되어 있어 EnableWindow를 중복 호출하지 않아도 되는 모양입니다.

본문에 있는 코드를 기반으로 다음과 같이 구현할 수 있습니다.

public sealed partial class ModalWindow
{
    private readonly Window _parent;

    public ModalWindow(Window parent)
    {
        InitializeComponent();

        _parent = parent;
        var parentHandle = WindowNative.GetWindowHandle(parent);
        var windowHandle = WindowNative.GetWindowHandle(this);

        var presenter = OverlappedPresenter.CreateForDialog();
        presenter.IsModal = true;

        if (IntPtr.Size == 8)
        {
            SetWindowLongPtr(windowHandle, -8, parentHandle);
        }
        else // 32-bit system
        {
            SetWindowLong(windowHandle, -8, parentHandle);
        }

        AppWindow.SetPresenter(presenter);
        AppWindow.Show();
        Closed += ModalWindow_Closed;
    }

    private void ModalWindow_Closed(object sender, WindowEventArgs args)
    {
        _parent.Activate();
    }

    [DllImport("User32.dll", CharSet = CharSet.Auto, EntryPoint = "SetWindowLongPtr")]
    private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);

    [DllImport("User32.dll", CharSet = CharSet.Auto, EntryPoint = "SetWindowLong")]
    private static extern IntPtr SetWindowLong(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
}
3개의 좋아요

이상하네요… 분명히 다른 프로젝트로 해봤을때는 상위 창이 멀쩡히 선택 가능해서 EnableWindow 함수를 넣어놓은 거였는데… 여기서는 또 없어도 된다니…

2개의 좋아요

AppWindow.Show 메서드 호출이 누락되었기 때문일 것 같습니다. 기본적으로 OverlappedPresenter의 다른 변경사항은 AppWindow.SetPresenter 호출 시 바로 AppWindow에 반영되는 것으로 알고 있는데, 테스트 해보니 IsModal 속성은 변경 후 AppWindow.Show 메서드를 호출해줘야 적용되는 모양입니다.

2개의 좋아요

그러네요. Activate() 대신에 AppWindow.Show(true)로 하니까 모달 창이 잘 적용되는군요.

4개의 좋아요

@루나시아 님이 언급하신 내용을 토대로 글을 수정했습니다.

4개의 좋아요