-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathFlexPanel.cs
More file actions
751 lines (667 loc) · 35.8 KB
/
FlexPanel.cs
File metadata and controls
751 lines (667 loc) · 35.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
// Standalone FlexPanel for WinUI3, implemented in Microsoft.UI.Reactor.Layout.
// No dependency on Microsoft.UI.Reactor.Core — usable in any WinUI3 app.
//
// AI-HINT: This is a WinUI Panel that delegates layout to Yoga.
// Two-pass measure: Pass 1 = content-size (NaN width/height), Pass 2 = flex distribution
// (definite main axis to enable grow/shrink). Arrange reads cached results from Yoga.
// Each child has a cached YogaNode; attached properties (Grow, Shrink, Basis, etc.)
// are synced to Yoga nodes in SyncYogaTree(). MeasureFunc bridge lets Yoga call
// back into WinUI Measure for leaf children that need intrinsic sizing.
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Reactor.Layout;
using Windows.Foundation;
namespace Microsoft.UI.Reactor.Layout;
/// <summary>
/// A WinUI3 Panel that implements CSS Flexbox layout using the Yoga layout engine.
/// Can be used standalone in XAML or through the Reactor framework.
/// </summary>
public partial class FlexPanel : Panel
{
// ── Yoga node cache: one YogaNode per UIElement child ──
// Per-instance YogaConfig so PointScaleFactor can track the live
// XamlRoot.RasterizationScale; default 1.0 rounds to integer DIPs and
// disagrees with WinUI's physical-pixel layout rounding on non-100%
// scales, producing ±1 px wobble during resize.
private readonly YogaConfig _yogaConfig = new();
private readonly Dictionary<UIElement, YogaNode> _nodeCache = new();
private readonly YogaNode _rootNode;
private readonly HashSet<UIElement> _syncCurrentChildren = new();
private readonly List<UIElement> _syncToRemove = new();
public FlexPanel()
{
_rootNode = new YogaNode(_yogaConfig);
Unloaded += OnUnloaded;
}
/// <summary>
/// Read the current rasterization scale from this panel's XamlRoot and
/// update <see cref="YogaConfig.PointScaleFactor"/> if it changed. Called
/// from <see cref="MeasureOverride"/> so the scale tracks the live system
/// DPI without subscribing to <c>XamlRoot.Changed</c>. The subscription
/// approach pinned every FlexPanel through XamlRoot's multicast delegate
/// list — fatal for virtualized lists where ItemsRepeater's recycle path
/// does not reliably fire Unloaded on every recycled container.
/// </summary>
private void SyncPointScaleLazy()
{
var scale = (float)(XamlRoot?.RasterizationScale ?? 1.0);
if (scale <= 0) return;
if (Math.Abs(_yogaConfig.PointScaleFactor - scale) < 0.0001f) return;
_yogaConfig.PointScaleFactor = scale;
_rootNode.MarkDirtyAndPropagate();
}
private void OnUnloaded(object sender, RoutedEventArgs e)
{
// Clear Yoga node cache when removed from the visual tree to avoid leaking references
foreach (var node in _nodeCache.Values)
_rootNode.RemoveChild(node);
_nodeCache.Clear();
}
// ── Container dependency properties ──
public static readonly DependencyProperty DirectionProperty =
DependencyProperty.Register(nameof(Direction), typeof(FlexDirection), typeof(FlexPanel),
new PropertyMetadata(FlexDirection.Row, OnContainerPropertyChanged));
public static readonly DependencyProperty JustifyContentProperty =
DependencyProperty.Register(nameof(JustifyContent), typeof(FlexJustify), typeof(FlexPanel),
new PropertyMetadata(FlexJustify.FlexStart, OnContainerPropertyChanged));
public static readonly DependencyProperty AlignItemsProperty =
DependencyProperty.Register(nameof(AlignItems), typeof(FlexAlign), typeof(FlexPanel),
new PropertyMetadata(FlexAlign.Stretch, OnContainerPropertyChanged));
public static readonly DependencyProperty AlignContentProperty =
DependencyProperty.Register(nameof(AlignContent), typeof(FlexAlign), typeof(FlexPanel),
new PropertyMetadata(FlexAlign.FlexStart, OnContainerPropertyChanged));
public static readonly DependencyProperty WrapProperty =
DependencyProperty.Register(nameof(Wrap), typeof(FlexWrap), typeof(FlexPanel),
new PropertyMetadata(FlexWrap.NoWrap, OnContainerPropertyChanged));
public static readonly DependencyProperty LayoutDirectionProperty =
DependencyProperty.Register(nameof(LayoutDirection), typeof(FlexLayoutDirection), typeof(FlexPanel),
new PropertyMetadata(FlexLayoutDirection.LTR, OnContainerPropertyChanged));
public static readonly DependencyProperty ColumnGapProperty =
DependencyProperty.Register(nameof(ColumnGap), typeof(double), typeof(FlexPanel),
new PropertyMetadata(0.0, OnContainerPropertyChanged));
public static readonly DependencyProperty RowGapProperty =
DependencyProperty.Register(nameof(RowGap), typeof(double), typeof(FlexPanel),
new PropertyMetadata(0.0, OnContainerPropertyChanged));
public static readonly DependencyProperty FlexPaddingProperty =
DependencyProperty.Register(nameof(FlexPadding), typeof(Thickness), typeof(FlexPanel),
new PropertyMetadata(default(Thickness), OnContainerPropertyChanged));
public FlexDirection Direction
{
get => (FlexDirection)GetValue(DirectionProperty);
set => SetValue(DirectionProperty, value);
}
public FlexJustify JustifyContent
{
get => (FlexJustify)GetValue(JustifyContentProperty);
set => SetValue(JustifyContentProperty, value);
}
public FlexAlign AlignItems
{
get => (FlexAlign)GetValue(AlignItemsProperty);
set => SetValue(AlignItemsProperty, value);
}
public FlexAlign AlignContent
{
get => (FlexAlign)GetValue(AlignContentProperty);
set => SetValue(AlignContentProperty, value);
}
public FlexWrap Wrap
{
get => (FlexWrap)GetValue(WrapProperty);
set => SetValue(WrapProperty, value);
}
public FlexLayoutDirection LayoutDirection
{
get => (FlexLayoutDirection)GetValue(LayoutDirectionProperty);
set => SetValue(LayoutDirectionProperty, value);
}
public double ColumnGap
{
get => (double)GetValue(ColumnGapProperty);
set => SetValue(ColumnGapProperty, value);
}
public double RowGap
{
get => (double)GetValue(RowGapProperty);
set => SetValue(RowGapProperty, value);
}
public Thickness FlexPadding
{
get => (Thickness)GetValue(FlexPaddingProperty);
set => SetValue(FlexPaddingProperty, value);
}
private static void OnContainerPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is FlexPanel panel)
panel.InvalidateMeasure();
}
// ── Attached properties (for children) ──
public static readonly DependencyProperty GrowProperty =
DependencyProperty.RegisterAttached("Grow", typeof(double), typeof(FlexPanel),
new PropertyMetadata(0.0, OnChildPropertyChanged));
public static readonly DependencyProperty ShrinkProperty =
DependencyProperty.RegisterAttached("Shrink", typeof(double), typeof(FlexPanel),
new PropertyMetadata(1.0, OnChildPropertyChanged));
public static readonly DependencyProperty BasisProperty =
DependencyProperty.RegisterAttached("Basis", typeof(double), typeof(FlexPanel),
new PropertyMetadata(double.NaN, OnChildPropertyChanged));
public static readonly DependencyProperty AlignSelfProperty =
DependencyProperty.RegisterAttached("AlignSelf", typeof(FlexAlign), typeof(FlexPanel),
new PropertyMetadata(FlexAlign.Auto, OnChildPropertyChanged));
public static readonly DependencyProperty PositionProperty =
DependencyProperty.RegisterAttached("Position", typeof(FlexPositionType), typeof(FlexPanel),
new PropertyMetadata(FlexPositionType.Relative, OnChildPropertyChanged));
public static readonly DependencyProperty LeftProperty =
DependencyProperty.RegisterAttached("Left", typeof(double), typeof(FlexPanel),
new PropertyMetadata(double.NaN, OnChildPropertyChanged));
public static readonly DependencyProperty TopProperty =
DependencyProperty.RegisterAttached("Top", typeof(double), typeof(FlexPanel),
new PropertyMetadata(double.NaN, OnChildPropertyChanged));
public static readonly DependencyProperty RightProperty =
DependencyProperty.RegisterAttached("Right", typeof(double), typeof(FlexPanel),
new PropertyMetadata(double.NaN, OnChildPropertyChanged));
public static readonly DependencyProperty BottomProperty =
DependencyProperty.RegisterAttached("Bottom", typeof(double), typeof(FlexPanel),
new PropertyMetadata(double.NaN, OnChildPropertyChanged));
// Attached property static accessors
public static void SetGrow(UIElement el, double value) => el.SetValue(GrowProperty, value);
public static double GetGrow(UIElement el) => (double)el.GetValue(GrowProperty);
public static void SetShrink(UIElement el, double value) => el.SetValue(ShrinkProperty, value);
public static double GetShrink(UIElement el) => (double)el.GetValue(ShrinkProperty);
public static void SetBasis(UIElement el, double value) => el.SetValue(BasisProperty, value);
public static double GetBasis(UIElement el) => (double)el.GetValue(BasisProperty);
public static void SetAlignSelf(UIElement el, FlexAlign value) => el.SetValue(AlignSelfProperty, value);
public static FlexAlign GetAlignSelf(UIElement el) => (FlexAlign)el.GetValue(AlignSelfProperty);
public static void SetPosition(UIElement el, FlexPositionType value) => el.SetValue(PositionProperty, value);
public static FlexPositionType GetPosition(UIElement el) => (FlexPositionType)el.GetValue(PositionProperty);
public static void SetLeft(UIElement el, double value) => el.SetValue(LeftProperty, value);
public static double GetLeft(UIElement el) => (double)el.GetValue(LeftProperty);
public static void SetTop(UIElement el, double value) => el.SetValue(TopProperty, value);
public static double GetTop(UIElement el) => (double)el.GetValue(TopProperty);
public static void SetRight(UIElement el, double value) => el.SetValue(RightProperty, value);
public static double GetRight(UIElement el) => (double)el.GetValue(RightProperty);
public static void SetBottom(UIElement el, double value) => el.SetValue(BottomProperty, value);
public static double GetBottom(UIElement el) => (double)el.GetValue(BottomProperty);
private static void OnChildPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is UIElement el && Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(el) is FlexPanel panel)
panel.InvalidateMeasure();
}
// ── Layout ──
// MeasureOverride — CSS block-level flex container semantics.
//
// CSS rule: a flex container is a block-level box. Its INLINE axis (width,
// for horizontal writing mode) is resolved against the containing block
// BEFORE flex layout runs — i.e. `width: auto` fills the parent's content
// width. Its BLOCK axis (height) is `auto` → content-sized from children.
// This is independent of flex-direction; direction only controls how
// children flow within the container.
//
// Translating to Yoga: the root node is always called with a DEFINITE
// inline-axis size (availableSize.Width when finite) and NaN on the block
// axis. Children measured under this rule see their cross-axis constraint
// naturally (align-items: stretch = fill container width), so text
// controls (RichTextBlock, TextBlock) wrap correctly in a single pass —
// no expensive infinite-width measurement followed by reflow.
//
// Escape hatch — `HorizontalAlignment != Stretch` on the FlexPanel itself
// maps to CSS `width: fit-content`. In that case the inline axis is NaN
// (content-size) capped by availableSize.Width. Slower for text-heavy
// children, but the user opted in.
// Cached child layout results from MeasureOverride, reused in ArrangeOverride
// to avoid re-running Yoga (which calls child.Measure()) during the arrange
// pass — calling Measure during Arrange can trigger LayoutCycleException.
private struct ChildLayout { public float X, Y, Width, Height; }
private readonly List<ChildLayout> _cachedChildLayouts = new();
private Size _cachedDesiredSize;
private bool _arranging;
// Tracks which children already had child.Measure() called via Yoga's
// MeasureFunction during the current pass. Children measured by Yoga
// must NOT be measured a second time with Yoga's resolved (rounded)
// layout size: a finite-height re-measure makes the child re-run its
// own measure logic and DesiredSize can drift by a sub-pixel — visible
// as ±1 px height wobble during pure-width window resize. StackPanel
// measures each child exactly once (Measure(availWidth, INF)); we do
// the same for children Yoga already measured, and only call Measure
// ourselves for children Yoga skipped (e.g. fixed-size children where
// Yoga uses the explicit dimension and bypasses MeasureFunction).
private readonly HashSet<UIElement> _measuredThisPass = new();
// Yoga's height-axis MeasureMode for the current MeasureFunction call,
// threaded down to a nested FlexPanel via [ThreadStatic]. Lets a child
// FlexPanel disambiguate two semantically-different "AtMost" cases:
// - WinUI AtMost from a non-Yoga parent (the standard Measure
// contract — VerticalAlignment.Stretch means "fill up to this"),
// - Yoga AtMost from an outer FlexPanel doing basis/FitContent
// measurement (a soft cap on content size, NOT a fill target —
// treating it as fill would make the inner report the cap as its
// DesiredSize and defeat the outer's flex-grow distribution).
// null = not nested under a FlexPanel measurement → use the WinUI
// contract directly. Yoga Exactly = stretch-fit allocation → fill.
// Yoga Undefined / AtMost = basis content measurement → do not fill.
[global::System.ThreadStatic]
private static YogaMeasureMode? _outerYogaHeightMode;
protected override Size MeasureOverride(Size availableSize)
{
SyncPointScaleLazy();
_measuredThisPass.Clear();
SyncYogaTree();
SetRootConstraints(availableSize);
bool hasDefiniteWidth = !float.IsInfinity((float)availableSize.Width);
bool hasDefiniteHeight = !float.IsInfinity((float)availableSize.Height);
// Inline-axis fill (CSS default) unless the user asked for fit-content
// via non-Stretch HorizontalAlignment. Width on the panel itself is
// already clamped by FrameworkElement.Measure before we get here.
bool fillInlineAxis = HorizontalAlignment == HorizontalAlignment.Stretch;
float rootWidth;
if (fillInlineAxis && hasDefiniteWidth)
{
// CSS: block-level flex container fills its containing block.
rootWidth = (float)availableSize.Width;
_rootNode.MaxWidth = YogaValue.Undefined;
}
else
{
// CSS fit-content: content-size the inline axis, capped by
// availableSize. This is the opt-in "shrink-wrap" path.
rootWidth = float.NaN;
_rootNode.MaxWidth = hasDefiniteWidth
? YogaValue.Point((float)availableSize.Width)
: YogaValue.Undefined;
}
// Block axis: three modes, in priority order.
//
// 1. Explicit Height(N): CSS `height: N` — definite container size.
// Pass N to Yoga; Yoga falls into StretchFit mode (justify-content
// / align-items / align-self all need a definite main axis).
//
// 2. VerticalAlignment.Stretch with a definite parent offer: WinUI
// Stretch + finite availableSize.Height means "fill my parent's
// slot" (analogous to CSS `height: 100%` against a definite-height
// parent). Pass availableSize.Height to Yoga as the container
// size so flex-grow children have a definite pool to distribute.
// This is the symmetric counterpart to inline-axis Stretch above
// and is what makes the canonical web flex pattern —
// header(auto) / body(flex:1) / footer(auto) filling a viewport —
// work without a hard-coded `.Height(N)` on the column.
//
// Wobble safety: when an outer FlexPanel runs Yoga in MaxContent
// mode (no explicit height, parent offer infinite — case 3
// below), Yoga's MeasureFunction calls children with
// hMode=Undefined; the MeasureFunction wrapper translates that
// into availableSize.Height = +∞ for the child. With +∞, the
// child below sees `hasDefiniteHeight = false` and falls into
// case 3 — content-sized — exactly as before. So nested
// FlexPanels under an unconstrained outer behave identically to
// the pre-fix code (no DesiredSize drift on horizontal resize).
//
// 3. Otherwise (no explicit Height, or parent offer is infinite, or
// VerticalAlignment != Stretch): CSS `height: auto`. Pass NaN —
// MaxContent mode, no shrink. Content overflows a smaller parent.
// Block axis: three modes, in priority order.
//
// 1. Explicit .Height(N): CSS `height: N` — definite. Pass N to
// Yoga (StretchFit mode for justify-content / align-items).
//
// 2. VerticalAlignment.Stretch with a definite parent offer: WinUI
// Stretch + finite availableSize.Height = "fill my parent's
// slot" (analogous to CSS `height: 100%` against a definite
// parent). Pass availableSize.Height to Yoga as the container
// size so flex-grow children have a definite pool to
// distribute. This is the symmetric counterpart to the inline
// axis above and is what makes the canonical web flex pattern —
// header(auto) / body(flex:1) / footer(auto) filling a
// viewport — work without forcing a hardcoded `.Height(N)`.
//
// The "is parent's AtMost a fill target?" question is what the
// [ThreadStatic] _outerYogaHeightMode resolves: when a parent
// FlexPanel's Yoga is in basis/FitContent measurement, we want
// to report content size, not the cap. Only Yoga's Exactly
// mode (and "no flex parent at all" — the normal WinUI
// contract) means fill. Without this discrimination a nested
// FlexPanel.Stretch under a flex-grow:1 sibling would report
// the cap as its DesiredSize and break the outer's grow
// distribution.
//
// Trade-off: `Border` (or any auto-height container with no
// flex semantics) wrapping a FlexPanel will inflate to the
// parent's offer rather than shrink-wrap content, because
// `_outerYogaHeightMode` is null in that case (no flex parent)
// so the WinUI Stretch contract applies. Users who want a
// content-sized FlexPanel inside an auto-height container
// should set `VerticalAlignment.Top` (or any non-Stretch).
//
// 3. Otherwise (no explicit Height and either parent offer is
// infinite, alignment != Stretch, or outer flex wants content
// size): CSS `height: auto`. Pass NaN — MaxContent mode, no
// shrink, content overflows a smaller parent.
bool hasExplicitHeight = !double.IsNaN(Height);
bool outerFlexWantsContent =
_outerYogaHeightMode == YogaMeasureMode.Undefined
|| _outerYogaHeightMode == YogaMeasureMode.AtMost;
bool fillBlockAxis = !hasExplicitHeight
&& !outerFlexWantsContent
&& VerticalAlignment == VerticalAlignment.Stretch
&& hasDefiniteHeight;
_rootNode.MaxHeight = YogaValue.Undefined;
float rootHeight = hasExplicitHeight
? (float)Height
: fillBlockAxis ? (float)availableSize.Height
: float.NaN;
_rootNode.CalculateLayout(
rootWidth,
rootHeight,
LayoutDirection);
// Clamp the reported height when the panel has a definite own-height
// (explicit Height(N) or block-axis fill against a definite parent
// offer — both cases the box resolves to that size, never more).
bool hasDefiniteOwnHeight = hasExplicitHeight || fillBlockAxis;
float reportedHeight = hasDefiniteOwnHeight
? Math.Min(_rootNode.LayoutHeight, (float)availableSize.Height)
: _rootNode.LayoutHeight;
_cachedDesiredSize = new Size(_rootNode.LayoutWidth, reportedHeight);
// Cache child positions and measure children at Yoga's resolved sizes.
// This fulfills the WinUI contract that all children must be Measured
// during MeasureOverride, and caches positions for ArrangeOverride.
_cachedChildLayouts.Clear();
for (int i = 0; i < Children.Count; i++)
{
var child = Children[i];
if (_nodeCache.TryGetValue(child, out var childNode))
{
var layout = new ChildLayout
{
X = childNode.LayoutX,
Y = childNode.LayoutY,
Width = childNode.LayoutWidth,
Height = childNode.LayoutHeight
};
_cachedChildLayouts.Add(layout);
// Only measure here for children Yoga didn't already measure
// (fixed-dimension children where Yoga uses the explicit value
// and bypasses MeasureFunction). Re-measuring a child that
// Yoga already measured — with the now-finite height —
// perturbs the child's own DesiredSize through internal
// sub-pixel rounding and is the cause of the resize wobble.
if (!_measuredThisPass.Contains(child))
{
var m = child is FrameworkElement cfe ? cfe.Margin : default;
child.Measure(new Size(
layout.Width + m.Left + m.Right,
layout.Height + m.Top + m.Bottom));
}
}
else
{
_cachedChildLayouts.Add(default);
}
}
return _cachedDesiredSize;
}
protected override Size ArrangeOverride(Size finalSize)
{
// If finalSize matches what we measured for, use cached positions directly.
// This avoids re-running Yoga (which would call child.Measure() via
// MeasureFunction callbacks), preventing LayoutCycleException.
bool sizeChanged =
Math.Abs(finalSize.Width - _cachedDesiredSize.Width) > 0.5 ||
Math.Abs(finalSize.Height - _cachedDesiredSize.Height) > 0.5;
if (sizeChanged)
{
// Final size differs from measured size — re-run Yoga to redistribute
// space, but suppress child.Measure() calls during this arrange pass.
_arranging = true;
try
{
_rootNode.MaxWidth = YogaValue.Undefined;
_rootNode.MaxHeight = YogaValue.Undefined;
// Block-axis policy at arrange time:
// - explicit Height: pass finalSize.Height (== Height) as
// definite so Yoga falls into StretchFit.
// - VerticalAlignment.Stretch (the user opted into "fill
// my parent's slot" — symmetric to the inline axis, which
// has always been treated as definite at finalSize.Width
// a few lines above): pass finalSize.Height as definite
// so Yoga distributes the slot across grow/shrink
// children. This is what gives the canonical web flex
// pattern — `header(auto) / body(flex:1) / footer(auto)`
// filling a viewport — a definite main-axis pool to
// distribute, without forcing a hardcoded `.Height(N)`.
// Wobble protection: this is in Arrange, not Measure, so
// DesiredSize is unaffected. The _arranging flag makes
// the Yoga rerun's MeasureFunction return cached child
// DesiredSize without re-measuring — sub-pixel jitter
// only shifts Yoga's internal positions, not children's
// own DesiredSize.
// - VerticalAlignment != Stretch (caller asked for
// fit-content vertically): pass NaN so Yoga stays in
// MaxContent mode and a horizontal-drag's sub-pixel
// finalSize.Height jitter doesn't drive flex-shrink.
bool hasExplicitHeight = !double.IsNaN(Height);
bool fillBlockAxisAtArrange = !hasExplicitHeight
&& VerticalAlignment == VerticalAlignment.Stretch
&& !double.IsInfinity(finalSize.Height);
float arrangeHeight = (hasExplicitHeight || fillBlockAxisAtArrange)
? (float)finalSize.Height
: float.NaN;
_rootNode.CalculateLayout(
(float)finalSize.Width,
arrangeHeight,
LayoutDirection);
// Update cached positions from the new layout
_cachedChildLayouts.Clear();
for (int i = 0; i < Children.Count; i++)
{
var child = Children[i];
if (_nodeCache.TryGetValue(child, out var childNode))
{
_cachedChildLayouts.Add(new ChildLayout
{
X = childNode.LayoutX,
Y = childNode.LayoutY,
Width = childNode.LayoutWidth,
Height = childNode.LayoutHeight
});
}
else
{
_cachedChildLayouts.Add(default);
}
}
}
finally
{
_arranging = false;
}
}
for (int i = 0; i < Children.Count && i < _cachedChildLayouts.Count; i++)
{
var layout = _cachedChildLayouts[i];
var child = Children[i];
// Expand arrange rect by margin: Yoga positions/sizes the content area,
// but WinUI's Arrange subtracts the child's Margin from the rect.
var m = child is FrameworkElement fe ? fe.Margin : default;
child.Arrange(new Rect(
layout.X - m.Left,
layout.Y - m.Top,
layout.Width + m.Left + m.Right,
layout.Height + m.Top + m.Bottom));
}
return finalSize;
}
private void SetRootConstraints(Size availableSize)
{
// Container properties
_rootNode.FlexDirection = Direction;
_rootNode.JustifyContent = JustifyContent;
_rootNode.AlignItems = AlignItems;
_rootNode.AlignContent = AlignContent;
_rootNode.FlexWrap = Wrap;
_rootNode.SetGap(YogaGutter.Column, (float)ColumnGap);
_rootNode.SetGap(YogaGutter.Row, (float)RowGap);
// FlexPadding
var p = FlexPadding;
_rootNode.SetPadding(YogaEdge.Left, YogaValue.Point((float)p.Left));
_rootNode.SetPadding(YogaEdge.Top, YogaValue.Point((float)p.Top));
_rootNode.SetPadding(YogaEdge.Right, YogaValue.Point((float)p.Right));
_rootNode.SetPadding(YogaEdge.Bottom, YogaValue.Point((float)p.Bottom));
}
private void SyncYogaTree()
{
// Remove nodes for children that are no longer present
_syncCurrentChildren.Clear();
foreach (UIElement child in Children)
_syncCurrentChildren.Add(child);
_syncToRemove.Clear();
foreach (var kvp in _nodeCache)
{
if (!_syncCurrentChildren.Contains(kvp.Key))
_syncToRemove.Add(kvp.Key);
}
foreach (var el in _syncToRemove)
{
if (_nodeCache.TryGetValue(el, out var node))
_rootNode.RemoveChild(node);
_nodeCache.Remove(el);
}
// Ensure each child has a YogaNode at the correct index
for (int i = 0; i < Children.Count; i++)
{
var child = Children[i];
if (!_nodeCache.TryGetValue(child, out var childNode))
{
childNode = new YogaNode(_yogaConfig);
_nodeCache[child] = childNode;
// Set measure function: delegates to WinUI Measure.
// During ArrangeOverride (_arranging=true), return the last
// DesiredSize without calling Measure — calling Measure during
// Arrange can trigger LayoutCycleException.
//
// Margin compensation: Yoga handles margins for positioning and
// spacing between children (synced in ApplyAttachedProperties).
// WinUI also subtracts Margin during Measure/Arrange. To avoid
// double-counting, we add the margin back to Yoga's constraints
// before calling WinUI Measure, and subtract it from DesiredSize
// before returning to Yoga.
var capturedChild = child;
var panel = this;
childNode.MeasureFunction = (node, w, wMode, h, hMode) =>
{
var m = capturedChild is FrameworkElement cfe ? cfe.Margin : default;
double mH = m.Left + m.Right;
double mV = m.Top + m.Bottom;
if (panel._arranging)
return new YogaSize(
Math.Max(0, (float)(capturedChild.DesiredSize.Width - mH)),
Math.Max(0, (float)(capturedChild.DesiredSize.Height - mV)));
// Yoga's constraints are content-area (excluding margin).
// Add margin so WinUI's subtraction yields the correct content area.
//
// Mode → WinUI constraint:
// - Undefined → +∞ (give me your content size).
// - AtMost / Exactly → finite (w + margin). Preserves
// the standard WinUI Measure contract — TextBlock
// wrapping, Image stretch sizing, etc. all depend on
// receiving a real cap rather than infinity.
//
// The "is this AtMost a fill target?" discrimination is
// not done by changing the constraint — that would break
// text wrapping. Instead the wrapper publishes Yoga's
// hMode via [ThreadStatic] _outerYogaHeightMode so a
// nested FlexPanel.MeasureOverride can tell whether it
// is being measured for content (Undefined / AtMost) or
// for fill (Exactly). See fillBlockAxis above.
var constraintW = wMode == YogaMeasureMode.Undefined ? double.PositiveInfinity : w + mH;
var constraintH = hMode == YogaMeasureMode.Undefined ? double.PositiveInfinity : h + mV;
var prevYogaH = _outerYogaHeightMode;
_outerYogaHeightMode = hMode;
try
{
capturedChild.Measure(new Size(constraintW, constraintH));
}
finally
{
_outerYogaHeightMode = prevYogaH;
}
panel._measuredThisPass.Add(capturedChild);
// Return content size (without margin) since Yoga tracks margins separately
return new YogaSize(
Math.Max(0, (float)(capturedChild.DesiredSize.Width - mH)),
Math.Max(0, (float)(capturedChild.DesiredSize.Height - mV)));
};
}
// Apply attached properties from the UIElement to the YogaNode
ApplyAttachedProperties(child, childNode);
// Mirror WinUI Visibility=Collapsed onto Yoga's Display=None:
// Collapsed is the XAML equivalent of CSS display:none — the
// element contributes nothing to main-axis size and no gap slot.
// StackPanel does the same.
childNode.Display = child.Visibility == Visibility.Collapsed
? YogaDisplay.None
: YogaDisplay.Flex;
// Ensure correct child order in Yoga tree
if (i < _rootNode.ChildCount)
{
if (_rootNode.GetChild(i) != childNode)
{
// Remove if present elsewhere and re-insert at correct position
_rootNode.RemoveChild(childNode);
_rootNode.InsertChild(childNode, i);
}
}
else
{
if (childNode.Owner != _rootNode)
_rootNode.InsertChild(childNode, i);
}
}
// Remove extra Yoga children beyond current count
while (_rootNode.ChildCount > Children.Count)
{
_rootNode.RemoveChild(_rootNode.ChildCount - 1);
}
}
private static void ApplyAttachedProperties(UIElement el, YogaNode node)
{
var grow = GetGrow(el);
var shrink = GetShrink(el);
var basis = GetBasis(el);
var alignSelf = GetAlignSelf(el);
var position = GetPosition(el);
node.Style.FlexGrow = (float)grow;
node.Style.FlexShrink = (float)shrink;
node.Style.FlexBasis = double.IsNaN(basis) ? YogaValue.Auto : YogaValue.Point((float)basis);
node.Style.AlignSelf = alignSelf;
node.Style.PositionType = position;
// Position insets
var left = GetLeft(el);
var top = GetTop(el);
var right = GetRight(el);
var bottom = GetBottom(el);
node.Style.Position[(int)YogaEdge.Left] = double.IsNaN(left) ? YogaValue.Undefined : YogaValue.Point((float)left);
node.Style.Position[(int)YogaEdge.Top] = double.IsNaN(top) ? YogaValue.Undefined : YogaValue.Point((float)top);
node.Style.Position[(int)YogaEdge.Right] = double.IsNaN(right) ? YogaValue.Undefined : YogaValue.Point((float)right);
node.Style.Position[(int)YogaEdge.Bottom] = double.IsNaN(bottom) ? YogaValue.Undefined : YogaValue.Point((float)bottom);
// If the child has explicit Width/Height set, pass them to Yoga
if (el is FrameworkElement fe)
{
node.Width = double.IsNaN(fe.Width) ? YogaValue.Auto : YogaValue.Point((float)fe.Width);
node.Height = double.IsNaN(fe.Height) ? YogaValue.Auto : YogaValue.Point((float)fe.Height);
// Margins
var margin = fe.Margin;
if (margin.Left != 0 || margin.Top != 0 || margin.Right != 0 || margin.Bottom != 0)
{
node.SetMargin(YogaEdge.Left, YogaValue.Point((float)margin.Left));
node.SetMargin(YogaEdge.Top, YogaValue.Point((float)margin.Top));
node.SetMargin(YogaEdge.Right, YogaValue.Point((float)margin.Right));
node.SetMargin(YogaEdge.Bottom, YogaValue.Point((float)margin.Bottom));
}
else
{
node.SetMargin(YogaEdge.Left, YogaValue.Undefined);
node.SetMargin(YogaEdge.Top, YogaValue.Undefined);
node.SetMargin(YogaEdge.Right, YogaValue.Undefined);
node.SetMargin(YogaEdge.Bottom, YogaValue.Undefined);
}
}
}
}