Skip to content

[.NET 11 API Proposal] Add APIs for performance improved, flicker-free WinForms UI mutation #14585

Description

@KlausLoeffelmann

Revised from the original filing: renamed FormAppearanceMode -> FormRevealMode (Inherit/Classic/Deferred); removed the DeferLocationChange/DeferWindowPos batching sub-feature, now tracked separately at KlausLoeffelmann#12 pending an anchor-layout-engine integration fix; changed SuspendPaintingScope/SuspendRelocationScope from readonly ref struct to sealed class : IDisposable so the scopes can span an await; made ISupportSuspendPainting/ISupportSuspendRelocation explicit interface implementations with protected ...Core() hooks instead of public virtual methods on Control; added Application.DefaultFormRevealMode / SetDefaultFormRevealMode / IsFormRevealDeferred and the corresponding VB Application Framework wiring. Tracked alongside the rest of the .NET 11 API batch in #14694.

Rationale

WinForms developers commonly need to perform a burst of UI mutations: adding many items, changing layout-affecting properties, or showing a form whose initial background and child controls are not ready at the same time. Today, applications rely on a mix of control-specific BeginUpdate / EndUpdate, SuspendLayout / ResumeLayout, hand-written WM_SETREDRAW, and manual DWM/window tricks.

Those techniques work, but they are inconsistent, easy to misbalance, and often require application code to understand HWND-level details. This proposal adds a small set of composable WinForms APIs for the common cases:

  • suspend painting for a control while a synchronous or asynchronous mutation runs;
  • suspend relocation/layout work for a control while a mutation runs; and
  • defer top-level form reveal to reduce the initial default-background flash, especially noticeable in dark-mode applications.

The goal is not to make the WinForms layout engine faster. The goal is to reduce intermediate visible states and collapse avoidable work during a UI-thread mutation.

API Proposal

namespace System.Windows.Forms;

public interface ISupportSuspendPainting
{
    void BeginSuspendPainting();
    void EndSuspendPainting();
}

public interface ISupportSuspendRelocation
{
    void BeginSuspendRelocation();
    void EndSuspendRelocation();
}
namespace System.Windows.Forms;

public sealed class SuspendPaintingScope : IDisposable
{
    public SuspendPaintingScope(ISupportSuspendPainting? target);
    public void Dispose();
}

public sealed class SuspendRelocationScope : IDisposable
{
    public SuspendRelocationScope(ISupportSuspendRelocation? target);
    public void Dispose();
}

public static class ControlMutationExtensions
{
    public static SuspendPaintingScope SuspendPainting(this ISupportSuspendPainting? target);
    public static SuspendRelocationScope SuspendRelocation(this ISupportSuspendRelocation? target);
}
namespace System.Windows.Forms;

public partial class Control : ISupportSuspendPainting, ISupportSuspendRelocation
{
    void ISupportSuspendPainting.BeginSuspendPainting() => BeginSuspendPaintingCore();
    void ISupportSuspendPainting.EndSuspendPainting() => EndSuspendPaintingCore();
    void ISupportSuspendRelocation.BeginSuspendRelocation() => BeginSuspendRelocationCore();
    void ISupportSuspendRelocation.EndSuspendRelocation() => EndSuspendRelocationCore();

    protected virtual void BeginSuspendPaintingCore();
    protected virtual void EndSuspendPaintingCore();
    protected virtual void BeginSuspendRelocationCore();
    protected virtual void EndSuspendRelocationCore();
}
namespace System.Windows.Forms;

public partial class ListView
{
    protected override void BeginSuspendPaintingCore();
    protected override void EndSuspendPaintingCore();
}

public partial class ListBox
{
    protected override void BeginSuspendPaintingCore();
    protected override void EndSuspendPaintingCore();
}

public partial class ComboBox
{
    protected override void BeginSuspendPaintingCore();
    protected override void EndSuspendPaintingCore();
}

public partial class TreeView
{
    protected override void BeginSuspendPaintingCore();
    protected override void EndSuspendPaintingCore();
}

public partial class RichTextBox
{
    protected override void BeginSuspendPaintingCore();
    protected override void EndSuspendPaintingCore();
}
namespace System.Windows.Forms;

public enum FormRevealMode
{
    Inherit = -1,
    Classic = 0,
    Deferred = 1,
}

public partial class Form
{
    public virtual FormRevealMode FormRevealMode { get; set; }
}

