0

I am using the expandable listview mentioned in this SO question by Ajay Kumar. This is working fine but when I modified it to incorporate FutureBuilder to load CircleAvatar, it is causing jank when I collapse the list.

  1. When I click on down arrow, it expands and shows person icons initially, because the image is not loaded. This is fine.
  2. Then the image is loaded and the person icon is replaced by the actual profile image in the CircleAvatar. This is fine too.
  3. But when I click on the up arrow to collapse the list, the profile image is being replaced by the person icon for the fraction of a second and that is causing the jank.

Please see attached image (sorry for poor resolution, generated using some online converter).

CircleAvatar causing jank

I don't understand why the CircleAvatar is downloading the profile image again. To make it remember the state, I tried to wrap it into a StatefulCircleAvatar but that is not working either.

Here is my code:

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(MaterialApp(home: Home()));
}

class Home extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.grey,
      appBar: AppBar(
        title: Text("Expandable List"),
        backgroundColor: Colors.redAccent,
      ),
      body: ListView.builder(
        itemBuilder: (BuildContext context, int index) {
          return ExpandableListView(title: "Title $index");
        },
        itemCount: 5,
      ),
    );
  }
}

class ExpandableListView extends StatefulWidget {
  final String title;

  const ExpandableListView({Key? key, required this.title}) : super(key: key);

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

class _ExpandableListViewState extends State<ExpandableListView> {
  bool expandFlag = false;
  List<String>? _cachedUsers;

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: EdgeInsets.symmetric(vertical: 1.0),
      child: Column(
        children: <Widget>[
          Container(
            color: Colors.blue,
            padding: EdgeInsets.symmetric(horizontal: 5.0),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: <Widget>[
                IconButton(
                    icon: Container(
                      height: 50.0,
                      width: 50.0,
                      decoration: const BoxDecoration(
                        color: Colors.orange,
                        shape: BoxShape.circle,
                      ),
                      child: Center(
                        child: Icon(
                          expandFlag ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
                          color: Colors.white,
                          size: 20.0,
                        ),
                      ),
                    ),
                    onPressed: () {
                      setState(() {
                        expandFlag = !expandFlag;
                        if(expandFlag) _cachedUsers = null;
                      });
                    }),
                Text(
                  widget.title,
                  style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
                )
              ],
            ),
          ),
          ExpandableContainer(
              expanded: expandFlag,
              child:
                (expandFlag && _cachedUsers == null) ?
                    FutureBuilder<List<String>>(
                      future: getMyusers(),
                      builder: (context, snapshot) {
                        if (!snapshot.hasData ||
                            snapshot.connectionState == ConnectionState.waiting) {

                          return Container(
                              height: 200,
                              width: 200,
                              child: Center(child: CircularProgressIndicator()));
                        }
                        if (snapshot.hasError) {
                          return Container(
                              height: 200,
                              width: 200,
                              child: Center(child: Text(snapshot.error.toString())));
                        }
                        _cachedUsers =  snapshot.data!;
                        return ListView.builder(
                          controller: ScrollController(),
                          itemCount: _cachedUsers!.length,
                          itemBuilder: (context, index) {
                            return Container(
                              decoration:
                              BoxDecoration(border: Border.all(width: 1.0, color: Colors.white), color: Colors.black),
                              child: ListTile(
                                title: Text(
                                  _cachedUsers![index],
                                  style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
                                ),
                                leading: StatefulCircleAvatar(userid:10),
                              ),
                            );
                          },
                        );
                      },
                    )
                    :
                    ListView.builder(
                      controller: ScrollController(),
                      itemCount:
                      _cachedUsers==null?0:_cachedUsers!.length,
                      itemBuilder: (context, index) {
                        return ListTile(
                          title: Text(
                            _cachedUsers![index],
                            style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
                          ),
                          leading:// getCircleAvatar(24,NetworkImage("https://lh3.googleusercontent.com/a/AATXAJxaVweFMPZXdbMuuwNAkqwdbw15IK75dYGjRHp6=s96-c"), Colors.amber,),
                          StatefulCircleAvatar(userid:10),
                        );
                      },
                    )
          )
        ],
      ),
    );
  }

  Future<List<String>> getMyusers() async{
    await Future.delayed(Duration(seconds: 1 ));
    return [for(int i=0; i<10; i++) 'user $i'];
  }
}

class ExpandableContainer extends StatelessWidget {
  final bool expanded;
  final double collapsedHeight;
  final double expandedHeight;
  final Widget child;

  ExpandableContainer({
    required this.child,
    this.collapsedHeight = 0.0,
    this.expandedHeight = 300.0,
    this.expanded = true,
  });

