0

I am working on my first Flutter app (in Android Studio) and I have wasted many hours trying to accomplish the following (including reading every StackOverflow post and webpage I could find).

My screen has three sections in a Column, that is within a SingleChildScrollView. The SingleChildScrollView is necessary for two reasons: to prevent overflow when the device keyboard opens, and to allow the page to scroll when the third section contains more items than will fit on the screen (note: I don't want the ListView in the bottom section to scroll on its own, because on small screens very few items can appear and the UI is not comfortable in that situation, so I'd rather the whole page scrolls).

This is what I am trying to accomplish: When there are few or no items in the ListView, I simply want the grey background color to extend to the bottom of the screen, so that there is no separate white area there (which looks bad). (Alternatively, how can I "fill" the white space at the bottom with the same background color. This would get the desired effect as well. I tried this in many ways as well without success!)

On the other hand, when there are many items, I want the ListView to extend as much as needed to show all of them (with the page scrollable accordingly).

See the screenshots:

The challenge

Page scrolling is necessary in some cases

I've tried every combination of surrounding widgets, both high in the tree and within it. I've tried calculating the height of the screen and setting the height of the bottom area "manually". I've read everything I could find on the topic and did not manage to find a solution: either the visual appearance is not what I want, or there are screen overflow errors, or unbounded constraint errors.

Your solution will be appreciated! Thanks in advance.

Here is the page code:

import 'package:flutter/material.dart';

late int countOfItems;

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

  @override
  State<TestScreen> createState() => _TestScreenState();
}

class _TestScreenState extends State<TestScreen> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      extendBody: true,
      appBar: AppBar(
        title: const Text("This App Title"),
        leading: GestureDetector(
          onTap: () {
            /* Write main menu listener code here */
          },
          child: const Icon(
            Icons.menu,
          ),
        ),
      ),
      body: SingleChildScrollView(
        scrollDirection: Axis.vertical,
        child: Column(children: [
          // Column for rest of screen below appbar
          // Header Section
          Container(
            padding: const EdgeInsets.symmetric(vertical: 33, horizontal: 25),
            // alignment: Alignment.bottomCenter,
            height: 140,
            decoration: BoxDecoration(color: Colors.blue.shade800),
            child: const Row(
                // Top main area row
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Column(
                      // Top main area column
                      mainAxisSize: MainAxisSize.min,
                      crossAxisAlignment: CrossAxisAlignment.center,
                      children: [
                        Text(
                          "Your current balance",
                          style: TextStyle(fontSize: 17, color: Colors.white),
                        ),
                        SizedBox(
                          height: 10,
                        ),
                        Text(
                          "\$250.00",
                          style: TextStyle(
                              fontSize: 36,
                              color: Colors.white,
                              fontWeight: FontWeight.bold),
                        ),
                      ]),
                ]),
          ),
          const SizedBox(
            // Vertical space above New Entry area
            // Separates between top static section and New Entry section
            height: 25,
          ),
          Container(
            // New Entry area
            padding: const EdgeInsets.symmetric(horizontal: 25),
            child: const Row(
              // New entry title
              children: [
                Text(
                  "New entry",
                  style: TextStyle(
                      color: Colors.black,
                      fontSize: 18,
                      fontWeight: FontWeight.bold),
                )
              ], // children
            ),
          ),
          const SizedBox(
            height: 25,
          ),
          Column(
              // New Entry area: two rows: three icons buttons row + input row
              children: [
                const Row(
                    // Three entry option buttons (icon + label)
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    children: [
                      Text(
                        "Opt 1",
                        style: TextStyle(
                        color: Colors.black,
                        fontSize: 28,
                        fontWeight: FontWeight.bold),
                      ), //
                      Text(
                        "Opt 2",
                        style: TextStyle(
                        color: Colors.black,
                        fontSize: 28,
                        fontWeight: FontWeight.bold),
                      ), // Gave
                      Text(
                        "Opt 3",
                        style: TextStyle(
                        color: Colors.black,
                        fontSize: 28,
                        fontWeight: FontWeight.bold),
                      ), // Spent
                    ]),
                Container(
                  // Input line (label + input + icon)
                  padding:
                      const EdgeInsets.symmetric(vertical: 33, horizontal: 25),
                  child: const Row(
                    // Three sub-elements: label + input + icon
                    children: [
                      Padding(
                        padding: EdgeInsets.fromLTRB(20, 8, 20, 8),
                        child: Text(
                          "Enter amount:",
                          style: TextStyle(fontSize: 20),
                        ),
                      ),
                      Expanded(
                        // Give the most space in this row to the text input field
                        child: TextField(
                          maxLength: 12,
                          textAlign: TextAlign.center,
                          style: TextStyle(fontSize: 26),
                          decoration: InputDecoration(
                            hintText: '0.00',
                            counterText: "",
                          ),
                        ),
                      ),
                      Padding(
                        padding: EdgeInsets.fromLTRB(15, 0, 10, 0),
                        child: IconButton(
                          icon: Icon(Icons.check_circle),
                          color: Colors.blue,
                          iconSize: 40.0,
                          onPressed: (null),
                        ),
                      ),
                    ], // Row children array
                  ),
                ),
              ]),
          Container(
            // Recent activity area
            padding: const EdgeInsets.fromLTRB(25, 20, 25, 0),
            decoration: BoxDecoration(color: Colors.grey.withOpacity(0.2)),
            child: const Column(
                // contains title + ListView
                mainAxisSize: MainAxisSize.max,
                children: [
                  Align(
                    alignment: Alignment.topLeft,
                    child: Text(
                      "Recent items",
                      style: TextStyle(
                          color: Colors.black,
                          fontSize: 18,
                          fontWeight: FontWeight.bold),
                    ),
                  ),
                  SizedBox(
                    height: 15,
                  ),
                  TestListView(),
                  SizedBox(
                    height: 10,
                  ),
                ]),
          ),
        ]),
      ),
    );
  }
}

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

  @override
  State<TestListView> createState() => _TestListViewState();
}