public partial class Application
{
    public static FormRevealMode DefaultFormRevealMode { get; }
    public static void SetDefaultFormRevealMode(FormRevealMode mode);
    public static bool IsFormRevealDeferred { get; }
}
Namespace Microsoft.VisualBasic.ApplicationServices

    Public Class ApplyApplicationDefaultsEventArgs
        Inherits EventArgs

        Public Property FormRevealMode As FormRevealMode
    End Class
End Namespace

API Usage

using (buttonPanel.SuspendPainting())
using (buttonPanel.SuspendRelocation())
{
    foreach (Button button in buttonPanel.Controls.OfType<Button>())
    {
        button.Text = GetUpdatedText(button);
        button.Width += 20;
    }
}
// Scopes are sealed classes, not ref structs, so they can span an await -
// e.g. suspend painting for the duration of an async reload.
private async void reloadButton_Click(object? sender, EventArgs e)
{
    using SuspendPaintingScope scope = listView.SuspendPainting();

    listView.Items.Clear();
    List<ListViewItem> items = await LoadItemsAsync();
    listView.Items.AddRange([.. items]);
}
// No call to SetDefaultFormRevealMode: FormRevealMode.Deferred is used automatically
// for all top-level forms because the application opted into dark mode.
Application.SetColorMode(SystemColorMode.Dark);
Application.Run(new MainForm());
// A splash screen wants an instant, non-deferred reveal even though the rest of the
// application defers by default in dark mode. FormRevealMode is a per-Form ambient
// property, so this does not require touching the process-wide default.
public partial class SplashScreenForm : Form
{
    public SplashScreenForm()
    {
        InitializeComponent();
        FormRevealMode = FormRevealMode.Classic;
    }
}
// Force deferred reveal process-wide regardless of color mode.
Application.SetDefaultFormRevealMode(FormRevealMode.Deferred);
Application.Run(new MainForm());

Alternative Designs

readonly ref struct scope types

The first draft used readonly ref struct scopes for SuspendPaintingScope/SuspendRelocationScope, reasoning that "forgot the using" becomes a compile error and the scopes can never be stored, boxed, or escape a stack frame. That tradeoff blocks the async-repopulate usage shown above: a ref struct local cannot be alive across an await, because the compiler cannot hoist it into the generated async state machine (CS4007). Since suspending painting/relocation across an awaited fetch is a common, legitimate WinForms pattern - not a hazard comparable to holding a lock across an await - sealed class : IDisposable is proposed instead. The tradeoff is one allocation per scope, acceptable for a UI-mutation-burst control-flow helper.

Public virtual Begin*/End* methods directly on Control

An earlier draft made BeginSuspendPainting/EndSuspendPainting/etc. public virtual methods on Control itself. That makes the manual Begin/End pair the primary IntelliSense surface on every Control-derived type - noise for the overwhelming majority of consumers who should use the scope APIs. Explicit interface implementation, with protected ...Core() hooks for controls that need to customize behavior (ListView, etc.), keeps the designer/codegen contract available while keeping the ergonomic surface clean.

Default interface implementations

ISupportSuspendPainting could provide default implementations and keep Control untouched. That would still require per-instance refcount state. A ConditionalWeakTable or similar side table would be more indirect and more expensive than storing state on Control itself. It would also not help controls that need native BeginUpdate / EndUpdate behavior.

Only expose extension methods, not interfaces

Extension methods on Control would cover today's HWND controls, but would not give future custom controls or HWND-less visual implementations a clear contract to implement. The interfaces keep the contract independent of Control.

Use only SuspendLayout

SuspendLayout prevents layout computation but does not suppress painting. It is necessary but not sufficient for flicker-free mutations.

Why FormRevealMode.Inherit is necessary, not optional

