Flutter (29): LayoutBuilder, AfterLayout

Time: Column:Mobile & Frontend views:187

29.1 LayoutBuilder

With LayoutBuilder, we can access the constraints information passed by the parent component during the layout process, allowing us to dynamically construct different layouts based on these constraints.

For example, we can implement a responsive Column component called ResponsiveColumn, which displays its child components in a single column when the available width is less than 200, and in two columns otherwise. Here’s a simple implementation:

class ResponsiveColumn extends StatelessWidget {
  const ResponsiveColumn({Key? key, required this.children}) : super(key: key);

  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    // Use LayoutBuilder to get the constraints from the parent and check if maxWidth is less than 200
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        if (constraints.maxWidth < 200) {
          // If maxWidth is less than 200, display in a single column
          return Column(children: children, mainAxisSize: MainAxisSize.min);
        } else {
          // If greater than 200, display in two columns
          var _children = <Widget>[];
          for (var i = 0; i < children.length; i += 2) {
            if (i + 1 < children.length) {
              _children.add(Row(
                children: [children[i], children[i + 1]],
                mainAxisSize: MainAxisSize.min,
              ));
            } else {
              _children.add(children[i]);
            }
          }
          return Column(children: _children, mainAxisSize: MainAxisSize.min);
        }
      },
    );
  }
}

class LayoutBuilderRoute extends StatelessWidget {
  const LayoutBuilderRoute({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    var _children = List.filled(6, Text("A"));
    // The maximum width of Column in this example is the width of the screen
    return Column(
      children: [
        // Restrict width to 190, less than 200
        SizedBox(width: 190, child: ResponsiveColumn(children: _children)),
        ResponsiveColumn(children: _children),
        LayoutLogPrint(child: Text("xx")) // To be introduced below
      ],
    );
  }
}

As we can see, using LayoutBuilder is quite simple, but don't underestimate its importance as it is very practical and has two main use cases:

  1. LayoutBuilder can be used to create responsive layouts based on the device's size.

  2. LayoutBuilder can help us efficiently troubleshoot issues. For example, when we encounter layout problems or want to debug the layout constraints of a specific node in the component tree, LayoutBuilder is very useful.

# Print Layout Constraints Information

To facilitate debugging, we encapsulate a component that can print the constraints passed from the parent component to its child:

class LayoutLogPrint<T> extends StatelessWidget {
  const LayoutLogPrint({
    Key? key,
    this.tag,
    required this.child,
  }) : super(key: key);

  final Widget child;
  final T? tag; // Specify a log tag

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(builder: (_, constraints) {
      // assert will be removed in the release version
      assert(() {
        print('${tag ?? key ?? child}: $constraints');
        return true;
      }());
      return child;
    });
  }
}

Now we can use LayoutLogPrint to log the constraints information at any position in the component tree, for example:

LayoutLogPrint(child: Text("xx"))

Console output:

flutter: Text("xx"): BoxConstraints(0.0<=w<=428.0, 0.0<=h<=823.0)

We can see that the maximum width of Text("xx") is 428, and the maximum height is 823.

Note! The premise is that we are using box model layout. If it is a Sliver layout, we can use SliverLayoutBuilder to print.

The complete example runs and looks like Figure :

Flutter (29): LayoutBuilder, AfterLayout


29.2 AfterLayout

1. Get Component Size and Position Relative to the Screen

Flutter is a reactive UI framework, which differs from imperative UI frameworks in that, in most cases, developers only need to focus on data changes. After data changes, the framework automatically rebuilds the UI without the need for developers to manually manipulate every component. Therefore, we find that widgets are defined as immutable and do not provide any APIs for manipulating components. As a result, if we want to get the size and position of a component in Flutter, it can be quite difficult. Of course, in most cases, this may not be needed, but there are scenarios where it is required, which is not an issue in imperative UI frameworks.

We know that a component's size and position can only be determined once the layout is complete, so the timing for obtaining this information must be after the layout is finished. At least, event dispatch occurs after the layout is complete. For example:

Builder(
  builder: (context) {
    return GestureDetector(
      child: Text('flutter@wendux'),
      onTap: () => print(context.size), // Print the size of the text
    );
  },
),

context.size can retrieve the size of the current context's RenderObject. For components like Builder, StatelessWidget, and StatefulWidget, which do not have a corresponding RenderObject (as these components are just for composing and proxying other components without layout and drawing logic), what we get is the RenderObject of the first descendant that has a RenderObject.

