Skip to content

Instantly share code, notes, and snippets.

@Tiagoperes
Created December 10, 2021 13:53
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Tiagoperes/12f786e06ad80752afacaf80bc49bcf9 to your computer and use it in GitHub Desktop.
Save Tiagoperes/12f786e06ad80752afacaf80bc49bcf9 to your computer and use it in GitHub Desktop.
Deep dive: Beagle styles in Flutter

Deep dive: Beagle styles in Flutter

Styling in Beagle works similarly to CSS in a web environment. The Style is not specific to each component, but a set of rules defined for every component. e.g. every component must support corner radius, which is style property. The same is valid for flex, size, position, positionType, borderWidth, borderColor and display. In summary, every component must support every property, which can be very challenging.

For Beagle Web we used CSS, which is easy. For iOS and Android, we used Yoga for iOS and Yoga for Android, which, can simulate CSS. Although not very simple, implementing this with Yoga was very doable. But what can we do in Flutter where there's no CSS and no Yoga?

We first tried to port the native Yoga (C#) to Flutter using dart:ffi. It took us a long time and everything was very hard. Although Yoga has a very good documentation for using its final libraries for iOS and Android, there's no documentation for using its core lib. With no documentation and no support from Yoga, Porting it to Dart turned out to be a big hassle. Moreover, there's a fundamental difference from the way Yoga calculates layouts to the way Flutter calculates layouts: Yoga fixes the size of every node and when a node changes its own size, it tells the parent to recalculate their new sizes. Flutter just pass size constraints from parents to children, a child must always respect the constraints defined by its parent.

In practice, Yoga comes and set a fixed size (constraints) on the parent, which forbids the child to ever grow bigger than that. So, if the child would get bigger than the parent, we're not able to detect it, because it actually never happens. If we're not able to identify that a child grows, it's impossible to know when to recalculate the parents sizes.

This all means that, by our understanding of Flutter and Yoga, we could never achieve dynamic layouts with Yoga in Flutter, e.g. an image loaded from a URL would never show, since we'd never detect that the size of the image changed once it loads.

We abandoned the idea to use Yoga in Flutter and implemented a native solution, which is described in the next sections.

General idea

To apply the styles to every component, our idea is to wrap the component in several widgets that can apply the style passed as parameter. For instance, if a background color is set, we wrap the component in a Container with a BoxDecoration. The widget responsible to apply all the styles is called "Styled" and is provided by the core Beagle Flutter library.

Every component is wrapped in a Styled, unless the component itself is a Styled or the component has been configured to not respond to beagle styles.

Components can be Styled by extending the original Styled widget. One would do it to organize the children of a non-leaf component according to the style passed by Beagle. The beagle:container, for example, inherits from Styled. A Card component would probably also inherit from Styled.

Components can tell Beagle not to apply styles by implementing getStyleConfig in their ComponentBuilder.

To make sure the root can also expand in a Flex layout, we wrap the root Node in a Flex widget.

Flex layout

In order to apply the Flex layout, the Styled widget wraps its children in a Flex widget. Furthermore, if style.flex.flex is set, it wraps everything in an Expanded widget, where flex is equivalent to style.flex.flex.

There's a single case where the Styled widget doesn't wrap its children in a Flex widget and it happens when the beagle style defines components that are absolute positioned. Read the section "Absolute positioning" for more details.

Flex factor

This is a small problem, but since it's a deep dive, it's important to notice that, in Flutter, the flex factor is an integer. This is not true for the original flex layout definition and also not true for Beagle. We can use values like 0.5 in Beagle, which would not work in Flutter.

To fix this, we multiply every flex factor coming from Beagle by 1000 and round it. This means that flex factors like 0.588 and 1.589 used in Beagle will work in Flutter, but 3 decimal cases is the maximum. 0.5789 and 0.5788 would be interpreted as the same thing. One decimal case would probably be enough, but to ensure compatibility, we decided to go with 3.

Unconstrained sizes

Flutter can be really annoying sometimes and when it can't calculate a size, it doesn't care to guess it or to just not render it. Unlike the other platforms, it breaks. For instance, when we have the structure Flex > Flex (flex: 1), Flutter can't calculate this size, because the outer Flex wants to be as small as the child allows and the inner Flex wants to be as big as the parent allows. This is not a problem in web, the CSS just assumes we want the behavior of the inner flex and expands to occupy all the size available. In Android and iOS (Yoga) it doesn't render anything, but also doesn't break. Another problem is that this forbids us to use unstyled components that always expand like the PageView, TabView and WebView (which are not problems is iOS and Android).

To fix this issue, we adopted the behavior we have in web, so, in practice, if a component doesn't have style.flex.flex, but has a child where style.flex.flex is set, we set the parent style.flex.flex to 1 (recursively). Before setting flex to 1, we also check if the node is not already constrained in the flex direction by its size, if it is, we don't set the flex factor. This has been implemented as a Beagle lifecycle algorithm that runs once the UI tree is available and before the expressions are evaluated (beforeViewSnapshot). This algorithm also assumes that every unstyled component (see the section "disabling Beagle styles for a component") has, implicitly, style.flex.flex = 1.

AlignItems: flex-start vs. stretch

The default value of the property style.flex.alignItems in Beagle is "stretch", which causes all components inside the layout to stretch along the cross axis as much as it can.

For instance, a column with the default value of stretch for alignItems renders like this in iOS:

flex-stretch-example-ios

Since it's a column, the cross-axis is the horizontal axis, so it occupies all the horizontal space available.

When alignItems is flex-start, it renders like this in iOS:

flex-start-example-ios

It occupies only the space it needs.

Unfortunately, alignItems: stretch in the Flex widget (Flutter) is not exactly the same as CSS's, Yoga's and Beagle's. While in Beagle stretch means "stretch along the cross axis as much as it can", in Flutter it means "stretch along the cross axis, no matter what the child says". i.e. when the child has a well defined size, Flutter doesn't respect it if it's under a Flex widget with alignItems: stretch. See the example below.

When alignItems is stretch and each child has width: 200, it renders like this in iOS:

flex-stretch-size-example-ios

In iOS, alignItems: stretch, stretches the view as long as the child allows.

When alignItems is stretch and each child has width: 200, it renders like this in Flutter:

flex-stretch-size-example-flutter

In Flutter, alignItems: stretch, stretches the view despite the width of children.

Because of this fundamental difference, we decided, in Flutter, to keep flexStart as the default value of the property style.flex.alignItems. "Flex-start" will always respect the size of the children and we believe this to be more important when building a layout.

Unimplemented features of the Beagle flex layout

The following properties are not implemented yet:

  • Basis
  • Grow
  • Shrink
  • FlexWrap
  • AlignSelf
  • AlignContent

Absolute positioning

Absolute positioning was a bit tricky to implement in Flutter. Beagle allows us to mix absolute positioned components with flex positioned components, i.e. we can have as children of the same parent both flex nodes and absolute nodes. This is not possible in Flutter.

The equivalent to absolute positioning in Flutter is the combination of the widgets Stack and Positioned. Every absolute positioned node must be child of Stack.

To adapt the Beagle UI tree to the Flutter UI tree, we must check every node in the Beagle tree for absolute positioned children. If the node has absolute positioned children, than it should be a Stack instead of a Flex. This algorithm, just like the "flex 1 algorithm" can be a Beagle lifecycle (beforeViewSnapshot). To mark that a components needs to be a Stack instead of a Flex, we created a new property in the style called isStack.

When applying the styles, we check if isStack is true. If it is, instead of rendering a Flex widget, we render a Stack. Also in the Styled widget, if the node is absolute positioned and has set a position (top, left, bottom or right), we wrap it under a Positioned widget.

Mixing flex positioned and absolute positioned nodes

The previous solution works perfectly, but only if all children of a component are absolute positioned. If any is not, it will render things on top of one another, which is not the expected behavior.

We should render all absolute positioned children as floating objects according to their positions. All other elements must be placed as they were inside a flex layout. In fact, the parent component (which will be rendered as a Stack by Flutter) can define a flex style. Overall, as it is, it makes no sense for Flutter.

To achieve the expected behavior, we must change the original Beagle UI tree. We can't have in Flutter a widget that will define both a Flex layout and a Stack layout. We must enhance our original lifecycle algorithm.

Instead of only looking if a node has absolute positioned children, we separate all children into two groups: flex and stack. If flex ends up empty, we do it like before, just set style.isStack to true and return. Otherwise, we also set the children of the node to be all the nodes in stack + a newly created node that is a flex layout containing all the flex children. The flex node will always be the first child of the stack. We also copy all configurations from style.flex of the original node to the newly created node.

Example: Original tree:

> beagle:container (flexDirection: row)
  > beagle:container (positionType: absolute)
  > beagle:container (positionType: absolute, top: 20, left: 20)
  > beagle:text
  > beagle:text

Tree after going through the absolute positioning algorithm:

> beagle:container (isStack: true)
  > beagle:container (flexDirection: row)
    > beagle:text
    > beagle:text
  > beagle:container (positionType: absolute)
  > beagle:container (positionType: absolute, top: 20, left: 20)

The drawback of this solution is that we can't place any flex element on top of an absolute element. The original order of the elements is not kept. But this can easily fixed by providing a json that works better with Flutter and works the same for all other platforms. In any case, we believe this solution will work for most scenarios without the need of tweaking the original JSON.

Fractioned positions

In Beagle it is possible to set top, bottom, left and right as percentages of the full width or height available. We currently don't support this in Beagle Flutter and all position values must be of type "REAL".

Background color, corner radius and border

These were the three easiest properties to implement, if any of them are set, we wrap the widget in a Container with a decoration. The decoration has color, border radius and border based on the values of the style.

This container is also the one responsible for adding paddings.

Size, margin, padding and display

All of these three values can be fractioned, i.e. they can be "PERCENT" instead of "REAL". When a value is "PERCENT" we must calculate it according to the space available. Unfortunately Flutter doesn't support percentage values in the Container widget, so we have to calculate them ourselves.

To calculate percentage values, we use the widget LayoutBuilder, which tells us what is the maximum width and maximum height (space available). We get these values and multiply them to the percentages informed by Beagle. If any of the sizes is infinity, we check the size o the parent RenderObject instead, until we find one that has constrained sizes.

To apply both the margin and size, we use a container. It's important to remark that the size also includes the value calculated for the padding.

To apply the padding we use same container used to apply the background color, corner radius and borders. We make it the child of the previous container (with the size and margin).

So, the overall structure is: LayoutBuilder > Container (margin and size) > Container (padding and decorations) > Widget (wrapped by a Flex or Stack)

If style.display is none, the size and margin of the container will be zero.

style.margin.start, style.margin.end, style.padding.start and style.padding.end have not been implemented yet.

Exclusively for the components TextInput and Button, fractioned paddings (percentages) doesn't work.

Styled widget UI tree

This is a summary of the structure described until now. The widget Styled creates the following UI tree when applying the styles from Beagle:

In the tree below, if the condition is not met, the block is returned without being wrapped. For a component with no style, for instance, it would return Flex > Original Widget.

:: if flex.flex is greater than zero
> Expanded
  :: if positionType is absolute and position is not null
  > Positioned
    :: if size, margin, padding, display, backgroundColor, cornerRadius, borderWidth or borderColor is set
    > LayoutBuilder 
      > Container (margin and size)
        > Container (padding and decoration)
          :: if isStack is true
          > Stack
            :: if isStack is null or false
              > Flex
                :: unconditionally (if true)
                  > Original Widget

Inheriting from Styled

Ideally, when a component is created, its children would automatically be layed out by Beagle, without any further action from the developer. Unfortunately, this is not possible, if the developer wants Beagle to setup the layout of its children, he/she must couple the widget's code to the Beagle library.

Fortunately this is not always necessary, since most components are leafs, i.e. they don't accept children. When a component does accept children, one must ask "do I know how I want to layout the children?". If you know, for instance, that your component is a column that renders each child 20 units from one another, then you don't need Beagle to tell you how to layout the children. The only cases where you really need to couple the widget's code to Beagle is when you want Beagle to place it child according to parameters from the incoming JSON like style.flex.direction and style.flex.justifyContent.

Examples of widgets in Beagle Flutter that handles the layout control of the children to Beagle: Container and SimpleForm.

Examples of widgets in Beagle Flutter that accepts children, but control the layout of their own children: ScreenComponent, ScrollView, ListView, GridView, PageView, TabBar and PullToRefresh.

An example of a custom component that would need to handle layout control to Beagle is a Card (white container with rounded corners, shadow and padding). This component should layout it's children according to the flex configuration coming from Beagle.

How to handle layout control to Beagle?

To handle layout control to Beagle is simple, you just need to make your widget extend Styled. Styled already implements the Build method, so, if you want to add more widgets to the build, be sure to call super.build() inside your class build method.

If you wanted to implement the aforementioned Card component, your code would look like this:

class Card extends Styled {
  Card({
    Key? key,
    BeagleStyle? style,
    List<Widget> children = const []
  }) : super(key: key, children: children, style: style);

  @override
  Widget build(BuildContext context) => Container(
    padding: EdgeInsets.all(15),
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.all(Radius.circular(10)),
      boxShadow: [BoxShadow(color: Color(0x0A000000), blurRadius: 1, spreadRadius: 2, offset: Offset(1, 1))],
    ),
    child: super.build(context),
  );
}

