Flutter: How to create a reverse ListView that initially fills empty space at the bottom
Asked Answered
P

7

10

I have a reverse ListView.builder that grows downward but initially has only a single widget in it.

See repo: https://github.com/gmlewis/reverse_listview

It puts all the empty space above the first widget in the list due to the reverse: true, and it looks like: https://github.com/gmlewis/reverse_listview/raw/master/assets/images/UndesiredListView.png

As more widgets are added, it looks like: https://github.com/gmlewis/reverse_listview/raw/master/assets/images/PopulatedListView.png

I would like it to fill all empty space at the bottom instead of at the top, and look like: https://github.com/gmlewis/reverse_listview/raw/master/assets/images/DesiredListView.png Obviously, when the content starts getting long enough to fill the screen, the initial widget scrolls off the top as you would expect.

I dug into the ScrollView and Viewport classes and found the anchor and center settings but could not make it do what I want.

It seems like my only remaining option might be to move the first widget to the bottom of the AppBar but I was hoping to not go that route.

Any ideas?

Penelope answered 12/7, 2018 at 15:54 Comment(2)
I am also facing same issue. If you would have found some please put it in comments.Sigridsigsmond
Where you ever able to solve this?Major
A
2
  1. Use a SizedBox between the options container and Compose Text container.
  2. Get the exact initial height of the SizedBox after the initial build is complete with the addPostFrameCallback.
  3. Keep making the SizedBox smaller as Text is input.
  4. Use TextPainter to get the height of the Text in order to proportionally reduce the height of SizedBox.

Please see the screen recording and the code below:

app screen recording

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Reverse ListView Demo'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

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

class _MyHomePageState extends State<MyHomePage> with TickerProviderStateMixin {
  final TextEditingController _textController = TextEditingController();

  double _sizedBoxHeight;
  Size _screenSize;
  double _textHeight;
  final GlobalKey _redKey = GlobalKey();
  final GlobalKey _appBarKey = GlobalKey();
  double appBarHeight;

  List<Widget> widgets = [];

  Size _getSizes(GlobalKey key) {
    final RenderBox renderBoxRed = key.currentContext.findRenderObject();
    final sizeRed = renderBoxRed.size;
    return sizeRed;
  }

  num _textDetails(BuildContext context, [text = ""]) {
    final TextPainter textPainter = TextPainter(
      text: TextSpan(
        text: text,
        style: TextStyle(
            color: Colors.black,
            fontSize: Theme.of(context).textTheme.bodyText2.fontSize),
      ),
      textDirection: TextDirection.ltr,
      textScaleFactor: MediaQuery.of(context).textScaleFactor,
    )..layout(minWidth: 0, maxWidth: _screenSize.width);
    if (text.isEmpty) {
      return textPainter.size.height;
    } else {
      return textPainter.computeLineMetrics().length;
    }
  }

  _insertBlanks() async {
    final Size _appBarSize = _getSizes(_appBarKey);
    final Size _containerSize = _getSizes(_redKey);

    final int _blankLinesTotal = ((_screenSize.height -
            _appBarSize.height -
            _containerSize.height -
            60) ~/
        _textHeight);

    final double blankLinesHeight = _textHeight * _blankLinesTotal;
    _sizedBoxHeight = blankLinesHeight +
        (_screenSize.height -
            _appBarSize.height -
            _containerSize.height -
            60 -
            blankLinesHeight);
    widgets.insert(0, SizedBox(height: _sizedBoxHeight));
    setState(() {});
  }

  void _addRequest(String text, BuildContext context) {
    setState(() {
      if (_sizedBoxHeight > 0) {
        final int _numLines = _textDetails(context, text);
        _sizedBoxHeight = _sizedBoxHeight - (_textHeight * _numLines);
        widgets[widgets.length - 2] =
            SizedBox(height: _sizedBoxHeight >= 0 ? _sizedBoxHeight : 0);
      }
      widgets.insert(0, Text(text));
    });
  }

