caduandrade / tabbed_view

Widget inspired by the classic Desktop-style tab component.
MIT License
49 stars 16 forks source link

Allow intrinsic height #37

Open fjelljager opened 1 year ago

fjelljager commented 1 year ago

Hi, I have another question about TabbedView if you would be willing to help me out,

I've been trying to implement TabbedView inside a column and would like it to resize so that the height equals the height of the selected tab content (instead of double.infinity).

To illustrate: I extended the example code so that TabbedView is in a column with another Container. The TabbedView is constrained to a fixed height and everything works as expected:

image
class TabbedViewExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        debugShowCheckedModeBanner: false, home: TabbedViewExamplePage());
  }
}

class TabbedViewExamplePage extends StatefulWidget {
  @override
  _TabbedViewExamplePageState createState() => _TabbedViewExamplePageState();
}

class _TabbedViewExamplePageState extends State<TabbedViewExamplePage> {
  late TabbedViewController _controller;

  @override
  void initState() {
    super.initState();
    List<TabData> tabs = [];

    tabs.add(TabData(
        text: 'Tab 1',
        leading: (context, status) => Icon(Icons.star, size: 16),
        content: Padding(child: Text('Hello'), padding: EdgeInsets.all(8))));
    tabs.add(TabData(
        text: 'Tab 2',
        content:
            Padding(child: Text('Hello again'), padding: EdgeInsets.all(8))));
    tabs.add(TabData(
        closable: false,
        text: 'TextField',
        content: Padding(
            child: TextField(
                decoration: InputDecoration(
                    isDense: true, border: OutlineInputBorder())),
            padding: EdgeInsets.all(8)),
        keepAlive: true));

    _controller = TabbedViewController(tabs);
  }

  @override
  Widget build(BuildContext context) {
    TabbedView tabbedView = TabbedView(controller: _controller);
    Widget w =
        TabbedViewTheme(child: tabbedView, data: TabbedViewThemeData.mobile());
    return Scaffold(body: Container(child: Column(
      children: [
        Container(
          color: Colors.blue,
          child: SizedBox(
            width: double.infinity,
            height: 400,
            child: w
          ),
        ),
        Expanded(
          child: Container(
            color: Colors.green,
            child: SizedBox(width: double.infinity,),
          ),
        )
      ],
    ), padding: EdgeInsets.all(32)));
  }
}

The problem occurs when I comment out height: 400 for the SizedBox around the TabbedView. I was expecting the TabbedView to shrink to the size of the content, but instead I get the following error:

image
The following assertion was thrown during performLayout():
RenderCustomMultiChildLayoutBox object was given an infinite size during layout.

This probably means that it is a render object that tries to be as big as possible, but it was put inside another render object that allows its children to pick their own size.
The nearest ancestor providing an unbounded height constraint is: RenderFlex#c885f relayoutBoundary=up2 OVERFLOWING
...  needs compositing
...  parentData: offset=Offset(32.0, 32.0) (can use size)
...  constraints: BoxConstraints(0.0<=w<=593.8, 0.0<=h<=564.8)
...  size: Size(621.8, 576.0)
...  direction: vertical
...  mainAxisAlignment: start
...  mainAxisSize: max
...  crossAxisAlignment: center
...  verticalDirection: down
The constraints that applied to the RenderCustomMultiChildLayoutBox were: BoxConstraints(w=593.8, 0.0<=h<=Infinity)
The exact size it was given was: Size(593.8, Infinity)

See https://flutter.dev/docs/development/ui/layout/box-constraints for more information.

Please could you let me know if this is a bug in TabbedView or if I just have a bad understanding of the layout system in Flutter and need to revise that? If the latter then and suggestions would be appreciated. Thank you for your time in advance!

caduandrade commented 1 year ago

Hi!

Hey! In this case, it is a misunderstanding of the layout in Flutter. If you notice, the same problem remains when replacing the TabbedView with a pure Container.

