[ Widget Preview ] Update generated scaffold project to include early preview rendering (#162847)

With this change, `flutter widget-preview start` will launch a working
widget preview environment that can render previews from a target
project.

Also fixes an issue where `--offline` wasn't being respected by some pub
operations.
This commit is contained in:
Ben Konyi 2025-02-11 11:41:28 -05:00 committed by GitHub
parent 94cd4b14c9
commit e7e5480a57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 543 additions and 41 deletions

View File

@ -124,7 +124,7 @@ class LocalSignals implements Signals {
// If _handlersList[signal] is empty, then lookup the cached stream
// controller and unsubscribe from the stream.
if (_handlersList.isEmpty) {
if (_handlersList[signal]!.isEmpty) {
await _streamSubscriptions[signal]?.cancel();
}
return true;

View File

@ -491,9 +491,26 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
await pub.interactively(
<String>[
pubAdd,
if (offline) '--offline',
'--directory',
widgetPreviewScaffoldProject.directory.path,
'${rootProject.manifest.appName}:{"path":${rootProject.directory.path}}',
// Ensure the path using POSIX separators, otherwise the "path_not_posix" check will fail.
'${rootProject.manifest.appName}:{"path":${rootProject.directory.path.replaceAll(r"\", "/")}}',
],
context: PubContext.pubAdd,
command: pubAdd,
touchesPackageConfig: true,
);
// Adds a dependency on flutter_lints, which is referenced by the
// analysis_options.yaml generated by the 'app' template.
await pub.interactively(
<String>[
pubAdd,
if (offline) '--offline',
'--directory',
widgetPreviewScaffoldProject.directory.path,
'flutter_lints',
],
context: PubContext.pubAdd,
command: pubAdd,

View File

@ -19,7 +19,7 @@ class PreviewCodeGenerator {
/// project.
final FlutterProject widgetPreviewScaffoldProject;
static const String generatedPreviewFilePath = 'lib/generated_preview.dart';
static const String generatedPreviewFilePath = 'lib/src/generated_preview.dart';
/// Generates code used by the widget preview scaffold based on the preview instances listed in
/// [previews].
@ -43,10 +43,7 @@ class PreviewCodeGenerator {
void populatePreviewsInGeneratedPreviewScaffold(PreviewMapping previews) {
final Library lib = Library(
(LibraryBuilder b) => b.body.addAll(<Spec>[
Directive.import(
// TODO(bkonyi): update with actual location in the framework
'package:widget_preview/widget_preview.dart',
),
Directive.import('package:flutter/widgets.dart'),
Method(
(MethodBuilder b) =>
b

View File

@ -353,6 +353,10 @@
"templates/plugin_swift_package_manager/macos.tmpl/projectName.tmpl/Sources/projectName.tmpl/PrivacyInfo.xcprivacy",
"templates/widget_preview_scaffold/lib/main.dart.tmpl",
"templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl",
"templates/widget_preview_scaffold/lib/src/controls.dart.tmpl",
"templates/widget_preview_scaffold/lib/src/generated_preview.dart.tmpl",
"templates/widget_preview_scaffold/lib/src/utils.dart.tmpl",
"templates/widget_preview_scaffold/pubspec.yaml.tmpl",
"templates/widget_preview_scaffold/README.md.tmpl",

View File

@ -2,25 +2,9 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// TODO(bkonyi): Implement.
import 'src/widget_preview_rendering.dart';
import 'src/generated_preview.dart';
import 'package:flutter/material.dart';
void main() {
runApp(const MainApp());
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: Center(
child: Text('Hello World!'),
),
),
);
}
Future<void> main() async {
await mainImpl(previewsProvider: previews);
}

View File

@ -0,0 +1,102 @@
// 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 'package:flutter/material.dart';
class _WidgetPreviewIconButton extends StatelessWidget {
const _WidgetPreviewIconButton({
required this.tooltip,
required this.onPressed,
required this.icon,
});
final String tooltip;
final void Function()? onPressed;
final IconData icon;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: Ink(
decoration: ShapeDecoration(
shape: const CircleBorder(),
color: onPressed != null ? Colors.lightBlue : Colors.grey,
),
child: IconButton(
onPressed: onPressed,
icon: Icon(
color: Colors.white,
icon,
),
),
),
);
}
}
/// Provides controls to change the zoom level of a [WidgetPreview].
class ZoomControls extends StatelessWidget {
/// Provides controls to change the zoom level of a [WidgetPreview].
const ZoomControls({
super.key,
required TransformationController transformationController,
}) : _transformationController = transformationController;
final TransformationController _transformationController;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
_WidgetPreviewIconButton(
tooltip: 'Zoom in',
onPressed: _zoomIn,
icon: Icons.zoom_in,
),
const SizedBox(
width: 10,
),
_WidgetPreviewIconButton(
tooltip: 'Zoom out',
onPressed: _zoomOut,
icon: Icons.zoom_out,
),
const SizedBox(
width: 10,
),
_WidgetPreviewIconButton(
tooltip: 'Reset zoom',
onPressed: _reset,
icon: Icons.refresh,
),
],
);
}
void _zoomIn() {
_transformationController.value = Matrix4.copy(
_transformationController.value,
).scaled(1.1);
}
void _zoomOut() {
final Matrix4 updated = Matrix4.copy(
_transformationController.value,
).scaled(0.9);
// Don't allow for zooming out past the original size of the widget.
// Assumes scaling is evenly applied to the entire matrix.
if (updated.entry(0, 0) < 1.0) {
updated.setIdentity();
}
_transformationController.value = updated;
}
void _reset() {
_transformationController.value = Matrix4.identity();
}
}

View File

@ -0,0 +1,7 @@
// 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 'package:flutter/widgets.dart';
List<WidgetPreview> previews() => <WidgetPreview>[];

View File

@ -0,0 +1,18 @@
// 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 'package:flutter/material.dart';
/// A basic vertical spacer.
class VerticalSpacer extends StatelessWidget {
/// Creates a basic vertical spacer.
const VerticalSpacer({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox(
height: 10,
);
}
}

View File

@ -0,0 +1,364 @@
// 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 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'controls.dart';
import 'utils.dart';
class WidgetPreviewWidget extends StatefulWidget {
const WidgetPreviewWidget({
super.key,
required this.preview,
});
final WidgetPreview preview;
@override
State<WidgetPreviewWidget> createState() => _WidgetPreviewWidgetState();
}
class _WidgetPreviewWidgetState extends State<WidgetPreviewWidget> {
final transformationController = TransformationController();
final deviceOrientation = ValueNotifier<Orientation>(Orientation.portrait);
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
final previewerConstraints =
WidgetPreviewerWindowConstraints.getRootConstraints(context);
final maxSizeConstraints = previewerConstraints.copyWith(
minHeight: previewerConstraints.maxHeight / 2.0,
maxHeight: previewerConstraints.maxHeight / 2.0,
);
Widget preview = _WidgetPreviewWrapper(
previewerConstraints: maxSizeConstraints,
child: SizedBox(
width: widget.preview.width,
height: widget.preview.height,
child: widget.preview.child,
),
);
preview = MediaQuery(
data: _buildMediaQueryOverride(),
child: preview,
);
preview = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.preview.name != null) ...[
Text(
widget.preview.name!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w300,
),
),
const VerticalSpacer(),
],
InteractiveViewerWrapper(
transformationController: transformationController,
child: preview,
),
const VerticalSpacer(),
Row(
mainAxisSize: MainAxisSize.min,
children: [
ZoomControls(
transformationController: transformationController,
),
],
),
],
);
return Padding(
padding: const EdgeInsets.all(16.0),
child: Card(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 16.0,
horizontal: 16.0,
),
child: preview,
),
),
);
}
MediaQueryData _buildMediaQueryOverride() {
var mediaQueryData = MediaQuery.of(context);
if (widget.preview.textScaleFactor != null) {
mediaQueryData = mediaQueryData.copyWith(
textScaler: TextScaler.linear(widget.preview.textScaleFactor!),
);
}
var size = Size(widget.preview.width ?? mediaQueryData.size.width,
widget.preview.height ?? mediaQueryData.size.height);
if (widget.preview.width != null || widget.preview.height != null) {
mediaQueryData = mediaQueryData.copyWith(
size: size,
);
}
return mediaQueryData;
}
}
/// An [InheritedWidget] that propagates the current size of the
/// WidgetPreviewScaffold.
///
/// This is needed when determining how to put constraints on previewed widgets
/// that would otherwise have infinite constraints.
class WidgetPreviewerWindowConstraints extends InheritedWidget {
const WidgetPreviewerWindowConstraints({
super.key,
required super.child,
required this.constraints,
});
final BoxConstraints constraints;
static BoxConstraints getRootConstraints(BuildContext context) {
final result = context
.dependOnInheritedWidgetOfExactType<WidgetPreviewerWindowConstraints>();
assert(
result != null,
'No WidgetPreviewerWindowConstraints founds in context',
);
return result!.constraints;
}
@override
bool updateShouldNotify(WidgetPreviewerWindowConstraints oldWidget) {
return oldWidget.constraints != constraints;
}
}
class InteractiveViewerWrapper extends StatelessWidget {
const InteractiveViewerWrapper({
super.key,
required this.child,
required this.transformationController,
});
final Widget child;
final TransformationController transformationController;
@override
Widget build(BuildContext context) {
return InteractiveViewer(
transformationController: transformationController,
scaleEnabled: false,
child: child,
);
}
}
// TODO(bkonyi): according to goderbauer@, this probably isn't the best approach to ensure we
// handle unconstrained widgets. This should be reworked.
/// Wrapper applying a custom render object to force constraints on
/// unconstrained widgets.
class _WidgetPreviewWrapper extends SingleChildRenderObjectWidget {
const _WidgetPreviewWrapper({
super.child,
required this.previewerConstraints,
});
/// The size of the previewer render surface.
final BoxConstraints previewerConstraints;
@override
RenderObject createRenderObject(BuildContext context) {
return _WidgetPreviewWrapperBox(
previewerConstraints: previewerConstraints,
child: null,
);
}
@override
void updateRenderObject(
BuildContext context,
_WidgetPreviewWrapperBox renderObject,
) {
renderObject.setPreviewerConstraints(previewerConstraints);
}
}
/// Custom render box that forces constraints onto unconstrained widgets.
class _WidgetPreviewWrapperBox extends RenderShiftedBox {
_WidgetPreviewWrapperBox({
required RenderBox? child,
required BoxConstraints previewerConstraints,
}) : _previewerConstraints = previewerConstraints,
super(child);
BoxConstraints _constraintOverride = const BoxConstraints();
BoxConstraints _previewerConstraints;
void setPreviewerConstraints(BoxConstraints previewerConstraints) {
if (_previewerConstraints == previewerConstraints) {
return;
}
_previewerConstraints = previewerConstraints;
markNeedsLayout();
}
@override
void layout(
Constraints constraints, {
bool parentUsesSize = false,
}) {
if (child != null && constraints is BoxConstraints) {
double minInstrinsicHeight;
try {
minInstrinsicHeight = child!.getMinIntrinsicHeight(
constraints.maxWidth,
);
} on Object {
minInstrinsicHeight = 0.0;
}
// Determine if the previewed widget is vertically constrained. If the
// widget has a minimum intrinsic height of zero given the widget's max
// width, it has an unconstrained height and will cause an overflow in
// the previewer. In this case, apply finite constraints (e.g., the
// constraints for the root of the previewer). Otherwise, use the
// widget's actual constraints.
_constraintOverride = minInstrinsicHeight == 0
? _previewerConstraints
: const BoxConstraints();
}
super.layout(
constraints,
parentUsesSize: parentUsesSize,
);
}
@override
void performLayout() {
final child = this.child;
if (child == null) {
size = Size.zero;
return;
}
final updatedConstraints = _constraintOverride.enforce(constraints);
child.layout(
updatedConstraints,
parentUsesSize: true,
);
size = constraints.constrain(child.size);
}
}
/// Custom [AssetBundle] used to map original asset paths from the parent
/// project to those in the preview project.
class PreviewAssetBundle extends PlatformAssetBundle {
// Assets shipped via package dependencies have paths that start with
// 'packages'.
static const String _kPackagesPrefix = 'packages';
@override
Future<ByteData> load(String key) {
// These assets are always present or are shipped via a package and aren't
// actually located in the parent project, meaning their paths did not need
// to be modified.
if (key == 'AssetManifest.bin' ||
key == 'AssetManifest.json' ||
key == 'FontManifest.json' ||
key.startsWith(_kPackagesPrefix)) {
return super.load(key);
}
// Other assets are from the parent project. Map their keys to those found
// in the pubspec.yaml of the preview envirnment.
return super.load('../../$key');
}
@override
Future<ImmutableBuffer> loadBuffer(String key) async {
return await ImmutableBuffer.fromAsset(
key.startsWith(_kPackagesPrefix) ? key : '../../$key',
);
}
}
/// Main entrypoint for the widget previewer.
///
/// We don't actually define this as `main` to avoid copying this file into
/// the preview scaffold project which prevents us from being able to use hot
/// restart to iterate on this file.
Future<void> mainImpl({
required List<WidgetPreview> Function() previewsProvider,
}) async {
runApp(
_WidgetPreviewScaffold(
previewsProvider: previewsProvider,
),
);
}
class _WidgetPreviewScaffold extends StatelessWidget {
const _WidgetPreviewScaffold({required this.previewsProvider});
final List<WidgetPreview> Function() previewsProvider;
@override
Widget build(BuildContext context) {
final List<WidgetPreview> previewList = previewsProvider();
Widget previewView;
if (previewList.isEmpty) {
previewView = const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Center(
// TODO: consider including details on how to get started
// with Widget Previews.
child: Text(
'No previews available',
style: TextStyle(color: Colors.white),
),
)
],
);
} else {
previewView = LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return WidgetPreviewerWindowConstraints(
constraints: constraints,
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
for (final WidgetPreview preview in previewList) WidgetPreviewWidget(preview: preview),
],
),
),
);
},
);
}
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Material(
color: Colors.transparent,
child: DefaultAssetBundle(
bundle: PreviewAssetBundle(),
child: previewView,
),
),
);
}
}

