microsoft / microsoft/microsoft-ui-reactor
Design proposal: FitParent — container-sized rebuild for charts and other size-baked elements
Nobody has claimed this yet.
- Dominant language
- C#
- Stars
- 646
- Forks
- 54
- Avg merge
- 1d 3h
- Merged PRs (30d)
- 84
Description
Summary
Reactor charts (and any element with explicit pixel Width/Height baked into a build-time render) can't size to their container — their geometry is computed at render time from _width/_height and frozen. To make these elements responsive we'd need a measure→rebuild loop driven from the layout system. This is a proposal for FitParent, a Reactor-level wrapper that provides exactly that, without any new native control.
Status: design proposal, not landing yet. Filed for archival / discussion.
Motivation
Today this works:
PieChart(data, ...).Width(800).Height(500)
But this doesn't:
PieChart(data, ...).Stretch()
PieChart.BuildElement at src/Reactor/Charting/Charts.cs:531 computes the chart's coordinates (slice arcs, axes, label centroids) from _width/_height at construction time. The Element tree is baked at the size you pass in. WinUI's stretch model can't help — by the time layout knows the container's size, the geometry has already been emitted.
Same shape applies to anything else where pixel coordinates are computed at build time (custom canvases, hand-built shape paths, anything using D3Canvas).
Approach
A wrapper component that:
- Mounts a placeholder, observes
SizeChangedon its hostBorder. - Debounces resize events by ~150 ms and only then commits the new size to state, triggering a re-render at the new dimensions.
- During the debounce window applies a
CompositeTransformto the host so the existing rendered visual scales smoothly with the drag, instead of vanishing and re-popping in.
Net effect: cheap visual feedback during the drag, one real rebuild on idle.
chart = Border(
FitParent((w, h) => PieChart(data, ...).Width(w).Height(h))
).Flex(grow: 1, basis: 0);
Reference implementation
Tested in a sample app against the PieChart + LabelView use case. Lives in user code today (an instance method on the root Component), but the only reason it's not a top-level helper is that UseElementRef, UseState, and UseRef are protected on Component — wrapping it as a Component.Func(...) would let it be a static factory.
public Element FitParent(Func<double, double, Element> render)
{
var hostRef = this.UseElementRef<FrameworkElement>();
var (committed, setCommitted) = UseState((W: 0.0, H: 0.0));
var pendingRef = UseRef((W: 0.0, H: 0.0));
var timerRef = UseRef<DispatcherQueueTimer?>(null);
Element child = committed.W > 0
? render(committed.W, committed.H)
: TextBlock("");
return Border(child)
.Ref(hostRef)
.OnSizeChanged((_, e) =>
{
var nw = e.NewSize.Width;
var nh = e.NewSize.Height;
pendingRef.Current = (nw, nh);
// Transient: scale the existing rendered chart around its arranged
// center, then translate so the scaled center lands on the host's
// new center. The host Border centers its explicit-size child when
// it fits and top-lefts it when it overflows — independently per
// axis — so we have to compute the actual arranged center.
if (committed.W > 0 && committed.H > 0 && hostRef.Current is { } host)
{
var s = Math.Min(nw / committed.W, nh / committed.H);
var canvasX = Math.Max(0, (nw - committed.W) / 2);
var canvasY = Math.Max(0, (nh - committed.H) / 2);
var centerX = canvasX + committed.W / 2;
var centerY = canvasY + committed.H / 2;
host.RenderTransformOrigin = new Point(0, 0);
host.RenderTransform = new CompositeTransform
{
CenterX = centerX,
CenterY = centerY,
ScaleX = s,
ScaleY = s,
TranslateX = nw / 2 - centerX,
TranslateY = nh / 2 - centerY,
};
}
// Commit: rebuild at the real (non-uniform) px when resize stops.
if (timerRef.Current is null)
{
var dq = DispatcherQueue.GetForCurrentThread();
if (dq is null) { setCommitted((nw, nh)); return; }
var t = dq.CreateTimer();
t.Interval = TimeSpan.FromMilliseconds(150);
t.IsRepeating = false;
t.Tick += (_, _) =>
{
if (hostRef.Current is { } h) h.RenderTransform = null;
setCommitted(pendingRef.Current);
};
timerRef.Current = t;
}
timerRef.Current.Stop();
timerRef.Current.Start();
});
}
Tradeoffs / open questions
-
Two-pass first render. Initial mount emits a placeholder;
SizeChangedfires; state commits; second render produces the real chart. There's a one-frame flash of the placeholder. Acceptable for charts; might feel wrong for tightly-coupled visual transitions. Mitigation: readActualWidth/Heightsynchronously onLoadedvia the same ref to skip the empty first commit. -
Scale-during-drag distorts everything inside the host. Axis labels stretch with the chart, line strokes thicken/thin, font metrics get fuzzy. For a 100–200 ms resize burst this looks fine; sustained it would look bad. The 150 ms debounce is the lever — shorter for crisper rebuilds, longer to bound rebuild churn.
-
Per-axis arranged-center math is load-bearing. The Border's child centering rule changes between fit and overflow per axis, independently. The reference impl handles this correctly across grow/shrink/asymmetric drags but anyone editing it should keep that in mind — earlier iterations got this wrong and produced "twice as fast" or "drifts toward top-left" behaviors.
-
Re-render churn at fast resize cadence.
SizeChangedfires on every pixel during a window drag. Each fire mutates a transform (cheap) and resets the timer (cheap). Only the final commit re-renders. Should be fine but worth a perf check on dense charts. -
No clean "resize done" event for window resizes. WinUI 3's window doesn't expose pointer-up for the system resize handle. Debounce is the only option there. For internal resize affordances (
GridSplitter, custom drag handles) callers haveDragCompleted/PointerReleasedand could commit synchronously — worth a future variant that takes an explicit "commit now" trigger. -
First render's placeholder is
TextBlock("")becauseBorder(child)is non-nullable. Cosmetic but worth replacing with something the reconciler can recognize as a "transparent stand-in" and skip allocating a realTextBlockfor. -
Should this live in core or charting? It's generic enough for any size-aware element. Suggested location:
src/Reactor/Layout/FitParent.csas aComponent.Func-based factory. Charts could ship a thin wrapperChart.FitParent(...)for ergonomic chained use.
Why not land it now
- The reference impl is a
Component-instance method on a sample app; promoting it to a public API needs the static-factory refactor and a real shape decision. - The two-pass first-render flash deserves polishing first.
- We'd want at least one selftest covering grow/shrink/asymmetric drag before adding it as documented surface.
Discovery story
Came out of debugging PieChart not respecting its container in a demo app — see PR for the related crash-hardening fixes (degenerate-size guard in PieChartElement.BuildElement, XamlReader.Load path for PathGeometry mounting, and stack-trace-aware ErrorFallback UI). Those land separately; this stays archived until we decide on the API shape.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading src/Reactor/Charting/Charts.cs around PieChart.BuildElement and the reference Component instance method described in the issue. Review the proposed src/Reactor/Layout/FitParent.cs location and the first-render, debounce, transform, and API-shape tradeoffs. Done would require an agreed public API and selftests for grow, shrink, and asymmetric resizing.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- desktop
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 30/100