Styled is a stateless widget, if your component needs to inherit from Styled, but also needs to be Stateful, it can inherit from StatefulStyled instead. As an illustration, see below the implementation of the BeagleContainer (Beagle default component):

class BeagleContainer extends StatefulStyled {
  const BeagleContainer({
    Key? key,
    this.onInit,
    BeagleStyle? style,
    List<Widget> children = const [],
  }) : super(key: key, style: style, children: children);

  /// Optional function to run once the container is created
  final Function? onInit;


  @override
  _BeagleContainer createState() => _BeagleContainer();
}

class _BeagleContainer extends StyledState<BeagleContainer> with AfterLayoutMixin<BeagleContainer> {
  @override
  void afterFirstLayout(BuildContext context) {
    if (widget.onInit != null) widget.onInit!();
  }
}

The AfterLayout allows us to run some code as soon as the component is first rendered. For more information, check this link.

Dealing with overshadowing by mixins

This is rare, but sometimes, a mixin overrides the methods build, initState or didUpdateWidget from StyledState. If this happens to your widget, you can still call buildStyled to build the widget with the styles, initStyled to initialize the state and updateStyled to update it.

See the example below where we needed to use this artifact to create the SimpleForm (Beagle default component):

class BeagleSimpleForm extends StatefulStyled {
  // ...
}

