diff --git a/packages/flutter_markdown/example/lib/demos/markdown_body_shrink_wrap_demo.dart b/packages/flutter_markdown/example/lib/demos/markdown_body_shrink_wrap_demo.dart index f48d6106cb33..057e44015ebb 100644 --- a/packages/flutter_markdown/example/lib/demos/markdown_body_shrink_wrap_demo.dart +++ b/packages/flutter_markdown/example/lib/demos/markdown_body_shrink_wrap_demo.dart @@ -17,16 +17,16 @@ const String _data = ''' '''; const String _notes = ''' -# Shrink wrap demo +# Shrink wrap demo --- ## Overview This example demonstrates how `MarkdownBody`'s `shrinkWrap` property works. -- If `shrinkWrap` is `true`, `MarkdownBody` will take the minimum height that +- If `shrinkWrap` is `true`, `MarkdownBody` will take the minimum height that wraps its content. -- If `shrinkWrap` is `false`, `MarkdownBody` will expand to the maximum allowed +- If `shrinkWrap` is `false`, `MarkdownBody` will expand to the maximum allowed height. '''; diff --git a/packages/rfw/CHANGELOG.md b/packages/rfw/CHANGELOG.md index cbee9e29c2e3..d9f31879a65a 100644 --- a/packages/rfw/CHANGELOG.md +++ b/packages/rfw/CHANGELOG.md @@ -1,6 +1,7 @@ ## 1.0.10 -* Fixes stale ignore: prefer_const_constructors. +* More documentation in the README.md file! +* Fixes stale ignore: `prefer_const_constructors`. * Updates minimum supported SDK version to Flutter 3.10/Dart 3.0. * Changes package internals to avoid explicit `as Uint8List` downcast. diff --git a/packages/rfw/README.md b/packages/rfw/README.md index 289411b5fdec..8669e16ca5f9 100644 --- a/packages/rfw/README.md +++ b/packages/rfw/README.md @@ -3,19 +3,9 @@ This package provides a mechanism for rendering widgets based on declarative UI descriptions that can be obtained at runtime. -### Status +## Status -This package was created without a clear idea of what problem it solves, -in order to see if it was interesting to people using Flutter and to -learn more about the problem space. - -So far it has received only minimal feedback, which either means it's perfectly -addressing the needs that people have from it or that it is completely out -of touch with what people want. We would love to know which, so if you consider -this package, please describe your experiences, positive or negative, on -[issue 90218](https://github.com/flutter/flutter/issues/90218). This will help us -determine whether to spend more effort on this package, or whether we should look at -creating other packages. +This package is relatively stable. We plan to keep the format and supported widget set backwards compatible, so that once a file works, it will keep working. _However_, this is best-effort @@ -23,14 +13,134 @@ only. To guarantee that files keep working as you expect, submit tests to this package (e.g. the binary file and the corresponding screenshot, as a golden test). -## Getting started +The set of widgets supported by this package is somewhat arbitrary. +PRs that add new widgets from Flutter's default widget libraries +(`widgets`, `material`, and`'cupertino`) are welcome. + +There are some known theoretical performance limitations with the +package's current implementation, but so far nobody has reported +experiencing them in production. Please [file +issues](https://github.com/flutter/flutter/issues/new?labels=p:%20rfw,package,P2) +if you run into them. + +## Feedback + +We would love to hear your experiences using this package, whether +positive or negative. In particular, stories of uses of this package +in production would be very interesting. Please add comments to [issue +90218](https://github.com/flutter/flutter/issues/90218). + +## Limitations + +Once you realize that you can ship UI (and maybe logic, e.g. using +Wasm; see the example below) you will slowly be tempted to move your +whole application to this model. + +This won't work. + +Flutter proper lets you create widgets for compelling UIs with +gestures and animations and so forth. With RFW you can use those +widgets, but it doesn't let you _create_ those widgets. + +For example, you don't want to use RFW to create a UI that involves +page transitions. You don't want to use RFW to create new widgets that +involve drag and drop. You don't want to use RFW to create widgets +that involve custom painters. + +Rather, RFW is best suited for interfaces made out of prebuilt +components. For example, a database front-end could use this to +describe bespoke UIs for editing different types of objects in the +database. Message-of-the-day announcements could be built using this +mechanism. Search interfaces could use this mechanism for rich result +cards. + +RFW is well-suited for describing custom UIs from a potentially +infinite set of UIs that could not possibly have been known when the +application was created. On the other hand, updating the application's +look and feel, changing how navigation works in an application, or +adding new features, are all changes that are best made in Flutter +itself, creating a new application and shipping that through normal +update channels. + +## Using Remote Flutter Widgets + + + +### Introduction + +The Remote Flutter Widgets (RFW) package combines widget descriptions +obtained at runtime, data obtained at runtime, some predefined widgets +provided at compile time, and some app logic provided at compile time +(possibly combined with other packages to enable new logic to be +obtained at runtime), to generate arbitrary widget trees at runtime. + +The widget descriptions obtained at runtime (e.g. over the network) +are called _remote widget libraries_. These are normally transported +in a binary format with the file extension `.rfw`. They can be written +in a text format (file extension `.rfwtxt`), and either used directly +or converted into the binary format for transport. The `rfw` package +provides APIs for parsing and encoding these formats. The +[parts of the package](https://pub.dev/documentation/rfw/latest/formats/formats-library.html) +that only deal with these formats can be imported directly and have no +dependency on Flutter's `dart:ui` library, which means they can be +used on the server or in command-line applications. + +The data obtained at runtime is known as _configuration data_ and is +represented by `DynamicContent` objects. It uses a data structure +similar to JSON (but it distinguishes `int` and `double` and does not +support `null`). The `rfw` package provides both binary and text +formats to carry this data; JSON can also be used directly (with some +caution), and the data can be created directly in Dart. This is +discussed in more detail in the +[DynamicContent](https://pub.dev/documentation/rfw/latest/rfw/DynamicContent-class.html) +API documentation. + +Remote widget libraries can use the configuration data to define how +the widgets are built. + +Remote widget libraries all eventually bottom out in the predefined +widgets that are compiled into the application. These are called +_local widget libraries_. The `rfw` package ships with two local +widget libraries, the [core +widgets](https://pub.dev/documentation/rfw/latest/rfw/createCoreWidgets.html) +from the `widgets` library (such as `Text`, `Center`, `Row`, etc), and +some of the [material +widgets](https://pub.dev/documentation/rfw/latest/rfw/createMaterialWidgets.html). + +Programs can define their own local widget libraries, to provide more +widgets for remote widget libraries to use. + +These components are combined using a +[`RemoteWidget`](https://pub.dev/documentation/rfw/latest/rfw/RemoteWidget-class.html) +widget and a +[`Runtime`](https://pub.dev/documentation/rfw/latest/rfw/Runtime-class.html) +object. + +The remote widget libraries can specify _events_ that trigger in +response to callbacks. For example, the `OutlinedButton` widget +defined in the +[Material](https://pub.dev/documentation/rfw/latest/rfw/createMaterialWidgets.html) +local widget library has an `onPressed` property which the remote +widget library can define as triggering an event. Events can contain +data (either hardcoded or obtained from the configuration data). + +These events result in a callback on the `RemoteWidget` being invoked. +Events can either have hardcoded results, or the `rfw` package can be +combined with other packages such as +[`wasm_run_flutter`](https://pub.dev/packages/wasm_run_flutter) so +that events trigger code obtained at runtime. That code typically +changes the configuration data, resulting in an update to the rendered +widgets. + +_See also: [API documentation](https://pub.dev/documentation/rfw/latest/rfw/rfw-library.html)_ + +### Getting started A Flutter application can render remote widgets using the `RemoteWidget` widget, as in the following snippet: + ```dart -// see example/hello - class Example extends StatefulWidget { const Example({Key? key}) : super(key: key); @@ -43,7 +153,7 @@ class _ExampleState extends State { final DynamicContent _data = DynamicContent(); // Normally this would be obtained dynamically, but for this example - // we hard-code the "remote" widgets into the app. + // we hardcode the "remote" widgets into the app. // // Also, normally we would decode this with [decodeLibraryBlob] rather than // parsing the text version using [parseLibraryFile]. However, to make it @@ -62,15 +172,18 @@ class _ExampleState extends State { ), ); '''); - + @override void initState() { super.initState(); + // Provide a local widget library. _runtime.update(const LibraryName(['core', 'widgets']), createCoreWidgets()); + // Specify a remote widget library. _runtime.update(const LibraryName(['main']), _remoteWidgets); + // Specify the configuration data. _data.update('greet', { 'name': 'World' }); } - + @override Widget build(BuildContext context) { return RemoteWidget( @@ -87,70 +200,74 @@ class _ExampleState extends State { } ``` -In this example, the "remote" widgets are hard-coded into the application. - -## Usage +In this example, the "remote" widgets are hardcoded into the +application (`_remoteWidgets`), the configuration data is hardcoded +and unchanging (`_data`), and the event handler merely prints a +message to the console. In typical usage, the remote widgets come from a server at runtime, either through HTTP or some other network transport. Separately, the -`DynamicContent` data is updated, either from the server or based on -local data. +`DynamicContent` data would be updated, either from the server or +based on local data. + +Similarly, events that are signalled by the user's interactions with +the remote widgets (`RemoteWidget.onEvent`) would typically be sent to +the server for the server to update the data, or would cause the data +to be updated directly, on the user's device, according to some +predefined logic. It is recommended that servers send binary data, decoded using `decodeLibraryBlob` and `decodeDataBlob`, when providing updates for the remote widget libraries and data. -Events (`onEvent`) are signalled by the user's interactions with the -remote widgets. The client is responsible for handling them, either by -sending the data to the server for the server to update the data, or -directly, on the user's device. +### Applying these concepts to typical use cases -## Limitations - -Once you realize that you can ship UI (and maybe logic, e.g. using -Wasm; see the example below) you will slowly be tempted to move your -whole application to this model. +#### Message of the day, advertising, announcements -This won't work. +When `rfw` is used for displaying content that is largely static in +presentation and updated only occasionally, the simplest approach is +to encode everything into the remote widget library, download that to +the client, and render it, with only minimal data provided in the +configuration data (e.g. the user's dark mode preference, their +username, the current date or time) and with a few predefined events +(such as one to signal the message should be closed and another to +signal the user checking a "do not show this again" checkbox, or +similar). -Flutter proper lets you create widgets for compelling UIs with -gestures and animations and so forth. With RFW you can use those -widgets, but it doesn't let you _create_ those widgets. +#### Dynamic data editors -For example, you don't want to use RFW to create a UI that involves -page transitions. You don't want to use RFW to create new widgets that -involve drag and drop. You don't want to use RFW to create widgets -that involve custom painters. +A more elaborate use case might involve remote widget libraries being +used to describe the UI for editing structured data in a database. In +this case, the data may be more important, containing the current data +being edited, and the events may signal to the application how to +update the data on the backend. -Rather, RFW is best suited for interfaces made out of prebuilt -components. For example, a database front-end could use this to -describe bespoke UIs for editing different types of objects in the -database. Message-of-the-day announcements could be built using this -mechanism. Search interfaces could use this mechanism for rich result -cards. +#### Search results -RFW is well-suited for describing custom UIs from a potentially -infinite set of UIs that could not possibly have been known when the -application was created. On the other hand, updating the application's -look and feel, changing how navigation works in an application, or -adding new features, are all changes that are best made in Flutter -itself, creating a new application and shipping that through normal -update channels. +A general search engine could have dedicated remote widgets defined +for different kinds of results, allowing the data to be formatted and +made interactive in ways that are specific to the query and in ways +that could not have been predicted when the application was created. +For example, new kinds of search results for current events could be +created on the fly and sent to the client without needing to update +the client application. -## Developing new local widget libraries +### Developing new local widget libraries A "local" widget library is one that describes the built-in widgets that your "remote" widgets are built out of. The RFW package comes -with some preprepared libraries, available through [createCoreWidgets] -and [createMaterialWidgets]. You can also create your own. +with some preprepared libraries, available through +[createCoreWidgets](https://pub.dev/documentation/rfw/latest/rfw/createCoreWidgets.html) +and +[createMaterialWidgets](https://pub.dev/documentation/rfw/latest/rfw/createMaterialWidgets.html). +You can also create your own. When developing new local widget libraries, it is convenient to hook into the `reassemble` method to update the local widgets. That way, changes can be seen in real time when hot reloading. + ```dart -// see example/local - class Example extends StatefulWidget { const Example({Key? key}) : super(key: key); @@ -161,7 +278,7 @@ class Example extends StatefulWidget { class _ExampleState extends State { final Runtime _runtime = Runtime(); final DynamicContent _data = DynamicContent(); - + @override void initState() { super.initState(); @@ -170,16 +287,19 @@ class _ExampleState extends State { @override void reassemble() { + // This function causes the Runtime to be updated any time the app is + // hot reloaded, so that changes to _createLocalWidgets can be seen + // during development. This function has no effect in production. super.reassemble(); _update(); } static WidgetLibrary _createLocalWidgets() => LocalWidgetLibrary({ 'GreenBox': (BuildContext context, DataSource source) { - return Container(color: const Color(0xFF002211), child: source.child(['child'])); + return Container(color: const Color(0xFF002211), child: source.child(['child'])); }, 'Hello': (BuildContext context, DataSource source) { - return Center(child: Text('Hello, ${source.v(["name"])}!', textDirection: TextDirection.ltr)); + return Center(child: Text('Hello, ${source.v(["name"])}!', textDirection: TextDirection.ltr)); }, }); @@ -208,7 +328,569 @@ class _ExampleState extends State { } ``` -## Fetching remote widget libraries remotely +Widgets in local widget libraries are represented by closures that are +invoked by the runtime whenever a local widget is referenced. + +The closure uses the +[LocalWidgetBuilder](https://pub.dev/documentation/rfw/latest/rfw/LocalWidgetBuilder.html) +signature. Like any builder in Flutter, it takes a +[`BuildContext`](https://api.flutter.dev/flutter/widgets/BuildContext-class.html), +which can be used to look up inherited widgets. + +> For example, widgets that need the current text direction might +> defer to `Directionality.of(context)`, with the given `BuildContext` +> as the context argument. + +The other argument is a [`DataSource`](https://pub.dev/documentation/rfw/latest/rfw/DataSource-class.html). +This gives access to the arguments that were provided to the widget in +the remote widget library. + +For example, consider the example above, where the remote widget library is: + + +```rfwtxt +import local; +widget root = GreenBox( + child: Hello(name: "World"), +); +``` + +The `GreenBox` widget is invoked with one argument (`child`), and the +`Hello` widget is invoked with one argument (`name`). + +In the definitions of `GreenBox` and `Hello`, the data source is used +to pull out these arguments. + +### Obtaining arguments from the `DataSource` + +The arguments are a tree of maps and lists with leaves that are Dart +scalar values (`int`, `double`, `bool`, or `String`), further widgets, +or event handlers. + +#### Scalars + +Here is an example of a more elaborate widget argument: + + +```rfwtxt +widget fruit = Foo( + bar: { quux: [ 'apple', 'banana', 'cherry' ] }, +); +``` + +To obtain a scalar value from the arguments, the +[DataSource.v](https://pub.dev/documentation/rfw/latest/rfw/DataSource/v.html) +method is used. This method takes a list of keys (strings or integers) +that denote the path to scalar in question. For instance, to obtain +"cherry" from the example above, the keys would be `bar`, `quux`, and +2, as in: + + +```dart + 'Foo': (BuildContext context, DataSource source) { + return Text(source.v(['bar', 'quux', 2])!); + }, +``` + +The `v` method is generic, with a type argument that specifies the +expected type (one of `int`, `double`, `bool`, or `String`). When the +value of the argument in the remote widget library does not match the +specified (or inferred) type given to `v`, or if the specified keys +don't lead to a value at all, it returns null instead. + +#### Maps and lists + +The `LocalWidgetBuilder` callback can inspect keys to see if they are +maps or lists before attempting to use them. For example, before +accessing a dozen fields from a map, one might use `isMap` to check if +the map is present at all. If it is not, then all the fields will be +null, and it is inefficient to fetch each one individually. + +The +[`DataSource.isMap`](https://pub.dev/documentation/rfw/latest/rfw/DataSource/isMap.html) +method is takes a list of keys (like `v`) and reports if the key +identifies a map. + +For example, in this case the `bar` argument can be treated either as +a map with a `name` subkey, or a scalar String: + + +```dart + 'Foo': (BuildContext context, DataSource source) { + if (source.isMap(['bar'])) { + return Text('${source.v(['bar', 'name'])}', textDirection: TextDirection.ltr); + } + return Text('${source.v(['bar'])}', textDirection: TextDirection.ltr); + }, +``` + +Thus either of the following would have the same result: + + +```rfwtxt + widget example1 = GreenBox( + child: Foo( + bar: 'Jean', + ), + ); +``` + + +```rfwtxt + widget example2 = GreenBox( + child: Foo( + bar: { name: 'Jean' }, + ), + ); +``` + +The +[`DataSource.isList`](https://pub.dev/documentation/rfw/latest/rfw/DataSource/isList.html) +method is similar but reports on whether the specified key identifies a list: + + +```dart + 'Foo': (BuildContext context, DataSource source) { + if (source.isList(['bar', 'quux'])) { + return Text('${source.v(['bar', 'quux', 2])}', textDirection: TextDirection.ltr); + } + return Text('${source.v(['baz'])}', textDirection: TextDirection.ltr); + }, +``` + +For lists, a `LocalWidgetBuilder` callback can iterate over the items +in the list using the +[`length`](https://pub.dev/documentation/rfw/latest/rfw/DataSource/length.html) +method, which returns the length of the list (or zero if the key does +not identify a list): + + +```dart + 'Foo': (BuildContext context, DataSource source) { + int length = source.length(['text']); + if (length > 0) { + StringBuffer text = StringBuffer(); + for (int index = 0; index < length; index += 1) { + text.write(source.v(['text', index])!); + } + return Text(text.toString(), textDirection: TextDirection.ltr); + } + return const Text('', textDirection: TextDirection.ltr); + }, +``` + +This could be used like this: + + +```rfwtxt + widget root = GreenBox( + child: Foo( + text: ['apple', 'banana'] + ), + ); +``` + +#### Widgets + +The `GreenBox` widget has a child widget, which is itself specified by +the remote widget. This is common, for example, `Row` and `Column` +widgets have children, `Center` has a child, and so on. Indeed, most +widgets have children, except for those like `Text`, `Image`, and +`Spacer`. + +The `GreenBox` definition uses +[`DataSource.child`](https://pub.dev/documentation/rfw/latest/rfw/DataSource/child.html) +to obtain the widget, in a manner similar to the `v` method: + + +```rfwtxt + 'GreenBox': (BuildContext context, DataSource source) { + return Container(color: const Color(0xFF002211), child: source.child(['child'])); + }, +``` + +Rather than returning `null` when the specified key points to an +argument that isn't a widget, the `child` method returns an +`ErrorWidget`. For cases where having `null` is acceptable, the +[`optionalChild`](https://pub.dev/documentation/rfw/latest/rfw/DataSource/optionalChild.html) method can be used: + + +```rfwtxt + 'GreenBox': (BuildContext context, DataSource source) { + return Container(color: const Color(0xFF002211), child: source.optionalChild(['child'])); + }, +``` + +It returns `null` when the specified key does not point to a widget. + +For widgets that take lists of children, the +[`childList`](https://pub.dev/documentation/rfw/latest/rfw/DataSource/childList.html) +method can be used. For example, this is how `Row` is defined in +`createCoreWidgets` (see in particular the `children` line): + + +```rfwtxt + 'Row': (BuildContext context, DataSource source) { + return Row( + mainAxisAlignment: ArgumentDecoders.enumValue(MainAxisAlignment.values, source, ['mainAxisAlignment']) ?? MainAxisAlignment.start, + mainAxisSize: ArgumentDecoders.enumValue(MainAxisSize.values, source, ['mainAxisSize']) ?? MainAxisSize.max, + crossAxisAlignment: ArgumentDecoders.enumValue(CrossAxisAlignment.values, source, ['crossAxisAlignment']) ?? CrossAxisAlignment.center, + textDirection: ArgumentDecoders.enumValue(TextDirection.values, source, ['textDirection']), + verticalDirection: ArgumentDecoders.enumValue(VerticalDirection.values, source, ['verticalDirection']) ?? VerticalDirection.down, + textBaseline: ArgumentDecoders.enumValue(TextBaseline.values, source, ['textBaseline']), + children: source.childList(['children']), + ); + }, +``` + +#### `ArgumentDecoders` + +It is common to need to decode types that are more structured than +merely `int`, `double`, `bool`, or `String` scalars, for example, +enums, `Color`s, or `Paint`s. + +The +[`ArgumentDecoders`](https://pub.dev/documentation/rfw/latest/rfw/ArgumentDecoders-class.html) +namespace offers some utility functions to make the decoding of such +values consistent. + +For example, the `Row` definition above has some cases of enums. To +decode them, it uses the +[`ArgumentDecoders.enumValue`](https://pub.dev/documentation/rfw/latest/rfw/ArgumentDecoders/enumValue.html) +method. + +#### Handlers + +The last kind of argument that widgets can have is callbacks. + +Since remote widget libraries are declarative and not code, they +cannot represent executable closures. Instead, they are represented as +events. For example, here is how the "7" button from the +[calculator example](https://github.com/flutter/packages/blob/main/packages/rfw/example/wasm/logic/calculator.rfwtxt) +is represented: + + +```rfwtxt +CalculatorButton(label: "7", onPressed: event "digit" { arguments: [7] }), +``` + +This creates a `CalculatorButton` widget with two arguments, `label`, +a string, and `onPressed`, an event, whose name is "digit" and whose +arguments are a map with one key, "arguments", whose value is a list +with one value 7. + +In that example, `CalculatorButton` is itself a remote widget that is +defined in terms of a `Button`, and the `onPressed` argument +is passed to the `onPressed` of the `Button`, like this: + + +```rfwtxt +widget CalculatorButton = Padding( + padding: [8.0], + child: SizedBox( + width: 100.0, + height: 100.0, + child: Button( + child: FittedBox(child: Text(text: args.label)), + onPressed: args.onPressed, + ), + ), +); +``` + +Subsequently, `Button` is defined in terms of a `GestureDetector` +local widget (which is defined in terms of the `GestureDetector` +widget from the Flutter framework), and the `args.onPressed` is passed +to the `onTap` argument of that `GestureDetector` widget. + +When all is said and done, and the button is pressed, an event with +the name "digit" and the given arguments is reported to the +`RemoteWidget`'s `onEvent` callback. That callback takes two +arguments, the event name and the event arguments. + +On the implementation side, in local widget libraries, arguments like +the `onTap` of the `GestureDetector` local widget must be turned into +a Dart closure that is passed to the actual Flutter widget called +`GestureDetector` as the value of its `onTap` callback. + +The simplest kind of callback closure is a `VoidCallback` (no +arguments, no return value). To turn an `event` value in a local +widget's arguments in the local widget library into a `VoidCallback` +in Dart that reports the event as described above, the +`DataSource.voidHandler` method is used. For example, here is a +simplified `GestureDetector` local widget that just implements `onTap` +(when implementing similar local widgets, you may use a similar +technique): + + +```dart + return GestureDetector( + onTap: source.voidHandler(['onTap']), + child: source.optionalChild(['child']), + ); +``` + +Sometimes, a callback has a different signature, in particular, it may +provide arguments. To convert the `event` value into a Dart callback +closure that reports an event as described above, the +`DataSource.handler` method is used. + +In addition to the list of keys that identify the `event` value, the +method itself takes a callback closure. That callback's purpose is to +convert the given `trigger` (a function which, when called, reports +the event) into the kind of callback closure the `Widget` expects. +This is usually written something like the following: + + +```dart + return GestureDetector( + onTapDown: source.handler(['onTapDown'], (HandlerTrigger trigger) => (TapDownDetails details) => trigger()), + child: source.optionalChild(['child']), + ); +``` + +To break this down more clearly: + + +```dart + return GestureDetector( + // onTapDown expects a function that takes a TapDownDetails + onTapDown: source.handler( // this returns a function that takes a TapDownDetails + ['onTapDown'], + (HandlerTrigger trigger) { // "trigger" is the function that will send the event to RemoteWidget.onEvent + return (TapDownDetails details) { // this is the function that is returned by handler() above + trigger(); // the function calls "trigger" + }; + }, + ), + child: source.optionalChild(['child']), + ); +``` + +In some cases, the arguments sent to the callback (the +`TapDownDetails` in this case) are useful and should be passed to the +`RemoteWidget.onEvent` as part of its arguments. This can be done by +passing some values to the `trigger` method, as in: + + +```dart + return GestureDetector( + onTapDown: source.handler(['onTapDown'], (HandlerTrigger trigger) { + return (TapDownDetails details) => trigger({ + x: details.globalPosition.dx, + y: details.globalPosition.dy, + }); + }), + child: source.optionalChild(['child']), + ); +``` + +Any arguments in the `event` get merged with the arguments passed to +the trigger. + +#### Animations + +The `rfw` package introduces a new Flutter widget called +[`AnimationDefaults`](https://pub.dev/documentation/rfw/latest/rfw/AnimationDefaults-class.html). + +This widget is exposed by `createCoreWidgets` under the same name, and +can be exposed in other local widget libraries as desired. This allows +remote widget libraries to configure the animation speed and curves of +entire subtrees more conveniently than repeating the details for each +widget. + +To support this widget, implement curve arguments using +[`ArgumentDecoders.curve`](https://pub.dev/documentation/rfw/latest/rfw/ArgumentDecoders/curve.html) +and duration arguments using +[`ArgumentDecoders.duration`](https://pub.dev/documentation/rfw/latest/rfw/ArgumentDecoders/duration.html). +This automatically defers to the defaults provided by +[`AnimationDefaults`]. Alternatively, the +[`AnimationDefaults.curveOf`](https://pub.dev/documentation/rfw/latest/rfw/AnimationDefaults/curveOf.html) +and +[`AnimationDefaults.durationOf`](https://pub.dev/documentation/rfw/latest/rfw/AnimationDefaults/durationOf.html) +methods can be used with a `BuildContext` directly to get curve and +duration settings for animations. + +The settings default to 200ms and the +[`Curves.fastOutSlowIn`](https://api.flutter.dev/flutter/animation/Curves/fastOutSlowIn-constant.html) +curve. + + +### Developing remote widget libraries + +Remote widget libraries are usually defined using a Remote Flutter +Widgets text library file (`rfwtxt` extension), which is then compiled +into a binary library file (`rfw` extension) on the server before +being sent to the client. + +The format of text library files is defined in detail in the API +documentation of the +[`parseLibraryFile`](https://pub.dev/documentation/rfw/latest/formats/parseLibraryFile.html) +function. + +Compiling a text `rfwtxt` file to the binary `rfw` format can be done +by calling +[`encodeLibraryBlob`](https://pub.dev/documentation/rfw/latest/formats/encodeLibraryBlob.html) +on the results of calling `parseLibraryFile`. + +The example in `example/wasm` has some [elaborate remote +widgets](https://github.com/flutter/packages/blob/main/packages/rfw/example/wasm/logic/calculator.rfwtxt), +including some that manipulate state (`Button`). + +#### State + +The canonical example of a state-manipulating widget is a button. +Buttons must react immediately (in milliseconds) and cannot wait for +logic that's possibly running on a remote server (maybe many hundreds +of milliseconds away). + +The aforementioned `Button` widget in the `wasm` example tracks a +local "down" state, manipulates it in reaction to +`onTapDown`/`onTapUp` events, and changes the shadow and margins of +the button based on its state: + + +```rfwtxt +widget Button { down: false } = GestureDetector( + onTap: args.onPressed, + onTapDown: set state.down = true, + onTapUp: set state.down = false, + onTapCancel: set state.down = false, + child: Container( + duration: 50, + margin: switch state.down { + false: [ 0.0, 0.0, 2.0, 2.0 ], + true: [ 2.0, 2.0, 0.0, 0.0 ], + }, + padding: [ 12.0, 8.0 ], + decoration: { + type: "shape", + shape: { + type: "stadium", + side: { width: 1.0 }, + }, + gradient: { + type: "linear", + begin: { x: -0.5, y: -0.25 }, + end: { x: 0.0, y: 0.5 }, + colors: [ 0xFFFFFF99, 0xFFEEDD00 ], + stops: [ 0.0, 1.0 ], + tileMode: "mirror", + }, + shadows: switch state.down { + false: [ { blurRadius: 4.0, spreadRadius: 0.5, offset: { x: 1.0, y: 1.0, } } ], + default: [], + }, + }, + child: DefaultTextStyle( + style: { + color: 0xFF000000, + fontSize: 32.0, + }, + child: args.child, + ), + ), +); +``` + +Because `Container` is implemented in `createCoreWidgets` using the +`AnimatedContainer` widget, changing the fields causes the button to +animate. The `duration: 50` argument sets the animation speed to 50ms. + +#### Lists + +Let us consider a remote widget library that is used to render data in +this form: + + +```json +{ games: [ +{"rating": 8.219, "users-rated": 16860, "name": "Twilight Struggle", "rank": 1, "link": "/boardgame/12333/twilight-struggle", "id": 12333}, +{"rating": 8.093, "users-rated": 11750, "name": "Through the Ages: A Story of Civilization", "rank": 2, "link": "/boardgame/25613/through-ages-story-civilization", "id": 25613}, +{"rating": 8.088, "users-rated": 34745, "name": "Agricola", "rank": 3, "link": "/boardgame/31260/agricola", "id": 31260}, +{"rating": 8.082, "users-rated": 8913, "name": "Terra Mystica", "rank": 4, "link": "/boardgame/120677/terra-mystica", "id": 120677}, +... +``` + +For the sake of this example, let us assume this data is registered +with the `DynamicContent` under the name `server`. + +> This configuration data is both valid JSON and a valid RFW data file, +> which shows how similar the two syntaxes are. +> +> This data is parsed by calling +> [`parseDataFile`](https://pub.dev/documentation/rfw/latest/formats/parseDataFile.html), +> which turns it into +> [`DynamicMap`](https://pub.dev/documentation/rfw/latest/formats/DynamicMap.html). +> That object is then passed to a +> [`DynamicContent`](https://pub.dev/documentation/rfw/latest/rfw/DynamicContent-class.html), +> using +> [`DynamicContent.update`](https://pub.dev/documentation/rfw/latest/rfw/DynamicContent/update.html) +> (this is where the name `server` would be specified) which is passed +> to a +> [`RemoteWidget`](https://pub.dev/documentation/rfw/latest/rfw/RemoteWidget-class.html) +> via the +> [`data`](https://pub.dev/documentation/rfw/latest/rfw/RemoteWidget/data.html) +> property. +> +> Ideally, rather than dealing with this text form on the client, the +> data would be turned into a binary form using +> [`encodeDataBlob`](https://pub.dev/documentation/rfw/latest/formats/encodeDataBlob.html) +> on the server, and then parsed on the client using +> [`decodeDataBlob`](https://pub.dev/documentation/rfw/latest/formats/decodeDataBlob.html). + +First, let's render a plain Flutter `ListView` with the name of each +product. The `Shop` widget below achieves this: + + +```rfwtxt +import widgets; + +widget Shop = ListView( + children: [ + Text(text: "Products:"), + ...for product in data.server.games: + Product(product: product) + ], +); + +widget Product = Text(text: args.product.name, softWrap: false, overflow: "fade"); +``` + +The `Product` widget here is not strictly necessary, it could be +inlined into the `Shop`. However, as with Flutter itself, it can be +easier to develop widgets when logically separate components are +separated into separate widgets. + +We can elaborate on this example, introducing a Material `AppBar`, +using a `ListTile` for the list items, and making them interactive (at +least in principle; the logic in the app would need to know how to +handle the "shop.productSelect" event): + + +```rfwtxt +import widgets; +import material; + +widget MaterialShop = Scaffold( + appBar: AppBar( + title: Text(text: ['Products']), + ), + body: ListView( + children: [ + ...for product in data.server.games: + Product(product: product) + ], + ), +); + +widget Product = ListTile( + title: Text(text: args.product.name), + onTap: event 'shop.productSelect' { name: args.product.name, path: args.product.link }, +); +``` + +### Fetching remote widget libraries remotely The example in `example/remote` shows how a program could fetch different user interfaces at runtime. In this example, the interface @@ -222,7 +904,7 @@ This example also shows how an application can implement custom local code for events; in this case, incrementing a counter (both of the "remote" widgets are just different ways of implementing a counter). -## Integrating with scripting language runtimes +### Integrating with scripting language runtimes The example in `example/wasm` shows how a program could fetch logic in addition to UI, in this case using Wasm compiled from C (and let us @@ -254,11 +936,15 @@ concerns on iOS, anyway). ## Contributing -If you run into any problems, please file a [new bug](https://github.com/flutter/flutter/issues/new?labels=p:%20rfw,package,P4), though -as noted above, you may have to fix the issue yourself and submit a PR. -See our [contributing guide](https://github.com/flutter/packages/blob/master/CONTRIBUTING.md) for details. +If you run into any problems, please file a [new +bug](https://github.com/flutter/flutter/issues/new?labels=p:%20rfw,package,P2). +Rather than waiting for a fix, we encourage you to consider submitting +a PR yourself. See our [contributing +guide](https://github.com/flutter/packages/blob/master/CONTRIBUTING.md) +for details. -Adding more widgets to `lib/flutter/core_widgets.dart` and `lib/flutter/material_widgets.dart` is welcome. +Adding more widgets to `lib/flutter/core_widgets.dart` and +`lib/flutter/material_widgets.dart` is welcome. When contributing code, ensure that `flutter test --coverage; lcov --list coverage/lcov.info` continues to show 100% test coverage, and diff --git a/packages/rfw/example/hello/lib/main.dart b/packages/rfw/example/hello/lib/main.dart index 9998030808f5..4df4c180e057 100644 --- a/packages/rfw/example/hello/lib/main.dart +++ b/packages/rfw/example/hello/lib/main.dart @@ -16,6 +16,10 @@ void main() { runApp(const Example()); } +// The "#docregion" comment helps us keep this code in sync with the +// excerpt in the rfw package's README.md file. +// +// #docregion Example class Example extends StatefulWidget { const Example({super.key}); @@ -54,8 +58,11 @@ class _ExampleState extends State { @override void initState() { super.initState(); + // Local widget library: _runtime.update(coreName, createCoreWidgets()); + // Remote widget library: _runtime.update(mainName, _remoteWidgets); + // Configuration data: _data.update('greet', {'name': 'World'}); } @@ -73,3 +80,4 @@ class _ExampleState extends State { ); } } +// #enddocregion Example diff --git a/packages/rfw/example/local/lib/main.dart b/packages/rfw/example/local/lib/main.dart index fc6bd0944aba..0b112efb4120 100644 --- a/packages/rfw/example/local/lib/main.dart +++ b/packages/rfw/example/local/lib/main.dart @@ -16,6 +16,10 @@ void main() { runApp(const Example()); } +// The "#docregion" comment helps us keep this code in sync with the +// excerpt in the rfw package's README.md file. +// +// #docregion Example class Example extends StatefulWidget { const Example({super.key}); @@ -35,6 +39,9 @@ class _ExampleState extends State { @override void reassemble() { + // This function causes the Runtime to be updated any time the app is + // hot reloaded, so that changes to _createLocalWidgets can be seen + // during development. This function has no effect in production. super.reassemble(); _update(); } @@ -87,3 +94,4 @@ class _ExampleState extends State { ); } } +// #enddocregion Example diff --git a/packages/rfw/example/wasm/logic/calculator.rfwtxt b/packages/rfw/example/wasm/logic/calculator.rfwtxt index 900d984a610d..c7a60a6c8c96 100644 --- a/packages/rfw/example/wasm/logic/calculator.rfwtxt +++ b/packages/rfw/example/wasm/logic/calculator.rfwtxt @@ -25,7 +25,12 @@ widget CalculatorPad = Column( children: [ Row( children: [ + // The "#docregion" pragma here allows us to inline this as an example + // in the "rfw" package's README.md file. It is unrelated to what this + // example is otherwise trying to show and can be disregarded. + // #docregion button7 CalculatorButton(label: "7", onPressed: event "digit" { arguments: [7] }), + // #enddocregion button7 CalculatorButton(label: "8", onPressed: event "digit" { arguments: [8] }), CalculatorButton(label: "9", onPressed: event "digit" { arguments: [9] }), SizedBox(width: 116.0, height: 116.0), @@ -58,6 +63,7 @@ widget CalculatorPad = Column( ], ); +// #docregion CalculatorButton widget CalculatorButton = Padding( padding: [8.0], child: SizedBox( @@ -69,7 +75,9 @@ widget CalculatorButton = Padding( ), ), ); +// #enddocregion CalculatorButton +// #docregion State widget Button { down: false } = GestureDetector( onTap: args.onPressed, onTapDown: set state.down = true, @@ -110,6 +118,7 @@ widget Button { down: false } = GestureDetector( ), ), ); +// #enddocregion State widget Display = Container( height: 80.0, diff --git a/packages/rfw/lib/src/dart/text.dart b/packages/rfw/lib/src/dart/text.dart index 9d089b2a8508..34ca06991b93 100644 --- a/packages/rfw/lib/src/dart/text.dart +++ b/packages/rfw/lib/src/dart/text.dart @@ -533,7 +533,7 @@ DynamicMap parseDataFile(String file) { /// event "..." { } /// ``` /// -/// Tthe string is the name of the event, and the arguments map is the data to +/// The string is the name of the event, and the arguments map is the data to /// send with the event. /// /// For example, the event handler in the following sequence sends the event diff --git a/packages/rfw/lib/src/flutter/core_widgets.dart b/packages/rfw/lib/src/flutter/core_widgets.dart index 99a9f7a04c5b..ac2790ceb42f 100644 --- a/packages/rfw/lib/src/flutter/core_widgets.dart +++ b/packages/rfw/lib/src/flutter/core_widgets.dart @@ -537,6 +537,8 @@ Map get _coreWidgetsDefinitions => (MainAxisAlignment.values, source, ['mainAxisAlignment']) ?? MainAxisAlignment.start, @@ -548,6 +550,7 @@ Map get _coreWidgetsDefinitions => rawRemoteWidgetSnippets = { +'root': ''' +// #docregion root +import local; +widget root = GreenBox( + child: Hello(name: "World"), +); +// #endregion root +''', + +'fruit': ''' +import local; +// #docregion fruit +widget fruit = Foo( + bar: { quux: [ 'apple', 'banana', 'cherry' ] }, +); +// #endregion fruit +''', + +'example1': ''' +import local; +// #docregion example1 +widget example1 = GreenBox( + child: Foo( + bar: 'Jean', + ), +); +// #endregion example1 +''', + +'example2': ''' +import local; +// #docregion example2 +widget example2 = GreenBox( + child: Foo( + bar: { name: 'Jean' }, + ), +); +// #endregion example2 +''', + +'example3': ''' +import local; +// #docregion example3 +widget example3 = GreenBox( + child: Foo( + text: ['apple', 'banana'] + ), +); +// #endregion example3 +''', + +'tap': ''' +import local; +import core; +widget tap = GestureDetector( + onTap: event 'test' { }, + child: SizedBox(), +); +''', + +'tapDown': ''' +import local; +import core; +widget tapDown = GestureDetector( + onTapDown: event 'test' { }, + child: SizedBox(), +); +''', + +'Shop': ''' +// #docregion Shop +import core; + +widget Shop = ListView( + children: [ + Text(text: "Products:"), + ...for product in data.server.games: + Product(product: product) + ], +); + +widget Product = Text(text: args.product.name, softWrap: false, overflow: "fade"); +// #enddocregion Shop +''', + +'MaterialShop': ''' +// #docregion MaterialShop +import core; +import material; + +widget MaterialShop = Scaffold( + appBar: AppBar( + title: Text(text: ['Products']), + ), + body: ListView( + children: [ + ...for product in data.server.games: + Product(product: product) + ], + ), +); + +widget Product = ListTile( + title: Text(text: args.product.name), + onTap: event 'shop.productSelect' { name: args.product.name, path: args.product.link }, +); +// #enddocregion MaterialShop +''', +}; + +const String gameData = +''' +// #docregion gameData +{ "games": [ +{"rating": 8.219, "users-rated": 16860, "name": "Twilight Struggle", "rank": 1, "link": "/boardgame/12333/twilight-struggle", "id": 12333}, +{"rating": 8.093, "users-rated": 11750, "name": "Through the Ages: A Story of Civilization", "rank": 2, "link": "/boardgame/25613/through-ages-story-civilization", "id": 25613}, +{"rating": 8.088, "users-rated": 34745, "name": "Agricola", "rank": 3, "link": "/boardgame/31260/agricola", "id": 31260}, +{"rating": 8.082, "users-rated": 8913, "name": "Terra Mystica", "rank": 4, "link": "/boardgame/120677/terra-mystica", "id": 120677}, +// #enddocregion gameData +// #docregion gameData +// #enddocregion gameData +] } +'''; + +List _createLocalWidgets(String region) { + switch (region) { + case 'root': + return [LocalWidgetLibrary({ + // #docregion defaultLocalWidgets + 'GreenBox': (BuildContext context, DataSource source) { + return Container(color: const Color(0xFF002211), child: source.child(['child'])); + }, + 'Hello': (BuildContext context, DataSource source) { + return Center(child: Text('Hello, ${source.v(["name"])}!', textDirection: TextDirection.ltr)); + }, + // #enddocregion defaultLocalWidgets + })]; + case 'fruit': + return [ + LocalWidgetLibrary({ + // #docregion v + 'Foo': (BuildContext context, DataSource source) { + return Text(source.v(['bar', 'quux', 2])!); + }, + // #enddocregion v + }), + LocalWidgetLibrary({ + // #docregion isList + 'Foo': (BuildContext context, DataSource source) { + if (source.isList(['bar', 'quux'])) { + return Text('${source.v(['bar', 'quux', 2])}', textDirection: TextDirection.ltr); + } + return Text('${source.v(['baz'])}', textDirection: TextDirection.ltr); + }, + // #enddocregion isList + }), + ]; + case 'example1': + case 'example2': + return [LocalWidgetLibrary({ + 'GreenBox': (BuildContext context, DataSource source) { + return Container(color: const Color(0xFF002211), child: source.child(['child'])); + }, + // #docregion isMap + 'Foo': (BuildContext context, DataSource source) { + if (source.isMap(['bar'])) { + return Text('${source.v(['bar', 'name'])}', textDirection: TextDirection.ltr); + } + return Text('${source.v(['bar'])}', textDirection: TextDirection.ltr); + }, + // #enddocregion isMap + })]; + case 'example3': + return [LocalWidgetLibrary({ + 'GreenBox': (BuildContext context, DataSource source) { + return Container(color: const Color(0xFF002211), child: source.child(['child'])); + }, + // #docregion length + 'Foo': (BuildContext context, DataSource source) { + final int length = source.length(['text']); + if (length > 0) { + final StringBuffer text = StringBuffer(); + for (int index = 0; index < length; index += 1) { + text.write(source.v(['text', index])); + } + return Text(text.toString(), textDirection: TextDirection.ltr); + } + return const Text('', textDirection: TextDirection.ltr); + }, + // #enddocregion length + })]; + case 'tap': + // #docregion onTap + return [ + LocalWidgetLibrary({ + // The local widget is called `GestureDetector`... + 'GestureDetector': (BuildContext context, DataSource source) { + // The local widget is implemented using the `GestureDetector` + // widget from the Flutter framework. + return GestureDetector( + onTap: source.voidHandler(['onTap']), + // A full implementation of a `GestureDetector` local widget + // would have more arguments here, like `onTapDown`, etc. + child: source.optionalChild(['child']), + ); + }, + }), + ]; + // #enddocregion onTap + case 'tapDown': + return [ + LocalWidgetLibrary({ + 'GestureDetector': (BuildContext context, DataSource source) { + // #docregion onTapDown + return GestureDetector( + onTapDown: source.handler(['onTapDown'], (HandlerTrigger trigger) => (TapDownDetails details) => trigger()), + child: source.optionalChild(['child']), + ); + // #enddocregion onTapDown + }, + }), + LocalWidgetLibrary({ + 'GestureDetector': (BuildContext context, DataSource source) { + // #docregion onTapDown-long + return GestureDetector( + // onTapDown expects a function that takes a TapDownDetails + onTapDown: source.handler( // this returns a function that takes a TapDownDetails + ['onTapDown'], + (HandlerTrigger trigger) { // "trigger" is the function that will send the event to RemoteWidget.onEvent + return (TapDownDetails details) { // this is the function that is returned by handler() above + trigger(); // the function calls "trigger" + }; + }, + ), + child: source.optionalChild(['child']), + ); + // #enddocregion onTapDown-long + }, + }), + LocalWidgetLibrary({ + 'GestureDetector': (BuildContext context, DataSource source) { + // #docregion onTapDown-position + return GestureDetector( + onTapDown: source.handler(['onTapDown'], (HandlerTrigger trigger) { + return (TapDownDetails details) => trigger({ + 'x': details.globalPosition.dx, + 'y': details.globalPosition.dy, + }); + }), + child: source.optionalChild(['child']), + ); + // #enddocregion onTapDown-position + }, + }), + ]; + case 'Shop': + case 'MaterialShop': + return []; + default: + fail('test has no defined local widgets for root widget "$region"'); + } +} + +void main() { + testWidgets('readme snippets', (WidgetTester tester) async { + final Runtime runtime = Runtime() + ..update(const LibraryName(['core']), createCoreWidgets()) + ..update(const LibraryName(['material']), createMaterialWidgets()); + final DynamicContent data = DynamicContent(parseDataFile(gameData)); + for (final String region in rawRemoteWidgetSnippets.keys) { + final String body = rawRemoteWidgetSnippets[region]!; + runtime.update(LibraryName([region]), parseLibraryFile(body)); + } + for (final String region in rawRemoteWidgetSnippets.keys) { + for (final WidgetLibrary localWidgets in _createLocalWidgets(region)) { + await tester.pumpWidget( + MaterialApp( + home: RemoteWidget( + runtime: runtime + ..update(const LibraryName(['local']), localWidgets), + data: data, + widget: FullyQualifiedWidgetName(LibraryName([region]), region), + ), + ), + ); + } + } + }); +} diff --git a/packages/video_player/video_player/test/web_vtt_test.dart b/packages/video_player/video_player/test/web_vtt_test.dart index b7a7bb51ce2b..dac6f49e3197 100644 --- a/packages/video_player/video_player/test/web_vtt_test.dart +++ b/packages/video_player/video_player/test/web_vtt_test.dart @@ -154,7 +154,7 @@ WEBVTT 00:05.200 --> 00:06.000 align:start size:50% You know I'm so excited my glasses are falling off here. -00:00:06.050 --> 00:00:06.150 +00:00:06.050 --> 00:00:06.150 I have a different time! 00:06.200 --> 00:06.900 diff --git a/script/configs/temp_exclude_excerpt.yaml b/script/configs/temp_exclude_excerpt.yaml index a2f7438478cc..7736efb7b8e7 100644 --- a/script/configs/temp_exclude_excerpt.yaml +++ b/script/configs/temp_exclude_excerpt.yaml @@ -26,6 +26,5 @@ - plugin_platform_interface - pointer_interceptor - quick_actions/quick_actions -- rfw - webview_flutter_android - webview_flutter_web