diff --git a/lib/core/motion/lazy_offstage.dart b/lib/core/motion/lazy_offstage.dart new file mode 100644 index 0000000..de38051 --- /dev/null +++ b/lib/core/motion/lazy_offstage.dart @@ -0,0 +1,74 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Like [Offstage], but skips laying out the hidden child again while it +/// stays hidden, even if the incoming constraints keep changing (#984). +/// +/// [RenderOffstage] still calls `child.layout(constraints)` on every frame +/// while offstage, so an animation that changes a sibling's size (e.g. the +/// sidebar-toggle spring resizing the workspace's [Expanded]) forces a full +/// layout pass through every hidden [QueryaSwitchingBody] layer each frame, +/// even though none of that layout is ever painted. [LazyOffstage] instead +/// reuses the child's last known size while hidden, and lays it out for real +/// exactly once when it becomes visible again (with the then-current +/// constraints, which may have changed while it was hidden). +class LazyOffstage extends SingleChildRenderObjectWidget { + const LazyOffstage({super.key, this.offstage = true, super.child}); + + final bool offstage; + + @override + RenderLazyOffstage createRenderObject(BuildContext context) => + RenderLazyOffstage(offstage: offstage); + + @override + void updateRenderObject( + BuildContext context, + RenderLazyOffstage renderObject, + ) { + renderObject.offstage = offstage; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('offstage', offstage)); + } + + @override + SingleChildRenderObjectElement createElement() => _LazyOffstageElement(this); +} + +class _LazyOffstageElement extends SingleChildRenderObjectElement { + _LazyOffstageElement(LazyOffstage super.widget); + + @override + void debugVisitOnstageChildren(ElementVisitor visitor) { + if (!(widget as LazyOffstage).offstage) { + super.debugVisitOnstageChildren(visitor); + } + } +} + +class RenderLazyOffstage extends RenderOffstage { + RenderLazyOffstage({super.offstage, super.child}); + + @override + void performLayout() { + if (offstage) { + final child = this.child; + // A child that has already been laid out at least once keeps its + // last known size while hidden, instead of being re-laid-out with + // whatever constraints happen to arrive this frame. `offstage`'s own + // setter (in RenderOffstage) already forces a real layout the moment + // this flips back to false, so the child is never shown with a stale + // size for constraints that changed while it was hidden. + if (child != null && child.hasSize) { + return; + } + child?.layout(constraints); + return; + } + super.performLayout(); + } +} diff --git a/lib/core/motion/querya_switching_body.dart b/lib/core/motion/querya_switching_body.dart index 1ede498..b3b0cc4 100644 --- a/lib/core/motion/querya_switching_body.dart +++ b/lib/core/motion/querya_switching_body.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'lazy_offstage.dart'; import 'querya_motion.dart'; import 'querya_motion_context.dart'; @@ -108,10 +109,13 @@ class _SwitchingLayerState extends State<_SwitchingLayer> { // Isolate paint; pause child tickers when inactive (opacity anim still runs). // Inactive child is offstaged once the exit transition completes to avoid // redundant layout passes during desktop window resizing (#901). + // LazyOffstage (not Offstage) additionally skips re-laying the hidden + // child out on every frame a sibling's size changes, e.g. the sidebar + // toggle spring resizing the workspace's Expanded (#984). final content = TickerMode( enabled: widget.active, child: RepaintBoundary( - child: Offstage( + child: LazyOffstage( offstage: _offstage, child: widget.child, ), diff --git a/test/core/motion/lazy_offstage_test.dart b/test/core/motion/lazy_offstage_test.dart new file mode 100644 index 0000000..3c00f89 --- /dev/null +++ b/test/core/motion/lazy_offstage_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/lazy_offstage.dart'; + +/// #984: while [LazyOffstage.offstage] is true, the hidden child must keep +/// its last known size even if a sibling's size change (e.g. the sidebar +/// toggle animation resizing the workspace next to it) feeds it new +/// constraints every frame. Once it's made visible again, it must be laid +/// out for real against whatever the constraints are *at that point*, +/// never a stale value from while it was hidden. +void main() { + Future capturedWidth( + WidgetTester tester, { + required double siblingWidth, + required bool hidden, + }) async { + late double captured; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 400, + height: 50, + child: Row( + children: [ + SizedBox(width: siblingWidth), + Expanded( + child: LazyOffstage( + offstage: hidden, + child: LayoutBuilder( + builder: (context, constraints) { + captured = constraints.maxWidth; + return const SizedBox.expand(); + }, + ), + ), + ), + ], + ), + ), + ), + ), + ); + return captured; + } + + testWidgets( + 'keeps the stale width while hidden despite constraint changes, ' + 'then relays out with the fresh width once shown again', + (tester) async { + // Initial layout (never laid out before): 400 - 100 = 300. + final initial = + await capturedWidth(tester, siblingWidth: 100, hidden: true); + expect(initial, 300); + + // Sibling grows while still hidden: a plain Offstage would forward this + // straight through (captured -> 150); LazyOffstage must not. + final whileHidden = + await capturedWidth(tester, siblingWidth: 250, hidden: true); + expect(whileHidden, 300); + + // Made visible again: must now reflect the current constraints (150), + // not the stale 300 from while it was hidden. + final whenShown = + await capturedWidth(tester, siblingWidth: 250, hidden: false); + expect(whenShown, 150); + }); +} diff --git a/test/core/motion/querya_switching_body_test.dart b/test/core/motion/querya_switching_body_test.dart index feccd4c..13097a6 100644 --- a/test/core/motion/querya_switching_body_test.dart +++ b/test/core/motion/querya_switching_body_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/motion/lazy_offstage.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_scope.dart'; import 'package:querya_desktop/core/motion/querya_switching_body.dart'; @@ -368,7 +369,7 @@ void main() { }); testWidgets( - 'inactive layer configures Offstage(offstage: true) once exit transition completes', + 'inactive layer configures LazyOffstage(offstage: true) once exit transition completes', (tester) async { await tester.pumpWidget( wrap( @@ -385,10 +386,11 @@ void main() { final offstageFinder = find.descendant( of: find.byType(QueryaSwitchingBody), - matching: find.byType(Offstage, skipOffstage: false), + matching: find.byType(LazyOffstage, skipOffstage: false), ); - final offstagesInitial = tester.widgetList(offstageFinder).toList(); + final offstagesInitial = + tester.widgetList(offstageFinder).toList(); expect(offstagesInitial.length, 2); // Active child A is onstage expect(offstagesInitial[0].offstage, isFalse); @@ -411,14 +413,14 @@ void main() { // While animating, both are onstage await tester.pump(const Duration(milliseconds: 50)); final offstagesAnimating = - tester.widgetList(offstageFinder).toList(); + tester.widgetList(offstageFinder).toList(); expect(offstagesAnimating[0].offstage, isFalse); expect(offstagesAnimating[1].offstage, isFalse); // Once settled, child A is offstaged, child B is onstage await tester.pumpAndSettle(); final offstagesSettled = - tester.widgetList(offstageFinder).toList(); + tester.widgetList(offstageFinder).toList(); expect(offstagesSettled[0].offstage, isTrue); expect(offstagesSettled[1].offstage, isFalse); });