Stuck When Scrolling in ListView.builder? Here Are the Causes and Solutions

When developing a Flutter app, I ran into a strange bug: the app got stuck when I scrolled back up to the top of a long screen. At first, I thought it was a rendering glitch, but after digging deeper, I found the real cause.

My screen used a ListView.builder, where each item could be different. Some were just simple Text, others were Button or TextField, and one was a complex custom widget with controllers and animations. It turned out that these complex widgets were the reason behind the stuck when scrolling in ListView.builder issue.

After some investigation, I discovered that the complex child widget was being disposed of when it scrolled out of view. When it was re-created while scrolling back up, it interfered with the scroll behavior and caused the screen to get stuck.

This is the lesson I learned from dealing with the stuck when scrolling in ListView.builder issue, and I’d like to share it with you.

Reproduction steps

  1. Create a ListView.builder with many items (so virtualization / recycling happens).
  2. Put a StatefulWidget item that manages e.g. AnimationController, ScrollController, media player, or timers.
  3. Scroll far enough that the item is recycled (goes off-screen).
  4. Scroll back up and observe: the list jumps, sticks, or refuses to reach the top smoothly.

If you add print() statements to the child widget’s initState / dispose, you’ll see it disposes when off-screen and re-inits when it comes back.

Causes of Getting Stuck When Scrolling in ListView.builder

ListView.builder (backed by SliverChildBuilderDelegate) virtualizes children: off-screen children may be disposed to free memory. If a child has important ephemeral state (controllers, subscriptions, internal scroll positions), disposing and recreating it while the user is actively scrolling can cause layout/scrolling glitches.

By default, SliverChildBuilderDelegate supports automatic keepalives (the addAutomaticKeepAlives flag defaults to true). But those keep-alives only do something if the child implements the keep-alive contract. That’s where AutomaticKeepAliveClientMixin comes in.

The Solution — preserve the child’s state

To resolve the issue of getting stuck when scrolling in ListView.builder, here are some steps you can take. Please keep in mind that in many real apps there is no reason to keep every ListView child alive. ListView.builder is designed to recycle and dispose children you don’t need active when they’re off-screen. The common specific case we see is:

  • Most children are plain widgets (text, simple rows, buttons) that are cheap to recreate — they should be allowed to be disposed.
  • One child is a complex custom StatefulWidget (media player, animation-heavy view, active timers, or a sub-scrollable) that must preserve its State while the user scrolls — otherwise re-creating it during active scroll causes layout jumps or “stuck” scrolling.

In that situation the correct approach is:

  1. Keep using ListView.builder (it is efficient and appropriate).
  2. Implement AutomaticKeepAliveClientMixin only on the complex child’s State so only that child is preserved.
  3. Ensure the ListView still uses automatic keep-alives (the default addAutomaticKeepAlives: true), and the complex child calls super.build(context) and returns true for wantKeepAlive.

Below is a complete, minimal main.dart example that demonstrates this pattern. Run it and watch the console to see that SimpleItem gets disposed when scrolled off-screen, while ComplexItem does not preventing the scroll-snap / stuck behavior.

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ListView keepAlive example',
      home: const Scaffold(
        appBar: AppBar(title: Text('Mixed children: keep one alive')),
        body: ExampleList(),
      ),
    );
  }
}

class ExampleList extends StatelessWidget {
  const ExampleList({super.key});

  @override
  Widget build(BuildContext context) {
    // A long list where index 5 is the "complex" item that needs keep-alive.
    return ListView.builder(
      itemCount: 100,
      itemBuilder: (context, index) {
        if (index == 5) {
          // Give it a stable key so Flutter can track identity
          return ComplexItem(key: const ValueKey('complex-item'));
        }
        return SimpleItem(index: index);
      },
    );
  }
}

class SimpleItem extends StatefulWidget {
  final int index;
  const SimpleItem({super.key, required this.index});

  @override
  State<SimpleItem> createState() => _SimpleItemState();
}

