跪拜 Guibai
← All articles
Frontend · Android · Flutter

Flutter Windows Multi-Window Focus Bug Fixed, but Impeller Breaks Thin Lines

By 恋猫de小郭 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Flutter desktop apps that use multi-window or rely on precise thin-line rendering will hit these issues immediately. The focus fix unblocks a whole class of desktop productivity apps, but the Impeller defect means any canvas drawing with sub-pixel strokes or alpha will look broken until the shader is patched.

Summary

A multi-window focus bug in Flutter Windows has been resolved after a root-cause analysis uncovered three interacting flaws. The engine was re-focusing the main window even on deactivation messages, the framework was incorrectly nesting child window focus scopes under the parent, and no check existed to prevent a window from stealing focus when another was already active. The fix addresses all three, making multi-window apps on Windows stable for the first time.

Merging that fix immediately surfaced a new problem: Flutter Windows is now enabling Impeller by default. The new renderer's SDF-based anti-aliasing adds a fixed-width transition band around strokes, so a 0.75 physical-pixel line renders as a solid 2-pixel-wide line with blur. Alpha values are also completely ignored because the SDF stroke shader clamps the minimum width to 1 pixel without proportionally reducing opacity.

Workarounds include replacing drawLine with drawRect, forcing Skia via a flag, or sticking to integer pixel widths. The rendering defect mirrors the teething problems Impeller had on Android, underscoring the maintenance burden of a custom rendering stack.

Takeaways
On WM_ACTIVATE, the engine was calling FocusRootViewOf even when the window was losing focus, causing the main window to re-activate itself and bury child windows.
The framework was nesting child window FocusScopes under the parent's FocusScope, so focusing a child also marked the parent as focused and triggered a native window activation.
No guard existed to check whether another window already held focus before restoring the old focus, creating a cascade of focus-stealing.
The fix skips focus changes on WA_INACTIVE, attaches each View's focus subtree directly to the global root scope, and only restores old focus when no other window has requested it.
Impeller's SDF smoothstep adds a fixed ~1.2px transition band around strokes, making a 0.75px line render 2.7× thicker than Skia's analytical AA.
Alpha values are ignored because SDFStroke clamps the minimum width to 1 pixel without reducing opacity, so 0xCC and 0x66 render identically.
Under canvas transform scaling, the fixed-width AA band scales up, turning a 0.5px line into a 12px blurry band at 4× zoom.
Workarounds: use drawRect instead of drawLine, pass --no-enable-impeller, or use 1.0 physical-pixel line widths.
Conclusions

The Impeller defect is a textbook case of a fixed-function SDF pipeline failing where an analytical rasterizer handled edge cases correctly. The smoothstep transition band is a constant added to coverage, so it dominates thin strokes and becomes invisible on thick ones, which is why the multiplier drops from 2.7× to 1.25× as stroke width increases.

Alpha being ignored is not a separate bug but a consequence of the same minimum-width clamp. The shader forces every stroke to at least 1 pixel wide at full opacity, which is the opposite of Skia's sub-pixel approach that scales alpha to simulate thinner lines. Fixing this requires threading alpha into the width-clamping logic.

The multi-window focus bug is a reminder that desktop window management is full of implicit state machines. Three separate layers — the OS, the engine, and the framework — each made a reasonable local decision that combined into a broken global behavior.

Concepts & terms
SDF (Signed Distance Field) anti-aliasing
A GPU-friendly technique where a shape is represented by a distance field. A smoothstep function creates a gradient transition at edges, but the transition width is fixed in physical pixels, so it adds a constant-width blur band regardless of stroke thickness.
Analytical anti-aliasing (Skia's approach)
Computes exact pixel coverage based on geometry. For a 0.75px line, it renders a 1px line at ~75% opacity, preserving the visual weight. SDF cannot do this because its transition band is a fixed addition, not a proportional blend.
UberSDF
Impeller's unified SDF shader that handles both filled and stroked paths. The fill path computes coverage correctly, which is why drawRect works as a workaround for thin lines.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