Although the size can be obtained when an event is clicked, there are two problems: first, it requires the user to manually trigger it, and second, the timing is too late. More often, we prefer to get size and position information immediately after the layout is finished. To solve this problem, I have encapsulated a component called AfterLayout, which can execute a callback after the child component's layout is complete and pass the RenderObject as a parameter.

Note: AfterLayout is a custom component and is not included in the Flutter component library. Readers can check the implementation source code and examples in the accompanying code. This section mainly discusses its functionality; we will introduce the implementation principles of AfterLayout in later chapters related to layout principles.

Example:

AfterLayout(
  callback: (RenderAfterLayout ral) {
    print(ral.size); // Size of the child component
    print(ral.offset); // Coordinates of the child component on the screen
  },
  child: Text('flutter@wendux'),
),

Console output after running:

flutter: Size(105.0, 17.0)
flutter: Offset(42.5, 290.0)

We can see that the actual length of the Text is 105, the height is 17, and its starting position coordinates are (42.5, 290.0).

2. Get Component Coordinates Relative to a Parent Component

RenderAfterLayout class inherits from RenderBox, and RenderBox has a localToGlobal method that can convert coordinates to those relative to a specified ancestor node. For example, the following code can print the coordinates of Text('A') within the parent Container:

Builder(builder: (context) {
  return Container(
    color: Colors.grey.shade200,
    alignment: Alignment.center,
    width: 100,
    height: 100,
    child: AfterLayout(
      callback: (RenderAfterLayout ral) {
        Offset offset = ral.localToGlobal(
          Offset.zero,
          // Pass a parent element
          ancestor: context.findRenderObject(),
        );
        print('A occupies the space in the Container: ${offset & ral.size}');
      },
      child: Text('A'),
    ),
  );
}),

3. AfterLayout Instance

Next, let's look at a test example of AfterLayout:

class AfterLayoutRoute extends StatefulWidget {
  const AfterLayoutRoute({Key? key}) : super(key: key);

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

class _AfterLayoutRouteState extends State<AfterLayoutRoute> {
  String _text = 'Flutter Practical Guide ';
  Size _size = Size.zero;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Padding(
          padding: const EdgeInsets.all(8.0),
          child: Builder(
            builder: (context) {
              return GestureDetector(
                child: Text(
                  'Text1: Tap me to get my size',
                  textAlign: TextAlign.center,
                  style: TextStyle(color: Colors.blue),
                ),
                onTap: () => print('Text1: ${context.size}'),
              );
            },
          ),
        ),
        AfterLayout(
          callback: (RenderAfterLayout ral) {
            print('Text2: ${ral.size}, ${ral.offset}');
          },
          child: Text('Text2: flutter@wendux'),
        ),
        Builder(builder: (context) {
          return Container(
            color: Colors.grey.shade200,
            alignment: Alignment.center,
            width: 100,
            height: 100,
            child: AfterLayout(
              callback: (RenderAfterLayout ral) {
                Offset offset = ral.localToGlobal(
                  Offset.zero,
                  ancestor: context.findRenderObject(),
                );
                print('A occupies the space in the Container: ${offset & ral.size}');
              },
              child: Text('A'),
            ),
          );
        }),
        Divider(),
        AfterLayout(
          child: Text(_text), 
          callback: (RenderAfterLayout value) {
            setState(() {
              // Update size information
              _size = value.size;
            });
          },
        ),
        // Display the size of the above Text
        Padding(
          padding: const EdgeInsets.symmetric(vertical: 8.0),
          child: Text(
            'Text size: $_size ',
            style: TextStyle(color: Colors.blue),
          ),
        ),
        ElevatedButton(
          onPressed: () {
            setState(() {
              _text += 'Flutter Practical Guide ';
            });
          },
          child: Text('Add String'),
        ),
      ],
    );
  }
}

After running, the effect is shown in Figure :

Flutter (29): LayoutBuilder, AfterLayout

When you click on Text1, you can see its size in the log panel. Clicking the “Add String” button will change the size of the text area displayed on the screen (next to the button) after the string size changes.


29.3 Flutter's Build and Layout

By observing the example of LayoutBuilder, we can also conclude something about Flutter's build and layout: Flutter’s build and layout can be interleaved during execution and do not strictly follow the order of build first and then layout. For example, in the previous case, when encountering the LayoutBuilder component during the build process, the builder of LayoutBuilder is executed in the layout phase (constraints information from the layout process can only be accessed during the layout phase). After creating a new widget in the builder, the Flutter framework will subsequently call that widget's build method, entering the build phase again.