Flutter Lesson 9: Common List Components



This content originally appeared on DEV Community and was authored by Ge Ji

In mobile application development, lists are one of the most common ways to display data. Whether it’s contact lists, product displays, or news feeds, list components are essential. Flutter provides a rich set of list-related components that can meet various complex layout and performance requirements. This lesson will detail the commonly used list components in Flutter and their advanced usage, helping you build efficient and beautiful list interfaces.

I. Basic Lists: ListView

ListView is the most basic list component in Flutter, which can arrange child components linearly along one direction. ListView has multiple constructors suitable for different usage scenarios.

1. Basic Usage: ListView with Direct Children

The simplest way to use ListView is to directly pass a list of child components in the children property:

ListView(
  // List padding
  padding: const EdgeInsets.all(16),
  // List items
  children: const [
    Text('Item 1', style: TextStyle(fontSize: 18)),
    Text('Item 2', style: TextStyle(fontSize: 18)),
    Text('Item 3', style: TextStyle(fontSize: 18)),
    Text('Item 4', style: TextStyle(fontSize: 18)),
    Text('Item 5', style: TextStyle(fontSize: 18)),
  ],
)

This approach is suitable for cases with a small number of child components, as it creates all child components at once, even if they haven’t been displayed on the screen yet.

2. Optimizing Long Lists: ListView.builder

When the number of list items is large (more than 10) or the list length is uncertain, you should use ListView.builder to build the list. It dynamically creates and destroys list items based on the scroll position, significantly improving performance:

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

  @override
  Widget build(BuildContext context) {
    // Simulate 1000 items of data
    return ListView.builder(
      // Number of list items
      itemCount: 1000,
      // List padding
      padding: const EdgeInsets.all(16),
      // Item builder
      itemBuilder: (context, index) {
        // Only created when needed for display
        return ListTile(
          title: Text('Item ${index + 1}'),
          subtitle: Text('This is item ${index + 1} in the list'),
          leading: const Icon(Icons.list),
          trailing: const Icon(Icons.arrow_forward_ios),
        );
      },
    );
  }
}

Core parameters of ListView.builder:

  • itemCount: The number of list items; when specified, the list will have a definite length
  • itemBuilder: Item construction function that receives an index parameter and returns the item at the corresponding position
  • physics: Controls scrolling behavior (e.g., NeverScrollableScrollPhysics disables scrolling)
  • shrinkWrap: Whether to determine the list size based on child component dimensions, default is false

3. Other ListView Constructors

ListView.separated: Allows adding separators between list items, suitable for scenarios requiring dividers:

ListView.separated(
  itemCount: 50,
  // Item builder
  itemBuilder: (context, index) {
    return ListTile(
      title: Text('Contact ${index + 1}'),
      leading: const CircleAvatar(child: Icon(Icons.person)),
    );
  },
  // Separator builder
  separatorBuilder: (context, index) {
    // Divider between each pair of items
    return const Divider(height: 1);
  },
)
  • ListView.custom: Fully customizes how list items are built, suitable for complex scenarios, needs to work with SliverChildBuilderDelegate or SliverChildListDelegate.

4. List Scroll Control

ScrollController allows you to control list scrolling behavior, such as scrolling to a specific position and listening to scroll events:

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

  @override
  State<ControlledListExample> createState() => _ControlledListExampleState();
}

class _ControlledListExampleState extends State<ControlledListExample> {
  // Create scroll controller
  final ScrollController _scrollController = ScrollController();

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

  // Scroll to top
  void _scrollToTop() {
    _scrollController.animateTo(
      0,
      duration: const Duration(milliseconds: 500),
      curve: Curves.easeInOut,
    );
  }

  // Scroll to bottom
  void _scrollToBottom() {
    _scrollController.animateTo(
      _scrollController.position.maxScrollExtent,
      duration: const Duration(milliseconds: 500),
      curve: Curves.easeInOut,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Controlled List')),
      body: ListView.builder(
        // Associate scroll controller
        controller: _scrollController,
        itemCount: 50,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text('Item ${index + 1}'),
          );
        },
      ),
      floatingActionButton: Row(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton(
            onPressed: _scrollToTop,
            child: const Icon(Icons.arrow_upward),
          ),
          const SizedBox(width: 10),
          FloatingActionButton(
            onPressed: _scrollToBottom,
            child: const Icon(Icons.arrow_downward),
          ),
        ],
      ),
    );
  }
}

II. List Items: ListTile and Custom List Items