  @override
  Widget build(BuildContext context) {
    double screenWidth = MediaQuery.of(context).size.width;
    return AnimatedContainer(
      duration: Duration(milliseconds: 500),
      curve: Curves.easeInOut,
      width: screenWidth,
      height: expanded ? expandedHeight : collapsedHeight,
      child: Container(
        child: child,
        decoration: BoxDecoration(border: Border.all(width: 1.0, color: Colors.blue)),
      ),
    );
  }
}

class StatefulCircleAvatar extends StatefulWidget {
  int userid;

  StatefulCircleAvatar({required this.userid});

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

class _StatefulCircleAvatarState extends State<StatefulCircleAvatar> with AutomaticKeepAliveClientMixin<StatefulCircleAvatar>{
  bool get wantKeepAlive => true;
  bool _checkLoading = true;
  NetworkImage? myImage;
  @override
  void initState() {
    getProfilepicUrl(widget.userid).then((value) {
      myImage = NetworkImage(value);
      myImage!.resolve(ImageConfiguration()).addListener(ImageStreamListener ( (_, __) {
        if (mounted) {
          setState(() {
            _checkLoading = false;
          });
        }
      }) as ImageStreamListener);
    });
  }
  Widget? ca;
  @override
  Widget build(BuildContext context) {
    print('sfca build called $_checkLoading');
    return _checkLoading == true ? CircleAvatar(
        child: personIcon) : CircleAvatar(
      backgroundImage: myImage,);  }

  Future<String> getProfilepicUrl(int userid) async{
    await Future.delayed(Duration(seconds: 1 ));
    print('getprofilepicurl called');
    return "https://lh3.googleusercontent.com/a/AATXAJxaVweFMPZXdbMuuwNAkqwdbw15IK75dYGjRHp6=s96-c";
  }

  Icon personIcon = const Icon(Icons.person);
}
Priyshrm
  • 662
  • 8
  • 20

1 Answers1

0

UPD: Added the actual fix to the asked question, but I would still recommend to use the technique I suggested at the bottom

ListView and ListView.builder particularly only render widgets which are in visible area. To preserve the state of ListView children, use AutomaticKeepAliveClientMixin.

You can try out the following code here: https://dartpad.dev/?id=7bef8a79f16bfb8259cf2204e3694822

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(
        scaffoldBackgroundColor: darkBlue,
      ),
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: ListView.builder(
          itemBuilder: (context, index) => MyStatefulWidget(index: index),
          itemCount: 10,
        ),
      ),
    );
  }
}

class MyStatefulWidget extends StatefulWidget {
  const MyStatefulWidget({required this.index, Key? key}) : super(key: key);

  final int index;

  @override
  State<MyStatefulWidget> createState() => _MyStatefulWidgetState();
}

class _MyStatefulWidgetState extends State<MyStatefulWidget> with AutomaticKeepAliveClientMixin {

  @override
  void initState() {
    super.initState();
    // Will only be called once, because the widget
    // saves its state even when not visible in ListView
    print("init state called at: ${widget.index}"); 
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Container(
      height: 400,
      child: Text("My Statful Widget ${widget.index + 1}"),
    );
  }
  
  @override
  bool get wantKeepAlive => true;
}

old answer below:

It probably happens because your FutureBuilder calls the function getMyusers() several times on state changed. What you could do is:

  1. Create a new variable inside of your _ExpandableListViewState:

late Future<List<String>> _myFutureUsers;

  1. Assign a value to it in init state to ensure it's only called once:
  @override
  void initState() {
    super.initState();
    _myFutureUsers = getMyusers()
  }
  1. Replace future inside your FutureBuilder with new variable:
    FutureBuilder<List<String>>(
                          future: _myFutureUsers,
                          ...
    )
Maksim Nikolaev
  • 794
  • 2
  • 13
  • Thanks for responding, Maksim. Actually, the use case that I am trying to build requires that the users list be refreshed when the list is expanded. So, waiting for profile pic to load upon expansion is ok. The the problem is when the list is collapsed. I am not calling getMyusers at that time. That's why the (expandFlag && _cachedUsers == null) is there. To avoid calling getMyUsers when expandFlag is false. – Priyshrm Jan 22 '22 at 15:36
  • Alright, sorry, I missunderstood the entire point. You can add AutomaticKeepAliveClientMixin. Check out this example and console output: https://dartpad.dev/?id=7bef8a79f16bfb8259cf2204e3694822 – Maksim Nikolaev Jan 23 '22 at 16:36
  • Not sure if you noticed but I already have added AutomaticKeepAliveClientMixin in the code that I posted in the question. That is not helping. Futher, your code does not reflect what I am trying to achieve. Specifically, your listview children do not use FutureBuilder. In my case, the listview children use CircleAvatar which uses a FutureBuilder to download the image. – Priyshrm Jan 27 '22 at 08:45