class _TestListViewState extends State<TestListView> {
  @override
  void initState() {
    super.initState();
    _getItems(); // Retrieve some recent activities to show on the main screen
  }

  void _getItems() {
    // countOfItems = -1;
    // countOfItems = 0;
    // countOfItems = 3;
    countOfItems = 10;

    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    // This builds the ListView of items or alternative message
    if (countOfItems == -1) {
      // Show Loading spinner
      return const Align(
        alignment: Alignment.topCenter,
        child: Padding(
          padding: EdgeInsets.fromLTRB(0, 28, 0, 28),
          child: CircularProgressIndicator(),
        ),
      );
    } else if (countOfItems == 0) {
      // Show No Items message
      return const Padding(
        padding: EdgeInsets.fromLTRB(0, 28, 0, 28),
        child: Text(
          "No items to show",
          style: TextStyle(color: Colors.black, fontSize: 16),
        ),
      );
    }
    // If reached here, return items in a ListView
    return ListView.separated(
      physics: const NeverScrollableScrollPhysics(),
      shrinkWrap: true,
      separatorBuilder: (BuildContext context, int index) => Divider(
        color: Colors.grey.withOpacity(0.2),
        endIndent: 10,
        indent: 10,
      ),
      itemCount: countOfItems,
      itemBuilder: (context, index) {
        return ListTile(
          title: Text("ListTile $index"),
          titleTextStyle:
              const TextStyle(fontWeight: FontWeight.w500, color: Colors.black),
          visualDensity: const VisualDensity(horizontal: 0, vertical: -4),
        );
      },
    );
  } // if
}
JRose
  • 1
  • 3

2 Answers2

0

In your Container for the 'recent items' section, add the following line:

constraints:
        BoxConstraints(minHeight: double.infinity),

It should make it take as much space as it can.

itayrabin
  • 398
  • 1
  • 2
  • 12
  • Thanks for your answer, but your suggestion (and many similar variations I tried) result in a rendering error (I think, because the page is enclosed in a SingleChildScrollView which does not impose a height limit). This is the error I get now when trying your suggestion: Exception caught by rendering library The following assertion was thrown during performLayout(): RenderBox was not laid out: RenderConstrainedBox#5a6bf relayoutBoundary=up13 NEEDS-PAINT NEEDS-COMPOSITING-BITS-UPDATE 'package:flutter/src/rendering/box.dart': Failed assertion: line 1966 pos 12: 'hasSize' – JRose Jul 24 '23 at 03:35
  • Have you tried putting the container inside an Expanded widget? – itayrabin Jul 24 '23 at 05:03
  • Yes. Expanded is not allowed within SingleChildScrollView. The error reported is: RenderFlex children have non-zero flex but incoming height constraints are unbounded. – JRose Jul 24 '23 at 08:19
0

Admittedly it is not a very sophisticated solution, but it works:

Wrap all the widgets that constitute the 'New entry' section of the screen in a Column, then wrap this Column in a Container. Set the color property of this Container to white or whatever color you want it to be:

Container(
  color: Colors.white,
  child: Column(
    children: [
      const SizedBox(
        // Vertical space above New Entry area
        // Separates between top static section and New Entry section
        height: 25,
        ),
      Container(
        // New Entry area
      ...

Then set the backgroundColor property of your Scaffold and the color property of the Container that contains you ListView to the same color. If you want to achieve the current look use Colors.grey[200]. Do not user colors with opacity.

@override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.grey[200],
      ...
          Container(
            // Recent activity area
            padding: const EdgeInsets.fromLTRB(25, 20, 25, 0),
            decoration: BoxDecoration(color: Colors.grey[200]),
            // Instead of decoration you can simply use:
            // color: Colors.grey[200],
            ...

Alternatively, you can make the 'Recent items' Container expand to fill the remaining space by using a combination of ConstrainedBox, IntrinsicHeight and Expanded widgets. This solution won't work, however, if you want to lay out a ListView inside the Expanded widget. So you would have to replace ListView.separated with a Column in your TestListView widget (this shouldn't be a problem, as your ListView is not scrollable anyway). This method is explained here in the 'Expanding content to fit the viewport' section.

After all these changes your code would look like this:

import 'package:flutter/material.dart';

late int countOfItems;

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

  @override
  State<TestScreen> createState() => _TestScreenState();
}

class _TestScreenState extends State<TestScreen> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // backgroundColor: Colors.grey[200],
      extendBody: true,
      appBar: AppBar(
        title: const Text("This App Title"),
        leading: GestureDetector(
          onTap: () {
            /* Write main menu listener code here */
          },
          child: const Icon(
            Icons.menu,
          ),
        ),
      ),
      body: SingleChildScrollView(
        scrollDirection: Axis.vertical,
        child: ConstrainedBox(
          constraints: BoxConstraints(
            minHeight: MediaQuery.of(context).size.height -
                AppBar().preferredSize.height -
                MediaQuery.of(context).viewPadding.top,
          ),
          child: IntrinsicHeight(
            child: Column(children: [
              // Column for rest of screen below appbar
              // Header Section
              Container(
                padding:
                    const EdgeInsets.symmetric(vertical: 33, horizontal: 25),
                // alignment: Alignment.bottomCenter,
                height: 140,
                decoration: BoxDecoration(color: Colors.blue.shade800),
                child: const Row(
                    // Top main area row
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Column(
                          // Top main area column
                          mainAxisSize: MainAxisSize.min,
                          crossAxisAlignment: CrossAxisAlignment.center,
                          children: [
                            Text(
                              "Your current balance",
                              style:
                                  TextStyle(fontSize: 17, color: Colors.white),
                            ),
                            SizedBox(
                              height: 10,
                            ),
                            Text(
                              "\$250.00",
                              style: TextStyle(
                                  fontSize: 36,
                                  color: Colors.white,
                                  fontWeight: FontWeight.bold),
                            ),
                          ]),
                    ]),
              ),
              const SizedBox(
                // Vertical space above New Entry area
                // Separates between top static section and New Entry section
                height: 25,
              ),
              Container(
                // New Entry area
                padding: const EdgeInsets.symmetric(horizontal: 25),
                child: const Row(
                  // New entry title
                  children: [
                    Text(
                      "New entry",
                      style: TextStyle(
                          color: Colors.black,
                          fontSize: 18,
                          fontWeight: FontWeight.bold),
                    )
                  ], // children
                ),
              ),
              const SizedBox(
                height: 25,
              ),
              Column(
                  // New Entry area: two rows: three icons buttons row + input row
                  children: [
                    const Row(
                        // Three entry option buttons (icon + label)
                        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                        children: [
                          Text(
                            "Opt 1",
                            style: TextStyle(
                                color: Colors.black,
                                fontSize: 28,
                                fontWeight: FontWeight.bold),
                          ), //
                          Text(
                            "Opt 2",
                            style: TextStyle(
                                color: Colors.black,
                                fontSize: 28,
                                fontWeight: FontWeight.bold),
                          ), // Gave
                          Text(
                            "Opt 3",
                            style: TextStyle(
                                color: Colors.black,
                                fontSize: 28,
                                fontWeight: FontWeight.bold),
                          ), // Spent
                        ]),
                    Container(
                      // Input line (label + input + icon)
                      padding: const EdgeInsets.symmetric(
                          vertical: 33, horizontal: 25),
                      child: const Row(
                        // Three sub-elements: label + input + icon
                        children: [
                          Padding(
                            padding: EdgeInsets.fromLTRB(20, 8, 20, 8),
                            child: Text(
                              "Enter amount:",
                              style: TextStyle(fontSize: 20),
                            ),
                          ),
                          Expanded(
                            // Give the most space in this row to the text input field
                            child: TextField(
                              maxLength: 12,
                              textAlign: TextAlign.center,
                              style: TextStyle(fontSize: 26),
                              decoration: InputDecoration(
                                hintText: '0.00',
                                counterText: "",
                              ),
                            ),
                          ),
                          Padding(
                            padding: EdgeInsets.fromLTRB(15, 0, 10, 0),
                            child: IconButton(
                              icon: Icon(Icons.check_circle),
                              color: Colors.blue,
                              iconSize: 40.0,
                              onPressed: (null),
                            ),
                          ),
                        ], // Row children array
                      ),
                    ),
                  ]),
              Expanded(
                child: Container(
                  // Recent activity area
                  padding: const EdgeInsets.fromLTRB(25, 20, 25, 0),
                  decoration: BoxDecoration(color: Colors.grey.withOpacity(0.2)),
                  child: const Column(
                      // contains title + ListView
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        Align(
                          alignment: Alignment.topLeft,
                          child: Text(
                            "Recent items",
                            style: TextStyle(
                                color: Colors.black,
                                fontSize: 18,
                                fontWeight: FontWeight.bold),
                          ),
                        ),
                        SizedBox(
                          height: 15,
                        ),
                        TestListView(),
                        SizedBox(
                          height: 10,
                        ),
                      ]),
                ),
              ),
            ]),
          ),
        ),
      ),
    );
  }
}

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

  @override
  State<TestListView> createState() => _TestListViewState();
}

