xwidget 0.0.16 xwidget: ^0.0.16 copied to clipboard
A package for building dynamic UIs in Flutter using an expressive, XML based markup language.
Note: This document is very much still a work in progress. #
What is XWidget? #
XWidget is a not-so-opinionated library for building dynamic UIs in Flutter using an expressive, XML based markup language.
That was a mouth full, so let's break it down. "Not-so-opinionated" means that you're not forced to use XWidget in any particular way. You can use as much or as little of the framework as you want - whatever makes sense for your project. There are, however, a few Best Practices that you should follow to help keep your code organized and your final build size down to a minimum.
An XWidget UI is defined in XML and parsed at runtime. You have access to all the Flutter widgets and classes you are used to working with, including widgets from 3rd party libraries and even your own custom widgets. This is achieved through code generation. You specify which widgets you want to use and XWidget will generate the appropriate classes and functions and make them available as XML elements. You'll have access to all of the Widgets' constructor arguments as element properties just as if you were writing Dart code. You'll even have code completion and access to Widgets' documentation in tooltips, if provided by the author, when you register the generated XML schema with your IDE.
For example:
<Column crossAxisAlignment="start">
<Text data="Hello World">
<TextStyle for="style" fontWeight="bold" color="#262626"/>
</Text>
<Text>Welcome to XWidget!</Text>
</Column>
Important: Only specify widgets that you actually use in your UI. Specifying unused widgets and helper classes in your configuration will bloat your app size. This is because code is generated for every component you specify and thus neutralizes Flutter's tree-shaking.
Quick Start #
This Quick Start guide will help you get up and running with XWidget in just a few minutes. For a more comprehensive description of the various components and features, please see the sections below.
-
Install XWidget using the following command:
$ flutter pub add xwidget
-
Create an inflater specification file. This is a Dart file that tells XWidget which widgets and helper classes you're planning on using in your fragments. See Inflaters for more.
// lib/xwidget/inflater_spec.dart import 'package:flutter/material.dart'; const Column? _column = null; const Container? _container = null; const Text? _text = null; const TextStyle? _textStyle = null;
-
Create a custom configuration file. This is an XML document that configures the inputs and outputs of XWidget's code generator. By default, XWidget looks for a file named
xwidget_config.yaml
in the project's root folder. Make sure thatsources
contains the location of the inflater spec you created in step #2. See Configuration for more.# xwidget_config.yaml inflaters: sources: [ "lib/xwidget/inflater_spec.dart" ]
-
Generate inflaters and fragment schema. By default, all generated Dart files are written to
lib/xwidget/generated
. The schema file is written to the project root asxwidget_schema.g.xsd
. See Code Generation for more.$ dart run xwidget:generate
-
Register the generated schema file with your IDE under the namespace
http://www.appfluent.us/xwidget
. This will provide validation, code completion, and tooltip documentation while editing your fragments. -
Register the generated components in your application's main method. You'll need to import XWidget and the generated code.
import 'package:xwidget/xwidget.dart'; import 'xwidget/generated/inflaters.g.dart'; main() async { WidgetsFlutterBinding.ensureInitialized(); // load resources i.e. fragments, values, etc. await Resources.instance.loadResources("resources"); // register XWidget components registerXWidgetInflaters(); ... }
-
Modify your project's
pubspec.yaml
and addresources/fragments/
toassets
. There's no need to add each individual fragment; however, if you use fragment folders, you'll need to add each folder here. See Resources for more.flutter: assets: - resources/fragments/
-
Create your UI fragment. By default, XWidget looks for fragments under
resources/fragments
. Fragments are XML documents that are "inflated" at runtime. See Fragments for more.<?xml version="1.0"?> <!-- resources/fragments/hello_world.xml --> <Column xmlns="http://www.appfluent.us/xwidget"> <Text data="Hello World"> <TextStyle for="style" fontWeight="bold" color="#262626"/> </Text> <Text>Welcome to XWidget!</Text> </Column>
-
Inflate your fragment. Where ever you want to render your fragment, simply call XWidget.inflateFragment(...) with the name of your fragment and
Dependencies
. See Dependencies for more.return Container( child: XWidget.inflateFragment("hello_world", Dependencies()) )
Example #
Please see the example folder of this package. It contains an XWidget version of Flutter's classic starter app. It only scratches the surface of XWidget's capabilities.
Configuration #
By default, XWidget's code generator looks for a custom configuration file named xwidget_config.yaml
in the project root. This custom configuration is layered on top of XWidget's own default configuration
which handles most of the configuration burden. See package:xwidget/res/default_config.yaml
for
details.
Since the default config does most of the heavy lifting, the typical config can be relatively simple like this example:
# custom config - xwidget_config.yaml
inflaters:
imports: [
"dart:ui",
"package:flutter/foundation.dart",
"package:flutter/gestures.dart",
]
sources: [ "lib/xwidget/inflater_spec.dart" ]
includes: [ "lib/xwidget/inflater_spec_includes.dart" ]
icons:
sources: [ "lib/xwidget/icon_spec.dart" ]
There are four top-level mappings that configure each of the four generated
outputs: inflaters
, schema
, controllers
, and icons
.
Inflaters Configuration #
# Responsible for configuring inputs and outputs to generate inflaters.
inflaters:
# The file path to save the generated code. The output contains all inflater classes and
# a library function to to register them. The default value can be overwritten.
#
# DEFAULT: "lib/xwidget/generated/inflaters.g.dart"
target:
# A list of additional imports to include in the generated output. Sometimes the code
# generator can't determine all the required imports. This happens because of a current
# limitation in dealing with default values for constructor arguments. This option
# allows manual configuration when needed. Custom imports are appended to XWidget's
# default list.
#
# DEFAULT: [ "package:xwidget/xwidget.dart" ]
imports: [ ]
# DEFAULT: none
sources: [ ]
# DEFAULT none
includes: [ ]
# DEFAULT: See 'package:xwidget/res/default_config.yaml'
constructor_exclusions: [ "<class_name | * for any>:<constructor_argument_name>", ]
# DEFAULT: See 'package:xwidget/res/default_config.yaml'
constructor_arg_defaults:
"<class_name | * for any>:<constructor_argument_name>": "<value>"
# DEFAULT: See 'package:xwidget/res/default_config.yaml'
constructor_arg_parsers:
# EXAMPLES:
# - "double": "double.parse(value)"
# - "Alignment": "parseAlignment(value)"
# - "*:width": "parseWidth(value)"
"<constructor_argument_type | * for any>(: <constructor_argument_name>)": "<parser_function_call>"
Schema Configuration #
# Responsible for configuring inputs and outputs to generate the inflater schema. Register the
# generated schema with your IDE to get code completion and documentation while editing UI markup.
schema:
# DEFAULT: "xwidget_schema.g.xsd"
target:
# DEFAULT: "xwidget|res/schema_template.xsd"
template:
# DEFAULT: See 'package:xwidget/res/default_config.yaml'
types:
# EXAMPLES
# - "bool": "boolAttributeType"
# - "BoxFit": "BoxFitAttributeType"
"<constructor_argument_type>": "<schema_defined_type>"
# DEFAULT: See 'package:xwidget/res/default_config.yaml'
attribute_exclusions: [
# EXAMPLES:
# - "*:child"
"<class_name | * for any>:<constructor_argument_name | * for any>",
]
Controllers Configuration #
controllers:
# DEFAULT: "lib/xwidget/generated/controllers.g.dart"
target:
# DEFAULT: [ "package:xwidget/xwidget.dart" ]
imports: [ ]
# DEFAULT: [ "lib/xwidget/controllers/**.dart" ]
sources: [ ]
Icons Configuration #
icons:
# DEFAULT: "lib/xwidget/generated/icons.g.dart"
target:
# DEFAULT: [ "package:xwidget/xwidget.dart" ]
imports: [ ]
# DEFAULT: none
sources: [ ]
Code Generation #
Add documentation here.
$ dart run xwidget:generate
$ dart run xwidget:generate --help
$ dart run xwidget:generate --config "my_config.yaml"
$ dart run xwidget:generate --only inflaters,controllers,icons
$ dart run xwidget:generate --allow-deprecated
Inflaters #
Inflaters are the heart of XWidget. They are responsible for building the UI at runtime by parsing attribute values and constructing the components defined in fragments. In other words, they are the primary mechanism by which your XML markup gets transformed from this:
<Container height="50" width="50">
<Text data="Hello world!"/>
</Container>
into the widget tree represented by this:
Container({
height: 50,
width: 50,
child: Text("Hello world!")
});
A good analogy is the relationship between a recipe, a chef and a meal. The recipe describes how to create the meal. It lists the ingredients, preparation instructions, etc. The chef does all the work described in the recipe. The meal is the finished product. Your XML markup is the recipe, the inflaters are the chefs in the kitchen, and the instantiated widget tree is the meal, which is then served to the end user.
Inflaters are generated from a user (a developer using XWidget) defined specification written in Dart. The specification is very simple and its sole purpose is to tell the code generator which widgets to generate code for. Once the code has been generated, the widgets can be referenced in your markup.
You can create inflaters for basically anything that is a class and has a public constructor. For example, BoxDecoration and TextStyle are not widgets, they're helper classes that style widgets.
// a very simple inflater specification
import 'package:flutter/material.dart';
const Container? _container = null;
const Text? _text = null;
const TextStyle? _textStyle = null;
You can add as many components as required by your application; however, you should only specify components that you actually need. Specifying unused components in your configuration will unnecessarily increase your app size. This is because code is generated for every component you specify and thus neutralizes Flutter's tree-shaking.
There are four built-in inflaters: <Controller>
, <DynamicBuilder>
, <ListOf>
, and
ValueListener
. You can read more about them in the [Custom Inflaters] section.
Parsers #
Add documentation here.
Custom Inflaters #
Add documentation here.
XML Schema #
Add documentation here.
Code Completion & Tooltip Documentation #
Add documentation here.
Dependencies #
In the context of XWidget, dependencies are data, objects, and functions needed to render a fragment.
The Dependencies
object, at its core, is just a map of dependencies as defined above. Every
inflate method call requires a Dependencies
object. It can be a new instance or one that was
received from a previous inflate method invocation.
Dependencies
objects have a few characteristics that make them a little more interesting than plain
old maps.
-
Values can be referenced using dot/bracket notation for easy access to nested collections. Nulls are handled automatically. If the underlying collection does not exist, reads will resolve to null and writes will create the appropriate collections and store the data.
Dart example:
// example using setValue final dependencies = Dependencies(); dependencies.setValue("users[0].name", "John Flutter"); dependencies.setValue("users[0].email", "name@example.com"); print(dependencies.getValue("users[0].name")); print(dependencies.getValue("users[0].email"));
Or you could use the constructor:
// example setting values via Dependencies constructor final dependencies = Dependencies({ "users[0].name": "John Flutter", "users[0].email": "name@example.com" }); print(dependencies.getValue("users[0].name")); print(dependencies.getValue("users[0].email"));
Markup usage example:
<!-- example iterating over a collection --> <forEach var="user" items="${users}"> <Row> <Text data="${user.name}"/> <Text data="${user.email}"/> </Row> </forEach>
-
Supports global data. Sometimes you just need to access data from multiple parts of an application without a lot of fuss. Global data are accessible across all
Dependencies
instances by adding aglobal
prefix to the key notation.Dart example:
// example setting global values final dependencies = Dependencies({ "global.users[0].name": "John Flutter", "global.users[0].email": "name@example.com" }); print(dependencies.getValue("global.users[0].name")); print(dependencies.getValue("global.users[0].email"));
Markup usage example:
<!-- example iterating over a global collection --> <forEach var="user" items="${global.users}"> <Row> <Text data="${user.name}"/> <Text data="${user.email}"/> </Row> </forEach>
-
When combined with the
ValueListener
custom widget, the UI can listen for data changes and update itself. In the following example, if the user's email address changes, then theText
widget is rebuilt.<!-- example listening to value changes --> <ValueListener varName="user.email"> <Text data="${user.email}"/> </ValueListener>
Note: Dependencies
also supports the bracket operator []; however, it behaves like an
ordinary map.
Fragments #
Add documentation here.
Controllers #
Add documentation here.
Expression Language (EL) #
Add documentation here.
Operators #
Below is the operator precedence and associativity table. Operators are executed according to their precedence level. If two operators share an operand, the operator with higher precedence will be executed first. If the operators have the same precedence level, it depends on the associativity. Both the precedence level and associativity can be seen in the table below.
Level | Operator | Category | Associativity |
---|---|---|---|
10 | () [] . |
Function call, scope, array/member access | |
9 | -expr !expr |
Unary Prefix | |
8 | * / ~/ % |
Multiplicative | Left-to-right |
7 | + - |
Additive | Left-to-right |
6 | < > <= >= |
Relational | |
5 | == != |
Equality | |
4 | && |
Logical AND | Left-to-right |
3 | || |
Logical OR | Left-to-right |
2 | expr1 ?? expr2 |
If null | Left-to-right |
1 | expr ? expr1 : expr2 |
Conditional (ternary) | Right-to-left |
Built-In Functions #
Name | Arguments | Returns | Description | Examples |
---|---|---|---|---|
contains | dynamic value dynamic searchValue |
bool | ${contains('I love XWidget', 'love'} ${contains(dependencyValue, 'hello'} |
|
containsKey | Map? map dynamic searchKey |
bool | ||
containsValue | Map? map dynamic searchValue |
bool | ||
diffDateTime | DateTime left DateTime right |
Duration | ||
durationInDays | Duration value | int | ||
durationInHours | Duration value | int | ||
durationInMinutes | Duration value | int | ||
durationInSeconds | Duration value | int | ||
durationInMills | Duration value | int | ||
endsWith | String value String searchValue |
bool | ||
eval | String? value | dynamic | ||
formatDateTime | String format DateTime dateTime |
String | ||
isEmpty | dynamic value | bool | ||
isNotEmpty | dynamic value | bool | ||
isNotNull | dynamic value | bool | ||
isNull | dynamic value | bool | ||
length | dynamic value | length | ||
matches | String value String regExp |
bool | ||
now | none | DateTime | ||
nowInUtc | none | DateTime | ||
startsWith | String value String searchValue |
bool | ||
substring | String value int start [int end = -1] |
String | ||
toDateTime | dynamic value | DateTime | ||
toDuration | String value | Duration | ||
toString | dynamic value | String |
Custom Functions #
Custom functions are functions that you define and add to your Dependencies
. They behave like
built-in functions except that they are bound to a single Dependencies
instance. Custom functions
can have up to 10 required and/or optional arguments.
For example:
dependencies["addNumbers"] = addNumbers
int addNumbers(int n1, [int n2 = 0, int n3 = 0, int n4 = 0, int n5 = 0]) {
return n1 + n2 + n3 + n4 + n5;
}
Example usage:
<Text data="${addNumbers(2,8,4}"/>
Resources #
Add documentation here.
Strings #
Add documentation here.
Ints #
Add documentation here.
Doubles #
Add documentation here.
Bools #
Add documentation here.
Colors #
Add documentation here.
Fragments #
Add documentation here.
Tags #
Tags are XML elements that do not, themselves, add components to the widget tree. They provide common structure and control elements for constructing the UI such as conditionals, iteration, fragment inclusion, etc. They are always represented in lowercase to distinguish them from inflaters.
<builder>
#
A tag that wraps its children in a builder function.
This tag is extremely useful when the parent requires a builder function, such as
PageView.builder.
Use vars
, multiChild
, and nullable
attributes to define the builder function signature.
When the builder function executes, the values of named arguments defined in vars
are stored
as dependencies in the current Dependencies
instance. The values of placeholder arguments (_) are
simply ignored. The BuildContext
is never stored as a dependency, even if explicitly named,
because it would cause a memory leak.
Attribute | Description | Required | Default |
---|---|---|---|
copyDependencies | Creates a copy of the current dependencies each time the builder function executes. All named vars are added to the copy. |
no | false |
for | The name of the parent attribute that will be assigned the builder function. | yes | null |
multiChild | Whether the builder function should return an array or a single widget. | no | false |
nullable | Whether the builder function can return null. | no | false |
vars | A comma separated list of builder function arguments. Values of named arguments are stored as dependencies. Supports up to five arguments. | no | null |
Example usage:
<PageView.builder>
<builder for="itemBuilder" vars="_,index" nullable="true">
<Container>
<Text data="${index}"/>
</Container>
</builder>
</PageView.builder>
<callback>
#
This tag allows you to bind an event handler with custom arguments. If you don't need to pass any
arguments, then just bind the handler using EL, like so: <TextButton onPressed="${onPressed}"/>
.
This is sufficient in most cases.
The callback
tag creates an event handler function for you and executes the action
when the
event is triggered. action
is an EL expression that is evaluated at the time of the event. Do not
enclose the expression in curly braces ${...}
, otherwise it will be evaluated immediately upon
creation instead of when the event is fired.
If the handler function defines arguments in its signature, you must declare those arguments using
the vars
attribute. This attribute takes a comma separated list of argument names. When the
handler is triggered, argument values are added to Dependencies
using the specified name as the
key, and can be referenced in the action
EL expression, if needed. They're also accessible
anywhere else that instance of Dependencies
is available. If you don't need the values, then use
and underscore (_) in place of the name. This will ignore those value and they won't be added to
Dependencies
e.g. ...vars="_,index"...
. BuildContext
is never added to Dependencies
even
when named, because this would cause a memory leak.
Attribute | Description | Required | Default |
---|---|---|---|
action | The El expression to evaluate when the event handler is triggered. | yes | null |
copyDependencies | Creates a copy of the current dependencies when the event handler is created. All named vars are added to the copy. |
no | false |
for | The name of the parent attribute that will be assigned the event handler. | yes | null |
returnVar | The storage destination within Dependencies for the return value of action . |
no | null |
vars | A comma separated list of handler function arguments. Values of named arguments are stored as dependencies. Supports up to five arguments. | no | null |
<TextButton>
<callback for="onPressed" action="doSomething('Hello World')"/>
<Text>Press Me</Text>
</TextButton>
<debug>
#
A simple tag that logs a debug message
Attribute | Description | Required | Default |
---|---|---|---|
message | The message to log. | yes | null |
<debug message="Hello world!"/>
<forEach>
#
Add documentation here.
Attribute | Description | Required | Default |
---|---|---|---|
copyDependencies | no | false | |
indexVar | no | null | |
items | yes | null | |
multiChild | no | null | |
nullable | no | null | |
var | yes | null |
<forEach var="user" items="${users}">
</forEach>
<forLoop>
#
Add documentation here.
Attribute | Description | Required | Default |
---|---|---|---|
begin | no | 0 | |
copyDependencies | no | false | |
end | no | 0 | |
step | no | 1 | |
var | yes | null |
<forLoop var="index" begin="1" end="5">
</forLoop>
<fragment>
#
A tag that renders a UI fragment
Attribute | Description | Required | Default |
---|---|---|---|
for | no | null | |
name | yes | null |
<AppBar>
<fragment for="leading" name="profile/icon"/>
</AppBar>
<if>
/<else>
#
Add documentation here.
Attribute | Description | Required | Default |
---|---|---|---|
test | yes | null |
<if test="${}">
<fragnent name=""/>
<else>
<fragnent name=""/>
</else>
</if>
<variable>
#
Add documentation here.
Attribute | Description | Required | Default |
---|---|---|---|
name | yes | null | |
value | yes | null |
<variable name="" value=""/>
Logging #
Add documentation here.
Best Practices #
Do use fragment folders #
Add documentation here.
Don't specify unused widgets #
Add documentation here.
Do check-in generated files into source control #
Add documentation here.
Recommended folder structure #
Add documentation here.
project
├── lib
│ └── xwidget # holds all specification files used in code generation
│ ├── controllers # holds all custom controllers
│ └── generated # holds all generated .g.dart files
└── resources
├── fragments # holds all fragments
└── values # holds all resource values i.e strings.xml, bools.xml, colors.xml, etc.
Tips and Tricks #
Regenerate inflaters after upgrading Flutter #
Add documentation here.
Use controllers to create reusable components #
Add documentation here.
FAQ #
1. What problems does XWidget solve? #
The first and most obvious answer is that it gives applications the flexibility to create and modify its UI at runtime. An app might want to give its users the ability to download a different look-and-feel or create dynamic forms all without a redeployment. You're only limited by the existing functionality of your custom controllers, since they're static Dart code.
It provides better separation between business and presentation layers out of the box. Sometimes developers struggle with the best way to separate these concerns. XWidget inherently addresses these problems in an uncomplicated way with fragments and controllers.
This may just be our opinion, but building views in code just feels clunky. We find it more enjoyable to write our UIs using markup - it feels more natural and it's certainly a lot easier to read. The experience should only get better as we improve IDE integration.
Roadmap #
The primary focus right now is documentation, critical bug fixes, more documentation, minor improvements, and oh, even more documentation. The implementation is already fairly stable, but lacks test coverage. Once the documentation is complete, we'll bump the minor version and concentrate on testing.
0.0.x Releases (2023) #
- Write README and API documentation
- Critical bug fixes
- Minor improvements
0.x Releases (2024) #
- Write unit, widget, UI, and performance tests
- Critical and major bug fixes
- Refine and add documentation as needed
1.0.0 Release (mid 2024) #
- Stable release
Known Issues #
None at the moment :)