Scrolling #

Scrolling is one of the most fundamental and crucial interactions in modern app interfaces. In Flutter, the scrolling system is designed with high flexibility and performance to support various interaction scenarios — from simple contact lists to complex transition effects like collapsing app bars, parallax effects, and dynamic combinations of grids and linear lists. Flutter’s entire scrolling ecosystem is built on the highly optimized Sliver architecture foundation. We’ll break down this architecture in depth so you can build smooth, memory-efficient interfaces free from performance degradation (jank).

Flutter’s Scrolling Architecture #

To use scrolling widgets optimally, you need to understand how Flutter renders scrollable elements behind the scenes. Flutter’s scrolling data flow and layout components are structured in the following layered arrangement:

flowchart TD
    ScrollView["ScrollView (ListView / GridView)"] --> Scrollable["Scrollable (Gesture & Physics)"]
    Scrollable --> Viewport["Viewport (Window View)"]
    Viewport --> Slivers["Sliver(s) (Dynamic Rendering)"]
    Slivers --> Children["Children Widgets"]

This scrolling system consists of several main components working synergistically:

  1. Scrollable: The non-visual component responsible for detecting user input gestures (like finger swipes or acceleration flings) and applying them into scroll physics calculations through the ScrollPhysics class.
  2. Viewport: The virtual visual window determining which screen area the user can see. The Viewport acts as the boundary of the display space dimensions.
  3. Sliver: A special layout unit designed to work efficiently inside the Viewport. Unlike regular widgets using RenderBox (static two-dimensional box layout), Slivers use RenderSliver, which allows size calculations based on how much of itself is currently visible in the Viewport (e.g., calculating scroll offsets and remaining display space).
  4. Lazy Loading: This is the most important feature of Flutter’s scrolling architecture. Only items entering or nearly entering the Viewport area get built and rendered. Items scrolled out of the Viewport are automatically destroyed from memory or kept in temporary cache to save system resources.

ListView — Vertical/Horizontal Lists #

ListView is the most fundamental widget for displaying a collection of linear elements in one direction (vertical or horizontal).

1. Standard Constructor (ListView) #

This default constructor is suitable for short lists whose values are already certain (static). Under the hood, this constructor renders all child widgets at once, without lazy loading.

ListView(
  padding: const EdgeInsets.all(16.0),
  children: [
    ListTile(
      leading: const Icon(Icons.payment),
      title: const Text('Payment Methods'),
      trailing: const Icon(Icons.chevron_right),
      onTap: () {},
    ),
    ListTile(
      leading: const Icon(Icons.security),
      title: const Text('Account Security'),
      trailing: const Icon(Icons.chevron_right),
      onTap: () {},
    ),
    const Divider(),
    ListTile(
      leading: const Icon(Icons.help),
      title: const Text('Help Center'),
      trailing: const Icon(Icons.chevron_right),
      onTap: () {},
    ),
  ],
)

2. ListView.builder (Dynamic Lazy Loading) #

This constructor must be used when you have long, unbounded (infinite), or dynamic data from an API. Items are only created when they approach the Viewport.

ListView.builder(
  itemCount: productList.length,
  // itemExtent: Helps Flutter calculate scroll positions in O(1)
  itemExtent: 80.0, 
  itemBuilder: (BuildContext context, int index) {
    final product = productList[index];
    return ListTile(
      key: ValueKey(product.id),
      title: Text(product.name),
      subtitle: Text('Stock: ${product.stock}'),
      trailing: Text('Rp ${product.price}'),
    );
  },
)

3. ListView.separated (List with Automatic Separators) #

If you need divider lines or decorative widgets between list items, the .separated constructor is the cleanest solution because it won’t render a separator before the first item or after the last item.

ListView.separated(
  itemCount: users.length,
  separatorBuilder: (BuildContext context, int index) {
    return const Divider(
      height: 1.0,
      color: Colors.grey,
    );
  },
  itemBuilder: (BuildContext context, int index) {
    return ListTile(
      title: Text(users[index].username),
      subtitle: Text(users[index].email),
    );
  },
)

4. Horizontal ListView #

You just change the scrollDirection property to create a sideways-scrolling list. Make sure child widgets have a defined width to avoid triggering layout errors.

SizedBox(
  height: 60.0,
  child: ListView.builder(
    scrollDirection: Axis.horizontal,
    itemCount: categoryList.length,
    itemBuilder: (BuildContext context, int index) {
      return Padding(
        padding: const EdgeInsets.symmetric(horizontal: 8.0),
        child: Chip(
          label: Text(categoryList[index]),
        ),
      );
    },
  ),
)

GridView — 2D Grid Layout #

GridView is used to render children in a two-dimensional grid form (rows and columns).

1. GridView.count (Static Column Count) #

You explicitly define the number of columns using the crossAxisCount property.

GridView.count(
  crossAxisCount: 3, // Locks the screen to always have 3 columns
  crossAxisSpacing: 10.0,
  mainAxisSpacing: 10.0,
  childAspectRatio: 1.0, // Item width:height ratio (1.0 = square)
  children: List.generate(9, (index) {
    return Container(
      color: Colors.blue[(index + 1) * 100],
      child: Center(child: Text('Item $index')),
    );
  }),
)

2. GridView.builder (Dynamic Grid Loading) #

Similar to ListView.builder, this constructor is very important for grids with large data amounts. You must provide the gridDelegate parameter.

GridView.builder(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,
    crossAxisSpacing: 8.0,
    mainAxisSpacing: 8.0,
    childAspectRatio: 0.75, // Item height is longer than its width
  ),
  itemCount: productCatalog.length,
  itemBuilder: (BuildContext context, int index) {
    return Card(
      child: Column(
        children: [
          Expanded(
            child: Image.network(productCatalog[index].imageUrl, fit: BoxFit.cover),
          ),
          Text(productCatalog[index].name),
        ],
      ),
    );
  },
)

3. Responsive Grid Using MaxCrossAxisExtent #

If you want the column count to automatically adjust to the device screen width (e.g., 2 columns on phones, 4 columns on small tablets, and 6 columns on desktop monitors), use SliverGridDelegateWithMaxCrossAxisExtent.

GridView.builder(
  gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
    maxCrossAxisExtent: 180.0, // Maximum width of each tile is 180px
    crossAxisSpacing: 12.0,
    mainAxisSpacing: 12.0,
    childAspectRatio: 1.0,
  ),
  itemCount: photoGallery.length,
  itemBuilder: (BuildContext context, int index) {
    return Image.network(photoGallery[index].url, fit: BoxFit.cover);
  },
)

SingleChildScrollView — Scrollable Content #

SingleChildScrollView is used to wrap one single widget (usually a Column or Form) so its content can scroll if the content size exceeds the physical screen bounds. This widget is very often used on form-filling screens or static article detail pages.

Widget build(BuildContext context) {
  return Scaffold(
    body: SafeArea(
      child: SingleChildScrollView(
        padding: const EdgeInsets.all(20.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            const Text('Registration Form', style: TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold)),
            const SizedBox(height: 20.0),
            TextFormField(decoration: const InputDecoration(labelText: 'Full Name')),
            const SizedBox(height: 16.0),
            TextFormField(decoration: const InputDecoration(labelText: 'Email Address')),
            const SizedBox(height: 16.0),
            TextFormField(decoration: const InputDecoration(labelText: 'Phone Number')),
            const SizedBox(height: 16.0),
            TextFormField(decoration: const InputDecoration(labelText: 'Password')),
            const SizedBox(height: 32.0),
            ElevatedButton(
              onPressed: () {},
              child: const Text('Create Account'),
            ),
          ],
        ),
      ),
    ),
  );
}
Main Rule: Never wrap large dynamic lists (like ListView.builder) inside a Column placed within a SingleChildScrollView unless you completely disable the list’s internal scrolling. This eliminates the usefulness of lazy loading and triggers very wasteful memory consumption because Flutter is forced to render all items at once off-screen.

PageView — Per-Page Scrolling #

PageView lets users scroll full screens one page at a time, either horizontally (default) or vertically. Common use scenarios for this widget include app onboarding screens, swipeable photo galleries, or short video feeds.

Here’s a PageView implementation example complete with navigation button controls and page dot indicators:

class OnboardingWidget extends StatefulWidget {
  const OnboardingWidget({super.key});

  @override
  State<OnboardingWidget> createState() => _OnboardingWidgetState();
}

class _OnboardingWidgetState extends State<OnboardingWidget> {
  final PageController _pageController = PageController();
  int _currentPageIndex = 0;

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        alignment: Alignment.bottomCenter,
        children: [
          PageView(
            controller: _pageController,
            onPageChanged: (int index) {
              setState(() {
                _currentPageIndex = index;
              });
            },
            children: [
              Container(color: Colors.teal, child: const Center(child: Text('Page 1'))),
              Container(color: Colors.deepOrange, child: const Center(child: Text('Page 2'))),
              Container(color: Colors.indigo, child: const Center(child: Text('Page 3'))),
            ],
          ),
          Positioned(
            bottom: 40.0,
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: List.generate(3, (index) {
                return AnimatedContainer(
                  duration: const Duration(milliseconds: 250),
                  margin: const EdgeInsets.symmetric(horizontal: 6.0),
                  width: _currentPageIndex == index ? 24.0 : 8.0,
                  height: 8.0,
                  decoration: BoxDecoration(
                    borderRadius: BorderRadius.circular(4.0),
                    color: _currentPageIndex == index ? Colors.white : Colors.white54,
                  ),
                );
              }),
            ),
          ),
        ],
      ),
    );
  }
}

ScrollController — Programmatic Control & Infinite Scroll #

ScrollController is used to monitor scrolling activity directly, detect the current scroll offset position, and trigger scroll position changes programmatically.

One of the most common implementations of ScrollController in real apps is the Infinite Scroll feature (Automatic Bottom Data Loading / Lazy Pagination).

Here’s a complete implementation example of the automatic pagination mechanism:

class InfiniteScrollList extends StatefulWidget {
  const InfiniteScrollList({super.key});

  @override
  State<InfiniteScrollList> createState() => _InfiniteScrollListState();
}

class _InfiniteScrollListState extends State<InfiniteScrollList> {
  final ScrollController _scrollController = ScrollController();
  final List<String> _items = List.generate(20, (index) => 'Initial Item #${index + 1}');
  bool _isLoadingMore = false;

  @override
  void initState() {
    super.initState();
    _scrollController.addListener(_onScrollChanged);
  }

  void _onScrollChanged() {
    // Check whether the current scroll position is nearing the bottom of the list
    if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
      _fetchMoreData();
    }
  }

  Future<void> _fetchMoreData() async {
    if (_isLoadingMore) return;

    setState(() {
      _isLoadingMore = true;
    });

    // Simulating an API call for 2 seconds
    await Future.delayed(const Duration(seconds: 2));

    final int currentLength = _items.length;
    setState(() {
      _items.addAll(List.generate(10, (index) => 'New Item #${currentLength + index + 1}'));
      _isLoadingMore = false;
    });
  }

  void _scrollToTop() {
    _scrollController.animateTo(
      0.0,
      duration: const Duration(milliseconds: 500),
      curve: Curves.easeOutCubic,
    );
  }

  @override
  void dispose() {
    _scrollController.removeListener(_onScrollChanged);
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Lazy Pagination')),
      body: ListView.builder(
        controller: _scrollController,
        itemCount: _items.length + (_isLoadingMore ? 1 : 0),
        itemBuilder: (BuildContext context, int index) {
          if (index < _items.length) {
            return ListTile(title: Text(_items[index]));
          } else {
            return const Padding(
              padding: EdgeInsets.all(16.0),
              child: Center(child: CircularProgressIndicator()),
            );
          }
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _scrollToTop,
        child: const Icon(Icons.arrow_upward),
      ),
    );
  }
}

The Sliver System — The Foundation of All Scrolling #

The Sliver system is the foundation on which all Flutter scrolling functionality is implemented. Slivers represent parts of the scroll area that can be configured to behave specially to achieve smooth visual transition effects.

CustomScrollView is the main container used to combine various Sliver components into one unified linear scroll flow:

flowchart TD
    CustomScrollView["CustomScrollView"] --> SliverAppBar["SliverAppBar (Collapsing Head)"]
    CustomScrollView --> SliverToBoxAdapter["SliverToBoxAdapter (Static Widget)"]
    CustomScrollView --> SliverList["SliverList (Vertical List)"]
    CustomScrollView --> SliverGrid["SliverGrid (2D Grid)"]
    CustomScrollView --> SliverFillRemaining["SliverFillRemaining (Viewport Filler)"]

Let’s discuss the essential Sliver components most often used in professional app development:

1. SliverAppBar (Dynamic Navigation) #

SliverAppBar can collapse, expand, or disappear dynamically following the screen swipe direction. Its key properties include:

  • pinned: Keeps the App Bar stuck to the very top of the screen while data is scrolled.
  • floating: Brings the App Bar back as soon as the user detects even a slight upward swipe gesture, without needing to return to the very top of the list.
  • snap: Combines the floating function so the App Bar immediately fully opens/closes automatically based on the short swipe direction.
SliverAppBar(
  expandedHeight: 250.0,
  pinned: true,
  flexibleSpace: FlexibleSpaceBar(
    title: const Text('User Profile'),
    background: Image.network(
      'https://example.com/banner.jpg',
      fit: BoxFit.cover,
    ),
  ),
)

2. SliverToBoxAdapter (Regular Widget Bridge) #

Standard non-Sliver widgets (like Container, Padding, or Card) can’t be placed directly inside the slivers parameter of a CustomScrollView. You must wrap those regular widgets with SliverToBoxAdapter so they can safely participate in the Sliver scrolling flow.

SliverToBoxAdapter(
  child: Padding(
    padding: const EdgeInsets.all(16.0),
    child: Card(
      child: ListTile(
        title: const Text('Promo Information'),
        subtitle: const Text('Up to 50% off this week only!'),
      ),
    ),
  ),
)

3. SliverList & SliverGrid (Dynamic Slivers) #

SliverList and SliverGrid are the Sliver versions of ListView and GridView. Both use a SliverChildDelegate to facilitate dynamic item creation.

SliverList(
  delegate: SliverChildBuilderDelegate(
    (BuildContext context, int index) {
      return ListTile(title: Text('List Item #$index'));
    },
    childCount: 50,
  ),
)

4. SliverFixedExtentList (High-Performance Optimization) #

If every row in your list has exactly the same pixel height (e.g., all ListTile-type list heights are 72.0 pixels), you’re highly recommended to use SliverFixedExtentList.

SliverFixedExtentList(
  itemExtent: 72.0, // Locks each row's height statically
  delegate: SliverChildBuilderDelegate(
    (BuildContext context, int index) {
      return ListTile(title: Text('Sliver Fixed Item #$index'));
    },
    childCount: 1000,
  ),
)

By providing the itemExtent value, Flutter doesn’t need to spend CPU processing power doing layout measurement calculations on child widgets. Flutter can directly calculate every item’s starting offset position through a simple $O(1)$ mathematical equation.


Anti-Pattern: shrinkWrap and NeverScrollableScrollPhysics #

One of the most common architectural mistakes found in beginner Flutter codebases is disabling the list’s internal scroll function and forcing the list to calculate its total size inside an unbounded container (like Column).