List items are the basic building blocks of lists. Flutter provides the preset ListTile style as well as support for fully custom list items.

1. ListTile Component

ListTile is a Material Design-style list item component that contains common list item elements such as icons, titles, and subtitles:

ListTile(
  // Leading icon
  leading: const Icon(Icons.email),
  // Main title
  title: const Text('Contact Us'),
  // Subtitle
  subtitle: const Text('support@example.com'),
  // Trailing icon
  trailing: const Icon(Icons.arrow_forward_ios),
  // Whether it's clickable
  enabled: true,
  // Whether it's selected
  selected: false,
  // Tap event
  onTap: () {
    print('ListTile tapped');
  },
  // Long press event
  onLongPress: () {
    print('ListTile long pressed');
  },
  // Spacing between leading icon and text
  contentPadding: const EdgeInsets.symmetric(horizontal: 16),
)

ListTile variants:

  • CheckboxListTile: List item with checkbox
  • RadioListTile: List item with radio button
  • SwitchListTile: List item with switch

Examples:

// List item with checkbox
CheckboxListTile(
  title: const Text('Remember me'),
  value: true,
  onChanged: (value) {},
  controlAffinity: ListTileControlAffinity.leading, // Checkbox position
)

// List item with switch
SwitchListTile(
  title: const Text('Dark mode'),
  value: false,
  onChanged: (value) {},
  secondary: const Icon(Icons.dark_mode),
)

2. Custom List Items

When ListTile can’t meet your needs, you can build custom list items using basic components like Container and Row:

final products = [
  PItem(
    'a',
    11.2,
    'https://picsum.photos/300/400?random=$3',
    23.1,
  ),
  PItem(
    'b',
    12.2,
    'https://picsum.photos/300/400?random=$5',
    26.1,
  ),
];

class PItem {
  final String name;
  final double price;
  final String imageUrl;
  final double rating;

  PItem(this.name, this.price, this.imageUrl, this.rating);
}

// Custom product list item
class ProductItem extends StatelessWidget {
  final String name;
  final double price;
  final String imageUrl;
  final double rating;

  const ProductItem({
    super.key,
    required this.name,
    required this.price,
    required this.imageUrl,
    required this.rating,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(12),
      margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(8),
        boxShadow: [
          BoxShadow(
            color: Colors.grey.withOpacity(0.2),
            blurRadius: 4,
            offset: const Offset(0, 2),
          ),
        ],
      ),
      child: Row(
        children: [
          // Product image
          ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: Image.network(
              imageUrl,
              width: 80,
              height: 80,
              fit: BoxFit.cover,
            ),
          ),
          const SizedBox(width: 12),
          // Product information
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  name,
                  style: const TextStyle(
                    fontSize: 16,
                    fontWeight: FontWeight.bold,
                  ),
                  maxLines: 2,
                  overflow: TextOverflow.ellipsis,
                ),
                const SizedBox(height: 4),
                // Rating
                Row(
                  children: [
                    const Icon(Icons.star, color: Colors.yellow, size: 16),
                    const SizedBox(width: 4),
                    Text(
                      rating.toString(),
                      style: const TextStyle(
                        fontSize: 14,
                        color: Colors.grey,
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ),
          // Price
          Text(
            '\$${price.toStringAsFixed(2)}',
            style: const TextStyle(
              fontSize: 16,
              fontWeight: FontWeight.bold,
              color: Colors.red,
            ),
          ),
        ],
      ),
    );
  }
}

// Using custom list items
ListView.builder(
  itemCount: products.length,
  itemBuilder: (context, index) {
    final product = products[index];
    return ProductItem(
      name: product.name,
      price: product.price,
      imageUrl: product.imageUrl,
      rating: product.rating,
    );
  },
)

III. Grid Layout: GridView

GridView is used to build grid layouts, suitable for scenarios like image galleries and product grids. It also provides multiple constructors to adapt to different usage needs.

1. GridView.count

GridView.count allows specifying the number of columns per row and is the most commonly used grid layout constructor:

GridView.count(
  // Number of columns per row
  crossAxisCount: 2,
  // Spacing between columns
  crossAxisSpacing: 10,
  // Spacing between rows
  mainAxisSpacing: 10,
  // Grid padding
  padding: const EdgeInsets.all(10),
  // Aspect ratio of child components
  childAspectRatio: 1.0, // Equal width and height
  // List of child components
  children: List.generate(20, (index) {
    return Container(
      color: Colors.blue[100 * ((index % 9) + 1)],
      child: Center(
        child: Text(
          'Item $index',
          style: const TextStyle(fontSize: 18),
        ),
      ),
    );
  }),
)

