mirror of
https://github.com/flutter/samples.git
synced 2025-11-08 13:58:47 +00:00
Add the infinite_list sample (#440)
This PR adds a Flutter sample app that shows an implementation of the "infinite list" UX pattern. That is, a list is shown to the user as if it was continuous although it is internally paginated. This is a common feature of mobile apps, from shopping catalogs through search engines to social media clients.
This commit is contained in:
66
infinite_list/lib/main.dart
Normal file
66
infinite_list/lib/main.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright 2020 The Chromium Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'src/catalog.dart';
|
||||
import 'src/item_tile.dart';
|
||||
|
||||
void main() {
|
||||
runApp(MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider<Catalog>(
|
||||
create: (context) => Catalog(),
|
||||
child: MaterialApp(
|
||||
title: 'Infinite List Sample',
|
||||
home: MyHomePage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Infinite List Sample'),
|
||||
),
|
||||
body: Selector<Catalog, int>(
|
||||
// Selector is a widget from package:provider. It allows us to listen
|
||||
// to only one aspect of a provided value. In this case, we are only
|
||||
// listening to the catalog's `itemCount`, because that's all we need
|
||||
// at this level.
|
||||
selector: (context, catalog) => catalog.itemCount,
|
||||
builder: (context, itemCount, child) => ListView.builder(
|
||||
// When `itemCount` is null, `ListView` assumes an infinite list.
|
||||
// Once we provide a value, it will stop the scrolling beyond
|
||||
// the last element.
|
||||
itemCount: itemCount,
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
itemBuilder: (context, index) {
|
||||
// Every item of the `ListView` is individually listening
|
||||
// to the catalog.
|
||||
var catalog = Provider.of<Catalog>(context);
|
||||
|
||||
// Catalog provides a single synchronous method for getting
|
||||
// the current data.
|
||||
var item = catalog.getByIndex(index);
|
||||
|
||||
if (item.isLoading) {
|
||||
return LoadingItemTile();
|
||||
}
|
||||
|
||||
return ItemTile(item: item);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
43
infinite_list/lib/src/api/fetch.dart
Normal file
43
infinite_list/lib/src/api/fetch.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright 2020 The Chromium Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'item.dart';
|
||||
import 'page.dart';
|
||||
|
||||
const catalogLength = 200;
|
||||
|
||||
/// This function emulates a REST API call. You can imagine replacing its
|
||||
/// contents with an actual network call, keeping the signature the same.
|
||||
///
|
||||
/// It will fetch a page of items from [startingIndex].
|
||||
Future<ItemPage> fetchPage(int startingIndex) async {
|
||||
// We're emulating the delay inherent to making a network call.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// If the [startingIndex] is beyond the bounds of the catalog, an
|
||||
// empty page will be returned.
|
||||
if (startingIndex > catalogLength) {
|
||||
return ItemPage(
|
||||
items: [],
|
||||
startingIndex: startingIndex,
|
||||
hasNext: false,
|
||||
);
|
||||
}
|
||||
|
||||
// The page of items is generated here.
|
||||
return ItemPage(
|
||||
items: List.generate(
|
||||
itemsPerPage,
|
||||
(index) => Item(
|
||||
color: Colors.primaries[index % Colors.primaries.length],
|
||||
name: 'Color #${startingIndex + index}',
|
||||
price: 50 + (index * 42) % 200,
|
||||
)),
|
||||
startingIndex: startingIndex,
|
||||
// Returns `false` if we've reached the [catalogLength].
|
||||
hasNext: startingIndex + itemsPerPage < catalogLength,
|
||||
);
|
||||
}
|
||||
23
infinite_list/lib/src/api/item.dart
Normal file
23
infinite_list/lib/src/api/item.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright 2020 The Chromium Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Item {
|
||||
final Color color;
|
||||
|
||||
final int price;
|
||||
|
||||
final String name;
|
||||
|
||||
Item({
|
||||
@required this.color,
|
||||
@required this.name,
|
||||
@required this.price,
|
||||
});
|
||||
|
||||
Item.loading() : this(color: Colors.grey, name: '...', price: 0);
|
||||
|
||||
bool get isLoading => name == '...';
|
||||
}
|
||||
24
infinite_list/lib/src/api/page.dart
Normal file
24
infinite_list/lib/src/api/page.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright 2020 The Chromium Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
import 'item.dart';
|
||||
|
||||
const int itemsPerPage = 20;
|
||||
|
||||
class ItemPage {
|
||||
final List<Item> items;
|
||||
|
||||
final int startingIndex;
|
||||
|
||||
final bool hasNext;
|
||||
|
||||
ItemPage({
|
||||
@required this.items,
|
||||
@required this.startingIndex,
|
||||
@required this.hasNext,
|
||||
});
|
||||
}
|
||||
120
infinite_list/lib/src/catalog.dart
Normal file
120
infinite_list/lib/src/catalog.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright 2020 The Chromium Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'api/fetch.dart';
|
||||
import 'api/item.dart';
|
||||
import 'api/page.dart';
|
||||
|
||||
/// The [Catalog] holds items in memory, provides a synchronous access
|
||||
/// to them via [getByIndex], and notifies listeners when there is any change.
|
||||
class Catalog extends ChangeNotifier {
|
||||
/// This is the maximum number of the items we want in memory in each
|
||||
/// direction from the current position. For example, if the user
|
||||
/// is currently looking at item number 400, we don't want item number
|
||||
/// 0 to be kept in memory.
|
||||
static const maxCacheDistance = 100;
|
||||
|
||||
/// The internal store of pages that we got from [fetchPage].
|
||||
/// The key of the map is the starting index of the page, for faster
|
||||
/// access.
|
||||
final Map<int, ItemPage> _pages = {};
|
||||
|
||||
/// A set of pages (represented by their starting index) that have started
|
||||
/// the fetch process but haven't ended it yet.
|
||||
///
|
||||
/// This is to prevent fetching of a page several times in a row. When a page
|
||||
/// is already being fetched, we don't initiate another fetch request.
|
||||
final Set<int> _pagesBeingFetched = {};
|
||||
|
||||
/// The size of the catalog. This is `null` at first, and only when the user
|
||||
/// reaches the end of the catalog, it will hold the actual number.
|
||||
int itemCount;
|
||||
|
||||
/// After the catalog is disposed, we don't allow it to call
|
||||
/// [notifyListeners].
|
||||
bool _isDisposed = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// This is a synchronous method that returns the item at [index].
|
||||
///
|
||||
/// If the item is already in memory, this will just return it. Otherwise,
|
||||
/// this method will initiate a fetch of the corresponding page, and will
|
||||
/// return [Item.loading].
|
||||
///
|
||||
/// The UI will be notified via [notifyListeners] when the fetch
|
||||
/// is completed. At that time, calling this method will return the newly
|
||||
/// fetched item.
|
||||
Item getByIndex(int index) {
|
||||
// Compute the starting index of the page where this item is located.
|
||||
// For example, if [index] is `42` and [itemsPerPage] is `20`,
|
||||
// then `index ~/ itemsPerPage` (integer division)
|
||||
// evaluates to `2`, and `2 * 20` is `40`.
|
||||
var startingIndex = (index ~/ itemsPerPage) * itemsPerPage;
|
||||
|
||||
// If the corresponding page is already in memory, return immediately.
|
||||
if (_pages.containsKey(startingIndex)) {
|
||||
var item = _pages[startingIndex].items[index - startingIndex];
|
||||
return item;
|
||||
}
|
||||
|
||||
// We don't have the data yet. Start fetching it.
|
||||
_fetchPage(startingIndex);
|
||||
|
||||
// In the meantime, return a placeholder.
|
||||
return Item.loading();
|
||||
}
|
||||
|
||||
/// This method initiates fetching of the [ItemPage] at [startingIndex].
|
||||
Future<void> _fetchPage(int startingIndex) async {
|
||||
if (_pagesBeingFetched.contains(startingIndex)) {
|
||||
// Page is already being fetched. Ignore the redundant call.
|
||||
return;
|
||||
}
|
||||
|
||||
_pagesBeingFetched.add(startingIndex);
|
||||
final page = await fetchPage(startingIndex);
|
||||
_pagesBeingFetched.remove(startingIndex);
|
||||
|
||||
if (!page.hasNext) {
|
||||
// The returned page has no next page. This means we now know the size
|
||||
// of the catalog.
|
||||
itemCount = startingIndex + page.items.length;
|
||||
}
|
||||
|
||||
// Store the new page.
|
||||
_pages[startingIndex] = page;
|
||||
_pruneCache(startingIndex);
|
||||
|
||||
if (!_isDisposed) {
|
||||
// Notify the widgets that are listening to the catalog that they
|
||||
// should rebuild.
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes item pages that are too far away from [currentStartingIndex].
|
||||
void _pruneCache(int currentStartingIndex) {
|
||||
// It's bad practice to modify collections while iterating over them.
|
||||
// So instead, we'll store the keys to remove in a separate Set.
|
||||
final keysToRemove = <int>{};
|
||||
for (final key in _pages.keys) {
|
||||
if ((key - currentStartingIndex).abs() > maxCacheDistance) {
|
||||
// This page's starting index is too far away from the current one.
|
||||
// We'll remove it.
|
||||
keysToRemove.add(key);
|
||||
}
|
||||
}
|
||||
for (final key in keysToRemove) {
|
||||
_pages.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
infinite_list/lib/src/item_tile.dart
Normal file
53
infinite_list/lib/src/item_tile.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright 2020 The Chromium Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'api/item.dart';
|
||||
|
||||
/// This is the widget responsible for building the item in the list,
|
||||
/// once we have the actual data [item].
|
||||
class ItemTile extends StatelessWidget {
|
||||
final Item item;
|
||||
|
||||
ItemTile({@required this.item, Key key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: ListTile(
|
||||
leading: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Container(
|
||||
color: item.color,
|
||||
),
|
||||
),
|
||||
title: Text(item.name, style: Theme.of(context).textTheme.headline6),
|
||||
trailing: Text('\$ ${(item.price / 100).toStringAsFixed(2)}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// This is the widget responsible for building the "still loading" item
|
||||
/// in the list (represented with "..." and a crossed square).
|
||||
class LoadingItemTile extends StatelessWidget {
|
||||
const LoadingItemTile({Key key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: ListTile(
|
||||
leading: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Placeholder(),
|
||||
),
|
||||
title: Text('...', style: Theme.of(context).textTheme.headline6),
|
||||
trailing: Text('\$ ...'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user