  @override
  initState() {
    super.initState();
    final Widget intro = buildHelperIntro();
    widgets.insert(0, intro);
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _insertBlanks();
    });
  }

  @override
  void dispose() {
    _textController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    _screenSize = MediaQuery.of(context).size;
    _textHeight = _textDetails(context);

    return Scaffold(
      appBar: AppBar(
        key: _appBarKey,
        elevation: 0.0,
        title: Text(widget.title),
      ),
      body: Container(
        color: Colors.black12, // Why doesn't this fill the full ListView?
        child: Column(
          children: <Widget>[
            Expanded(
              child: ListView.builder(
                reverse: true,
                itemBuilder: (_, index) => widgets[index],
                itemCount: widgets.length,
              ),
            ),
            const Divider(height: 1.0),
            Container(
              decoration: BoxDecoration(color: Theme.of(context).cardColor),
              child: _buildTextComposer(),
            ),
          ],
        ),
      ),
    );
  }

  Widget buildHelperIntro() {
    return Container(
      key: _redKey,
      color: Colors.black12,
      child: Column(
        children: <Widget>[
          Row(
            mainAxisSize: MainAxisSize.max,
            children: <Widget>[
              Expanded(
                child: Container(
                  padding: const EdgeInsets.fromLTRB(0.0, 4.0, 0.0, 8.0),
                  color: const Color(0xFF2196F3),
                  child: const Text(
                    "Hi! Try clicking an option below.",
                    textAlign: TextAlign.center,
                    style: TextStyle(
                      fontWeight: FontWeight.bold,
                      fontSize: 24.0,
                      color: Colors.white,
                    ),
                  ),
                ),
              ),
            ],
          ),
          Options(
            [
              "Option number 1",
              "Option number 2",
              "Option number 3",
              "Option number 4",
              "Option number 5",
            ],
            (text) {
              _addRequest(text, context);
            },
          ),
        ],
      ),
    );
  }

  updateSubmitButton() {
    if (!mounted) {
      return;
    }
    setState(() {
      // Force evaluation of _textController state.
    });
  }

  void _handleSubmitted(String text, BuildContext context) {
    _textController.clear();
    FocusScope.of(context).requestFocus(FocusNode()); // dismiss keyboard.
    updateSubmitButton();
    _addRequest(text, context);
  }

  Widget _buildTextComposer() {
    return IconTheme(
      data: IconThemeData(color: Theme.of(context).accentColor),
      child: Container(
        height: 60.0,
        margin: const EdgeInsets.symmetric(horizontal: 8.0),
        child: Row(children: <Widget>[
          Flexible(
            child: Card(
              color: Colors.white,
              shape: const RoundedRectangleBorder(
                side: BorderSide(
                  color: Colors.black12,
                ),
                borderRadius: BorderRadius.all(Radius.circular(20.0)),
              ),
              child: Padding(
                padding: const EdgeInsets.all(12.0),
                child: TextField(
                  controller: _textController,
                  onChanged: (String text) {
                    updateSubmitButton();
                  },
                  onSubmitted: (text) {
                    _handleSubmitted(text, context);
                  },
                  decoration: const InputDecoration.collapsed(
                    hintText: "Start typing...",
                  ),
                ),
              ),
            ),
          ),
          Container(
              child: IconButton(
            icon: const Icon(Icons.send, color: Color(0xFF2196F3)),
            onPressed: _textController.text.length > 0
                ? () => _handleSubmitted(_textController.text, context)
                : null,
          )),
        ]),
      ),
    );
  }
}

typedef StringCallback(String text);

class Options extends StatelessWidget {
  final List<String> requests;
  final StringCallback addRequest;

  const Options(this.requests, this.addRequest);

  @override
  Widget build(BuildContext context) {
    final children = List.generate(
        2 * requests.length - 1,
        (int index) => index % 2 == 0
            ? _SingleRequest(requests[index ~/ 2], addRequest)
            : const Divider(color: Colors.black));

    return CustomPaint(
      painter: _DrawArc(),
      child: Padding(
        padding: const EdgeInsets.fromLTRB(14.0, 8.0, 14.0, 8.0),
        child: Card(
          elevation: 10.0,
          color: Colors.white,
          shape: const RoundedRectangleBorder(
              borderRadius: BorderRadius.all(Radius.circular(20.0))),
          child: Padding(
            padding: const EdgeInsets.fromLTRB(0.0, 8.0, 0.0, 8.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: children,
            ),
          ),
        ),
      ),
    );
  }
}

class _SingleRequest extends StatelessWidget {
  final String request;
  final StringCallback addRequest;

  const _SingleRequest(this.request, this.addRequest);

