[ Widget Preview ] Remove WidgetPreview in favor of using annotation properties (#165500)

This change reworks how users define previews in their code, expands the
number of valid 'functions' that can be used to create previews, and
allows for specifying a 'wrapper' function to wrap the previewed widget
with

The `WidgetPreview` class has been removed from the framework, with its
properties being added to the `Preview` annotation class instead to
remove some boilerplate from the preview declaration workflow.

Before:

```dart
@Preview()
List<WidgetPreview> previews() => <WidgetPreview>[
      WidgetPreview(
        name: 'Top-level preview',
        child: Text('Foo'),
      ),
  ];
```

After:

```dart
@Preview(name: 'Top-level preview')
Widget previews() => Text('Foo');
```

Previews can now be defined using top-level functions, constructors and
factories which take no arguments, and static methods within classes:

Examples:

```dart
@Preview(name: 'Top-level preview')
Widget previews() => Text('Foo');

class MyWidget extends StatelessWidget {
  @Preview(name: 'Constructor preview')
  MyWidget.preview();

  @Preview(name: 'Factory preview')
  factory MyWidget.factoryPreview() => MyWidget.preview();

  @Preview(name: 'Static preview')
  static Widget previewStatic() => Text('Static');

  @override
  Widget build(BuildContext context) {
    return Text('MyWidget');
  }
}
```

Users can also provide a `wrapper` function with the signature `Widget
Function(Widget)` to easily wrap previewed widget with shared
bootstrapping logic.

Example:

```dart
Widget testWrapper(Widget child) {
  return Provider<int>.value(
    value: 42,
    child: child,
  );
}

@Preview(name: 'Preview with wrapper', wrapper: testWrapper)
Widget preview() {
  return Text('Attributes');
}
```

Which is effectively the same as:

```dart
@Preview(name: 'Preview with wrapper')
Widget preview() {
  return Provider<int>.value(
    value: 42,
    child: Text('Attributes'),
  );
}
```

Finally, for situations where a `BuildContext` is needed, users can
return a `WidgetBuilder` from their preview function:

```dart
@Preview('Builder preview')
WidgetBuilder builderPreview() {
  return (BuildContext context) {
    // TODO: retrieve state from context.
    return Text('Foo');
  };
}
```
This commit is contained in:
Ben Konyi 2025-03-21 21:35:36 -04:00 committed by GitHub
parent 31ff6497f1
commit 41c427c6de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 740 additions and 142 deletions

View File

@ -13,70 +13,71 @@ import 'framework.dart';
///
/// {@tool snippet}
///
/// Functions annotated with `@Preview()` must return a `WidgetPreview`
/// and be public, top-level functions.
/// Functions annotated with `@Preview()` must return a `Widget` or
/// `WidgetBuilder` and be public. This annotation can only be applied
/// to top-level functions, static methods defined within a class, and
/// public `Widget` constructors and factories with no required arguments.
///
/// ```dart
/// @Preview()
/// WidgetPreview widgetPreview() {
/// return const WidgetPreview(name: 'Preview 1', child: Text('Foo'));
/// @Preview(name: 'Top-level preview')
/// Widget preview() => const Text('Foo');
///
/// @Preview(name: 'Builder preview')
/// WidgetBuilder builderPreview() {
/// return (BuildContext context) {
/// return const Text('Builder');
/// };
/// }
///
/// class MyWidget extends StatelessWidget {
/// @Preview(name: 'Constructor preview')
/// const MyWidget.preview({super.key});
///
/// @Preview(name: 'Factory constructor preview')
/// factory MyWidget.factoryPreview() => const MyWidget.preview();
///
/// @Preview(name: 'Static preview')
/// static Widget previewStatic() => const Text('Static');
///
/// @override
/// Widget build(BuildContext context) {
/// return const Text('MyWidget');
/// }
/// }
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [WidgetPreview], a data class used to specify widget previews.
// TODO(bkonyi): link to actual documentation when available.
class Preview {
base class Preview {
/// Annotation used to mark functions that return widget previews.
const Preview();
}
/// Wraps a [Widget], initializing various state and properties to allow for
/// previewing of the [Widget] in the widget previewer.
///
/// WARNING: This interface is not stable and **will change**.
///
/// See also:
///
/// * [Preview], an annotation class used to mark functions returning widget
/// previews.
// TODO(bkonyi): link to actual documentation when available.
class WidgetPreview {
/// Wraps [child] in a [WidgetPreview] instance that applies some set of
/// properties.
const WidgetPreview({
required this.child,
this.name,
this.width,
this.height,
this.textScaleFactor,
});
const Preview({this.name, this.width, this.height, this.textScaleFactor, this.wrapper});
/// A description to be displayed alongside the preview.
///
/// If not provided, no name will be associated with the preview.
final String? name;
/// The [Widget] to be rendered in the preview.
final Widget child;
/// Artificial width constraint to be applied to the [child].
/// Artificial width constraint to be applied to the previewed widget.
///
/// If not provided, the previewed widget will attempt to set its own width
/// constraints and may result in an unbounded constraint error.
final double? width;
/// Artificial height constraint to be applied to the [child].
/// Artificial height constraint to be applied to the previewed widget.
///
/// If not provided, the previewed widget will attempt to set its own height
/// constraints and may result in an unbounded constraint error.
final double? height;
/// Applies font scaling to text within the [child].
/// Applies font scaling to text within the previewed widget.
///
/// If not provided, the default text scaling factor provided by [MediaQuery]
/// will be used.
final double? textScaleFactor;
/// Wraps the previewed [Widget] in a [Widget] tree.
///
/// This function can be used to perform dependency injection or setup
/// additional scaffolding needed to correctly render the preview.
// TODO(bkonyi): provide an example.
final Widget Function(Widget)? wrapper;
}

View File

@ -181,6 +181,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
late final FlutterProject rootProject = getRootProject();
late final PreviewDetector _previewDetector = PreviewDetector(
projectRoot: rootProject.directory,
logger: logger,
fs: fs,
onChangeDetected: onChangeDetected,
@ -260,7 +261,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
await _populatePreviewPubspec(rootProject: rootProject);
}
final PreviewMapping initialPreviews = await _previewDetector.initialize(rootProject.directory);
final PreviewMapping initialPreviews = await _previewDetector.initialize();
_previewCodeGenerator.populatePreviewsInGeneratedPreviewScaffold(initialPreviews);
if (boolArg(kLaunchPreviewer)) {

View File

@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:built_collection/built_collection.dart';
import 'package:code_builder/code_builder.dart';
import '../base/file_system.dart';
@ -30,37 +31,100 @@ class PreviewCodeGenerator {
/// An example of a formatted generated file containing previews from two files could be:
///
/// ```dart
/// import 'package:foo/foo.dart' as _i1;
/// import 'package:foo/src/bar.dart' as _i2;
/// import 'package:widget_preview/widget_preview.dart';
/// import 'widget_preview.dart' as _i1;
/// import 'package:splash/foo.dart' as _i2;
/// import 'package:splash/main.dart' as _i3;
/// import 'package:flutter/widgets.dart' as _i4;
///
/// List<WidgetPreview> previews() => [
/// _i1.fooPreview(),
/// _i2.barPreview1(),
/// _i3.barPreview2(),
/// List<_i1.WidgetPreview> previews() => [
/// _i1.WidgetPreview(height: 100.0, width: 10000.0, child: _i2.preview()),
/// _i1.WidgetPreview(
/// name: 'Foo',
/// height: 50 + 20,
/// width: 200.0,
/// textScaleFactor: 2.0,
/// child: _i3.preview(),
/// ),
/// _i1.WidgetPreview(
/// name: 'Baz',
/// height: 50.0,
/// width: 200.0,
/// textScaleFactor: 3.0,
/// child: _i2.stateInjector(_i3.preview()),
/// ),
/// _i1.WidgetPreview(name: 'Bar', child: _i4.Builder(builder: _i3.preview2())),
/// _i1.WidgetPreview(name: 'Constructor preview', height: 50.0, width: 100.0, child: _i3.MyWidget()),
/// _i1.WidgetPreview(
/// name: 'Named constructor preview',
/// height: 50.0,
/// width: 100.0,
/// child: _i3.MyWidget.preview(),
/// ),
/// _i1.WidgetPreview(
/// name: 'Static preview',
/// height: 50.0,
/// width: 100.0,
/// child: _i3.MyWidget.staticPreview(),
/// ),
/// ];
/// ```
void populatePreviewsInGeneratedPreviewScaffold(PreviewMapping previews) {
final TypeReference returnType =
(TypeReferenceBuilder()
..symbol = 'List'
..types = ListBuilder<Reference>(<Reference>[
refer('WidgetPreview', 'widget_preview.dart'),
]))
.build();
final Library lib = Library(
(LibraryBuilder b) => b.body.addAll(<Spec>[
Directive.import('package:flutter/widgets.dart'),
Method(
(MethodBuilder b) =>
b
..body =
literalList(<Object?>[
for (final MapEntry<PreviewPath, List<String>>(
key: (path: String _, :Uri uri),
value: List<String> previewMethods,
)
in previews.entries) ...<Object?>[
for (final String method in previewMethods)
refer(method, uri.toString()).call(<Expression>[]),
],
]).code
..name = 'previews'
..returns = refer('List<WidgetPreview>'),
),
Method((MethodBuilder b) {
final List<Expression> previewExpressions = <Expression>[];
for (final MapEntry<PreviewPath, List<PreviewDetails>>(
key: (path: String _, :Uri uri),
value: List<PreviewDetails> previewMethods,
)
in previews.entries) {
for (final PreviewDetails preview in previewMethods) {
Expression previewWidget = refer(
preview.functionName,
uri.toString(),
).call(<Expression>[]);
if (preview.isBuilder) {
previewWidget = refer(
'Builder',
'package:flutter/widgets.dart',
).newInstance(<Expression>[], <String, Expression>{'builder': previewWidget});
}
if (preview.hasWrapper) {
previewWidget = refer(
preview.wrapper!,
preview.wrapperLibraryUri,
).call(<Expression>[previewWidget]);
}
previewExpressions.add(
refer(
'WidgetPreview',
'widget_preview.dart',
).newInstance(<Expression>[], <String, Expression>{
if (preview.name != null) PreviewDetails.kName: refer(preview.name!).expression,
...?_buildDoubleParameters(key: PreviewDetails.kHeight, property: preview.height),
...?_buildDoubleParameters(key: PreviewDetails.kWidth, property: preview.width),
...?_buildDoubleParameters(
key: PreviewDetails.kTextScaleFactor,
property: preview.textScaleFactor,
),
'child': previewWidget,
}),
);
}
}
b
..body = literalList(previewExpressions).code
..name = 'previews'
..returns = returnType;
}),
]),
);
final DartEmitter emitter = DartEmitter.scoped(useNullSafetySyntax: true);
@ -70,4 +134,16 @@ class PreviewCodeGenerator {
// TODO(bkonyi): do we want to bother with formatting this?
generatedPreviewFile.writeAsStringSync(lib.accept(emitter).toString());
}
Map<String, Expression>? _buildDoubleParameters({
required String key,
required String? property,
}) {
if (property == null) {
return null;
}
return <String, Expression>{
key: CodeExpression(Code('${double.tryParse(property) ?? property}')),
};
}
}

View File

@ -3,13 +3,16 @@
// found in the LICENSE file.
import 'dart:async';
import 'dart:collection';
import 'package:analyzer/dart/analysis/analysis_context.dart';
import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/token.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/file_system/physical_file_system.dart';
import 'package:meta/meta.dart';
import 'package:watcher/watcher.dart';
import '../base/file_system.dart';
@ -25,11 +28,14 @@ import 'preview_code_generator.dart';
typedef PreviewPath = ({String path, Uri uri});
/// Represents a set of previews for a given file.
typedef PreviewMapping = Map<PreviewPath, List<String>>;
typedef PreviewMapping = Map<PreviewPath, List<PreviewDetails>>;
extension on Token {
/// Convenience getter to identify tokens for private fields and functions.
bool get isPrivate => toString().startsWith('_');
/// Convenience getter to identify WidgetBuilder types.
bool get isWidgetBuilder => toString() == 'WidgetBuilder';
}
extension on Annotation {
@ -50,27 +56,191 @@ extension on ParsedUnitResult {
PreviewPath toPreviewPath() => (path: path, uri: uri);
}
/// Contains details related to a single preview instance.
final class PreviewDetails {
PreviewDetails({required this.functionName, required this.isBuilder});
@visibleForTesting
PreviewDetails.test({
required this.functionName,
required this.isBuilder,
String? name,
String? width,
String? height,
String? textScaleFactor,
String? wrapper,
String? wrapperLibraryUri = '',
}) : _name = name,
_width = width,
_height = height,
_textScaleFactor = textScaleFactor,
_wrapper = wrapper,
_wrapperLibraryUri = wrapperLibraryUri;
@visibleForTesting
PreviewDetails copyWith({
String? functionName,
bool? isBuilder,
String? name,
String? width,
String? height,
String? textScaleFactor,
String? wrapper,
String? wrapperLibraryUri,
}) {
return PreviewDetails.test(
functionName: functionName ?? this.functionName,
isBuilder: isBuilder ?? this.isBuilder,
name: name ?? this.name,
width: width ?? this.width,
height: height ?? this.height,
textScaleFactor: textScaleFactor ?? this.textScaleFactor,
wrapper: wrapper ?? this.wrapper,
wrapperLibraryUri: wrapperLibraryUri ?? this.wrapperLibraryUri,
);
}
static const String kName = 'name';
static const String kWidth = 'width';
static const String kHeight = 'height';
static const String kTextScaleFactor = 'textScaleFactor';
static const String kWrapper = 'wrapper';
static const String kWrapperLibraryUri = 'wrapperLibraryUrl';
/// The name of the function returning the preview.
final String functionName;
/// Set to `true` if the preview function is returning a [WidgetBuilder]
/// instead of a [Widget].
final bool isBuilder;
/// A description to be displayed alongside the preview.
///
/// If not provided, no name will be associated with the preview.
String? get name => _name;
String? _name;
/// Artificial width constraint to be applied to the [child].
///
/// If not provided, the previewed widget will attempt to set its own width
/// constraints and may result in an unbounded constraint error.
String? get width => _width;
String? _width;
/// Artificial height constraint to be applied to the [child].
///
/// If not provided, the previewed widget will attempt to set its own height
/// constraints and may result in an unbounded constraint error.
String? get height => _height;
String? _height;
/// Applies font scaling to text within the [child].
///
/// If not provided, the default text scaling factor provided by [MediaQuery]
/// will be used.
String? get textScaleFactor => _textScaleFactor;
String? _textScaleFactor;
/// The name of a tear-off used to wrap the [Widget] returned by the preview
/// function defined by [functionName].
///
/// If not provided, the [Widget] returned by [functionName] will be used by
/// the previewer directly.
String? get wrapper => _wrapper;
String? _wrapper;
/// The URI for the library containing the declaration of [wrapper].
String? get wrapperLibraryUri => _wrapperLibraryUri;
String? _wrapperLibraryUri;
bool get hasWrapper => _wrapper != null;
void _setField({required NamedExpression node}) {
final String key = node.name.label.name;
final Expression expression = node.expression;
final String source = expression.toSource();
switch (key) {
case kName:
_name = source;
case kWidth:
_width = source;
case kHeight:
_height = source;
case kTextScaleFactor:
_textScaleFactor = source;
case kWrapper:
_wrapper = source;
_wrapperLibraryUri = (node.expression as SimpleIdentifier).element!.library2!.identifier;
default:
throw StateError('Unknown Preview field "$name": $source');
}
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (identical(other, this)) {
return true;
}
return other.runtimeType == runtimeType &&
other is PreviewDetails &&
other.functionName == functionName &&
other.isBuilder == isBuilder &&
other.height == height &&
other.width == width &&
other.textScaleFactor == textScaleFactor &&
other.wrapper == wrapper &&
other.wrapperLibraryUri == wrapperLibraryUri;
}
@override
String toString() =>
'PreviewDetails(function: $functionName isBuilder: $isBuilder $kName: $name '
'$kWidth: $width $kHeight: $height $kTextScaleFactor: $textScaleFactor $kWrapper: $wrapper '
'$kWrapperLibraryUri: $wrapperLibraryUri)';
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => Object.hashAll(<Object?>[
functionName,
isBuilder,
height,
width,
textScaleFactor,
wrapper,
wrapperLibraryUri,
]);
}
class PreviewDetector {
PreviewDetector({
required this.projectRoot,
required this.fs,
required this.logger,
required this.onChangeDetected,
required this.onPubspecChangeDetected,
});
final Directory projectRoot;
final FileSystem fs;
final Logger logger;
final void Function(PreviewMapping) onChangeDetected;
final void Function() onPubspecChangeDetected;
StreamSubscription<WatchEvent>? _fileWatcher;
final PreviewDetectorMutex _mutex = PreviewDetectorMutex();
late final PreviewMapping _pathToPreviews;
late final AnalysisContextCollection collection = AnalysisContextCollection(
includedPaths: <String>[projectRoot.absolute.path],
resourceProvider: PhysicalResourceProvider.INSTANCE,
);
/// Starts listening for changes to Dart sources under [projectRoot] and returns
/// the initial [PreviewMapping] for the project.
Future<PreviewMapping> initialize(Directory projectRoot) async {
Future<PreviewMapping> initialize() async {
// Find the initial set of previews.
_pathToPreviews = findPreviewFunctions(projectRoot);
_pathToPreviews = await findPreviewFunctions(projectRoot);
final Watcher watcher = Watcher(projectRoot.path);
_fileWatcher = watcher.events.listen((WatchEvent event) async {
@ -87,7 +257,7 @@ class PreviewDetector {
return;
}
logger.printStatus('Detected change in $eventPath.');
final PreviewMapping filePreviewsMapping = findPreviewFunctions(
final PreviewMapping filePreviewsMapping = await findPreviewFunctions(
fs.file(Uri.file(event.path)),
);
final bool hasExistingPreviews =
@ -102,13 +272,13 @@ class PreviewDetector {
}
if (filePreviewsMapping.isNotEmpty) {
// The set of previews has changed, but there are still previews in the file.
final MapEntry<PreviewPath, List<String>>(
final MapEntry<PreviewPath, List<PreviewDetails>>(
key: PreviewPath location,
value: List<String> filePreviews,
value: List<PreviewDetails> filePreviews,
) = filePreviewsMapping.entries.first;
logger.printStatus('Updated previews for ${location.uri}: $filePreviews');
if (filePreviews.isNotEmpty) {
final List<String>? currentPreviewsForFile = _pathToPreviews[location];
final List<PreviewDetails>? currentPreviewsForFile = _pathToPreviews[location];
if (filePreviews != currentPreviewsForFile) {
_pathToPreviews[location] = filePreviews;
}
@ -127,63 +297,201 @@ class PreviewDetector {
}
Future<void> dispose() async {
await _fileWatcher?.cancel();
// Guard disposal behind a mutex to make sure the analyzer has finished
// processing the latest file updates to avoid throwing an exception.
await _mutex.runGuarded(() async {
await _fileWatcher?.cancel();
await collection.dispose();
});
}
/// Search for functions annotated with `@Preview` in the current project.
PreviewMapping findPreviewFunctions(FileSystemEntity entity) {
final AnalysisContextCollection collection = AnalysisContextCollection(
includedPaths: <String>[entity.absolute.path],
resourceProvider: PhysicalResourceProvider.INSTANCE,
);
Future<PreviewMapping> findPreviewFunctions(FileSystemEntity entity) async {
final PreviewMapping previews = PreviewMapping();
for (final AnalysisContext context in collection.contexts) {
logger.printStatus('Finding previews in ${context.contextRoot.root.path}...');
// Only process one FileSystemEntity at a time so we don't invalidate an AnalysisSession that's
// in use when we call context.changeFile(...).
await _mutex.runGuarded(() async {
// TODO(bkonyi): this can probably be replaced by a call to collection.contextFor(...),
// but we need to figure out the right path format for Windows.
for (final AnalysisContext context in collection.contexts) {
logger.printStatus('Finding previews in ${entity.path}...');
for (final String filePath in context.contextRoot.analyzedFiles()) {
logger.printTrace('Checking file: $filePath');
if (!filePath.isDartFile) {
continue;
// If we're processing a single file, it means the file watcher detected a
// change in a Dart source. We need to notify the analyzer that this file
// has changed so it can reanalyze the file.
if (entity is File) {
context.changeFile(entity.path);
await context.applyPendingFileChanges();
}
final SomeParsedLibraryResult lib = context.currentSession.getParsedLibrary(filePath);
if (lib is ParsedLibraryResult) {
for (final ParsedUnitResult libUnit in lib.units) {
final List<String> previewEntries = previews[libUnit.toPreviewPath()] ?? <String>[];
for (final CompilationUnitMember entity in libUnit.unit.declarations) {
if (entity is FunctionDeclaration && !entity.name.isPrivate) {
bool foundPreview = false;
for (final Annotation annotation in entity.metadata) {
if (annotation.isPreview) {
// What happens if the annotation is applied multiple times?
foundPreview = true;
break;
}
}
if (foundPreview) {
logger.printStatus('Found preview at:');
logger.printStatus('File path: ${libUnit.uri}');
logger.printStatus('Preview function: ${entity.name}');
logger.printStatus('');
previewEntries.add(entity.name.toString());
}
for (final String filePath in context.contextRoot.analyzedFiles()) {
logger.printTrace('Checking file: $filePath');
if (!filePath.isDartFile || !filePath.startsWith(entity.path)) {
logger.printTrace('Skipping $filePath');
continue;
}
final SomeResolvedLibraryResult lib = await context.currentSession.getResolvedLibrary(
filePath,
);
// TODO(bkonyi): ensure this can handle part files.
if (lib is ResolvedLibraryResult) {
for (final ResolvedUnitResult libUnit in lib.units) {
final List<PreviewDetails> previewEntries =
previews[libUnit.toPreviewPath()] ?? <PreviewDetails>[];
final PreviewVisitor visitor = PreviewVisitor();
libUnit.unit.visitChildren(visitor);
previewEntries.addAll(visitor.previewEntries);
if (previewEntries.isNotEmpty) {
previews[libUnit.toPreviewPath()] = previewEntries;
}
}
if (previewEntries.isNotEmpty) {
previews[libUnit.toPreviewPath()] = previewEntries;
}
} else {
logger.printWarning('Unknown library type at $filePath: $lib');
}
} else {
logger.printWarning('Unknown library type at $filePath: $lib');
}
}
}
final int previewCount = previews.values.fold<int>(
0,
(int count, List<String> value) => count + value.length,
);
logger.printStatus('Found $previewCount ${pluralize('preview', previewCount)}.');
final int previewCount = previews.values.fold<int>(
0,
(int count, List<PreviewDetails> value) => count + value.length,
);
logger.printStatus('Found $previewCount ${pluralize('preview', previewCount)}.');
});
return previews;
}
}
/// Visitor which detects previews and extracts [PreviewDetails] for later code
/// generation.
// TODO(bkonyi): this visitor needs better error detection to identify invalid
// previews and report them to the previewer without causing the entire
// environment to shutdown or fail to render valid previews.
class PreviewVisitor extends RecursiveAstVisitor<void> {
final List<PreviewDetails> previewEntries = <PreviewDetails>[];
FunctionDeclaration? _currentFunction;
ConstructorDeclaration? _currentConstructor;
MethodDeclaration? _currentMethod;
PreviewDetails? _currentPreview;
/// Handles previews defined on top-level functions.
@override
void visitFunctionDeclaration(FunctionDeclaration node) {
assert(_currentFunction == null);
if (node.name.isPrivate) {
return;
}
final TypeAnnotation? returnType = node.returnType;
if (returnType == null || returnType.question != null) {
return;
}
_scopedVisitChildren(node, (FunctionDeclaration? node) => _currentFunction = node);
}
/// Handles previews defined on constructors.
@override
void visitConstructorDeclaration(ConstructorDeclaration node) {
_scopedVisitChildren(node, (ConstructorDeclaration? node) => _currentConstructor = node);
}
/// Handles previews defined on static methods within classes.
@override
void visitMethodDeclaration(MethodDeclaration node) {
if (!node.isStatic) {
return;
}
_scopedVisitChildren(node, (MethodDeclaration? node) => _currentMethod = node);
}
@override
void visitAnnotation(Annotation node) {
if (!node.isPreview) {
return;
}
assert(_currentFunction != null || _currentConstructor != null || _currentMethod != null);
if (_currentFunction != null) {
final NamedType returnType = _currentFunction!.returnType! as NamedType;
_currentPreview = PreviewDetails(
functionName: _currentFunction!.name.toString(),
isBuilder: returnType.name2.isWidgetBuilder,
);
} else if (_currentConstructor != null) {
final SimpleIdentifier returnType = _currentConstructor!.returnType as SimpleIdentifier;
final Token? name = _currentConstructor!.name;
_currentPreview = PreviewDetails(
functionName: '$returnType${name == null ? '' : '.$name'}',
isBuilder: false,
);
} else if (_currentMethod != null) {
final NamedType returnType = _currentMethod!.returnType! as NamedType;
final ClassDeclaration parentClass = _currentMethod!.parent! as ClassDeclaration;
_currentPreview = PreviewDetails(
functionName: '${parentClass.name}.${_currentMethod!.name}',
isBuilder: returnType.name2.isWidgetBuilder,
);
}
node.visitChildren(this);
previewEntries.add(_currentPreview!);
_currentPreview = null;
}
@override
void visitNamedExpression(NamedExpression node) {
// Extracts named properties from the @Preview annotation.
_currentPreview?._setField(node: node);
}
void _scopedVisitChildren<T extends AstNode>(T node, void Function(T?) setter) {
setter(node);
node.visitChildren(this);
setter(null);
}
}
/// Used to protect global state accessed in blocks containing calls to
/// asynchronous methods.
///
/// Originally from DDS:
/// https://github.com/dart-lang/sdk/blob/3fe58da3cfe2c03fb9ee691a7a4709082fad3e73/pkg/dds/lib/src/utils/mutex.dart
class PreviewDetectorMutex {
/// Executes a block of code containing asynchronous calls atomically.
///
/// If no other asynchronous context is currently executing within
/// [criticalSection], it will immediately be called. Otherwise, the
/// caller will be suspended and entered into a queue to be resumed once the
/// lock is released.
Future<T> runGuarded<T>(FutureOr<T> Function() criticalSection) async {
try {
await _acquireLock();
return await criticalSection();
} finally {
_releaseLock();
}
}
Future<void> _acquireLock() async {
if (!_locked) {
_locked = true;
return;
}
final Completer<void> request = Completer<void>();
_outstandingRequests.add(request);
await request.future;
}
void _releaseLock() {
if (_outstandingRequests.isNotEmpty) {
final Completer<void> request = _outstandingRequests.removeFirst();
request.complete();
return;
}
// Only release the lock if no other requests are pending to prevent races
// between the next request from the queue to be handled and incoming
// requests.
_locked = false;
}
bool _locked = false;
final Queue<Completer<void>> _outstandingRequests = Queue<Completer<void>>();
}

View File

@ -353,6 +353,7 @@
"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.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",

View File

@ -3,5 +3,6 @@
// found in the LICENSE file.
import 'package:flutter/widgets.dart';
import 'widget_preview.dart';
List<WidgetPreview> previews() => <WidgetPreview>[];

View File

@ -0,0 +1,53 @@
// 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';
/// Wraps a [Widget], initializing various state and properties to allow for
/// previewing of the [Widget] in the widget previewer.
///
/// WARNING: This interface is not stable and **will change**.
///
/// See also:
///
/// * [Preview], an annotation class used to mark functions returning widget
/// previews.
// TODO(bkonyi): link to actual documentation when available.
class WidgetPreview {
/// Wraps [child] in a [WidgetPreview] instance that applies some set of
/// properties.
const WidgetPreview({
required this.child,
this.name,
this.width,
this.height,
this.textScaleFactor,
});
/// A description to be displayed alongside the preview.
///
/// If not provided, no name will be associated with the preview.
final String? name;
/// The [Widget] to be rendered in the preview.
final Widget child;
/// Artificial width constraint to be applied to the [child].
///
/// If not provided, the previewed widget will attempt to set its own width
/// constraints and may result in an unbounded constraint error.
final double? width;
/// Artificial height constraint to be applied to the [child].
///
/// If not provided, the previewed widget will attempt to set its own height
/// constraints and may result in an unbounded constraint error.
final double? height;
/// Applies font scaling to text within the [child].
///
/// If not provided, the default text scaling factor provided by [MediaQuery]
/// will be used.
final double? textScaleFactor;
}

View File

@ -10,6 +10,7 @@ import 'package:flutter/services.dart';
import 'controls.dart';
import 'generated_preview.dart';
import 'utils.dart';
import 'widget_preview.dart';
class WidgetPreviewWidget extends StatefulWidget {
const WidgetPreviewWidget({

View File

@ -70,6 +70,7 @@ void main() {
projectRoot = createBasicProjectStructure(fs);
logger = BufferLogger.test();
previewDetector = PreviewDetector(
projectRoot: projectRoot,
logger: logger,
fs: fs,
onChangeDetected: onChangeDetectedRoot,
@ -89,15 +90,48 @@ void main() {
addPreviewContainingFile(projectRoot, <String>['src', 'bar.dart']),
];
addNonPreviewContainingFile(projectRoot, <String>['baz.dart']);
final PreviewMapping mapping = previewDetector.findPreviewFunctions(projectRoot);
final PreviewMapping mapping = await previewDetector.findPreviewFunctions(projectRoot);
expect(mapping.keys.toSet(), previewFiles.toSet());
});
testUsingContext('can detect previews in updated files', () async {
final List<PreviewDetails> expectedPreviewDetails = <PreviewDetails>[
PreviewDetails.test(functionName: 'previews', isBuilder: false, name: 'Top-level preview'),
PreviewDetails.test(
functionName: 'builderPreview',
isBuilder: true,
name: 'Builder preview',
),
PreviewDetails.test(
functionName: 'attributesPreview',
isBuilder: false,
name: 'Attributes preview',
width: '100.0',
height: '100',
textScaleFactor: '2',
wrapper: 'testWrapper',
),
PreviewDetails.test(
functionName: 'MyWidget.preview',
isBuilder: false,
name: 'Constructor preview',
),
PreviewDetails.test(
functionName: 'MyWidget.factoryPreview',
isBuilder: false,
name: 'Factory constructor preview',
),
PreviewDetails.test(
functionName: 'MyWidget.previewStatic',
isBuilder: false,
name: 'Static preview',
),
];
// Create two files with existing previews and one without.
final PreviewMapping expectedInitialMapping = <PreviewPath, List<String>>{
addPreviewContainingFile(projectRoot, <String>['foo.dart']): <String>['previews'],
addPreviewContainingFile(projectRoot, <String>['src', 'bar.dart']): <String>['previews'],
final PreviewMapping expectedInitialMapping = <PreviewPath, List<PreviewDetails>>{
addPreviewContainingFile(projectRoot, <String>['foo.dart']): expectedPreviewDetails,
addPreviewContainingFile(projectRoot, <String>['src', 'bar.dart']): expectedPreviewDetails,
};
final PreviewPath nonPreviewContainingFile = addNonPreviewContainingFile(
projectRoot,
@ -107,15 +141,15 @@ void main() {
Completer<void> completer = Completer<void>();
onChangeDetected = (PreviewMapping updated) {
// The new preview in baz.dart should be included in the preview mapping.
expect(updated, <PreviewPath, List<String>>{
expect(stripNonDeterministicFields(updated), <PreviewPath, List<PreviewDetails>>{
...expectedInitialMapping,
nonPreviewContainingFile: <String>['previews'],
nonPreviewContainingFile: expectedPreviewDetails,
});
completer.complete();
};
// Initialize the file watcher.
final PreviewMapping initialPreviews = await previewDetector.initialize(projectRoot);
expect(initialPreviews, expectedInitialMapping);
final PreviewMapping initialPreviews = await previewDetector.initialize();
expect(stripNonDeterministicFields(initialPreviews), expectedInitialMapping);
// Update the file without an existing preview to include a preview and ensure it triggers
// the preview detector.
@ -125,7 +159,7 @@ void main() {
completer = Completer<void>();
onChangeDetected = (PreviewMapping updated) {
// The removed preview in baz.dart should not longer be included in the preview mapping.
expect(updated, expectedInitialMapping);
expect(stripNonDeterministicFields(updated), expectedInitialMapping);
completer.complete();
};
@ -135,6 +169,65 @@ void main() {
await completer.future;
});
testUsingContext('can detect previews in newly added files', () async {
final List<PreviewDetails> expectedPreviewDetails = <PreviewDetails>[
PreviewDetails.test(functionName: 'previews', isBuilder: false, name: 'Top-level preview'),
PreviewDetails.test(
functionName: 'builderPreview',
isBuilder: true,
name: 'Builder preview',
),
PreviewDetails.test(
functionName: 'attributesPreview',
isBuilder: false,
name: 'Attributes preview',
width: '100.0',
height: '100',
textScaleFactor: '2',
wrapper: 'testWrapper',
),
PreviewDetails.test(
functionName: 'MyWidget.preview',
isBuilder: false,
name: 'Constructor preview',
),
PreviewDetails.test(
functionName: 'MyWidget.factoryPreview',
isBuilder: false,
name: 'Factory constructor preview',
),
PreviewDetails.test(
functionName: 'MyWidget.previewStatic',
isBuilder: false,
name: 'Static preview',
),
];
// The initial mapping should be empty as there's no files containing previews.
final PreviewMapping expectedInitialMapping = <PreviewPath, List<PreviewDetails>>{};
final Completer<void> completer = Completer<void>();
late final PreviewPath previewContainingFilePath;
onChangeDetected = (PreviewMapping updated) {
if (completer.isCompleted) {
return;
}
// The new previews in baz.dart should be included in the preview mapping.
expect(stripNonDeterministicFields(updated), <PreviewPath, List<PreviewDetails>>{
previewContainingFilePath: expectedPreviewDetails,
});
completer.complete();
};
// Initialize the file watcher.
final PreviewMapping initialPreviews = await previewDetector.initialize();
expect(stripNonDeterministicFields(initialPreviews), expectedInitialMapping);
// Create baz.dart, which contains previews.
previewContainingFilePath = addPreviewContainingFile(projectRoot, <String>['baz.dart']);
await completer.future;
});
testUsingContext('can detect changes in the pubspec.yaml', () async {
// Create an initial pubspec.
populatePubspec(projectRoot, 'abc');
@ -144,7 +237,7 @@ void main() {
completer.complete();
};
// Initialize the file watcher.
final PreviewMapping initialPreviews = await previewDetector.initialize(projectRoot);
final PreviewMapping initialPreviews = await previewDetector.initialize();
expect(initialPreviews, isEmpty);
// Change the contents of the pubspec and verify the callback is invoked.
@ -154,10 +247,55 @@ void main() {
});
}
/// Creates a copy of [mapping] with [PreviewDetails] entries that have non-deterministic values
/// that differ per run (e.g., temporary file paths).
PreviewMapping stripNonDeterministicFields(PreviewMapping mapping) {
return mapping.map<PreviewPath, List<PreviewDetails>>((
PreviewPath key,
List<PreviewDetails> value,
) {
return MapEntry<PreviewPath, List<PreviewDetails>>(
key,
value.map((PreviewDetails details) => details.copyWith(wrapperLibraryUri: '')).toList(),
);
});
}
const String previewContainingFileContents = '''
@Preview()
// This isn't necessarily valid code. We're just looking for the annotation
WidgetPreview previews() => WidgetPreview();
@Preview(name: 'Top-level preview')
Widget previews() => Text('Foo');
@Preview(name: 'Builder preview')
WidgetBuilder builderPreview() {
return (BuildContext context) {
return Text('Builder');
};
}
Widget testWrapper(Widget child) {
return child;
}
@Preview(name: 'Attributes preview', height: 100, width: 100.0, textScaleFactor: 2, wrapper: testWrapper)
Widget attributesPreview() {
return Text('Attributes');
}
class MyWidget extends StatelessWidget {
@Preview(name: 'Constructor preview')
MyWidget.preview();
@Preview(name: 'Factory constructor preview')
MyWidget.factoryPreview() => MyWidget.preview();
@Preview(name: 'Static preview')
static Widget previewStatic() => Text('Static');
@override
Widget build(BuildContext context) {
return Text('MyWidget');
}
}
''';
const String nonPreviewContainingFileContents = '''

View File

@ -190,12 +190,12 @@ void main() {
);
const String samplePreviewFile = '''
@Preview()
WidgetPreview preview() => const WidgetPreview(child: Text('Foo'));''';
@Preview(name: 'preview')
Widget preview() => Text('Foo');''';
const String expectedGeneratedFileContents = '''
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:flutter_project/foo.dart' as _i1;import 'package:flutter/widgets.dart';List<WidgetPreview> previews() => [_i1.preview()];''';
import 'widget_preview.dart' as _i1;import 'package:flutter_project/foo.dart' as _i2;List<_i1.WidgetPreview> previews() => [_i1.WidgetPreview(name: 'preview', child: _i2.preview(), )];''';
testUsingContext(
'start finds existing previews and injects them into ${PreviewCodeGenerator.generatedPreviewFilePath}',

View File

@ -40,28 +40,45 @@ void main() {
expect(generatedPreviewFile, isNot(exists));
// Populate the generated preview file.
codeGenerator.populatePreviewsInGeneratedPreviewScaffold(<PreviewPath, List<String>>{
(path: '', uri: Uri(path: 'foo.dart')): <String>['preview'],
(path: '', uri: Uri(path: 'src/bar.dart')): <String>['barPreview1', 'barPreview2'],
});
codeGenerator.populatePreviewsInGeneratedPreviewScaffold(
<PreviewPath, List<PreviewDetails>>{
(path: '', uri: Uri(path: 'foo.dart')): <PreviewDetails>[
PreviewDetails(functionName: 'preview', isBuilder: false),
],
(path: '', uri: Uri(path: 'src/bar.dart')): <PreviewDetails>[
PreviewDetails(functionName: 'barPreview1', isBuilder: false),
PreviewDetails(functionName: 'barPreview2', isBuilder: false),
PreviewDetails.test(
functionName: 'barPreview3',
isBuilder: true,
name: 'Foo',
width: '123',
height: '456',
textScaleFactor: '50',
wrapper: 'wrapper',
wrapperLibraryUri: 'wrapper.dart',
),
],
},
);
expect(generatedPreviewFile, exists);
// Check that the generated file contains:
// - An import of the widget preview library
// - Prefixed imports for both 'foo.dart' and 'src/bar.dart'
// - A top-level function 'List<WidgetPreview> previews()'
// - A returned list containing function calls to 'preview()' from 'foo.dart' and 'barPreview1()'
// and 'barPreview2()' from 'src/bar.dart'
// - A returned list containing function calls to 'preview()' from 'foo.dart' and
// 'barPreview1()', 'barPreview2()', and 'barPreview3()' from 'src/bar.dart'
//
// 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:flutter/widgets.dart';List<WidgetPreview> previews() => [_i1.preview(), _i2.barPreview1(), _i2.barPreview2(), ];''';
import 'widget_preview.dart' as _i1;import 'foo.dart' as _i2;import 'src/bar.dart' as _i3;import 'wrapper.dart' as _i4;import 'package:flutter/widgets.dart' as _i5;List<_i1.WidgetPreview> previews() => [_i1.WidgetPreview(child: _i2.preview()), _i1.WidgetPreview(child: _i3.barPreview1()), _i1.WidgetPreview(child: _i3.barPreview2()), _i1.WidgetPreview(name: Foo, height: 456.0, width: 123.0, textScaleFactor: 50.0, child: _i4.wrapper(_i5.Builder(builder: _i3.barPreview3())), ), ];''';
expect(generatedPreviewFile.readAsStringSync(), expectedGeneratedPreviewFileContents);
// Regenerate the generated file with no previews.
codeGenerator.populatePreviewsInGeneratedPreviewScaffold(
const <PreviewPath, List<String>>{},
const <PreviewPath, List<PreviewDetails>>{},
);
expect(generatedPreviewFile, exists);
@ -69,7 +86,8 @@ import 'foo.dart' as _i1;import 'src/bar.dart' as _i2;import 'package:flutter/wi
// - An import of the widget preview library
// - A top-level function 'List<WidgetPreview> previews()' that returns an empty list.
const String emptyGeneratedPreviewFileContents = '''
import 'package:flutter/widgets.dart';List<WidgetPreview> previews() => [];''';
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'widget_preview.dart' as _i1;List<_i1.WidgetPreview> previews() => [];''';
expect(generatedPreviewFile.readAsStringSync(), emptyGeneratedPreviewFileContents);
},
);