flutterdart

How to make the height of a PageView take the height required by the active page?


There is a PageView inside a Column. This PageView is a block that should take the minimum height required by whatever the active page is.

For instance, if the first page takes 300px of height and the remaining left 500px, and the active page is the first one then, the height of the PageView itself should take 300px of height.

It is important to mention that in order for a PageView to exist inside a Column, a fixed height to the PageView must be specified, otherwise an unbound height exception will be thrown by the Flutter SDK.

How can such a behavior be accomplished?


Solution

  • Disclaimer: This is my (hour-long) take on this small challenge. It's possible there are better solutions to do this or that I didn't take something into account. Make sure to test well this solution. If other readers found something wrong, make sure to point it out in the comment or propose an alternative solution as another answer.


    I created a more-or-less universal widget to tackle that problem. It has two limitations you need to be aware of:

    1. It won't work if you put a widget that doesn't support getDryLayout into the children.
    2. It does two runs of laying out the children for each layout. If you have more complex widget (or rather render object) trees in the children, this solution is most likely not good (performant) enough for you.
      • For the first run it lays the children with a max height from some constant (the _firstLayoutMaxHeight) so that the children can tell their size. If your children are taller than that, you need to adjust it accordingly (or better yet find a better, more focused solution).

    If you had narrowed down your problem to something specific, like a page view of images specifically, it would be much easier to address the particular problem better.

    Here's the solution video, its usage, and the code for the widget I wrote (feel free to use it) along with some explanations:

    enter image description here

    import 'dart:ui';
    
    import 'package:flutter/material.dart';
    import 'package:flutter/rendering.dart';
    
    void main() {
      runApp(
        const MaterialApp(
          home: Home(),
        ),
      );
    }
    
    class Home extends StatefulWidget {
      const Home({super.key});
    
      @override
      State<Home> createState() => _HomeState();
    }
    
    class _HomeState extends State<Home> {
      final _controller = PageController();
    
      @override
      void dispose() {
        _controller.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: const Text('PageView adaptive height'),
          ),
          body: Column(
            children: [
              PageViewHeightAdaptable(
                controller: _controller,
                children: [
                  Container(height: 100, width: 50, color: Colors.red),
                  Container(
                    height: 300,
                    color: Colors.green,
                    child: const AspectRatio(
                      aspectRatio: 1,
                      child: Placeholder(),
                    ),
                  ),
                  Container(height: 200, color: Colors.blue),
                  Container(height: 400, color: Colors.red),
                  Container(height: 600, color: Colors.blue),
                ],
              ),
              const FlutterLogo(),
            ],
          ),
        );
      }
    }
    
    import 'dart:ui';
    
    import 'package:flutter/material.dart';
    import 'package:flutter/rendering.dart';
    
    const _firstLayoutMaxHeight = 10000.0;
    
    class PageViewHeightAdaptable extends StatefulWidget {
      const PageViewHeightAdaptable({
        super.key,
        required this.controller,
        required this.children,
      }) : assert(children.length > 0, 'children must not be empty');
    
      final PageController controller;
      final List<Widget> children;
    
      @override
      State<PageViewHeightAdaptable> createState() =>
          _PageViewHeightAdaptableState();
    }
    
    class _PageViewHeightAdaptableState extends State<PageViewHeightAdaptable> {
      final _sizes = <int, Size>{};
    
      @override
      void didUpdateWidget(PageViewHeightAdaptable oldWidget) {
        super.didUpdateWidget(oldWidget);
    
        _sizes.clear();
      }
    
      @override
      Widget build(BuildContext context) {
        return ListenableBuilder(
          listenable: widget.controller,
          builder: (context, child) => _SizingContainer(
            sizes: _sizes,
            page: widget.controller.hasClients ? widget.controller.page ?? 0 : 0,
            child: child!,
          ),
          child: LayoutBuilder(
            builder: (context, constraints) => PageView(
              controller: widget.controller,
              children: [
                for (final (i, child) in widget.children.indexed)
                  Stack(
                    alignment: Alignment.topCenter,
                    clipBehavior: Clip.hardEdge,
                    children: [
                      SizedBox.fromSize(size: _sizes[i]),
                      Positioned(
                        left: 0,
                        top: 0,
                        right: 0,
                        child: _SizeAware(
                          child: child,
                          // don't setState, we'll use it in the layout phase
                          onSizeLaidOut: (size) {
                            _sizes[i] = size;
                          },
                        ),
                      ),
                    ],
                  ),
              ],
            ),
          ),
        );
      }
    }
    
    typedef _OnSizeLaidOutCallback = void Function(Size);
    
    class _SizingContainer extends SingleChildRenderObjectWidget {
      const _SizingContainer({
        super.child,
        required this.sizes,
        required this.page,
      });
    
      final Map<int, Size> sizes;
      final double page;
    
      @override
      _RenderSizingContainer createRenderObject(BuildContext context) {
        return _RenderSizingContainer(
          sizes: sizes,
          page: page,
        );
      }
    
      @override
      void updateRenderObject(
        BuildContext context,
        _RenderSizingContainer renderObject,
      ) {
        renderObject
          ..sizes = sizes
          ..page = page;
      }
    }
    
    class _RenderSizingContainer extends RenderProxyBox {
      _RenderSizingContainer({
        RenderBox? child,
        required Map<int, Size> sizes,
        required double page,
      })  : _sizes = sizes,
            _page = page,
            super(child);
    
      Map<int, Size> _sizes;
      Map<int, Size> get sizes => _sizes;
      set sizes(Map<int, Size> value) {
        if (_sizes == value) return;
        _sizes = value;
        markNeedsLayout();
      }
    
      double _page;
      double get page => _page;
      set page(double value) {
        if (_page == value) return;
        _page = value;
        markNeedsLayout();
      }
    
      @override
      void performLayout() {
        if (child case final child?) {
          child.layout(
            constraints.copyWith(
              minWidth: constraints.maxWidth,
              minHeight: 0,
              maxHeight:
                  constraints.hasBoundedHeight ? null : _firstLayoutMaxHeight,
            ),
            parentUsesSize: true,
          );
    
          final a = sizes[page.floor()]!;
          final b = sizes[page.ceil()]!;
    
          final height = lerpDouble(a.height, b.height, page - page.floor());
    
          child.layout(
            constraints.copyWith(minHeight: height, maxHeight: height),
            parentUsesSize: true,
          );
          size = child.size;
        } else {
          size = computeSizeForNoChild(constraints);
        }
      }
    }
    
    class _SizeAware extends SingleChildRenderObjectWidget {
      const _SizeAware({
        required Widget child,
        required this.onSizeLaidOut,
      }) : super(child: child);
    
      final _OnSizeLaidOutCallback onSizeLaidOut;
    
      @override
      _RenderSizeAware createRenderObject(BuildContext context) {
        return _RenderSizeAware(
          onSizeLaidOut: onSizeLaidOut,
        );
      }
    
      @override
      void updateRenderObject(BuildContext context, _RenderSizeAware renderObject) {
        renderObject.onSizeLaidOut = onSizeLaidOut;
      }
    }
    
    class _RenderSizeAware extends RenderProxyBox {
      _RenderSizeAware({
        RenderBox? child,
        required _OnSizeLaidOutCallback onSizeLaidOut,
      })  : _onSizeLaidOut = onSizeLaidOut,
            super(child);
    
      _OnSizeLaidOutCallback? _onSizeLaidOut;
      _OnSizeLaidOutCallback get onSizeLaidOut => _onSizeLaidOut!;
      set onSizeLaidOut(_OnSizeLaidOutCallback value) {
        if (_onSizeLaidOut == value) return;
        _onSizeLaidOut = value;
        markNeedsLayout();
      }
    
      @override
      void performLayout() {
        super.performLayout();
    
        onSizeLaidOut(
          getDryLayout(
            constraints.copyWith(maxHeight: double.infinity),
          ),
        );
      }
    }
    
    
    

    Time for some explanations.

    I create a PageView inside, that lays their individual children in stacks, where a SizedBox defines child size and the second Positioned child is the actual PageView's child, clipped. With the _SizeAware I collect the children's desired height (from the dry layout on infinite height constraint) which is saved to a map. The _SizingContainer lays the children out in the first run causing the callback to be fired. The map is updated with the children sizes. The _SizingContainer has these new sizes, because we were always modifying and passing this map by a reference. Then, we do a second run of the layout, this time we take the current progress of swiping/animating the page and do a linear interpolation between the sizes of the two shown children using that progress and use that interpolated value as the height for our child - PageView.