  @override
  Widget build(BuildContext context) {
    return FlatButton(
      onPressed: () {
        addRequest(request);
      },
      child: Row(
        mainAxisSize: MainAxisSize.max,
        mainAxisAlignment: MainAxisAlignment.start,
        children: <Widget>[
          const Padding(
            padding: const EdgeInsets.fromLTRB(4.0, 0.0, 0.0, 3.0),
            child: CircleAvatar(
              radius: 10.0,
              backgroundColor: Color(0xFF2196F3),
              child: CircleAvatar(
                radius: 6.0,
                backgroundColor: Colors.white,
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.fromLTRB(12.0, 6.0, 0.0, 4.0),
            child: Text(
              request,
              style: const TextStyle(
                color: Colors.black,
                fontSize: 20.0,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _DrawArc extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    const padH = 52.0;
    final Paint paint = Paint()..color = const Color(0xFF2196F3);
    canvas.drawRect(Rect.fromLTWH(0.0, 0.0, size.width, padH), paint);
    final h = 0.1 * size.height;
    canvas.drawArc(Rect.fromLTWH(0.0, -0.5 * h + padH, size.width, h), 0.0,
        math.pi, false, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}
Asymptotic answered 8/12, 2020 at 12:11 Comment(1)
Welcome @Glenn. I liked your clean code and simple but beautiful UI.Asymptotic
V
5
 SingleChildScrollView(
          child : ListView.builer(
            shrinkWrap: true,
            reverse: true,
            physics: NeverScrollableScrollPhysics(),
            itemBuilder: (BuildContext context, int index) {
              return something;
            },
          )
      );

Disable the scroll of ListView and add SingleChildScrollView on top. Hope this works for you.

Viddah answered 22/3, 2019 at 16:27 Comment(1)
That's very close, Kathirva, thank you... but I want it to still advance naturally and show the last item added (like a chat window) when more items are added in _addRequest (past the bottom of the screen). How would you focus the scroll on the last item every time a new item is added to the widget list?Penelope
A
2
  1. Use a SizedBox between the options container and Compose Text container.
  2. Get the exact initial height of the SizedBox after the initial build is complete with the addPostFrameCallback.
  3. Keep making the SizedBox smaller as Text is input.
  4. Use TextPainter to get the height of the Text in order to proportionally reduce the height of SizedBox.

Please see the screen recording and the code below:

app screen recording

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Reverse ListView Demo'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

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

class _MyHomePageState extends State<MyHomePage> with TickerProviderStateMixin {
  final TextEditingController _textController = TextEditingController();

  double _sizedBoxHeight;
  Size _screenSize;
  double _textHeight;
  final GlobalKey _redKey = GlobalKey();
  final GlobalKey _appBarKey = GlobalKey();
  double appBarHeight;

  List<Widget> widgets = [];

  Size _getSizes(GlobalKey key) {
    final RenderBox renderBoxRed = key.currentContext.findRenderObject();
    final sizeRed = renderBoxRed.size;
    return sizeRed;
  }

  num _textDetails(BuildContext context, [text = ""]) {
    final TextPainter textPainter = TextPainter(
      text: TextSpan(
        text: text,
        style: TextStyle(
            color: Colors.black,
            fontSize: Theme.of(context).textTheme.bodyText2.fontSize),
      ),
      textDirection: TextDirection.ltr,
      textScaleFactor: MediaQuery.of(context).textScaleFactor,
    )..layout(minWidth: 0, maxWidth: _screenSize.width);
    if (text.isEmpty) {
      return textPainter.size.height;
    } else {
      return textPainter.computeLineMetrics().length;
    }
  }

  _insertBlanks() async {
    final Size _appBarSize = _getSizes(_appBarKey);
    final Size _containerSize = _getSizes(_redKey);

    final int _blankLinesTotal = ((_screenSize.height -
            _appBarSize.height -
            _containerSize.height -
            60) ~/
        _textHeight);

    final double blankLinesHeight = _textHeight * _blankLinesTotal;
    _sizedBoxHeight = blankLinesHeight +
        (_screenSize.height -
            _appBarSize.height -
            _containerSize.height -
            60 -
            blankLinesHeight);
    widgets.insert(0, SizedBox(height: _sizedBoxHeight));
    setState(() {});
  }

  void _addRequest(String text, BuildContext context) {
    setState(() {
      if (_sizedBoxHeight > 0) {
        final int _numLines = _textDetails(context, text);
        _sizedBoxHeight = _sizedBoxHeight - (_textHeight * _numLines);
        widgets[widgets.length - 2] =
            SizedBox(height: _sizedBoxHeight >= 0 ? _sizedBoxHeight : 0);
      }
      widgets.insert(0, Text(text));
    });
  }

  @override
  initState() {
    super.initState();
    final Widget intro = buildHelperIntro();
    widgets.insert(0, intro);
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _insertBlanks();
    });
  }

  @override
  void dispose() {
    _textController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    _screenSize = MediaQuery.of(context).size;
    _textHeight = _textDetails(context);

    return Scaffold(
      appBar: AppBar(
        key: _appBarKey,
        elevation: 0.0,
        title: Text(widget.title),
      ),
      body: Container(
        color: Colors.black12, // Why doesn't this fill the full ListView?
        child: Column(
          children: <Widget>[
            Expanded(
              child: ListView.builder(
                reverse: true,
                itemBuilder: (_, index) => widgets[index],
                itemCount: widgets.length,
              ),
            ),
            const Divider(height: 1.0),
            Container(
              decoration: BoxDecoration(color: Theme.of(context).cardColor),
              child: _buildTextComposer(),
            ),
          ],
        ),
      ),
    );
  }

  Widget buildHelperIntro() {
    return Container(
      key: _redKey,
      color: Colors.black12,
      child: Column(
        children: <Widget>[
          Row(
            mainAxisSize: MainAxisSize.max,
            children: <Widget>[
              Expanded(
                child: Container(
                  padding: const EdgeInsets.fromLTRB(0.0, 4.0, 0.0, 8.0),
                  color: const Color(0xFF2196F3),
                  child: const Text(
                    "Hi! Try clicking an option below.",
                    textAlign: TextAlign.center,
                    style: TextStyle(
                      fontWeight: FontWeight.bold,
                      fontSize: 24.0,
                      color: Colors.white,
                    ),
                  ),
                ),
              ),
            ],
          ),
          Options(
            [
              "Option number 1",
              "Option number 2",
              "Option number 3",
              "Option number 4",
              "Option number 5",
            ],
            (text) {
              _addRequest(text, context);
            },
          ),
        ],
      ),
    );
  }

  updateSubmitButton() {
    if (!mounted) {
      return;
    }
    setState(() {
      // Force evaluation of _textController state.
    });
  }

  void _handleSubmitted(String text, BuildContext context) {
    _textController.clear();
    FocusScope.of(context).requestFocus(FocusNode()); // dismiss keyboard.
    updateSubmitButton();
    _addRequest(text, context);
  }

  Widget _buildTextComposer() {
    return IconTheme(
      data: IconThemeData(color: Theme.of(context).accentColor),
      child: Container(
        height: 60.0,
        margin: const EdgeInsets.symmetric(horizontal: 8.0),
        child: Row(children: <Widget>[
          Flexible(
            child: Card(
              color: Colors.white,
              shape: const RoundedRectangleBorder(
                side: BorderSide(
                  color: Colors.black12,
                ),
                borderRadius: BorderRadius.all(Radius.circular(20.0)),
              ),
              child: Padding(
                padding: const EdgeInsets.all(12.0),
                child: TextField(
                  controller: _textController,
                  onChanged: (String text) {
                    updateSubmitButton();
                  },
                  onSubmitted: (text) {
                    _handleSubmitted(text, context);
                  },
                  decoration: const InputDecoration.collapsed(
                    hintText: "Start typing...",
                  ),
                ),
              ),
            ),
          ),
          Container(
              child: IconButton(
            icon: const Icon(Icons.send, color: Color(0xFF2196F3)),
            onPressed: _textController.text.length > 0
                ? () => _handleSubmitted(_textController.text, context)
                : null,
          )),
        ]),
      ),
    );
  }
}

typedef StringCallback(String text);

class Options extends StatelessWidget {
  final List<String> requests;
  final StringCallback addRequest;

  const Options(this.requests, this.addRequest);

  @override
  Widget build(BuildContext context) {
    final children = List.generate(
        2 * requests.length - 1,
        (int index) => index % 2 == 0
            ? _SingleRequest(requests[index ~/ 2], addRequest)
            : const Divider(color: Colors.black));

    return CustomPaint(
      painter: _DrawArc(),
      child: Padding(
        padding: const EdgeInsets.fromLTRB(14.0, 8.0, 14.0, 8.0),
        child: Card(
          elevation: 10.0,
          color: Colors.white,
          shape: const RoundedRectangleBorder(
              borderRadius: BorderRadius.all(Radius.circular(20.0))),
          child: Padding(
            padding: const EdgeInsets.fromLTRB(0.0, 8.0, 0.0, 8.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: children,
            ),
          ),
        ),
      ),
    );
  }
}

class _SingleRequest extends StatelessWidget {
  final String request;
  final StringCallback addRequest;

  const _SingleRequest(this.request, this.addRequest);

  @override
  Widget build(BuildContext context) {
    return FlatButton(
      onPressed: () {
        addRequest(request);
      },
      child: Row(
        mainAxisSize: MainAxisSize.max,
        mainAxisAlignment: MainAxisAlignment.start,
        children: <Widget>[
          const Padding(
            padding: const EdgeInsets.fromLTRB(4.0, 0.0, 0.0, 3.0),
            child: CircleAvatar(
              radius: 10.0,
              backgroundColor: Color(0xFF2196F3),
              child: CircleAvatar(
                radius: 6.0,
                backgroundColor: Colors.white,
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.fromLTRB(12.0, 6.0, 0.0, 4.0),
            child: Text(
              request,
              style: const TextStyle(
                color: Colors.black,
                fontSize: 20.0,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _DrawArc extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    const padH = 52.0;
    final Paint paint = Paint()..color = const Color(0xFF2196F3);
    canvas.drawRect(Rect.fromLTWH(0.0, 0.0, size.width, padH), paint);
    final h = 0.1 * size.height;
    canvas.drawArc(Rect.fromLTWH(0.0, -0.5 * h + padH, size.width, h), 0.0,
        math.pi, false, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}
Asymptotic answered 8/12, 2020 at 12:11 Comment(1)
Welcome @Glenn. I liked your clean code and simple but beautiful UI.Asymptotic
M
1
return ListView.builder(
  shrinkWrap: true,
  reverse: true,
  itemCount: messages.length,
  itemBuilder: (context, itemIndex) {
    return ConversationListItem(messages[messages.length - 1 - itemIndex]);
  },
);
Millham answered 29/9, 2020 at 2:14 Comment(3)
Sorry, but the only difference here from the actual repo demo is the addition of shrinkWrap: true, and this behaves identically to the repo.Penelope
The index of List of Items (items) now is idx = items.lengh -1 - itemIndex, then the iteration of list of items is n, n-1, n-2 to 0. ConversationListItem(messages[messages.length - 1 - itemIndex]);Millham
There is no ConversationListItem in the repo.Penelope
G
0

Maybe I am misunderstanding your question. but why don't you just remove reverse: true as it is default false

Glauconite answered 27/12, 2018 at 1:32 Comment(1)
Unfortunately, it doesn't behave the same... specifically, it doesn't track the most recent additions to the list. Additionally, the custom rendering isn't preserved nicely.Penelope
L
0
StoreConnector<_ViewModel, List<Message>>(
      converter: (store) {
        // check mark ,reverse data list
        if (isReverse) return store.state.dialogList;
        return store.state.dialogList.reversed.toList();
      },
      builder: (context, dialogs) {
        // Add a callback when UI render after. then change it direction;
        WidgetsBinding.instance.addPostFrameCallback((t) {
          // check it's items could be scroll
          bool newMark = _listViewController.position.maxScrollExtent > 0;
          if (isReverse != newMark) { // need 
            isReverse = newMark;  // rebuild listview
            setState(() {});
          }
        });

        return ListView.builder(
          reverse: isReverse, // if data less, it will false now.
          controller: _listViewController,
          itemBuilder: (context, index) => _bubbleItem(context, dialogs[index], index),
          itemCount: dialogs.length,
        );
      },
    )
Laundress answered 26/4, 2020 at 5:54 Comment(0)
E
0
SingleChildScrollView(
      reverse: false,
      child: ListView.builder(
        shrinkWrap: true,
        reverse: true,
        physics: NeverScrollableScrollPhysics(),
        itemCount: 10,
        itemBuilder: (context, index){
          return Container();
        },
      ),
    )
Egoism answered 5/8, 2023 at 19:19 Comment(2)
This System is used to show last index fast in any list. Hope This help Everyone. thanks everyone from bangladeshEgoism
Your answer could be improved with additional supporting information. Please edit to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers in the help center.Nl
P
0
SingleChildScrollView(
  reverse: true,
  child: ListView.builder(
    shrinkWrap: true,
    reverse: false,
    physics: NeverScrollableScrollPhysics(),
    itemCount: 200,
    itemBuilder: (context, index){
      return Container();
    },
  ),
)

try like this, it's work for me

Paddy answered 28/10, 2023 at 19:45 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.