WinUI doesn’t support window size limits by default, but you can easily add this feature yourself.

WinUI functionality can often be extended using the Win32 API, and this is exactly one of those cases. While WinUI provides a modern and streamlined way to build Windows apps, some advanced features — like setting window size limits — aren’t natively supported. That’s where the Win32 API comes in handy. By leveraging its powerful capabilities, you can fill in the gaps and create more polished user experience.

To restrict the window size, we need to override the window procedure and intercept the WM_GETMINMAXINFO window message. For this, we’ll use functions from the Win32 API, which are most conveniently accessed via CsWin32.

The file NativeMethods.txt will look like this:

GetDpiForWindow
SetWindowLongPtr
CallWindowProc
WM_GETMINMAXINFO
MINMAXINFO

To override the window procedure, you can use the following code:

private readonly WNDPROC nativeWindowProc;

public MainWindow()
{
    // Replace the window procedure with our custom one
    var p = PInvoke.SetWindowLongPtr(
        hWnd: new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this)),
        nIndex: WINDOW_LONG_PTR_INDEX.GWL_WNDPROC,
        dwNewLong: Marshal.GetFunctionPointerForDelegate<WNDPROC>(WindowProc));

    // Store the original window procedure to call it later
    nativeWindowProc = Marshal.GetDelegateForFunctionPointer<WNDPROC>(p);
}

private LRESULT WindowProc(HWND hWnd, uint msg, WPARAM wParam, LPARAM lParam)
{
    // Call the original window procedure for default handling
    return PInvoke.CallWindowProc(nativeWindowProc, hWnd, msg, wParam, lParam);
}

But this code won’t work. The issue lies in how the WNDPROC delegate is handled. When you pass a delegate to SetWindowLongPtr, it’s essentially a function pointer that the system will call later. However, if you don’t store this delegate in a field, the .NET garbage collector might clean it up, thinking it’s no longer needed. Once the delegate is collected, the function pointer becomes invalid, and any attempt to call it will crash your application.

In other words, the delegate “disappears” because it’s not referenced anywhere in your code, and the garbage collector removes it. When the system tries to call the window procedure later, it points to an invalid memory location, causing your app to fail.

Here’s the corrected version of the code:

private readonly WNDPROC windowProc; // Store the delegate to prevent the garbage collection
private readonly WNDPROC nativeWindowProc;

public MainWindow()
{
    // Initialize the delegate and store it in a field
    windowProc = new WNDPROC(WindowProc);

    // Replace the window procedure with our custom one
    var p = PInvoke.SetWindowLongPtr(
        hWnd: new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this)),
        nIndex: WINDOW_LONG_PTR_INDEX.GWL_WNDPROC,
        dwNewLong: Marshal.GetFunctionPointerForDelegate<WNDPROC>(windowProc));

    // Store the original window procedure to call it later
    nativeWindowProc = Marshal.GetDelegateForFunctionPointer<WNDPROC>(p);
}

private LRESULT WindowProc(HWND hWnd, uint msg, WPARAM wParam, LPARAM lParam)
{
    // Call the original window procedure for default handling
    return PInvoke.CallWindowProc(nativeWindowProc, hWnd, msg, wParam, lParam);
}

Now we’re ready to restrict the window size. For this, we’ll use four readonly properties:

private int? MinWidth { get; }
private int? MinHeight { get; }
private int? MaxWidth { get; }
private int? MaxHeight { get; }

These properties define the minimum and maximum dimensions of the window in absolute values (assuming a DPI scale factor of 1).

Why is this important? Modern displays often use DPI scaling to make text and UI elements appear sharper and more readable. However, this means that the actual pixel dimensions of the window can vary depending on the user’s display settings. By defining the size limits in absolute values (as if the DPI scale factor was 1), we ensure that the window behaves consistently across different devices.

In the systems with multiple monitors (e.g., a laptop with an external display), each monitor can have its own DPI scaling factor. For example, the laptop screen might use a scaling factor of 125%, while the external monitor uses 100%. To handle this, we’ll dynamically adjust the window size limits based on the DPI scaling of the monitor where the window is currently displayed. This ensures that the window’s size constraints are applied correctly, no matter which monitor the user moves it to.

Here’s the final version of our window procedure:

private unsafe LRESULT WindowProc(HWND hWnd, uint msg, WPARAM wParam, LPARAM lParam)
{
    // Check if the message is WM_GETMINMAXINFO, which is used to set window size limits
    if (msg == PInvoke.WM_GETMINMAXINFO)
    {
        // Get the DPI of the monitor where the window is currently displayed
        var dpi = PInvoke.GetDpiForWindow(new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this)));

        // Calculate the scaling factor based on the DPI (96 DPI = 100% scaling)
        var scalingFactor = dpi / 96f;

        // Cast the lParam to a MINMAXINFO pointer to modify the window size constraints
        MINMAXINFO* minMaxInfo = (MINMAXINFO*)lParam.Value;

        // Apply the minimum width, scaled to the current DPI
        if (MinWidth != null)
        {
            minMaxInfo->ptMinTrackSize.X = (int)(MinWidth * scalingFactor);
        }

        // Apply the minimum height, scaled to the current DPI
        if (MinHeight != null)
        {
            minMaxInfo->ptMinTrackSize.Y = (int)(MinHeight * scalingFactor);
        }

        // Apply the maximum width, scaled to the current DPI
        if (MaxWidth != null)
        {
            minMaxInfo->ptMaxTrackSize.X = (int)(MaxWidth * scalingFactor);
        }

        // Apply the maximum height, scaled to the current DPI
        if (MaxHeight != null)
        {
            minMaxInfo->ptMaxTrackSize.Y = (int)(MaxHeight * scalingFactor);
        }
    }

    // Call the original window procedure to handle all other messages
    return PInvoke.CallWindowProc(nativeWindowProc, hWnd, msg, wParam, lParam);
}

Using a pointer (unsafe code) instead of StructToPtr is more efficient and straightforward for this scenario. Since the MINMAXINFO structure is already in unmanaged memory (passed via lParam), a pointer allows us to directly modify the data without copying it back and forth. This approach is faster and aligns better with Win32 API practices, where pointers are commonly used to interact with low-level structures. StructToPtr would add unnecessary overhead, making the code slower and more complex.

In conclusion, by leveraging the Win32 API and a bit of unsafe code, we’ve successfully added a crucial feature that WinUI lacks out of the box: the ability to restrict the window size. This approach not only enhances the user experience by ensuring your app’s window stays within defined bounds but also demonstrates how seamlessly WinUI can be extended with traditional Windows APIs. Whether you’re building a compact utility or a full-fledged desktop application, this technique empowers you to create more polished and professional apps. Happy coding!