Scaffold(
        body: Container(
            child: Column(
              children: [
                Container(
                  color: Colors.blue,
                  height: 400,
                ),
                Expanded(
                  child: Container(color: Colors.green),
                )
              ],
              crossAxisAlignment: CrossAxisAlignment.stretch,
            ),
            padding: EdgeInsets.all(32)));

I don't know what other components would be on the screen or where this one would fit, but it might not be appropriate to use Column in this case. If the height is dynamic, you might have to use a LayoutBuilder as well. It is also possible to create your own layout.

fjelljager commented 1 year ago

Thank you for getting back to me!

I've read your answer and looked into it some more, I think you may have understood slightly what I'm trying to do.

It's not necessary only with a column, I'll show an example even closer to your original:

In your original example, I added a container behind the text in the first tab just to help illustrate so it looks like this:

tabs.add(TabData(
        text: 'Tab 1',
        leading: (context, status) => Icon(Icons.star, size: 16),
        content: Padding(child: Container(
          color: Colors.red,
          child: Text('Hello'),
        ), padding: EdgeInsets.all(8))));
image

Note how the red container and whole TabbedView expand to fill the screen.

Now I've simply replaced TabbedView with a container and it behaves as I would like it to (i.e. the red container wraps tightly around the Text instead of expanding to the edge of the screen):

@override
  Widget build(BuildContext context) {
    TabbedView tabbedView = TabbedView(controller: _controller);
    Widget w =
        TabbedViewTheme(child: tabbedView, data: TabbedViewThemeData.mobile());
    return Scaffold(body: Container(
        // child: w,
        child: Container(
          child: Container(
            color: Colors.red,
            child: Text('Hello'),
          ),
        ),
        padding: EdgeInsets.all(32)));
  }
image

Anyway, I forked the repository and found out how to fix it. I made the following changes to the code for TabbedView:

// Layout delegate for [TabbedView]
class _TabbedViewLayout extends MultiChildLayoutDelegate {
  @override
  void performLayout(Size size) {
    Size childSize = Size.zero;
    if (hasChild(1)) {
      childSize = layoutChild(
          1,
          BoxConstraints(
              minWidth: size.width,
              maxWidth: size.width,
              minHeight: 0,
              maxHeight: size.height));
      positionChild(1, Offset.zero);
    }
    double height = math.max(0, size.height - childSize.height);
    // ORIGINAL CODE
    // layoutChild(2, BoxConstraints.tightFor(width: size.width, height: height));
    // NEW CODE
    final padding = 8.0;
    layoutChild(2, BoxConstraints.tightFor(width: size.width, height: childSize.height + padding));

    positionChild(2, Offset(0, childSize.height));
  }

  @override
  bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate) {
    return false;
  }
}
image

I only changed two lines, however it now behaves as I wanted it to (at least in the vertical direction).

I was thinking that maybe another parameter called Bool tightLayout for TabbedView could allow you to use TabbedView in either way depending on your needs. The only thing to solve is getting the correct padding.

Please could I either a) ask for this as a feature request or b) try to implement it and then submit a pull request?

Thank you

caduandrade commented 1 year ago

Hi @fjelljager!

Are you wanting TabbedView to have the height according to your content right? If so, then you’re looking for something like:

IntrinsicHeight(child: tabbedView)

But that won’t work because CustomMultiChildLayout will always take up the entire space of the parent container. That’s why in the example, it seems to work but in fact, continues to fill all the available space. If you create a wrapper container of another color you will see that it continues to fill everything. This would still be a problem if you were to put it inside a layout because the height will remain "infinite".

I believe it would be necessary to swap the use of CustomMultiChildLayout for a layout made from scratch by extending MultiChildRenderObjectWidget (as is done in TabsAreaLayout). Then you could implement the computeMaxIntrinsicHeight and computeMinIntrinsicHeight methods.

The TabbedView could take a boolean parameter to indicate whether or not the layout should have the "Intrinsic height" behavior.

fjelljager commented 1 year ago

Hi @caduandrade

Thank you for getting back to me!

