Skip to content

Commit b265654

Browse files
authored
Add infinite scroll example (#163)
1 parent 330a0b2 commit b265654

11 files changed

Lines changed: 366 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ macos
3535
web
3636
android
3737
linux
38+
windows
3839

3940
coverage/
4041
lcov.info
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Miscellaneous
2+
*.class
3+
*.log
4+
*.pyc
5+
*.swp
6+
.DS_Store
7+
.atom/
8+
.build/
9+
.buildlog/
10+
.history
11+
.svn/
12+
.swiftpm/
13+
migrate_working_dir/
14+
15+
# IntelliJ related
16+
*.iml
17+
*.ipr
18+
*.iws
19+
.idea/
20+
21+
# The .vscode folder contains launch configuration and tasks you configure in
22+
# VS Code which you may wish to be included in version control, so this line
23+
# is commented out by default.
24+
#.vscode/
25+
26+
# Flutter/Dart/Pub related
27+
**/doc/api/
28+
**/ios/Flutter/.last_build_id
29+
.dart_tool/
30+
.flutter-plugins-dependencies
31+
.pub-cache/
32+
.pub/
33+
/build/
34+
/coverage/
35+
36+
# Symbolication related
37+
app.*.symbols
38+
39+
# Obfuscation related
40+
app.*.map.json
41+
42+
# Android Studio will place build artifacts here
43+
/android/app/debug
44+
/android/app/profile
45+
/android/app/release

examples/infinite_scroll/.metadata

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# This file tracks properties of this Flutter project.
2+
# Used by Flutter tool to assess capabilities and perform upgrades etc.
3+
#
4+
# This file should be version controlled and should not be manually edited.
5+
6+
version:
7+
revision: "19074d12f7eaf6a8180cd4036a430c1d76de904e"
8+
channel: "stable"
9+
10+
project_type: app
11+
12+
# Tracks metadata for the flutter migrate command
13+
migration:
14+
platforms:
15+
- platform: root
16+
create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
17+
base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
18+
- platform: android
19+
create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
20+
base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
21+
- platform: ios
22+
create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
23+
base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
24+
- platform: linux
25+
create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
26+
base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
27+
- platform: macos
28+
create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
29+
base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
30+
- platform: web
31+
create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
32+
base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
33+
- platform: windows
34+
create_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
35+
base_revision: 19074d12f7eaf6a8180cd4036a430c1d76de904e
36+
37+
# User provided section
38+
39+
# List of Local paths (relative to this file) that should be
40+
# ignored by the migrate tool.
41+
#
42+
# Files that are not part of the templates will be ignored by default.
43+
unmanaged_files:
44+
- 'lib/main.dart'
45+
- 'ios/Runner.xcodeproj/project.pbxproj'

examples/infinite_scroll/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# infinite_scroll
2+
3+
A new Flutter project.
4+
5+
## Getting Started
6+
7+
This project is a starting point for a Flutter application.
8+
9+
A few resources to get you started if this is your first Flutter project:
10+
11+
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
12+
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
13+
14+
For help getting started with Flutter development, view the
15+
[online documentation](https://docs.flutter.dev/), which offers tutorials,
16+
samples, guidance on mobile development, and a full API reference.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
include: package:flutter_lints/flutter.yaml
2+
3+
linter:
4+
rules:
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import 'dart:convert';
2+
3+
import 'package:disco/disco.dart';
4+
import 'package:flutter_solidart/flutter_solidart.dart';
5+
import 'package:http/http.dart' as http;
6+
import 'package:infinite_scroll/domain/post.dart';
7+
8+
class PostController {
9+
static const _postLimit = 20;
10+
static const _throttleDuration = Duration(milliseconds: 300);
11+
12+
// Provider
13+
static final provider = Provider((_) => PostController());
14+
15+
PostController({http.Client? httpClient}) : _httpClient = httpClient ?? http.Client();
16+
17+
final http.Client _httpClient;
18+
final _posts = <Post>[];
19+
20+
final hasReachedMax = Signal(false);
21+
final _startIndex = Signal<int>(0);
22+
late final postsResource = Resource(
23+
_getPosts,
24+
debounceDelay: _throttleDuration,
25+
source: _startIndex,
26+
);
27+
28+
Future<List<Post>> _getPosts() async {
29+
final index = _startIndex.value;
30+
final response = await _fetchPosts(startIndex: index);
31+
if (response.isEmpty) {
32+
hasReachedMax.value = true;
33+
return _posts;
34+
}
35+
36+
_posts.addAll(response);
37+
return _posts;
38+
}
39+
40+
void loadMore() {
41+
if (!hasReachedMax.value && !postsResource.state.isLoading) {
42+
_startIndex.value = _posts.length;
43+
}
44+
}
45+
46+
/// https://jsonplaceholder.typicode.com/posts?_start=0&_limit=20
47+
Future<List<Post>> _fetchPosts({required int startIndex}) async {
48+
final response = await _httpClient.get(
49+
Uri.https('jsonplaceholder.typicode.com', '/posts', <String, String>{
50+
'_start': '$startIndex',
51+
'_limit': '$_postLimit',
52+
}),
53+
);
54+
55+
if (response.statusCode == 200) {
56+
final body = json.decode(response.body) as List;
57+
return List<Post>.from(body.map((x) => Post.fromJson(x)));
58+
}
59+
60+
throw Exception('error fetching posts');
61+
}
62+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import 'package:flutter/foundation.dart';
2+
3+
@immutable
4+
class Post {
5+
const Post({required this.id, required this.title, required this.body});
6+
7+
final int id;
8+
final String title;
9+
final String body;
10+
11+
factory Post.fromJson(Map<String, dynamic> json) {
12+
return Post(
13+
id: json['id'] as int? ?? 0,
14+
title: json['title'] as String? ?? '',
15+
body: json['body'] as String? ?? '',
16+
);
17+
}
18+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:infinite_scroll/ui/posts_page.dart';
3+
4+
void main() {
5+
runApp(const MyApp());
6+
}
7+
8+
class MyApp extends StatelessWidget {
9+
const MyApp({super.key});
10+
11+
@override
12+
Widget build(BuildContext context) {
13+
return MaterialApp(
14+
title: 'Flutter Demo',
15+
theme: ThemeData(
16+
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
17+
useMaterial3: true,
18+
),
19+
home: const PostsPage(),
20+
);
21+
}
22+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import 'package:disco/disco.dart';
2+
import 'package:flutter/material.dart';
3+
import 'package:flutter_solidart/flutter_solidart.dart';
4+
import 'package:infinite_scroll/controllers/post_controller.dart';
5+
import 'package:infinite_scroll/domain/post.dart';
6+
7+
class PostsPage extends StatelessWidget {
8+
const PostsPage({super.key});
9+
10+
@override
11+
Widget build(BuildContext context) {
12+
return ProviderScope(
13+
providers: [PostController.provider],
14+
child: Scaffold(
15+
appBar: AppBar(title: const Text('Posts')),
16+
body: const PostsList(),
17+
),
18+
);
19+
}
20+
}
21+
22+
class PostsList extends StatefulWidget {
23+
const PostsList({super.key});
24+
25+
@override
26+
State<PostsList> createState() => _PostsListState();
27+
}
28+
29+
class _PostsListState extends State<PostsList> {
30+
final scrollController = ScrollController();
31+
late final postController = PostController.provider.of(context);
32+
bool hasTriggeredLoadMore = false; // prevent multiple load more calls
33+
34+
@override
35+
void initState() {
36+
super.initState();
37+
scrollController.addListener(onScroll);
38+
}
39+
40+
@override
41+
Widget build(BuildContext context) {
42+
return SignalBuilder(
43+
builder: (_, _) {
44+
final postsState = postController.postsResource.state;
45+
final hasReachedMax = postController.hasReachedMax.value;
46+
47+
return postsState.when(
48+
ready: (posts) {
49+
if (posts.isEmpty) {
50+
return const Center(child: Text('no posts'));
51+
}
52+
53+
return ListView.builder(
54+
itemBuilder: (BuildContext context, int index) {
55+
return index >= posts.length
56+
? const BottomLoader()
57+
: PostListItem(post: posts[index]);
58+
},
59+
itemCount: hasReachedMax ? posts.length : posts.length + 1,
60+
controller: scrollController,
61+
);
62+
},
63+
error: (e, st) => Text('Error : $e'),
64+
loading: () => const Center(child: CircularProgressIndicator.adaptive()),
65+
);
66+
},
67+
);
68+
}
69+
70+
@override
71+
void dispose() {
72+
scrollController.removeListener(onScroll);
73+
scrollController.dispose();
74+
75+
super.dispose();
76+
}
77+
78+
void onScroll() {
79+
if (isBottom && !hasTriggeredLoadMore) {
80+
hasTriggeredLoadMore = true;
81+
postController.loadMore();
82+
}
83+
84+
if (!isBottom) {
85+
hasTriggeredLoadMore = false;
86+
}
87+
}
88+
89+
bool get isBottom {
90+
if (!scrollController.hasClients) return false;
91+
final maxScroll = scrollController.position.maxScrollExtent;
92+
final currentScroll = scrollController.offset;
93+
return currentScroll >= (maxScroll * 0.95);
94+
}
95+
}
96+
97+
class BottomLoader extends StatelessWidget {
98+
const BottomLoader({super.key});
99+
100+
@override
101+
Widget build(BuildContext context) {
102+
return const Center(
103+
child: SizedBox(
104+
height: 24,
105+
width: 24,
106+
child: CircularProgressIndicator.adaptive(strokeWidth: 1.5),
107+
),
108+
);
109+
}
110+
}
111+
112+
class PostListItem extends StatelessWidget {
113+
const PostListItem({required this.post, super.key});
114+
115+
final Post post;
116+
117+
@override
118+
Widget build(BuildContext context) {
119+
final textTheme = Theme.of(context).textTheme;
120+
return ListTile(
121+
leading: Text('${post.id}', style: textTheme.bodySmall),
122+
title: Text(post.title),
123+
isThreeLine: true,
124+
subtitle: Text(post.body),
125+
dense: true,
126+
);
127+
}
128+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: infinite_scroll
2+
description: "A new Flutter project."
3+
publish_to: "none"
4+
version: 1.0.0+1
5+
6+
environment:
7+
sdk: ^3.10.1
8+
9+
resolution: workspace
10+
11+
dependencies:
12+
disco: ^1.0.3+1
13+
flutter:
14+
sdk: flutter
15+
flutter_solidart: ^2.7.1
16+
http: ^1.6.0
17+
18+
dev_dependencies:
19+
flutter_test:
20+
sdk: flutter
21+
flutter_lints: ^6.0.0
22+
23+
flutter:
24+
uses-material-design: true

0 commit comments

Comments
 (0)