View File

@ -7,6 +7,7 @@ import 'dart:convert';
import 'package:flutter_tools/src/artifacts.dart';
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/io.dart';
import 'package:flutter_tools/src/cache.dart';
import 'package:flutter_tools/src/globals.dart' as globals;
import 'package:process/process.dart';
@ -47,7 +48,7 @@ Future<void> analyzeProject(
List<String> expectedFailures = const <String>[],
}) async {
final String flutterToolsSnapshotPath = globals.fs.path.absolute(
globals.fs.path.join('..', '..', 'bin', 'cache', 'flutter_tools.snapshot'),
globals.fs.path.join(Cache.flutterRoot!, 'bin', 'cache', 'flutter_tools.snapshot'),
);
final List<String> args = <String>[flutterToolsSnapshotPath, 'analyze'];

View File

@ -31,11 +31,12 @@ void main() {
late LoggingProcessManager loggingProcessManager;
late FakeStdio mockStdio;
late Logger logger;
late FileSystem fs;
late LocalFileSystem fs;
late BotDetector botDetector;
late Platform platform;
setUp(() {
setUp(() async {
await ensureFlutterToolsSnapshot();
loggingProcessManager = LoggingProcessManager();
logger = BufferLogger.test();
fs = LocalFileSystem.test(signals: Signals.test());
@ -43,10 +44,16 @@ void main() {
tempDir = fs.systemTempDirectory.createTempSync('flutter_tools_create_test.');
mockStdio = FakeStdio();
platform = FakePlatform.fromPlatform(const LocalPlatform());
// Most, but not all, tests will run some variant of "pub get" after creation,
// which in turn will check for the presence of the Flutter SDK root. Without
// this field set consistently, the order of the tests becomes important *or*
// you need to remember to set it everywhere.
Cache.flutterRoot = fs.path.absolute('..', '..');
});
tearDown(() {
tryToDelete(tempDir);
fs.dispose();
});
Future<Directory> createRootProject() async {
@ -91,11 +98,13 @@ void main() {
final Directory widgetPreviewScaffoldDir = widgetPreviewScaffoldFromRootProject(
rootProject: rootProject ?? fs.currentDirectory,
);
expect(widgetPreviewScaffoldDir, exists);
expect(
widgetPreviewScaffoldDir.childFile(PreviewCodeGenerator.generatedPreviewFilePath),
exists,
);
// Don't perform analysis on Windows since `dart pub add` will use '\' for
// path dependencies and cause analysis to fail.
// TODO(bkonyi): enable analysis on Windows once https://github.com/dart-lang/pub/issues/4520
// is resolved.
if (!platform.isWindows) {
await analyzeProject(widgetPreviewScaffoldDir.path);
}
}
Future<void> cleanWidgetPreview({required Directory rootProject}) async {
@ -181,13 +190,12 @@ void main() {
);
const String samplePreviewFile = '''
// This doesn't need to be valid code for testing as long as it has the @Preview() annotation
@Preview()
WidgetPreview preview() => WidgetPreview();''';
WidgetPreview preview() => const WidgetPreview(child: Text('Foo'));''';
const String expectedGeneratedFileContents = '''
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:flutter_project/foo.dart' as _i1;import 'package:widget_preview/widget_preview.dart';List<WidgetPreview> previews() => [_i1.preview()];''';
import 'package:flutter_project/foo.dart' as _i1;import 'package:flutter/widgets.dart';List<WidgetPreview> previews() => [_i1.preview()];''';
testUsingContext(
'start finds existing previews and injects them into ${PreviewCodeGenerator.generatedPreviewFilePath}',

View File

@ -25,7 +25,7 @@ void main() {
final Directory projectDir =
fs.currentDirectory.childDirectory('project')
..createSync()
..childDirectory('lib').createSync();
..childDirectory('lib/src').createSync(recursive: true);
project = FlutterProject(projectDir, manifest, manifest);
codeGenerator = PreviewCodeGenerator(widgetPreviewScaffoldProject: project, fs: fs);
});
@ -56,7 +56,7 @@ void main() {
// The generated file is unfortunately unformatted.
const String expectedGeneratedPreviewFileContents = '''
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'foo.dart' as _i1;import 'src/bar.dart' as _i2;import 'package:widget_preview/widget_preview.dart';List<WidgetPreview> previews() => [_i1.preview(), _i2.barPreview1(), _i2.barPreview2(), ];''';
import 'foo.dart' as _i1;import 'src/bar.dart' as _i2;import 'package:flutter/widgets.dart';List<WidgetPreview> previews() => [_i1.preview(), _i2.barPreview1(), _i2.barPreview2(), ];''';
expect(generatedPreviewFile.readAsStringSync(), expectedGeneratedPreviewFileContents);
// Regenerate the generated file with no previews.
@ -69,7 +69,7 @@ import 'foo.dart' as _i1;import 'src/bar.dart' as _i2;import 'package:widget_pre
// - An import of the widget preview library
// - A top-level function 'List<WidgetPreview> previews()' that returns an empty list.
const String emptyGeneratedPreviewFileContents = '''
import 'package:widget_preview/widget_preview.dart';List<WidgetPreview> previews() => [];''';
import 'package:flutter/widgets.dart';List<WidgetPreview> previews() => [];''';
expect(generatedPreviewFile.readAsStringSync(), emptyGeneratedPreviewFileContents);
},
);