Scrolling upwards in a big list causes the scroll to oscillate

Issue

I have a Column with a header text and a nested StreamBuilder through which I build out a list of custom items that I receive from Firebase through my BloC. I noticed however that if I scroll downwards and then try to scroll back up, either slowly or fast, the scroll seems to oscillate (up/down) very quickly and makes very small progress towards going up.

I ran the app in Profile mode and did witness shader junk (which I read can be fixed by warming up Skia shaders) and some other lag but nothing during the issue that I describe. Everything is under 16ms. The grouped_list library doesn’t seem to have any active, related issues either so I’m not sure if it’s something on that end. Here’s my page’s code and a video to better describe the issue:

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

  @override
  State<StatefulWidget> createState() => PickUpPageState();
}

class PickUpPageState extends State<PickUpPage> {
  late PickUpScreenBloc _bloc;
  late GameDetailsBloc _detailsBloc;
  late final StreamSubscription _idsStreamSub;

  @override
  void initState() {
    super.initState();
    _bloc = PickUpScreenBloc();
    _detailsBloc = GameDetailsBloc();
    _setListeners();
  }

  void _setListeners() {
    _idsStreamSub = _bloc.idsStream.listen((ids) {
      _detailsBloc.getDetailsUsingIds(ids);
    });
  }

  @override
  void dispose() {
    _idsStreamSub.cancel();
    _bloc.dispose();
    _detailsBloc.dispose();
    super.dispose();
  }

  @override
  void deactivate() {
    _bloc.dispose();
    _detailsBloc.dispose();
    super.deactivate();
  }

  // used to rebuild the page when a user logged-in and returned to the list page
  FutureOr _onNavigateBack(dynamic val) {
    setState(() {});
  }

  void _handleGameSelected(PickUpGameDetails details) {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => GameDetailsPage(
          details: details,
        ),
      ),
    ).then((value) => _onNavigateBack(value));
  }

  @override
  Widget build(BuildContext context) {
    User? user = FirebaseAuth.instance.currentUser;
    return Column(
      children: [
        Container(
          alignment: Alignment.centerLeft,
          padding:
              const EdgeInsets.only(top: 15, left: 15, right: 15, bottom: 15),
          child: user == null
              ? const Text(
                  'Choose a pick-up game to play in:',
                  style: TextStyle(
                    fontSize: 16,
                    fontWeight: FontWeight.bold,
                    color: Colors.black,
                  ),
                )
              : Text(
                  'Hey ${user.displayName == null ? '{no display name}' : user.displayName!}, choose a pick-up game to play in:',
                  style: const TextStyle(
                    fontSize: 16,
                    fontWeight: FontWeight.bold,
                    color: Colors.black,
                  ),
                ),
        ),
        const SizedBox(
          height: 10,
        ),
        BlocProvider.value(
          value: _detailsBloc,
          child: StreamBuilder<PickUpGameDetails>(
              stream: _detailsBloc.gameDetailsStream,
              builder: (context, snapshot) {
                if (!snapshot.hasError) {
                  if (snapshot.connectionState == ConnectionState.waiting) {
                    return const CircularProgressIndicator();
                  } else {
                    return Expanded(
                      child: GroupedListView<PickUpGameDetails, String>(
                        elements: _detailsBloc.gameDetailsList,
                        sort: true,
                        order: GroupedListOrder.ASC,
                        groupComparator: (group1, group2) =>
                            group1.compareTo(group2),
                        groupBy: (gameItem) =>
                            gameItem.gameData!.dateTime!.substring(4, 8),
                        itemComparator: (item1, item2) =>
                            GameData.getGame24hTime(item1.gameData!.dateTime!)
                                .compareTo(GameData.getGame24hTime(
                                    item2.gameData!.dateTime!)),
                        indexedItemBuilder: (BuildContext context,
                                PickUpGameDetails details, int index) =>
                            InkWell(
                          splashColor: const Color(0xffff5a5f),
                          child: PickUpGameItem(
                              details.gameId!, details, Key(index.toString())),
                          onTap: () => {_handleGameSelected(details)},
                        ),
                        groupHeaderBuilder: (PickUpGameDetails details) =>
                            Padding(
                          padding: const EdgeInsets.only(
                              left: 20, top: 5, bottom: 5),
                          child: Text(
                            details.gameData!.formattedDateTime,
                            style: const TextStyle(
                                fontWeight: FontWeight.bold,
                                fontSize: 20,
                                color: Colors.black),
                          ),
                        ),
                      ),
                    );
                  }
                } else {
                  return ErrorWidget('Something went wrong!');
                }
              }),
        ),
      ],
    );
  }
}