// ANTI-PATTERN: Ruining app performance
Widget build(BuildContext context) {
  return SingleChildScrollView(
    child: Column(
      children: [
        const HeaderWidget(),
        ListView.builder(
          shrinkWrap: true, // DANGER: Forces the ListView to measure the total height of all items
          physics: const NeverScrollableScrollPhysics(), // DANGER: Disables the internal scroll mechanism
          itemCount: 1000,
          itemBuilder: (context, index) => ListTile(title: Text('Data #$index')),
        ),
      ],
    ),
  );
}

Why Is This Combination Very Dangerous? #

  • Loses the Lazy Loading Feature: When you set shrinkWrap: true, the ListView is forced to calculate the entire content height of all 1000 items to report to its parent container. As a result, Flutter builds and lays out all 1000 widgets at once in memory when the page first opens, even though the user only sees the first 5 items on their phone screen. This wastes RAM memory and dramatically slows the app’s initial frame rendering.
  • Double Scroll Processing: The system detects swipes through SingleChildScrollView then forwards them to the widgets inside it inefficiently.

A Clean Solution Using CustomScrollView #

Instead of wrapping the list inside a SingleChildScrollView and Column, you should migrate your interface structure entirely into one unified CustomScrollView leveraging the full power of Slivers:

// BEST SOLUTION: Using CustomScrollView & Slivers
Widget build(BuildContext context) {
  return CustomScrollView(
    slivers: [
      // Wrap the static header using SliverToBoxAdapter
      const SliverToBoxAdapter(
        child: HeaderWidget(),
      ),
      // Use SliverList to lazily display dynamic data
      SliverList(
        delegate: SliverChildBuilderDelegate(
          (BuildContext context, int index) {
            return ListTile(title: Text('Optimized Data #$index'));
          },
          childCount: 1000,
        ),
      ),
    ],
  );
}

Scrolling Performance Tips #

To keep your app’s interface visuals at 60 FPS or 120 FPS on high-refresh-rate screens, follow these performance optimization guidelines:

  1. Determine Size Dimensions Early: Use the itemExtent or prototypeItem property on ListView to free the rendering engine from repeated child dimension calculation processes.
  2. Optimize Subtree Structure with Const: Apply const constructors on static decoration widgets inside list builders. This cuts Flutter’s workload in comparing widget tree differences (widget diffing) during screen reconstruction.
  3. Isolate Repaint Areas with RepaintBoundary: If your list items contain dynamic animations or frequently updated custom widgets (like download progress indicators), wrap those widgets with RepaintBoundary. This separates that item’s painting layer from the rest of the list, so Flutter only redraws the animated component without reprocessing the whole list.
  4. Configure the Viewport Cache Wisely: You can set the cacheExtent property on scroll widgets. The default value covers about 250px before and after the active display area. Setting this number slightly higher can smooth complex image rendering so it’s ready before being scrolled into view by the user, but don’t make it too large because it increases memory allocation load.
ListView.builder(
  cacheExtent: 400.0, // Builds items 400px early before entering the viewport
  itemBuilder: (BuildContext context, int index) {
    return RepaintBoundary(
      child: ComplexAnimatedItem(index: index),
    );
  },
)

Summary #

  • One Sliver Ecosystem: All Flutter scrolling widgets (like ListView and GridView) are wrapped implementations of the CustomScrollView widget containing a collection of Sliver components.
  • Use ListView.builder: Avoid loading dynamic data in the default ListView constructor. Leverage builders to enable automatic lazy loading to save RAM.
  • Move Away from shrinkWrap: Avoid the shrinkWrap: true and NeverScrollableScrollPhysics combination pattern for large lists. Replace it with the CustomScrollView and SliverList structure so scrolling performance stays smooth.
  • Use ScrollController: Monitor pixel shifts precisely to trigger programmatic actions (like loading the next page of data when nearing the bottom of the page).
  • Lock itemExtent: Speed up list rendering by statically providing definite size dimensions via itemExtent when all rows have uniform heights.

← Previous: Layout   Next: Input & Forms →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact