Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions lib/core/motion/lazy_offstage.dart
Original file line number Diff line number Diff line change
@@ -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<bool>('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();
}
}
6 changes: 5 additions & 1 deletion lib/core/motion/querya_switching_body.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';

import 'lazy_offstage.dart';
import 'querya_motion.dart';
import 'querya_motion_context.dart';

Expand Down Expand Up @@ -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,
),
Expand Down
69 changes: 69 additions & 0 deletions test/core/motion/lazy_offstage_test.dart
Original file line number Diff line number Diff line change
@@ -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<double> 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);
});
}
12 changes: 7 additions & 5 deletions test/core/motion/querya_switching_body_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(
Expand All @@ -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<Offstage>(offstageFinder).toList();
final offstagesInitial =
tester.widgetList<LazyOffstage>(offstageFinder).toList();
expect(offstagesInitial.length, 2);
// Active child A is onstage
expect(offstagesInitial[0].offstage, isFalse);
Expand All @@ -411,14 +413,14 @@ void main() {
// While animating, both are onstage
await tester.pump(const Duration(milliseconds: 50));
final offstagesAnimating =
tester.widgetList<Offstage>(offstageFinder).toList();
tester.widgetList<LazyOffstage>(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<Offstage>(offstageFinder).toList();
tester.widgetList<LazyOffstage>(offstageFinder).toList();
expect(offstagesSettled[0].offstage, isTrue);
expect(offstagesSettled[1].offstage, isFalse);
});
Expand Down
Loading