1
0
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:
Filip Hracek
2020-05-15 17:53:54 -07:00
committed by GitHub
parent baa1f976b2
commit 1d8cfa11c5
73 changed files with 1947 additions and 0 deletions

View 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);
},
),
),
);
}
}

View 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,
);
}

View 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 == '...';
}

View 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,
});
}

View 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);
}
}
}

View 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('\$ ...'),
),
);
}
}