Yes exactly, that's what I'm looking for. Thanks for pointing out that what I was saying didn't work, I went and tried it with a container behind and saw what you mean.

I've been doing some research into MultiChildRenderObjectWidget like you suggested and tried to make a basic implementation. I had some limited success, i.e. it seemed like it would work (because it now does not render the coloured container behind). However I eventually go lost with the computeMaxIntrinsicHeight method for TabsAreaLayout and ContentArea since it's quite complicated to understand what's going on. Please bear in mind I am quite new to Flutter so I I have not tried to go this deep into widgets before.

Here is the code I wrote so far:

Layout for TabbedView

class TabbedViewMultiChildRenderObjectWidget extends MultiChildRenderObjectWidget {
  TabsArea? tabsArea;

  TabbedViewMultiChildRenderObjectWidget({
    Key? key,
    required this.tabsArea,
    required ContentArea contentArea,
  }) : super(
          key: key,
          children:
              (tabsArea == null) ? [contentArea] : [tabsArea!, contentArea],
        );

  @override
  RenderObject createRenderObject(BuildContext context) {
    return TabbedViewRenderObject(tabsAreaVisible: (tabsArea != null));
  }
}

class TabbedViewParentData extends ContainerBoxParentData<RenderBox>
    with ContainerParentDataMixin<RenderBox> {}

class TabbedViewRenderObject extends RenderBox
    with
        ContainerRenderObjectMixin<RenderBox, TabbedViewParentData>,
        RenderBoxContainerDefaultsMixin<RenderBox, TabbedViewParentData> {
  late bool tabsAreaVisible;

  TabbedViewRenderObject({required bool tabsAreaVisible}) {
    this.tabsAreaVisible = tabsAreaVisible;
  }

  @override
  void setupParentData(covariant RenderObject child) {
    child.parentData = TabbedViewParentData();
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    defaultPaint(context, offset);
  }

  @override
  void performLayout() {
    RenderBox? contentArea;

    if (this.tabsAreaVisible) {
      // Case where tabs are visible
      var tabsArea = firstChild;

      if (tabsArea == null) {
        size = constraints.smallest;
        return;
      }

      tabsArea.layout(
          BoxConstraints.tight(
              Size(constraints.maxWidth,
                  tabsArea.getMaxIntrinsicHeight(constraints.maxWidth)
              )),
          parentUsesSize: true
      );

      contentArea = childAfter(tabsArea);

      if (contentArea == null) {
        size = constraints.smallest;
        return;
      }

      // print(contentArea.getMaxIntrinsicHeight(constraints.maxWidth));
      // ^Throws an error

      // TODO: Find intrinsic max height of contentArea
      contentArea.layout(BoxConstraints.tight(
          Size(constraints.maxWidth, constraints.maxHeight - 300)),
          parentUsesSize: true);

      var contentAreaParentData =
          contentArea.parentData as TabbedViewParentData;
      contentAreaParentData.offset = Offset(
        0,
        tabsArea.size.height,
      );

      size = Size(
          constraints.maxWidth, tabsArea.size.height + contentArea.size.height);
    } else {
      // Case where tabs aren't visible
      contentArea = firstChild;

      if (contentArea == null) {
        size = constraints.smallest;
        return;
      }

      contentArea.layout(constraints, parentUsesSize: true);

      size = Size(constraints.maxWidth, contentArea.size.height);
    }
  }
}

Method for _TabsAreaLayoutRenderBox (I couldn't work out how to do the calculation but I found that 28 works... I know this wouldn't be a correct for a production version but it's just to illustrate).

@override double computeMaxIntrinsicHeight(double width) {
    // TODO: implement computeMaxIntrinsicHeight
    // return super.computeMaxIntrinsicHeight(width);

    return 28;
  }

The content area also needs an implementation, but again I'm not too sure how to do it. Currently calling print(contentArea.getMaxIntrinsicHeight(constraints.maxWidth)); throws an error so it seems like it doesn't currently have an intrinsic height.

image

If you could provide any pointers that would be much appreciated :).