class _SimpleItemState extends State<SimpleItem> {
  @override
  void initState() {
    super.initState();
    // Notice this will be called every time the item is created
    // which happens frequently for off-screen recycled items.
    // Helpful for debugging.
    // ignore: avoid_print
    print('SimpleItem.init ${widget.index}');
  }

  @override
  void dispose() {
    // ignore: avoid_print
    print('SimpleItem.dispose ${widget.index}');
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // A cheap row that is fine to recreate
    return Container(
      height: 100,
      alignment: Alignment.centerLeft,
      padding: const EdgeInsets.symmetric(horizontal: 16),
      child: Text('Simple item #${widget.index}'),
    );
  }
}

// This is the complex item that we WANT to keep alive.
class ComplexItem extends StatefulWidget {
  const ComplexItem({super.key});

  @override
  State<ComplexItem> createState() => _ComplexItemState();
}

class _ComplexItemState extends State<ComplexItem>
    with AutomaticKeepAliveClientMixin<ComplexItem>, SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 2),
    )..repeat(reverse: true);
    // ignore: avoid_print
    print('ComplexItem.init');
  }

  @override
  void dispose() {
    _controller.dispose();
    // ignore: avoid_print
    print('ComplexItem.dispose');
    super.dispose();
  }

  // REPORT that we want to be kept alive when off-screen.
  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    // IMPORTANT: call super.build when using AutomaticKeepAliveClientMixin
    super.build(context);

    return Container(
      height: 220,
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text('Complex item (kept alive)', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
          const SizedBox(height: 12),
          // An animated box to simulate heavy/expensive widget
          AnimatedBuilder(
            animation: _controller,
            builder: (context, child) {
              final size = 40.0 + 40.0 * _controller.value;
              return Container(width: size, height: size, color: Colors.blueAccent);
            },
          ),
          const SizedBox(height: 12),
          const Text('This widget holds an active AnimationController and should not be disposed while the user is scrolling.'),
        ],
      ),
    );
  }
}

Important implementation details & common pitfalls

  • Call super.build(context) inside build() when using AutomaticKeepAliveClientMixin. Forgetting this can silently fail to register the keep-alive.
  • If your widget needs TickerProvider, combine with TickerProviderStateMixin or SingleTickerProviderStateMixin.
  • Check SliverChildBuilderDelegate flags: addAutomaticKeepAlives is true by default. Usually you don’t need to change it. If someone created the ListView with addAutomaticKeepAlives: false, the mixin won’t have effect.

Extra tips for this pattern

  • Use stable keys for complex items (ValueKey(id)), especially if your list can reorder or items have identity.
  • Conditional keep-alive: if you only sometimes want to preserve the complex item (e.g., only while playing media), you can expose a keepAlive boolean in the widget and make wantKeepAlive return that value. Then call updateKeepAlive() when the value changes (see the example below).
@override
bool get wantKeepAlive => _shouldKeepAlive;

//Call this function to the changes conditions (e.g., video starts/stops)
void _setShouldKeepAlive(bool value) {
  if (value != _shouldKeepAlive) {
    _shouldKeepAlive = value;
    updateKeepAlive();
  }
}
  • If you have many complex items that all need keeping alive, consider hoisting heavy state (players, controllers) to a manager (provider/Riverpod or a caching service) to avoid keeping hundreds of widgets alive.

Performance considerations

Keeping many complex children alive can increase memory and CPU usage. If you have hundreds of items, keeping too many of them alive is not feasible. Use keep-alive only for items that:

  • Are expensive to reinitialize, and
  • Have limited total count (or can be conditionally kept alive), or
  • The UX requires them not to reset while the user scrolls.

If you need to keep many items’ state but want to limit memory usage, consider moving state to a cache or manager and reusing it across list items.

The issue of getting stuck when scrolling in ListView.builder might seem trivial, but it can significantly impact user experience if not handled properly. I hope the experience I’ve shared here can serve as a useful reference for anyone facing a similar challenge.

Scroll to top