Core parameters:

  • crossAxisCount: Number of components in the cross-axis direction (i.e., columns per row)
  • childAspectRatio: Aspect ratio of child components, affecting the shape of grid items
  • crossAxisSpacing and mainAxisSpacing: Control spacing between grid items

2. GridView.builder

When there are many grid items, you should use GridView.builder to optimize performance, as it dynamically creates and destroys grid items:

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

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      // Grid layout delegate
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 3, // 3 columns
        crossAxisSpacing: 4,
        mainAxisSpacing: 4,
        childAspectRatio: 0.75, // 4:3 aspect ratio
      ),
      // Number of grid items
      itemCount: 50,
      // Grid item builder
      itemBuilder: (context, index) {
        // Build image grid item
        return ClipRRect(
          borderRadius: BorderRadius.circular(4),
          child: Image.network(
            // Use network image
            'https://picsum.photos/300/400?random=$index',
            fit: BoxFit.cover,
          ),
        );
      },
    );
  }
}

GridView.builder needs to specify grid layout rules through the gridDelegate parameter. Common ones are:

  • SliverGridDelegateWithFixedCrossAxisCount: Fixed number of columns
  • SliverGridDelegateWithMaxCrossAxisExtent: Automatically calculates number of columns based on maximum width

3. GridView.extent

GridView.extent allows specifying the maximum width of grid items and automatically calculates how many columns can fit in each row:

GridView.extent(
  // Maximum width of grid items
  maxCrossAxisExtent: 150,
  // Column spacing
  crossAxisSpacing: 10,
  // Row spacing
  mainAxisSpacing: 10,
  // Padding
  padding: const EdgeInsets.all(10),
  // Child components
  children: List.generate(12, (index) {
    return Container(
      color: Colors.green[100 * ((index % 9) + 1)],
      child: Center(
        child: Text('Item $index'),
      ),
    );
  }),
)

This approach is suitable for scenarios where you need to automatically adjust the number of columns on different screen sizes.


IV. List Scroll Listening and Pull-to-Refresh

In practical applications, we often need to listen to the scroll state of lists (e.g., to implement infinite scroll loading) or provide pull-to-refresh functionality.

1. Pull-to-Refresh: RefreshIndicator

RefreshIndicator is the official component for implementing pull-to-refresh functionality, which is simple to use and conforms to Material Design specifications:

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

  @override
  State<RefreshableList> createState() => _RefreshableListState();
}

class _RefreshableListState extends State<RefreshableList> {
  List<int> _items = List.generate(20, (index) => index);

  // Simulate refreshing data
  Future<void> _refreshData() async {
    // Simulate network request delay
    await Future.delayed(const Duration(seconds: 2));

    // Update data
    setState(() {
      _items = List.generate(20, (index) => index);
    });
  }

  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
      // Refresh indicator color
      color: Colors.blue,
      // Refresh background color
      backgroundColor: Colors.white,
      // Refresh callback function (must return Future)
      onRefresh: _refreshData,
      // Child list
      child: ListView.builder(
        itemCount: _items.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text('Item ${_items[index] + 1}'),
          );
        },
      ),
    );
  }
}

The core of RefreshIndicator is the onRefresh callback function, which must return a Future. When the Future completes, the refresh indicator will stop animating.

2. Infinite Scroll and Scroll Listening

By listening to list scroll events, you can implement infinite scroll loading functionality (automatically loading more data when the user scrolls to the bottom of the list):

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

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

class _InfiniteScrollListState extends State<InfiniteScrollList> {
  final List<int> _items = List.generate(20, (index) => index);
  final ScrollController _scrollController = ScrollController();
  bool _isLoading = false;
  int _page = 1;

  @override
  void initState() {
    super.initState();
    // Listen to scroll events
    _scrollController.addListener(_onScroll);
  }

  @override
  void dispose() {
    // Remove listener and release resources
    _scrollController.removeListener(_onScroll);
    _scrollController.dispose();
    super.dispose();
  }

  // Scroll event handling
  void _onScroll() {
    // Check if scrolled to near the bottom
    if (_scrollController.position.pixels >=
            _scrollController.position.maxScrollExtent - 200 &&
        !_isLoading) {
      // Load more data
      _loadMoreData();
    }
  }

