From 96409451686ed8d7384e2ee6f5751cbeaccd80e7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 25 Sep 2026 15:05:55 +0300 Subject: [PATCH] perf(dialogs): drop the Opacity around the backdrop blur; animate sigma and tint (Closes #886) The dialog backdrop faded in by wrapping a BackdropFilter in an Opacity driven by the route animation, which asks the compositor for an extra full-screen offscreen layer on every frame of the enter and exit transitions. Animate the blur sigma and the tint alpha directly with the transition progress instead. The frost still fades in and out without pop-in, ends at the same sigma 8 / black 0.32 as before, and nothing wraps the filter in an Opacity. The filter is not built at progress 0. --- benchmark/grid_perf_bench.dart | 39 +++++++++++- lib/shared/widgets/app_dialog.dart | 49 ++++++++++----- test/shared/app_dialog_test.dart | 96 +++++++++++++++++++++++++----- 3 files changed, 152 insertions(+), 32 deletions(-) diff --git a/benchmark/grid_perf_bench.dart b/benchmark/grid_perf_bench.dart index 87c493d..d8a5e4a 100644 --- a/benchmark/grid_perf_bench.dart +++ b/benchmark/grid_perf_bench.dart @@ -1,9 +1,10 @@ -// ignore_for_file: depend_on_referenced_packages +// ignore_for_file: depend_on_referenced_packages, use_build_context_synchronously // Frame-timing benchmark for VirtualResultGrid. Run in profile mode: // flutter run --profile -d linux -t benchmark/grid_perf_bench.dart --dart-define=MODE=scroll // MODE: scroll (horizontal pan) | edit (staged cell edits on a sorted grid) // | select (mouse-drag selection through ResultsTab, incl. stats) +// | dialog (open / close showAppDialog repeatedly over a busy grid) import 'dart:async'; import 'dart:developer' as developer; import 'dart:io'; @@ -17,6 +18,7 @@ import 'package:querya_desktop/core/theme/querya_theme.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/result_grid_view.dart'; import 'package:querya_desktop/features/workspace/results_tab.dart'; +import 'package:querya_desktop/shared/widgets/app_dialog.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:vm_service/vm_service.dart' as vms; import 'package:vm_service/vm_service_io.dart' as vms_io; @@ -25,6 +27,7 @@ const mode = String.fromEnvironment('MODE', defaultValue: 'scroll'); const rowCount = int.fromEnvironment('ROWS', defaultValue: 5000); const colCount = int.fromEnvironment('COLS', defaultValue: 120); const seconds = int.fromEnvironment('SECS', defaultValue: 6); +final dialogHost = GlobalKey(); const profile = bool.fromEnvironment('PROFILE', defaultValue: false); vms.VmService? _vm; @@ -57,7 +60,19 @@ void main() { runApp(ShadcnApp( theme: td, home: material.Scaffold( - body: mode == 'select' + body: mode == 'dialog' + ? KeyedSubtree( + key: dialogHost, + child: ListenableBuilder( + listenable: buffer, + builder: (_, __) => VirtualResultGrid( + columns: columns, + rows: buffer.effectiveRows, + stagingBuffer: buffer, + ), + ), + ) + : mode == 'select' ? ListenableBuilder( listenable: buffer, builder: (_, __) => ResultsTab( @@ -137,7 +152,25 @@ Future _run(DataGridStagingBuffer buffer) async { : null; } var dir = 1.0; - while (DateTime.now().isBefore(end)) { + if (mode == 'dialog') { + final ctx = dialogHost.currentContext!; + while (DateTime.now().isBefore(end)) { + unawaited(showAppDialog( + context: ctx, + builder: (_) => const Center( + child: SizedBox( + width: 420, + height: 260, + child: Card(child: Center(child: Text('bench dialog'))), + ), + ), + )); + await Future.delayed(const Duration(milliseconds: 600)); + Navigator.of(ctx).pop(); + await Future.delayed(const Duration(milliseconds: 600)); + } + } + while (mode != 'dialog' && DateTime.now().isBefore(end)) { await SchedulerBinding.instance.endOfFrame; if (mode == 'scroll') { final p = pos!; diff --git a/lib/shared/widgets/app_dialog.dart b/lib/shared/widgets/app_dialog.dart index ec8a2a3..bfb9e35 100644 --- a/lib/shared/widgets/app_dialog.dart +++ b/lib/shared/widgets/app_dialog.dart @@ -101,27 +101,16 @@ class _BlurredDialogScaffoldState extends State<_BlurredDialogScaffold> { child: Stack( fit: StackFit.expand, children: [ - // Backdrop: static blur; only opacity/dim animate (blur sigma is expensive). + // Backdrop: blur strength and tint animate directly. No Opacity wraps + // the BackdropFilter, so no extra full-screen offscreen layer is + // composited on every frame of the enter / exit transition. Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: widget.barrierDismissible ? widget.onDismiss : null, child: AnimatedBuilder( animation: curved, - child: ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), - child: const ColoredBox( - color: Color(0x52000000), // black @ 0.32 - ), - ), - ), - builder: (ctx, child) { - return Opacity( - opacity: curved.value.clamp(0.0, 1.0), - child: child, - ); - }, + builder: (ctx, _) => _DialogBackdrop(progress: curved.value), ), ), ), @@ -140,3 +129,33 @@ class _BlurredDialogScaffoldState extends State<_BlurredDialogScaffold> { ); } } + +/// Frosted, dimmed dialog backdrop at [progress] (0 = none, 1 = full). +/// +/// Both blur sigma and tint alpha scale with [progress], so the frost fades in +/// and out without pop-in and without an enclosing `Opacity`. +class _DialogBackdrop extends StatelessWidget { + const _DialogBackdrop({required this.progress}); + + /// Full-strength blur sigma. + static const double sigma = 8; + + /// Full-strength tint alpha (black @ 0.32). + static const double tintAlpha = 0.32; + + final double progress; + + @override + Widget build(BuildContext context) { + final t = progress.clamp(0.0, 1.0); + if (t <= 0) return const SizedBox.expand(); + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: sigma * t, sigmaY: sigma * t), + child: ColoredBox( + color: const Color(0xFF000000).withValues(alpha: tintAlpha * t), + ), + ), + ); + } +} diff --git a/test/shared/app_dialog_test.dart b/test/shared/app_dialog_test.dart index 6a6b8bb..c112f2c 100644 --- a/test/shared/app_dialog_test.dart +++ b/test/shared/app_dialog_test.dart @@ -121,27 +121,95 @@ void main() { builder: (c) => const SimpleDialog(title: Text('X')), ); await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); expect(find.byType(BackdropFilter), findsWidgets); expect(find.byType(FadeTransition), findsWidgets); expect(find.byType(SlideTransition), findsWidgets); expect(find.byType(ScaleTransition), findsNothing); }); - testWidgets('backdrop keeps static blur sigma; opacity carries enter', - (tester) async { - final ctx = await pumpHost(tester); - - showAppDialog( - context: ctx, - builder: (c) => const SimpleDialog(title: Text('Blur')), - ); - // Mid-enter: blur must stay sigma 8 (not sigma * t). - await tester.pump(); - await tester.pump(const Duration(milliseconds: 40)); + group('backdrop transition (#886)', () { + final fullFilter = ImageFilter.blur(sigmaX: 8, sigmaY: 8); + final fullTint = const Color(0xFF000000).withValues(alpha: 0.32); - final filter = tester.widget(find.byType(BackdropFilter)); - expect(filter.filter, ImageFilter.blur(sigmaX: 8, sigmaY: 8)); - expect(find.byType(Opacity), findsWidgets); + Color tintOf(WidgetTester tester) { + final box = tester.widget( + find.descendant( + of: find.byType(BackdropFilter), + matching: find.byType(ColoredBox), + ), + ); + return box.color; + } + + testWidgets('blur and tint scale in during enter; no Opacity wraps the filter', + (tester) async { + final ctx = await pumpHost(tester); + + showAppDialog( + context: ctx, + builder: (c) => const SimpleDialog(title: Text('Blur')), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + + final filter = tester.widget(find.byType(BackdropFilter)); + expect(filter.filter, isNot(fullFilter), reason: 'sigma is still ramping'); + expect(tintOf(tester).a, greaterThan(0)); + expect(tintOf(tester).a, lessThan(fullTint.a)); + + // The frost must not sit under an Opacity (that forces an offscreen layer). + expect( + find.ancestor( + of: find.byType(BackdropFilter), + matching: find.byType(Opacity), + ), + findsNothing, + ); + }); + + testWidgets('settles at full blur and tint', (tester) async { + final ctx = await pumpHost(tester); + + showAppDialog( + context: ctx, + builder: (c) => const SimpleDialog(title: Text('Settled')), + ); + await tester.pumpAndSettle(); + + final filter = tester.widget(find.byType(BackdropFilter)); + expect(filter.filter, fullFilter); + expect(tintOf(tester), fullTint); + expect( + find.ancestor( + of: find.byType(BackdropFilter), + matching: find.byType(Opacity), + ), + findsNothing, + ); + }); + + testWidgets('blur and tint fade out again on dismiss', (tester) async { + final ctx = await pumpHost(tester); + + final future = showAppDialog( + context: ctx, + builder: (c) => const SimpleDialog(title: Text('Leaving')), + ); + await tester.pumpAndSettle(); + + Navigator.of(ctx, rootNavigator: true).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 40)); + + final filter = tester.widget(find.byType(BackdropFilter)); + expect(filter.filter, isNot(fullFilter)); + expect(tintOf(tester).a, lessThan(fullTint.a)); + + await tester.pumpAndSettle(); + expect(find.byType(BackdropFilter), findsNothing); + await future; + }); }); testWidgets('enter uses standard duration under full motion', (tester) async {