class PickUpGameItem extends StatefulWidget {
  final String gameId;
  final PickUpGameDetails details;

  const PickUpGameItem(this.gameId, this.details, Key? key) : super(key: key);

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

class _PickUpGameItemState extends State<PickUpGameItem> {
  PickUpGameDetails? _gameDetails;
  GameDetailsBloc? _detailsBloc;

  @override
  void initState() {
    super.initState();
    _detailsBloc = BlocProvider.of<GameDetailsBloc>(context);
    _detailsBloc!.subscribeToGameDetailsUpdatesWithId(widget.gameId);
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<Tuple2<String, PickUpGameDetails>>(
        stream: _detailsBloc!.detailsUpdatesStream,
        builder: (context, snapshot) {
          if (snapshot.hasError ||
              snapshot.data == null ||
              snapshot.connectionState == ConnectionState.waiting) {
            _gameDetails = widget.details;
          } else {
            if (snapshot.data!.item1 == widget.gameId) {
              _gameDetails = snapshot.data!.item2;
            } else {
              _gameDetails = widget.details;
            }
          }
          return Container(
            padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
            child: Row(
              children: [
                ClipRRect(
                  borderRadius: BorderRadius.circular(15.0),
                  child: _gameDetails!.locationInfo == null
                      ? const SizedBox()
                      : CachedNetworkImage(
                          imageUrl:
                              _gameDetails!.locationInfo!.pictures.elementAt(0),
                          width: 80,
                          height: 80,
                          fit: BoxFit.fill,
                          placeholder: (context, url) => const SizedBox(
                              child: Center(child: CircularProgressIndicator()),
                              width: 10,
                              height: 10),
                        ),
                ),
                const SizedBox(
                  width: 10,
                ),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        _gameDetails!.locationInfo == null
                            ? 'Loading...'
                            : _gameDetails!.locationInfo!.nam,
                        style: const TextStyle(
                            color: Colors.black,
                            fontWeight: FontWeight.bold,
                            fontSize: 16),
                        maxLines: 2,
                        overflow: TextOverflow.ellipsis,
                      ),
                      const SizedBox(
                        height: 10,
                      ),
                      _gameDetails!.gameData!.hostInfo == null
                          ? Text(
                              _gameDetails!.gameData!.gameTypeMsg!,
                              style: const TextStyle(
                                  color: Colors.black,
                                  fontWeight: FontWeight.normal,
                                  fontSize: 14),
                              maxLines: 2,
                              overflow: TextOverflow.ellipsis,
                            )
                          : Text(
                              '${_gameDetails!.gameData!.gameTypeMsg!} with ${_gameDetails!.gameData!.hostInfo!.hostNickname}.',
                              style: const TextStyle(
                                  color: Colors.black,
                                  fontWeight: FontWeight.normal,
                                  fontSize: 14),
                              maxLines: 2,
                              overflow: TextOverflow.ellipsis,
                            ),
                    ],
                  ),
                ),
                const SizedBox(
                  width: 5,
                ),
                Flexible(
                  flex: 0,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        GameData.getGameTimestamp(
                            _gameDetails!.gameData!.dateTime!),
                        style:
                            const TextStyle(color: Colors.black, fontSize: 15),
                      ),
                      const SizedBox(
                        height: 10,
                      ),
                      Row(
                        children: [
                          Text(
                            '${_gameDetails!.gameData!.getCurrentPlayerNumber()}/${_gameDetails!.gameData!.maxPlayers}',
                            style: const TextStyle(
                                color: Colors.grey,
                                fontWeight: FontWeight.normal,
                                fontSize: 14),
                          ),
                          const SizedBox(
                            width: 5,
                          ),
                          const ImageIcon(
                              AssetImage('assets/icons/profile.png'))
                        ],
                      ),
                    ],
                  ),
                ),
              ],
            ),
          );
        });
  }
}

Video link can be found here

Solution

Add mainAxisSize : MainAxisSize.min to the column widget
Add shrinkWrap : true to the list view widget.
Add physics NeverScrollPhysics to the list view.
Wrap the column with a singleChildScrollView.
If it throws an overflow in the bottom wrap the singleChildScrollView with a container of height same as the device and width as device width.

Answered By – Kaushik Chandru

Answer Checked By – Cary Denson (FlutterFixes Admin)

Leave a Reply

Your email address will not be published. Required fields are marked *