Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to scroll an ExpansionTile / Listview when a tile is expanded?

We have a bunch of ExpansionTile within a ListView (so far not too crazy, right?). Our users would like us to "auto scroll" the list when they open up an ExpansionTile so the new expanded tile is now at the top (or as top as it can be) of the screen so they don't have to manually scroll to see what we just opened.

I think we need something like a ScrollController on the ListView -- and to register an onExpansionChanged .. to do .. something. It's the something that's more than a bit vague to me. :)

Any pointers or help on how to do this would be greatly appreciated!

TIA!

like image 907
sjmcdowall Avatar asked Feb 22 '19 14:02

sjmcdowall


People also ask

How do I scroll to a specific item in ListView Flutter?

To scroll a Flutter ListView widget horizontally, set scrollDirection property of the ListView widget to Axis.

How do I scroll to the bottom ListView builder in Flutter?

builder and you need to start your ListView at the bottom. In order to do that you need to use scrollController. jumpTo(scrollController. position.

How do you make a scrollable list in Flutter?

Just change Column widget to ListView widget — and that's all. Your container becomes scrollable.


2 Answers

If I got right what your asking for, is basically a way to scroll automatically your ExpansionTile up so the user doesn't have to do it manually to see its content, right?

Then, you can actually use a ScrollController in your ListView and take advantage of the onExpansionChanged callback of the ExpansionTile to scroll it up/down accordingly to the size of the tile vs. its current position.

But now you are wondering: how do I know how much to scroll?

For that, either you know previously how exactly is the height of your widget when constructing it by giving it some constraints (like building a Container(heigh: 100.0)) or, in this particular scenario, you don't know exactly how many pixels the ExpansionTile height will take. So, we can use a GlobalKey and then check its rendering height by checking its RenderBox at runtime to know exactly how many pixels it's taking when collapsed and multiply it by its index.

  ExpansionTile _buildExpansionTile(int index) {
    final GlobalKey expansionTileKey = GlobalKey();
    double previousOffset;

    return ExpansionTile(
      key: expansionTileKey,
      onExpansionChanged: (isExpanded) {
        if (isExpanded) previousOffset = _scrollController.offset;
        _scrollToSelectedContent(isExpanded, previousOffset, index, expansionTileKey);
      },
      title: Text('My expansion tile $index'),
      children: _buildExpansionTileChildren(),
    );
  }

Ok, now we have a ListView that will use this _buildExpansionTile in its generator with a GlobalKey. We are also saving the scroll offset of the ListView when we expand the tile. But, how do we actually scroll up to show its content? Let's see the _scrollToSelectedContent method.

  void _scrollToSelectedContent(bool isExpanded, double previousOffset, int index, GlobalKey myKey) {
    final keyContext = myKey.currentContext;

    if (keyContext != null) {
      // make sure that your widget is visible
      final box = keyContext.findRenderObject() as RenderBox;
      _scrollController.animateTo(isExpanded ? (box.size.height * index) : previousOffset,
          duration: Duration(milliseconds: 500), curve: Curves.linear);
    }
  }

So, we are accessing the render object by its key and then use the ListView scroll controller to scroll up when expanded and down back to its initial position when collapsed by multiplying its height with its index. You don't have to scroll back, it was just my personal preference for this example. This will result in something like this:

example

And here you have the full example in a StatefulWidget

import 'package:flutter/material.dart';

void main() => runApp(MaterialApp(home: MyApp()));

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final ScrollController _scrollController = ScrollController();

  void _scrollToSelectedContent(bool isExpanded, double previousOffset, int index, GlobalKey myKey) {
    final keyContext = myKey.currentContext;

    if (keyContext != null) {
      // make sure that your widget is visible
      final box = keyContext.findRenderObject() as RenderBox;
      _scrollController.animateTo(isExpanded ? (box.size.height * index) : previousOffset,
          duration: Duration(milliseconds: 500), curve: Curves.linear);
    }
  }

  List<Widget> _buildExpansionTileChildren() => [
        FlutterLogo(
          size: 50.0,
        ),
        Text(
          'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse vulputate arcu interdum lacus pulvinar aliquam. Donec ut nunc eleifend, volutpat tellus vel, volutpat libero. Vestibulum et eros lorem. Nam ut lacus sagittis, varius risus faucibus, lobortis arcu. Nullam tempor vehicula nibh et ornare. Etiam interdum tellus ut metus faucibus semper. Aliquam quis ullamcorper urna, non semper purus. Mauris luctus quam enim, ut ornare magna vestibulum vel. Donec consectetur, quam a mattis tincidunt, augue nisi bibendum est, quis viverra risus odio ac ligula. Nullam vitae urna malesuada magna imperdiet faucibus non et nunc. Integer magna nisi, dictum a tempus in, bibendum quis nisi. Aliquam imperdiet metus id metus rutrum scelerisque. Morbi at nisi nec risus accumsan tempus. Curabitur non sem sit amet tellus eleifend tincidunt. Pellentesque sed lacus orci.',
          textAlign: TextAlign.justify,
        ),
      ];

  ExpansionTile _buildExpansionTile(int index) {
    final GlobalKey expansionTileKey = GlobalKey();
    double previousOffset;

    return ExpansionTile(
      key: expansionTileKey,
      onExpansionChanged: (isExpanded) {
        if (isExpanded) previousOffset = _scrollController.offset;
        _scrollToSelectedContent(isExpanded, previousOffset, index, expansionTileKey);
      },
      title: Text('My expansion tile $index'),
      children: _buildExpansionTileChildren(),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('MyScrollView'),
      ),
      body: ListView.builder(
        controller: _scrollController,
        itemCount: 100,
        itemBuilder: (BuildContext context, int index) => _buildExpansionTile(index),
      ),
    );
  }
}
like image 58
Miguel Ruivo Avatar answered Nov 12 '22 05:11