An ambient property in WinForms needs a concrete enum member as its [AmbientValue] sentinel - there is no "absence of value" state the CodeDOM serializer and the ShouldSerialize/Reset designer pattern can key off of. Inherit = -1 (not the enum's zero value, which stays Classic for a conservative default(FormRevealMode)) follows the exact precedent already established by RightToLeft.Inherit and by the sibling VisualStylesMode.Inherit in #14587.

Why Application.SetDefaultFormRevealMode / DefaultFormRevealMode, not some other shape

This directly follows the naming convention already shipped for every other process-wide configuration switch on Application: SetColorMode/ColorMode, SetHighDpiMode/HighDpiMode, and the sibling SetDefaultVisualStylesMode/DefaultVisualStylesMode (#14587). It is not a new pattern requiring independent justification.

Why Inherit does not throw at the Application level (unlike VisualStylesMode.Inherit)

SetDefaultVisualStylesMode(Inherit) throws in #14587, because there is nothing above Application for visual-styles rendering to inherit from. FormRevealMode is different: Application-level Inherit has a well-defined resolution - Deferred when Application.IsDarkModeEnabled, Classic otherwise - because dark-mode state is itself a further, independent signal to fall back to. Application.IsFormRevealDeferred is the fully-resolved query (mirrors Application.IsDarkModeEnabled); Application.DefaultFormRevealMode may return the raw, unresolved Inherit (mirrors Application.ColorMode returning the unresolved System).

Why SetDefaultFormRevealMode is freely reassignable, unlike the write-once SetDefaultVisualStylesMode

FormRevealMode's Inherit resolution is derived from Application.ColorMode/IsDarkModeEnabled, which is deliberately kept freely reassignable to leave room for a future live system-theme-change event. Locking SetDefaultFormRevealMode after first use would make a form created after such a live change silently use a stale reveal behavior. VisualStylesMode has no equivalent live-changing input - it is a static, one-time rendering-version choice - so its write-once semantics remain correct there and are not inconsistent with this proposal.

Dark-mode-conditional default, not an unconditional one

An earlier draft made Deferred the unconditional runtime default whenever SetDefaultFormRevealMode is never called - a compatibility-relevant, opt-out behavior change for every existing application. Tying the default to Application.IsDarkModeEnabled instead means an application that never touches SystemColorMode/dark mode sees no behavior change, while the scenario the feature exists for (dark-mode startup flash) is fixed by default - piggybacking the fix on the same opt-in gesture (dark mode) that causes the problem in the first place.

Risks

  • Ref-count imbalance remains possible when callers invoke Begin* / End* directly through the interfaces. The scope APIs make the ergonomic path safer, but the interface methods must still tolerate nesting and unbalanced calls.
  • WM_SETREDRAW suppresses painting for HWND-backed controls but does not make arbitrary custom drawing atomic. Controls can still invalidate or repaint after the scope exits.
  • Existing controls with public BeginUpdate / EndUpdate methods must keep source and binary compatibility. Their suspension overrides should route through existing update behavior rather than changing those public APIs.
  • FormRevealMode.Deferred depends on DWM support. On unsupported systems or unsupported window kinds, it must be inert and preserve classic behavior.
  • Deferred form reveal can reduce the default-background flash, but a deep tree of late-painting child controls can still update visibly after reveal.
  • Uncloak timing is the hardest part of the form behavior. Uncloaking too early preserves the flash; uncloaking too late makes startup feel slower.
  • FormRevealMode only ever applies to top-level, non-MDI-child forms - DWMWA_CLOAK composes per top-level HWND, so a child (WS_CHILD) window has no independent DWM surface to cloak. This is a flat, Form-only property with no Control-parent-chain resolution (unlike VisualStylesMode, which genuinely needs one because rendering style cascades through nested controls).
  • Special cases such as MDI children, owned/tool windows, splash screens, layered/opacity windows, per-monitor DPI changes during creation, and handle recreation need conservative handling.
  • All APIs are UI-thread-oriented and do not make WinForms controls thread-safe.

Will this feature affect UI controls?

Yes. The painting and relocation APIs are implemented by Control and selected existing controls. They affect rendering and layout timing but do not change designer serialization, accessibility names/roles, localization, or existing layout results.

FormRevealMode.Deferred affects top-level form startup presentation. It should be documented as a visual startup behavior, not an accessibility feature. It should preserve accessibility/UIA object creation and not hide forms from accessibility clients longer than necessary. It has no localization requirements.

The new APIs should be available to designer-generated code because InitializeComponent lives in the user's assembly. FormRevealMode additionally needs [AmbientValue(FormRevealMode.Inherit)] and CodeDOM serialization support so an inherited-form designer can explicitly reset a derived form back to Inherit even though there is no multi-level parent chain to walk.

Status Checklist

  • API proposal has api-suggestion label
  • Background, API Proposal, API Usage, and Risks sections are complete
  • API shape has been discussed with the team
  • Review the issue for compatibility with what the API review board expects
  • Change label to api-ready-for-review
  • If late in the release cycle, also add the blocking label to expedite the review appointment
  • API review completed — label changed to api-approved

Metadata

Metadata

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions