Flutter Layouts & Responsive Design

ListView & Scroll Physics

55 min Lesson 6 of 16

Introduction to ListView

The ListView widget is the most commonly used scrollable widget in Flutter. It displays its children one after another in the scroll direction (vertical by default). Understanding the different constructors and their performance implications is critical for building smooth, efficient scrolling interfaces.

Important: ListView takes up all available space in the cross axis (full width when scrolling vertically). It can only scroll in one direction. For two-dimensional scrolling, you would need a different approach.

ListView with Children (Default Constructor)

The default ListView constructor takes a list of children widgets. All children are built immediately, even those not visible on screen. This is suitable for small lists with a known number of items (roughly fewer than 20-30 items).

Basic ListView

ListView(
  padding: const EdgeInsets.all(16.0),
  children: const [
    ListTile(
      leading: Icon(Icons.person),
      title: Text('Ahmed Al-Farsi'),
      subtitle: Text('Software Engineer'),
    ),
    Divider(),
    ListTile(
      leading: Icon(Icons.person),
      title: Text('Sara Johnson'),
      subtitle: Text('Product Designer'),
    ),
    Divider(),
    ListTile(
      leading: Icon(Icons.person),
      title: Text('Omar Khalid'),
      subtitle: Text('Data Scientist'),
    ),
  ],
)
Warning: Never use the default ListView constructor for large or dynamically generated lists. Since it builds all children upfront, it causes poor performance and high memory usage. Use ListView.builder instead.

ListView.builder (Lazy Loading)

The ListView.builder constructor creates items lazily — only building widgets that are currently visible on screen (plus a small buffer). This is the recommended approach for most lists, especially those with many items or items loaded from a database or API.

ListView.builder Example

// Contact list with 1000 items - only visible items are built
class ContactListScreen extends StatelessWidget {
  final List<String> contacts = List.generate(
    1000,
    (index) => 'Contact \${index + 1}',
  );

  ContactListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: contacts.length,
      itemBuilder: (context, index) {
        return ListTile(
          leading: CircleAvatar(
            child: Text(contacts[index][0]),
          ),
          title: Text(contacts[index]),
          subtitle: Text('+966 5\${index.toString().padLeft(8, '0')}'),
          trailing: const Icon(Icons.chevron_right),
          onTap: () {
            // Handle tap
          },
        );
      },
    );
  }
}

ListView.separated

The ListView.separated constructor is similar to ListView.builder but adds a separator widget between each item. This is perfect for lists that need dividers, spacing, or alternating backgrounds.

ListView.separated Example

ListView.separated(
  itemCount: messages.length,
  separatorBuilder: (context, index) => const Divider(
    height: 1.0,
    indent: 72.0,  // Align with text after avatar
  ),
  itemBuilder: (context, index) {
    final message = messages[index];
    return ListTile(
      leading: CircleAvatar(
        backgroundImage: NetworkImage(message.avatarUrl),
      ),
      title: Text(message.sender),
      subtitle: Text(
        message.text,
        maxLines: 1,
        overflow: TextOverflow.ellipsis,
      ),
      trailing: Text(
        message.timeAgo,
        style: const TextStyle(fontSize: 12, color: Colors.grey),
      ),
    );
  },
)

ListView.custom

The ListView.custom constructor provides maximum control by accepting a SliverChildDelegate. You can use SliverChildBuilderDelegate for lazy building or SliverChildListDelegate for static lists with advanced options like findChildIndexCallback for efficient reordering.

ListView.custom Example

ListView.custom(
  childrenDelegate: SliverChildBuilderDelegate(
    (context, index) {
      return Card(
        key: ValueKey(items[index].id),
        child: ListTile(
          title: Text(items[index].name),
        ),
      );
    },
    childCount: items.length,
    findChildIndexCallback: (Key key) {
      // Helps Flutter efficiently find widgets during reordering
      final valueKey = key as ValueKey<int>;
      final index = items.indexWhere((item) => item.id == valueKey.value);
      return index != -1 ? index : null;
    },
  ),
)

ScrollController

A ScrollController lets you programmatically control and monitor the scroll position. You can scroll to specific positions, listen for scroll events, and determine the current scroll offset.

ScrollController Usage

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

  @override
  State<ScrollableListScreen> createState() => _ScrollableListScreenState();
}

class _ScrollableListScreenState extends State<ScrollableListScreen> {
  final ScrollController _scrollController = ScrollController();
  bool _showScrollToTop = false;

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

  void _onScroll() {
    final showButton = _scrollController.offset > 200;
    if (showButton != _showScrollToTop) {
      setState(() => _showScrollToTop = showButton);
    }
  }

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

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView.builder(
        controller: _scrollController,
        itemCount: 100,
        itemBuilder: (context, index) => ListTile(
          title: Text('Item \${index + 1}'),
        ),
      ),
      floatingActionButton: _showScrollToTop
          ? FloatingActionButton(
              onPressed: _scrollToTop,
              child: const Icon(Icons.arrow_upward),
            )
          : null,
    );
  }
}
Warning: Always dispose of ScrollController in the dispose() method to prevent memory leaks. Also remove listeners if you added them manually.