Miguel Ruivo


Based on this answer you can calculate how much is needed to scroll by getting the current scroll offset of ListView from its controller:

_scrollController.offset

and add it to the current ExpansionTile Y position given from its key:

RenderBox _box = _ExpansiontileKey.currentContext.findRenderObject();
yPosition = _box.localToGlobal(Offset.zero).dy;

This position considers the status bar and AppBar heights, so the scroll point can be calculated by:

scrollPoint = _scrollController.offset + yPosition - MediaQueryData.fromWindow(window).padding.top - 56;

MediaQueryData.fromWindow(window).padding.top will give you the statusbar height and from constats.dart you can get the AppBar height:

/// The height of the toolbar component of the [AppBar].
const double kToolbarHeight = 56.0;

You can avoid miscalculations of the last items by checking if the scrollPoint is below the maximum extent of the scroll:

if(_scrollPoint <= _scrollController.position.maxScrollExtent)
  _scrollController.animateTo(_scrollPoint, duration: Duration(milliseconds: 500), curve: Curves.fastOutSlowIn);
else
  _scrollController.animateTo(_scrollController.position.maxScrollExtent,
    duration: Duration(milliseconds: 500), curve: Curves.fastOutSlowIn
  );

Notice that ExpansionTiles own expansion animation takes 200ms like shown in its definition: const Duration _kExpand = Duration(milliseconds: 200); therefore it is a good idea to wait for expansion animation to finish before starting scrolling so you can get the updated values.

One approach is to delay the the call using Future.delayed(duration):

Future.delayed(Duration(milliseconds: 250)).then((value){
  RenderBox _box = _ExpansiontileKey.currentContext.findRenderObject();
  yPosition = _box.localToGlobal(Offset.zero).dy;
  scrollPoint = _scrollController.offset + yPosition - 
    MediaQueryData.fromWindow(window).padding.top - 56;
    if(_scrollPoint <= _scrollController.position.maxScrollExtent)
      _scrollController.animateTo(_scrollPoint, duration: Duration(milliseconds: 
        500), curve: Curves.fastOutSlowIn);
    else
      _scrollController.animateTo(_scrollController.position.maxScrollExtent, 
        duration: Duration(milliseconds: 500), curve: Curves.fastOutSlowIn);
  });

Other than that you can make your own ExpansionTile and give it whatever duration you want for expanding.

like image 41
Marcelo Ludovico Avatar answered Nov 12 '22 05:11

Marcelo Ludovico