class _TestListViewState extends State<TestListView> {
  @override
  void initState() {
    super.initState();
    _getItems(); // Retrieve some recent activities to show on the main screen
  }

  void _getItems() {
    // countOfItems = -1;
    // countOfItems = 0;
    countOfItems = 3;
    // countOfItems = 10;

    setState(() {});
  }

  List<Widget> createTiles() {
    var listTiles = <Widget>[];
    for (var i = 1; i <= countOfItems; i++) {
      listTiles.add(
        ListTile(
          title: Text("ListTile $i"),
          titleTextStyle:
              const TextStyle(fontWeight: FontWeight.w500, color: Colors.black),
          visualDensity: const VisualDensity(horizontal: 0, vertical: -4),
        ),
      );
      listTiles.add(
        Divider(
          color: Colors.grey.withOpacity(0.2),
          endIndent: 10,
          indent: 10,
        ),
      );
    }
    return listTiles;
  }

  @override
  Widget build(BuildContext context) {
    // This builds the ListView of items or alternative message
    if (countOfItems == -1) {
      // Show Loading spinner
      return const Align(
        alignment: Alignment.topCenter,
        child: Padding(
          padding: EdgeInsets.fromLTRB(0, 28, 0, 28),
          child: CircularProgressIndicator(),
        ),
      );
    } else if (countOfItems == 0) {
      // Show No Items message
      return const Padding(
        padding: EdgeInsets.fromLTRB(0, 28, 0, 28),
        child: Text(
          "No items to show",
          style: TextStyle(color: Colors.black, fontSize: 16),
        ),
      );
    }
    // If reached here, return items in a ListView
    return Column(
      children: createTiles(),
    );
  } // if
}

For this use-case, this method in my opinion is unnecessarily complicated, error-prone, and may have performance implications, as explained here, so I would choose the first solution.

Peter Henter
  • 163
  • 6
  • Hey @Peter, this is a good solution for the alternative approach (addressing just the aesthetic issue I mentioned in my question), thank you very much! I actually tried something similar during my trial-and-error explorations, but it didn't work well, because I was using withOpacity to set the color, but I didn't realize that until I saw your answer. So, thanks also for the "Do not user colors with opacity." heads-up, that was a valuable tidbit as well! – JRose Jul 25 '23 at 11:07
  • I guess I need to conclude that there is actually no way in Flutter to make a bottom-most widget (that is inside a SingleChildScrollView) expand to the bottom of the screen when it would otherwise be smaller, but extend to a taller height when its content requires it (as in the example in my question above). I say this because I found no solution online, I could not solve it with lots of different types of attempts, and no one is providing an answer here (yet?). I would still be happy to see a solid answer to the question, if one exists! Thanks. – JRose Jul 25 '23 at 11:11
  • Hi @JRose, there is a way to expand your bottom widget, but IMHO it isn't worth the hassle in your case. I added it to my answer anyway, just for educational purposes. :) – Peter Henter Jul 25 '23 at 14:25
  • Hi @Peter, that's it!!! Thanks to your clear explanation, I now understand what that page described. I agree that it is overkill in my situation, but good to know it is possible. I tried to upvote your answer, but the system won't let me (not enough points yet). Appreciate your time. – JRose Jul 25 '23 at 14:44