Scroll Physics

Flutter provides different ScrollPhysics implementations that control how a scrollable widget responds to user input — how it bounces, clamps, or behaves when reaching the edge.

Scroll Physics Types

// BouncingScrollPhysics - iOS-style bounce at edges
ListView.builder(
  physics: const BouncingScrollPhysics(),
  itemCount: 50,
  itemBuilder: (context, index) => ListTile(title: Text('Item \$index')),
)

// ClampingScrollPhysics - Android-style glow at edges (default on Android)
ListView.builder(
  physics: const ClampingScrollPhysics(),
  itemCount: 50,
  itemBuilder: (context, index) => ListTile(title: Text('Item \$index')),
)

// NeverScrollableScrollPhysics - Disables scrolling entirely
// Useful for nested ListViews where parent handles scrolling
ListView.builder(
  physics: const NeverScrollableScrollPhysics(),
  shrinkWrap: true,
  itemCount: 5,
  itemBuilder: (context, index) => ListTile(title: Text('Item \$index')),
)

// AlwaysScrollableScrollPhysics - Allows scrolling even if content fits
ListView(
  physics: const AlwaysScrollableScrollPhysics(),
  children: const [Text('Short content that still scrolls')],
)
Tip: Use BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()) to get the iOS bounce effect while ensuring the list is always scrollable — this is useful for pull-to-refresh functionality.

Scroll Direction and Horizontal Lists

By default, ListView scrolls vertically. Set scrollDirection: Axis.horizontal to create horizontal scrolling lists, commonly used for carousels, category selectors, and image galleries.

Horizontal Carousel

// Horizontal image carousel
SizedBox(
  height: 200.0,  // Must constrain height for horizontal ListView
  child: ListView.builder(
    scrollDirection: Axis.horizontal,
    itemCount: imageUrls.length,
    itemBuilder: (context, index) {
      return Padding(
        padding: const EdgeInsets.symmetric(horizontal: 8.0),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(12.0),
          child: Image.network(
            imageUrls[index],
            width: 300.0,
            fit: BoxFit.cover,
          ),
        ),
      );
    },
  ),
)

shrinkWrap and itemExtent

Two important properties that affect ListView performance and behavior:

shrinkWrap and itemExtent

// shrinkWrap: true - ListView takes only the space it needs
// WARNING: This disables lazy loading! Use sparingly.
Column(
  children: [
    const Text('Header'),
    ListView.builder(
      shrinkWrap: true,  // Wraps to content height
      physics: const NeverScrollableScrollPhysics(),  // Disable its own scrolling
      itemCount: 5,
      itemBuilder: (context, index) => ListTile(
        title: Text('Item \$index'),
      ),
    ),
    const Text('Footer'),
  ],
)

// itemExtent: Forces each item to exact height (improves performance)
ListView.builder(
  itemExtent: 72.0,  // Each item is exactly 72 pixels tall
  itemCount: 1000,
  itemBuilder: (context, index) => ListTile(
    leading: CircleAvatar(child: Text('\${index + 1}')),
    title: Text('Fixed height item'),
  ),
)
Performance Note: Setting itemExtent significantly improves scrolling performance because Flutter does not need to measure each child — it already knows the exact layout. Use it whenever all items have the same height.

Practical Example: Chat Messages

Chat Message List

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

  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  final ScrollController _scrollController = ScrollController();
  final List<ChatMessage> _messages = [];

  void _scrollToBottom() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded(
          child: ListView.builder(
            controller: _scrollController,
            reverse: true,  // Start from bottom like chat apps
            physics: const BouncingScrollPhysics(),
            padding: const EdgeInsets.all(16.0),
            itemCount: _messages.length,
            itemBuilder: (context, index) {
              final message = _messages[index];
              return Align(
                alignment: message.isMine
                    ? Alignment.centerRight
                    : Alignment.centerLeft,
                child: Container(
                  margin: const EdgeInsets.only(bottom: 8.0),
                  padding: const EdgeInsets.all(12.0),
                  decoration: BoxDecoration(
                    color: message.isMine
                        ? Colors.blue.shade100
                        : Colors.grey.shade200,
                    borderRadius: BorderRadius.circular(16.0),
                  ),
                  child: Text(message.text),
                ),
              );
            },
          ),
        ),
        // Message input area would go here
      ],
    );
  }
}
Tip: Using reverse: true on a ListView makes it start from the bottom, which is the natural behavior for chat applications. New messages appear at the bottom and the user scrolls up to see older messages.