  // Simulate loading more data
  Future<void> _loadMoreData() async {
    setState(() {
      _isLoading = true;
    });

    // Simulate network request delay
    await Future.delayed(const Duration(seconds: 2));

    setState(() {
      _page++;
      // Add new data
      final newItems = List.generate(10, (index) => _items.length + index);
      _items.addAll(newItems);
      _isLoading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      // Number of list items (existing data + loading indicator)
      itemCount: _items.length + (_isLoading ? 1 : 0),
      itemBuilder: (context, index) {
        // If it's the last item and loading, show loading indicator
        if (index == _items.length) {
          return const Padding(
            padding: EdgeInsets.symmetric(vertical: 20),
            child: Center(
              child: CircularProgressIndicator(),
            ),
          );
        }

        // Normal list item
        return ListTile(
          title: Text('Item ${_items[index] + 1}'),
          subtitle: Text('Page ${(_items[index] ~/ 10) + 1}'),
        );
      },
    );
  }
}

Key points for implementing infinite scroll:

  1. Use ScrollController to listen to scroll events
  2. Determine if scrolling to near the bottom position (usually a certain distance from the bottom)
  3. Trigger logic to load more data and display loading indicator
  4. Update list data after loading is complete and hide loading indicator

3. Scroll Position Restoration

In some scenarios (such as after screen rotation or returning after page switching), you need to restore the scroll position of the list. Flutter provides the keepScrollOffset property of ScrollController to achieve this:

final ScrollController _scrollController = ScrollController(
  keepScrollOffset: true, // Default is true, will save scroll position
);

For scenarios where list items change dynamically, you may need to use PageStorageKey to help Flutter save and restore scroll positions:

ListView.builder(
  key: const PageStorageKey<String>('my_list_key'),
  // ...other parameters
)

V. List Performance Optimization

When a list contains a large amount of data or complex list items, performance optimization becomes particularly important. Here are some common list performance optimization techniques:

1. Use Appropriate List Constructors

  • Small amount of fixed data: Use regular constructors of ListView or GridView
  • Large amount of data or dynamic data: Use ListView.builder or GridView.builder
  • Need separators: Use ListView.separated

2. Reduce Rebuild Scope

  • Extract list items into independent StatelessWidget or StatefulWidget
  • Use const constructors to create immutable list items:
// Before optimization
itemBuilder: (context, index) {
  return ListTile(
    title: Text('Item $index'),
    leading: Icon(Icons.item),
  );
}

// After optimization
itemBuilder: (context, index) {
  return MyListItem(
    index: index,
  );
}

// List item component
class MyListItem extends StatelessWidget {
  final int index;

  // Use const constructor
  const MyListItem({super.key, required this.index});

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text('Item $index'),
      leading: const Icon(Icons.item), // Use const for unchanged parts
    );
  }
}

3. Image Optimization

  • Use appropriately sized images to avoid shrinking large images
  • Implement image caching (can use cached_network_image library)
  • Pause image loading when list is scrolling, resume after scrolling stops

4. Avoid Time-Consuming Operations in Build Methods

Ensure the itemBuilder method is concise and efficient. Avoid performing:

  • Complex calculations
  • Network requests
  • Creation of a large number of objects

5. Use RepaintBoundary to Reduce Redraws

For list items that redraw frequently, wrap them with RepaintBoundary to avoid affecting other list items:

itemBuilder: (context, index) {
  return RepaintBoundary(
    child: AnimatedListItem(index: index),
  );
}

VI. Example: Product List Application

The following implements a fully functional product list application with features including grid/list switching, pull-to-refresh, and infinite scrolling:

// Product model
class Product {
  final int id;
  final String name;
  final String description;
  final double price;
  final double rating;
  final String imageUrl;

  Product({
    required this.id,
    required this.name,
    required this.description,
    required this.price,
    required this.rating,
    required this.imageUrl,
  });
}

// Main application
class ProductListApp extends StatefulWidget {
  const ProductListApp({super.key});

  @override
  State<ProductListApp> createState() => _ProductListAppState();
}

class _ProductListAppState extends State<ProductListApp> {
  // Product list data
  final List<Product> _products = [];
  // Scroll controller
  final ScrollController _scrollController = ScrollController();
  // Loading state
  bool _isLoading = false;
  // Current page number
  int _page = 1;
  // Layout mode (grid/list)
  bool _isGridMode = true;

  @override
  void initState() {
    super.initState();
    // Initial data loading
    _loadProducts();
    // Listen to scroll events
    _scrollController.addListener(_onScroll);
  }

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