class BeagleSimpleFormState extends StyledState<BeagleSimpleForm> with BeagleConsumer {
  // ...

  @override
  Widget buildBeagleWidget(BuildContext context) => buildStyled(context);
}

In the code above, the mixin BeagleConsumer overshadowed Styled.build, if I called super.build(context) instead of buildStyled(context), I'd end up with an infinite loop (stack overflow).

Disabling decorations (padding, background, corner radius and border) for a component

As explained in the previous topics, to apply a style, we don't alter the widget directly, we wrap it under other widgets and style these widgets instead. Although it works most of the times, sometimes it doesn't, mainly for decorations.

Take the BeagleButton, for instance, it outputs an ElevatedButton from Material. When we style it with the Styled widget, we apply the padding, background color, corner radius and border to a container that wraps the ElevatedButton and not the ElevatedButton itself. See the example below:

{
  "_beagleComponent_": "beagle:button",
  "text": "Hello World",
  "onPress": [{
    "_beagleAction_": "beagle:alert",
    "message": "Clicked!"
  }],
  "style": {
    "backgroundColor": "green",
    "cornerRadius": {
      "radius": 100
    },
    "borderWidth": 5,
    "size": {
      "height": {
        "value": 200,
        "type": "REAL"
      },
      "width": {
        "value": 200,
        "type": "REAL"
      }
    }
  }
}

This is how Beagle Flutter would render it by default:

default button style

This is clearly not what we anted to achieve. To correctly implement the styles for button, we must disable the decorations applied by the Styled widget and handle them inside the BeagleButton itself, applying it to the ElevatedButton.

We can disable the application of the decorations by the Styled widget by implementing getStyleConfig in the component's builder. Also in the builder, we can pass the entire BeagleStyle to the widget so it can handle it.

class _ButtonBuilder extends ComponentBuilder {
  @override
  StyleConfig? getStyleConfig() => StyleConfig.enabled(shouldDecorate: false);

  @override
  Widget buildForBeagle(element, _, __) {
    return BeagleButton(
    onPress: element.getAttributeValue('onPress'),
    text: element.getAttributeValue('text'),
    enabled: element.getAttributeValue('enabled'),
    styleId: element.getAttributeValue('styleId'),
    style: element.getStyle(),
  );
}}

By disabling the default behavior for decorations and implementing them ourselves, we get the desired result: correct button style

Disabling Beagle styles for a component

Some components not only don't accept styles, but can't be put inside the structure created by the Styled widget. Examples are: ScrollView, WebView and PageView.

To prevent a component from being wrapped under a Styled widget, in its builder, implement the method getStyleConfig. See the example below for the PageView (Beagle default component):

