diff --git a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/controls.dart.tmpl b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/controls.dart.tmpl index 1e0125645b2..6158994763d 100644 --- a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/controls.dart.tmpl +++ b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/controls.dart.tmpl @@ -65,7 +65,7 @@ class ZoomControls extends StatelessWidget { _WidgetPreviewIconButton( tooltip: 'Reset zoom', onPressed: enabled ? _reset : null, - icon: Icons.refresh, + icon: Icons.zoom_out_map, ), ], ); @@ -95,3 +95,32 @@ class ZoomControls extends StatelessWidget { _transformationController.value = Matrix4.identity(); } } + +/// A button that triggers a "soft" restart of a previewed widget. +/// +/// A soft restart removes the previewed widget from the widget tree for a frame before +/// re-inserting it on the next frame. This has the effect of re-running local initializers in +/// State objects, which normally requires a hot restart to accomplish in a normal application. +class SoftRestartButton extends StatelessWidget { + const SoftRestartButton({ + super.key, + required this.enabled, + required this.softRestartListenable, + }); + + final ValueNotifier softRestartListenable; + final bool enabled; + + @override + Widget build(BuildContext context) { + return _WidgetPreviewIconButton( + tooltip: 'Hot restart', + onPressed: enabled ? _onRestart : null, + icon: Icons.refresh, + ); + } + + void _onRestart() { + softRestartListenable.value = true; + } +} diff --git a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl index 8449dd0ec5b..61ef0554b09 100644 --- a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl +++ b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl @@ -117,12 +117,18 @@ class WidgetPreviewWidget extends StatefulWidget { final WidgetPreview preview; @override - State createState() => _WidgetPreviewWidgetState(); + State createState() => WidgetPreviewWidgetState(); } -class _WidgetPreviewWidgetState extends State { +class WidgetPreviewWidgetState extends State { final transformationController = TransformationController(); final deviceOrientation = ValueNotifier(Orientation.portrait); + final softRestartListenable = ValueNotifier(false); + final key = GlobalKey(); + + /// Returns the last size of the previewed widget. + Size get lastChildSize => + (key.currentContext!.findRenderObject() as RenderBox).size; @override void initState() { @@ -140,19 +146,41 @@ class _WidgetPreviewWidgetState extends State { ); bool errorThrownDuringTreeConstruction = false; - Widget preview; - // Catch any unhandled exceptions and display an error widget instead of taking - // down the entire preview environment. - try { - preview = widget.preview.builder(); - } on Object catch (error, stackTrace) { - errorThrownDuringTreeConstruction = true; - preview = _WidgetPreviewErrorWidget( - error: error, - stackTrace: stackTrace, - size: maxSizeConstraints.biggest, - ); - } + + // Wrap the previewed widget with a ValueListenableBuilder responsible for performing a "soft" + // restart. + // + // A soft restart simply removes the previewed widget from the widget tree for a frame before + // re-inserting it on the next frame. This has the effect of re-running local initializers in + // State objects, which normally requires a hot restart to accomplish in a normal application. + Widget preview = ValueListenableBuilder( + valueListenable: softRestartListenable, + builder: (context, performRestart, _) { + try { + final previewWidget = Container( + key: key, + child: widget.preview.builder(), + ); + if (performRestart) { + WidgetsBinding.instance.addPostFrameCallback((_) { + // Trigger a rebuild on the next frame to re-insert previewWidget. + softRestartListenable.value = false; + }, debugLabel: 'Soft Restart'); + return SizedBox.fromSize(size: lastChildSize); + } + return previewWidget; + } on Object catch (error, stackTrace) { + // Catch any unhandled exceptions and display an error widget instead of taking + // down the entire preview environment. + errorThrownDuringTreeConstruction = true; + return _WidgetPreviewErrorWidget( + error: error, + stackTrace: stackTrace, + size: maxSizeConstraints.biggest, + ); + } + }, + ); preview = _WidgetPreviewWrapper( previewerConstraints: maxSizeConstraints, @@ -184,13 +212,19 @@ class _WidgetPreviewWidgetState extends State { const VerticalSpacer(), Row( mainAxisSize: MainAxisSize.min, + // If an unhandled exception was caught and we're displaying an error + // widget, these controls should be disabled. + // TODO(bkonyi): improve layout of controls. children: [ ZoomControls( transformationController: transformationController, - // If an unhandled exception was caught and we're displaying an error - // widget, these controls should be disabled. enabled: !errorThrownDuringTreeConstruction, ), + const SizedBox(width: 30), + SoftRestartButton( + enabled: !errorThrownDuringTreeConstruction, + softRestartListenable: softRestartListenable, + ), ], ), ], diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/update_widget_preview_scaffold.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/update_widget_preview_scaffold.dart index 2cba040acf1..c418c1fa9f3 100644 --- a/packages/flutter_tools/test/widget_preview_scaffold.shard/update_widget_preview_scaffold.dart +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/update_widget_preview_scaffold.dart @@ -6,11 +6,11 @@ import 'dart:io'; import 'package:path/path.dart' as path; // flutter_ignore: package_path_import -import 'widget_preview_scaffold/test/widget_preview_scaffold_test_utils.dart'; +import 'widget_preview_scaffold/test/widget_preview_scaffold_change_detector.dart'; /// Regenerates the widget_preview_scaffold if needed. void main() { - if (WidgetPreviewScaffoldTestUtils.checkForTemplateUpdates( + if (WidgetPreviewScaffoldChangeDetector.checkForTemplateUpdates( widgetPreviewScaffoldProject: Directory( Platform.script.resolve('widget_preview_scaffold/').path, ), diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/controls.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/controls.dart index 1e0125645b2..6158994763d 100644 --- a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/controls.dart +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/controls.dart @@ -65,7 +65,7 @@ class ZoomControls extends StatelessWidget { _WidgetPreviewIconButton( tooltip: 'Reset zoom', onPressed: enabled ? _reset : null, - icon: Icons.refresh, + icon: Icons.zoom_out_map, ), ], ); @@ -95,3 +95,32 @@ class ZoomControls extends StatelessWidget { _transformationController.value = Matrix4.identity(); } } + +/// A button that triggers a "soft" restart of a previewed widget. +/// +/// A soft restart removes the previewed widget from the widget tree for a frame before +/// re-inserting it on the next frame. This has the effect of re-running local initializers in +/// State objects, which normally requires a hot restart to accomplish in a normal application. +class SoftRestartButton extends StatelessWidget { + const SoftRestartButton({ + super.key, + required this.enabled, + required this.softRestartListenable, + }); + + final ValueNotifier softRestartListenable; + final bool enabled; + + @override + Widget build(BuildContext context) { + return _WidgetPreviewIconButton( + tooltip: 'Hot restart', + onPressed: enabled ? _onRestart : null, + icon: Icons.refresh, + ); + } + + void _onRestart() { + softRestartListenable.value = true; + } +} diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview_rendering.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview_rendering.dart index 8449dd0ec5b..61ef0554b09 100644 --- a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview_rendering.dart +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview_rendering.dart @@ -117,12 +117,18 @@ class WidgetPreviewWidget extends StatefulWidget { final WidgetPreview preview; @override - State createState() => _WidgetPreviewWidgetState(); + State createState() => WidgetPreviewWidgetState(); } -class _WidgetPreviewWidgetState extends State { +class WidgetPreviewWidgetState extends State { final transformationController = TransformationController(); final deviceOrientation = ValueNotifier(Orientation.portrait); + final softRestartListenable = ValueNotifier(false); + final key = GlobalKey(); + + /// Returns the last size of the previewed widget. + Size get lastChildSize => + (key.currentContext!.findRenderObject() as RenderBox).size; @override void initState() { @@ -140,19 +146,41 @@ class _WidgetPreviewWidgetState extends State { ); bool errorThrownDuringTreeConstruction = false; - Widget preview; - // Catch any unhandled exceptions and display an error widget instead of taking - // down the entire preview environment. - try { - preview = widget.preview.builder(); - } on Object catch (error, stackTrace) { - errorThrownDuringTreeConstruction = true; - preview = _WidgetPreviewErrorWidget( - error: error, - stackTrace: stackTrace, - size: maxSizeConstraints.biggest, - ); - } + + // Wrap the previewed widget with a ValueListenableBuilder responsible for performing a "soft" + // restart. + // + // A soft restart simply removes the previewed widget from the widget tree for a frame before + // re-inserting it on the next frame. This has the effect of re-running local initializers in + // State objects, which normally requires a hot restart to accomplish in a normal application. + Widget preview = ValueListenableBuilder( + valueListenable: softRestartListenable, + builder: (context, performRestart, _) { + try { + final previewWidget = Container( + key: key, + child: widget.preview.builder(), + ); + if (performRestart) { + WidgetsBinding.instance.addPostFrameCallback((_) { + // Trigger a rebuild on the next frame to re-insert previewWidget. + softRestartListenable.value = false; + }, debugLabel: 'Soft Restart'); + return SizedBox.fromSize(size: lastChildSize); + } + return previewWidget; + } on Object catch (error, stackTrace) { + // Catch any unhandled exceptions and display an error widget instead of taking + // down the entire preview environment. + errorThrownDuringTreeConstruction = true; + return _WidgetPreviewErrorWidget( + error: error, + stackTrace: stackTrace, + size: maxSizeConstraints.biggest, + ); + } + }, + ); preview = _WidgetPreviewWrapper( previewerConstraints: maxSizeConstraints, @@ -184,13 +212,19 @@ class _WidgetPreviewWidgetState extends State { const VerticalSpacer(), Row( mainAxisSize: MainAxisSize.min, + // If an unhandled exception was caught and we're displaying an error + // widget, these controls should be disabled. + // TODO(bkonyi): improve layout of controls. children: [ ZoomControls( transformationController: transformationController, - // If an unhandled exception was caught and we're displaying an error - // widget, these controls should be disabled. enabled: !errorThrownDuringTreeConstruction, ), + const SizedBox(width: 30), + SoftRestartButton( + enabled: !errorThrownDuringTreeConstruction, + softRestartListenable: softRestartListenable, + ), ], ), ], diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/soft_restart_test.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/soft_restart_test.dart new file mode 100644 index 00000000000..6d7122e9c3e --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/soft_restart_test.dart @@ -0,0 +1,73 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:widget_preview_scaffold/src/controls.dart'; +import 'package:widget_preview_scaffold/src/widget_preview.dart'; +import 'package:widget_preview_scaffold/src/widget_preview_rendering.dart'; + +import 'widget_preview_scaffold_test_utils.dart'; + +void main() { + testWidgets( + 'Soft restart removes and re-inserts previewed widget into the widget tree', + (tester) async { + const String kTestText = 'Foo'; + final WidgetPreviewerWidgetScaffolding widgetPreview = + WidgetPreviewerWidgetScaffolding( + child: WidgetPreviewWidget( + preview: WidgetPreview(builder: () => const Text(kTestText)), + ), + ); + + await tester.pumpWidget(widgetPreview); + final Finder softRestartButton = find.byType(SoftRestartButton); + final WidgetPreviewWidgetState state = tester + .state(find.byWidget(widgetPreview.child)); + + bool removedFromTree = false; + final Completer completer = Completer(); + state.softRestartListenable.addListener(() { + if (state.softRestartListenable.value) { + expect(removedFromTree, false); + removedFromTree = true; + } else { + expect(removedFromTree, true); + completer.complete(); + } + }); + + // Start with the widget in the tree. + expect(removedFromTree, false); + final Size originalSize = state.lastChildSize; + final Finder fooTextFinder = find.text(kTestText); + expect(fooTextFinder, findsOne); + + // Perform a "soft" restart and render a single frame. + await tester.tap(softRestartButton); + await tester.pump(); + + // The previewed widget should be replaced by a SizedBox of the same size for a single frame. + final Finder placeholderBoxFinder = find.byWidgetPredicate( + (Widget widget) => + widget is SizedBox && + widget.height == originalSize.height && + widget.width == originalSize.width, + ); + expect(placeholderBoxFinder, findsOne); + expect(fooTextFinder, findsNothing); + expect(removedFromTree, true); + + // Render another frame and verify the previewed widget is added back to the tree. + await tester.pump(); + expect(placeholderBoxFinder, findsNothing); + expect(fooTextFinder, findsOne); + + await completer.future; + }, + ); +} diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/template_change_detection_smoke_test.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/template_change_detection_smoke_test.dart index cf5008c4f87..debfda2bcc0 100644 --- a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/template_change_detection_smoke_test.dart +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/template_change_detection_smoke_test.dart @@ -6,11 +6,11 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -import 'widget_preview_scaffold_test_utils.dart'; +import 'widget_preview_scaffold_change_detector.dart'; void main() { test('Widget Preview Scaffold template change detection', () { - if (WidgetPreviewScaffoldTestUtils.checkForTemplateUpdates( + if (WidgetPreviewScaffoldChangeDetector.checkForTemplateUpdates( widgetPreviewScaffoldProject: Directory( Platform.script.resolve('.').path, ), diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_preview_scaffold_change_detector.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_preview_scaffold_change_detector.dart new file mode 100644 index 00000000000..f0943f578be --- /dev/null +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_preview_scaffold_change_detector.dart @@ -0,0 +1,69 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +abstract class WidgetPreviewScaffoldChangeDetector { + static final Set _ignoreDiffSet = { + // The pubspec can't be compared directly to the template since the SDK version is populated + // when the template is hydrated based on the current SDK version. + 'pubspec.yaml', + 'lib/src/generated_preview.dart', + }; + + /// Checks to see if the widget_preview_scaffold template files have been updated. + /// + /// Returns true if the widget_preview_scaffold project should be regenerated. + static bool checkForTemplateUpdates({ + required Directory widgetPreviewScaffoldProject, + required Directory widgetPreviewScaffoldTemplateDir, + }) { + bool updateDetected = false; + for (final FileSystemEntity entity in Directory( + widgetPreviewScaffoldTemplateDir.absolute.path, + ).listSync(recursive: true)) { + final String scaffoldPath = + entity.path + .replaceAll('.tmpl', '') + .split('widget_preview_scaffold/') + .last; + if (_ignoreDiffSet.contains(scaffoldPath)) { + continue; + } + final String resolvedScaffoldPath = + '${widgetPreviewScaffoldProject.absolute.path}$scaffoldPath'; + if (entity is Directory) { + if (!Directory(resolvedScaffoldPath).existsSync()) { + stdout.writeln( + 'ERROR: Failed to find directory at $resolvedScaffoldPath.', + ); + updateDetected = true; + } + } else if (entity is File) { + final File scaffoldFile = File(resolvedScaffoldPath); + if (!scaffoldFile.existsSync()) { + stdout.writeln( + 'ERROR: Failed to find file at $resolvedScaffoldPath.', + ); + updateDetected = true; + continue; + } + final String templateContent = entity.readAsStringSync(); + final String scaffoldContent = scaffoldFile.readAsStringSync(); + if (templateContent != scaffoldContent) { + stdout.writeln( + 'ERROR: The contents of $resolvedScaffoldPath do not match the contents of the template at ' + '${entity.path}.', + ); + updateDetected = true; + } + } else { + throw StateError( + 'Unexpected FileSystemEntity type: ${entity.runtimeType}', + ); + } + } + return updateDetected; + } +} diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_preview_scaffold_test_utils.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_preview_scaffold_test_utils.dart index e102dfe3e5a..ee9ff5557d0 100644 --- a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_preview_scaffold_test_utils.dart +++ b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_preview_scaffold_test_utils.dart @@ -2,68 +2,37 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:widget_preview_scaffold/src/widget_preview_rendering.dart'; -abstract class WidgetPreviewScaffoldTestUtils { - static final Set _ignoreDiffSet = { - // The pubspec can't be compared directly to the template since the SDK version is populated - // when the template is hydrated based on the current SDK version. - 'pubspec.yaml', - 'lib/src/generated_preview.dart', - }; +class WidgetPreviewerWidgetScaffolding extends StatelessWidget { + const WidgetPreviewerWidgetScaffolding({super.key, required this.child}); - /// Checks to see if the widget_preview_scaffold template files have been updated. - /// - /// Returns true if the widget_preview_scaffold project should be regenerated. - static bool checkForTemplateUpdates({ - required Directory widgetPreviewScaffoldProject, - required Directory widgetPreviewScaffoldTemplateDir, - }) { - bool updateDetected = false; - for (final FileSystemEntity entity in Directory( - widgetPreviewScaffoldTemplateDir.absolute.path, - ).listSync(recursive: true)) { - final String scaffoldPath = - entity.path - .replaceAll('.tmpl', '') - .split('widget_preview_scaffold/') - .last; - if (_ignoreDiffSet.contains(scaffoldPath)) { - continue; - } - final String resolvedScaffoldPath = - '${widgetPreviewScaffoldProject.absolute.path}$scaffoldPath'; - if (entity is Directory) { - if (!Directory(resolvedScaffoldPath).existsSync()) { - stdout.writeln( - 'ERROR: Failed to find directory at $resolvedScaffoldPath.', + final Widget child; + + @override + Widget build(BuildContext context) { + return WidgetsApp( + color: Colors.blue, + home: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return WidgetPreviewerWindowConstraints( + constraints: constraints, + child: child, ); - updateDetected = true; - } - } else if (entity is File) { - final File scaffoldFile = File(resolvedScaffoldPath); - if (!scaffoldFile.existsSync()) { - stdout.writeln( - 'ERROR: Failed to find file at $resolvedScaffoldPath.', - ); - updateDetected = true; - continue; - } - final String templateContent = entity.readAsStringSync(); - final String scaffoldContent = scaffoldFile.readAsStringSync(); - if (templateContent != scaffoldContent) { - stdout.writeln( - 'ERROR: The contents of $resolvedScaffoldPath do not match the contents of the template at ' - '${entity.path}.', - ); - updateDetected = true; - } - } else { - throw StateError( - 'Unexpected FileSystemEntity type: ${entity.runtimeType}', - ); - } - } - return updateDetected; + }, + ), + pageRouteBuilder: + (RouteSettings settings, WidgetBuilder builder) => + PageRouteBuilder( + settings: settings, + pageBuilder: + ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) => builder(context), + ), + ); } }