  // Load product data
  Future<void> _loadProducts({bool isRefresh = false}) async {
    if (isRefresh) {
      // Reset page number when refreshing
      _page = 1;
    }

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

    // Simulate network request
    await Future.delayed(const Duration(seconds: 1));

    // Generate mock data
    final newProducts = List.generate(10, (index) {
      final id = ((_page - 1) * 10) + index + 1;
      return Product(
        id: id,
        name: 'Product $id',
        description: 'This is a description for product $id',
        price: 10.0 + (id * 0.5),
        rating: 3.0 + (id % 2) + (id % 10) * 0.1,
        imageUrl: 'https://picsum.photos/300/300?random=$id',
      );
    });

    setState(() {
      if (isRefresh) {
        _products.clear();
      }
      _products.addAll(newProducts);
      _page++;
      _isLoading = false;
    });
  }

  // Pull to refresh
  Future<void> _refreshProducts() async {
    await _loadProducts(isRefresh: true);
  }

  // Load more when scrolling to bottom
  void _onScroll() {
    if (_scrollController.position.pixels >=
            _scrollController.position.maxScrollExtent - 300 &&
        !_isLoading) {
      _loadProducts();
    }
  }

  // Toggle layout mode
  void _toggleLayoutMode() {
    setState(() {
      _isGridMode = !_isGridMode;
    });
  }

  // Build product item
  Widget _buildProductItem(Product product) {
    if (_isGridMode) {
      // Grid mode item
      return Card(
        elevation: 2,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Expanded(
              child: ClipRRect(
                borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
                child: Image.network(
                  product.imageUrl,
                  width: double.infinity,
                  fit: BoxFit.cover,
                ),
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(8),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    product.name,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 4),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      Text(
                        '\$${product.price.toStringAsFixed(2)}',
                        style: const TextStyle(color: Colors.red),
                      ),
                      Row(
                        children: [
                          const Icon(Icons.star, color: Colors.yellow, size: 16),
                          const SizedBox(width: 2),
                          Text(
                            product.rating.toStringAsFixed(1),
                            style: const TextStyle(fontSize: 14),
                          ),
                        ],
                      ),
                    ],
                  ),
                ],
              ),
            ),
          ],
        ),
      );
    } else {
      // List mode item
      return ListTile(
        leading: ClipRRect(
          borderRadius: BorderRadius.circular(4),
          child: Image.network(
            product.imageUrl,
            width: 50,
            height: 50,
            fit: BoxFit.cover,
          ),
        ),
        title: Text(product.name),
        subtitle: Text(
          product.description,
          maxLines: 1,
          overflow: TextOverflow.ellipsis,
        ),
        trailing: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              '\$${product.price.toStringAsFixed(2)}',
              style: const TextStyle(
                color: Colors.red,
                fontWeight: FontWeight.bold,
              ),
            ),
            Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                const Icon(Icons.star, color: Colors.yellow, size: 14),
                const SizedBox(width: 2),
                Text(
                  product.rating.toStringAsFixed(1),
                  style: const TextStyle(fontSize: 12),
                ),
              ],
            ),
          ],
        ),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Product List'),
        actions: [
          // Layout toggle button
          IconButton(
            icon: Icon(_isGridMode ? Icons.list : Icons.grid_view),
            onPressed: _toggleLayoutMode,
          ),
        ],
      ),
      body: RefreshIndicator(
        onRefresh: _refreshProducts,
        child: _products.isEmpty && _isLoading
            ? const Center(child: CircularProgressIndicator())
            : _products.isEmpty
                ? const Center(child: Text('No products found'))
                : _isGridMode
                    ? GridView.builder(
                        controller: _scrollController,
                        padding: const EdgeInsets.all(8),
                        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                          crossAxisCount: 2,
                          crossAxisSpacing: 8,
                          mainAxisSpacing: 8,
                          childAspectRatio: 0.8,
                        ),
                        itemCount: _products.length + (_isLoading ? 1 : 0),
                        itemBuilder: (context, index) {
                          if (index == _products.length) {
                            return const Center(child: CircularProgressIndicator());
                          }
                          return _buildProductItem(_products[index]);
                        },
                      )
                    : ListView.builder(
                        controller: _scrollController,
                        itemCount: _products.length + (_isLoading ? 1 : 0),
                        itemBuilder: (context, index) {
                          if (index == _products.length) {
                            return const Padding(
                              padding: EdgeInsets.symmetric(vertical: 20),
                              child: Center(child: CircularProgressIndicator()),
                            );
                          }
                          return _buildProductItem(_products[index]);
                        },
                      ),
      ),
    );
  }
}


This content originally appeared on DEV Community and was authored by Ge Ji