class _PageViewBuilder extends ComponentBuilder {
  @override
  StyleConfig getStyleConfig() => StyleConfig.disabled();

  @override
  Widget buildForBeagle(element, children, _) => BeaglePageView(
    currentPage: element.getAttributeValue('currentPage'),
    onPageChange: element.getAttributeValue('onPageChange'),
    children: children,
  );
}

Most components that needs to be "unstyled" also needs to have a fixed height. Because of this, by default, we wrap them under an Expanded widget. To disable this behavior, you can provide a StyleConfig where shouldExpand is false (StyleConfig.disabled(shouldExpand: false)). Just be careful with this because you could easily fall into the common Flutter exception: "Size is infinite" or "Size is unconstrained".

It is also a good idea for unstyled components to, whenever possible, wrap their children in a Flex layout. This prevents a child with style.flex.flex: 1 from failing with the Flutter exception Wrong use of ParentData (can't expand if not in a flex layout). Some Beagle default components that are unstyled and wrap their children in a Flex widget are: ScreenComponent and ScrollView. To use the Beagle's default values for Flex instead of Flutter's, you can use BeagleFlexWidget instead of Flex. The BeagleFlexWidget inherits from Flex and just alter the default values.

The problem with scrollable contents

In Flutter, every time we have a scroll view, the content have infinite hight in the direction of the list. This prevents us from having anything that expands (style.flex.flex > 0 or unstyled components) inside a scroll view, because it wouldn't know what to expand into.

These are the default components of Beagle that behaves like a scroll, i.e. uses the Flutter's ListView widget: ListView, ScrollView and PullToRefresh. So, in practice, we can't have a descendent of any of these components with flex style.flex.flex > 0, unless a parent defines a specific size.

So, scroll views inside scroll views won't work, unless the inner scroll view has its size defined. A ScrollView or PageView under a PullToRefresh would also not work, and so on.

The BeagleListView

This is not an easy problem to fix, but why would anyone place a PageView inside a ScrollView or PullToRefresh? These are very edge cases. The only component that really poses an issue is the Beagle's ListView. It is very common to have a ListView inside a ScrollView, PullToRefresh or another ListView.

So, at least for the BeagleListView, we need to solve this problem.

Fortunately, the Flutter's ListView has an option to not expand to the entire space available, it's called shrinkWrap. If we set shrinkWrap to true and disable the default Beagle's behavior of wrapping the unstyled component under an Expanded widget, it works! A ListView can now be placed inside another ListView, ScrollView or PullToRefresh.

The default Beagle's behavior of wrapping the unstyled component under an Expanded widget can be disabled by implementing getStyleConfig in the component's builder. getStyleConfig() => StyleConfig.disabled(shouldExpand: false).

A side effect of this solution is that a ListView won't respect the size of the parent, i.e., when the parent has set its size, the ListView overflows it instead of placing a scroll.

The only way the ListView will respect the parent's size and never overflows is if we also wrap it under an Expanded widget and set shrinkWrap to false. And now we see that the problem is not so simple. We can't have an Expanded widget because this BeagleListView might be a direct child of a BeagleScrollView or a BeaglePullToRefresh and they have infinite sizes.

So, we must know if we're inside a ListView or inside a Flex layout (Beagle container). If we're inside a ListView we must set shrinkWrap to false, otherwise, we must wrap it under an Expanded widget.

To implement this behavior, we created a new Widget called FlexibleListView. This widget checks whether or not it's under a Flex layout. If it is, it wraps the ListView under an Expanded widget, otherwise, it just sets shrinkWrap to true. This is done by using the AfterLayout lifecycle and checking if the render object's parent data is a FlexParentData.

In the end, when creating the BeagleListView, we use a FlexibleListView instead of the original Flutter's ListView widget, allowing the ListView component from Beagle to work despite its parent layout.

Since the BeagleListView and BeagleGridView are, internally, the same component, everything said here is also valid to the BeagleGridView.

Conclusion

It took us a long time to roll back in the idea of porting Yoga to Flutter, which prevented us from releasing a stable version of Beagle Flutter sooner. Looking back, we should have dedicated more time researching why maybe porting Yoga wouldn't work instead of immediately trying to implement it. In many cases it seemed that we were almost done and making the layout work dynamically would be a simple task, we didn't take the proper time to stop and check if it would really be simple. It turned out to be impossible (at least as far as our attempts went), and in the end, we lost many months of development. It was a big mistake we don't expect to make again, a lesson.

With a deadline of december of 2021, we didn't have much time to think of a layout engine dedicated to Beagle in Flutter (a new RenderObject entirely). So we decided to use everything Flutter already provides to us. Beagle uses the Flex Layout, margins, paddings, colors, borders, etc. Flutter has an equivalence to all of this, all we needed was to build a UI tree capable of rendering what we needed. So, in about 3 weeks, style by style, we made an equivalence in Flutter and applied it to the tree structure using the Styled widget.

The current solution is not perfect, Flutter is very restrict in where each widget goes, while Beagle lets you place components wherever you want. It might still be possible to get a "Size is unconstrained" or "Wrong use of parentData" error because of how the tree was calculated, but we think it will work for most scenarios and we ask any problems to be registered as issues in our GitHub page. Another problem is that, due to time constraints, we weren't able to look into more complex properties of the Beagle's flex layout like grow and shrink, or fractioned absolute positioning. We expect to address these in future versions of Beagle Flutter 2.x.

When developing this feature, we took a big look into the current state of layout and styling in Beagle as a whole. Overall, we didn't like it too much. The way it is right now makes it hard to write in the backend, hard to understand and mainly, hard to implement. Issues found when implementing it in Flutter also happened to be found in the other platforms. Throughout this process we came up with many ideas to make it better, so expect significant changes in the future!

We hope you enjoy the first stable version of Beagle Flutter (it's called 2.0, but it's the first, lol) and help us improve it! If you find an issue, you're welcome to register it here. If you feel comfortable fixing the issue yourself, we'd appreciate a pull request!

@arthurbleilzup
Copy link

Awesome article!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment