В этом туториале мы собираемся разработать приложение, которое получает данные через интернет и отобразим их списком. Примерно вот так
Окей, начнем с создания проекта. Пропишем в командной строке следующее
flutter create flutter_infinite_list
Далее идем в наш файл зависимостей pubspec.yaml и добавляем нужные нам
name: flutter_infinite_list
description: A new Flutter project.
version: 1.0.0+1
environment:
sdk: ">=2.0.0-dev.68.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
flutter_bloc: 0.4.11
http: 0.12.0
equatable: 0.1.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
После этого устанавливаем эти зависимости следующей командой
flutter packages get
Для этого приложения мы будем использовать jsonplaceholder для получения моковых данных. Если вы не знакомы с этим сервисом, это онлайн REST API сервис, который может отдавать фейковые данные. Это очень полезно для построения прототипов приложения.
Открыв следующую ссылку jsonplaceholder.typicode.com/posts?_start=0&_limit=2 вы увидите JSON ответ, с которым мы будем работать.
[
{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipitnsuscipit recusandae consequuntur expedita et cumnreprehenderit molestiae ut ut quas totamnnostrum rerum est autem sunt rem eveniet architecto"
},
{
"userId": 1,
"id": 2,
"title": "qui est esse",
"body": "est rerum tempore vitaensequi sint nihil reprehenderit dolor beatae ea dolores nequenfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendisnqui aperiam non debitis possimus qui neque nisi nulla"
}
]
Заметьте, в нашем GET запросе мы указали начальное и конечное ограничение в качестве параметра.
Отлично, теперь мы знаем как будут выглядеть структура наших данные! Давайте создадим модель для них.
Создадим файл post.dart со следующим содержанием
import 'package:equatable/equatable.dart';
class Post extends Equatable {
final int id;
final String title;
final String body;
Post({this.id, this.title, this.body}) : super([id, title, body]);
@override
String toString() => 'Post { id: $id }';
}
Post это только класс с id, title и body. Мы так же можем переопределить функцию toString для отображения удобной строки позднее. В дополнении мы расширяем класс Equatable, таким образом мы можем сравнивать объекты Posts.
Теперь у нас есть модель ответа от сервера, давайте реализовывать бизнес логику (Business Logic Component (bloc)).
Перед тем как мы окунемся в разработку приложения, необходимо определить что наш PostBloc будет делать.
На верхнем уровне, он будет отвечать за обработку действий юзера (скроллинг) и получение новых постов, когда слой презентации запросит их. Давайте начнем реализовывать это.
Наш PostBloc будет отвечать только на один event. Получение данных, которое будет показывать на экране по мере необходимости. Создадим класс post_event.dart и имплементируем наше событие
import 'package:equatable/equatable.dart';
abstract class PostEvent extends Equatable {}
class Fetch extends PostEvent {
@override
String toString() => 'Fetch';
}
Снова переопределим toString для более легкого чтения строки отображающего наш ивент. Так же нам необходимо расширить класс Equatable для сравнения объектов.
Резюмируя, наш PostBloc будет получать PostEvents и конвертировать их в PostStates. Мы разработали все ивенты PostEvents (Fetch), перейдем к PostState.
Наш презентационный слой должен иметь несколько состояний для корректного отображения.
isInitializing — сообщит презентационному слою, что необходимо отобразить индикатор загрузки, пока данные грузятся.
posts — отобразит список объектов Post
isError — сообщит слою, что при загрузке данных произошла ошибок
hasReachedMax — индикация достижения последней доступной записи
Создадим класс post_state.dart со следующим содержанием
import 'package:equatable/equatable.dart';
import 'package:flutter_infinite_list/models/models.dart';
abstract class PostState extends Equatable {
PostState([Iterable props]) : super(props);
}
class PostUninitialized extends PostState {
@override
String toString() => 'PostUninitialized';
}
class PostInitialized extends PostState {
final List<Post> posts;
final bool hasError;
final bool hasReachedMax;
PostInitialized({
this.hasError,
this.posts,
this.hasReachedMax,
}) : super([posts, hasError, hasReachedMax]);
factory PostInitialized.success(List<Post> posts) {
return PostInitialized(
posts: posts,
hasError: false,
hasReachedMax: false,
);
}
factory PostInitialized.failure() {
return PostInitialized(
posts: [],
hasError: true,
hasReachedMax: false,
);
}
PostInitialized copyWith({
List<Post> posts,
bool hasError,
bool hasReachedMax,
}) {
return PostInitialized(
posts: posts ?? this.posts,
hasError: hasError ?? this.hasError,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
);
}
@override
String toString() =>
'PostInitialized { posts: ${posts.length}, hasError: $hasError, hasReachedMax: $hasReachedMax }';
}
Мы использовали паттерн Factory для удобства и читабельности. Вместо ручного создания сущностей PostState мы можем использовать различные фабрики, например PostState.initial()
Теперь у нас есть ивенты и состояния, пора создать наш PostBloc
Для упрощения наш PostBloc будет иметь прямую зависимость http client, однако в продакшене вам бы следовало завернуть ее во внешнюю зависимость в api client и использовать Repository паттерн.
Создадим post_bloc.dart
import 'package:bloc/bloc.dart';
import 'package:meta/meta.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_infinite_list/bloc/bloc.dart';
import 'package:flutter_infinite_list/models/models.dart';
class PostBloc extends Bloc<PostEvent, PostState> {
final http.Client httpClient;
PostBloc({@required this.httpClient});
@override
// TODO: implement initialState
PostState get initialState => null;
@override
Stream<PostState> mapEventToState(
PostState currentState,
PostEvent event,
) async* {
// TODO: implement mapEventToState
yield null;
}
}
Заметьте, что только из объявления нашего класса можно сказать, что он будет принимать на вход PostEvents и отдавать PostStates
Перейдем к разработке initialState
import 'package:bloc/bloc.dart';
import 'package:meta/meta.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_infinite_list/bloc/bloc.dart';
import 'package:flutter_infinite_list/models/models.dart';
class PostBloc extends Bloc<PostEvent, PostState> {
final http.Client httpClient;
PostBloc({@required this.httpClient});
@override
PostState get initialState => PostState.initial();
@override
Stream<PostState> mapEventToState(
PostState currentState,
PostEvent event,
) async* {
// TODO: implement mapEventToState
yield null;
}
}
Далее необходимо реализовать mapEventToState, который будет срабатывать каждый раз при отправке события.
import 'dart:convert';
import 'package:meta/meta.dart';
import 'package:http/http.dart' as http;
import 'package:bloc/bloc.dart';
import 'package:flutter_infinite_list/bloc/bloc.dart';
import 'package:flutter_infinite_list/models/models.dart';
class PostBloc extends Bloc<PostEvent, PostState> {
final http.Client httpClient;
PostBloc({@required this.httpClient});
@override
get initialState => PostState.initial();
@override
Stream<PostState> mapEventToState(currentState, event) async* {
if (event is Fetch && !currentState.hasReachedMax) {
try {
final posts = await _fetchPosts(currentState.posts.length, 20);
if (posts.isEmpty) {
yield currentState.copyWith(hasReachedMax: true);
} else {
yield PostState.success(currentState.posts + posts);
}
} catch (_) {
yield PostState.failure();
}
}
}
Future<List<Post>> _fetchPosts(int startIndex, int limit) async {
final response = await httpClient.get(
'https://jsonplaceholder.typicode.com/posts?_start=$startIndex&_limit=$limit');
if (response.statusCode == 200) {
final data = json.decode(response.body) as List;
return data.map((rawPost) {
return Post(
id: rawPost['id'],
title: rawPost['title'],
body: rawPost['body'],
);
}).toList();
} else {
throw Exception('error fetching posts');
}
}
}
Теперь каждый раз PostEvent отправляется, если это событие выборки и мы не достигли конца списка будет отображено следующие 20 записей.
Немного доработаем наш PostBloc
import 'dart:convert';
import 'package:meta/meta.dart';
import 'package:rxdart/rxdart.dart';
import 'package:http/http.dart' as http;
import 'package:bloc/bloc.dart';
import 'package:flutter_infinite_list/bloc/bloc.dart';
import 'package:flutter_infinite_list/models/models.dart';
class PostBloc extends Bloc<PostEvent, PostState> {
final http.Client httpClient;
PostBloc({@required this.httpClient});
@override
Stream<PostEvent> transform(Stream<PostEvent> events) {
return (events as Observable<PostEvent>)
.debounce(Duration(milliseconds: 500));
}
@override
get initialState => PostState.initial();
@override
Stream<PostState> mapEventToState(currentState, event) async* {
if (event is Fetch && !currentState.hasReachedMax) {
try {
final posts = await _fetchPosts(currentState.posts.length, 20);
if (posts.isEmpty) {
yield currentState.copyWith(hasReachedMax: true);
} else {
yield PostState.success(currentState.posts + posts);
}
} catch (_) {
yield PostState.failure();
}
}
}
Future<List<Post>> _fetchPosts(int startIndex, int limit) async {
final response = await httpClient.get(
'https://jsonplaceholder.typicode.com/posts?_start=$startIndex&_limit=$limit');
if (response.statusCode == 200) {
final data = json.decode(response.body) as List;
return data.map((rawPost) {
return Post(
id: rawPost['id'],
title: rawPost['title'],
body: rawPost['body'],
);
}).toList();
} else {
throw Exception('error fetching posts');
}
}
}
Отлично, мы завершили реализацию бизнес логики!
Создадим класс main.dart и реализуем в нем runApp для отрисовки нашего UI
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Infinite Scroll',
home: Scaffold(
appBar: AppBar(
title: Text('Posts'),
),
body: HomePage(),
),
);
}
}
Далее создадим HomePage, который отобразит наши посты и подключится к PostBloc
class HomePage extends StatefulWidget {
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _scrollController = ScrollController();
final PostBloc _postBloc = PostBloc(httpClient: http.Client());
final _scrollThreshold = 200.0;
_HomePageState() {
_scrollController.addListener(_onScroll);
_postBloc.dispatch(Fetch());
}
@override
Widget build(BuildContext context) {
return BlocBuilder(
bloc: _postBloc,
builder: (BuildContext context, PostState state) {
if (state.isInitializing) {
return Center(
child: CircularProgressIndicator(),
);
}
if (state.isError) {
return Center(
child: Text('failed to fetch posts'),
);
}
if (state.posts.isEmpty) {
return Center(
child: Text('no posts'),
);
}
return ListView.builder(
itemBuilder: (BuildContext context, int index) {
return index >= state.posts.length
? BottomLoader()
: PostWidget(post: state.posts[index]);
},
itemCount:
state.hasReachedMax ? state.posts.length : state.posts.length + 1,
controller: _scrollController,
);
},
);
}
@override
void dispose() {
_postBloc.dispose();
super.dispose();
}
void _onScroll() {
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels;
if (maxScroll - currentScroll <= _scrollThreshold) {
_postBloc.dispatch(Fetch());
}
}
}
Далее реализуем BottomLoader, который будет показывать пользователю загрузку новых постов.
class BottomLoader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.center,
child: Center(
child: SizedBox(
width: 33,
height: 33,
child: CircularProgressIndicator(
strokeWidth: 1.5,
),
),
),
);
}
}
И наконец, реализуем PostWidget, который будет отрисовывать один объект типа Post
class PostWidget extends StatelessWidget {
final Post post;
const PostWidget({Key key, @required this.post}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListTile(
leading: Text(
post.id.toString(),
style: TextStyle(fontSize: 10.0),
),
title: Text('${post.title}'),
isThreeLine: true,
subtitle: Text(post.body),
dense: true,
);
}
}
На этом все, сейчас вы можете запустить приложение и посмотреть результат
Исходники проекта можно скачать на Github
Автор: ermolnik
Долго не мог найти зачем класс Equatable в данном примере? Так и не нашел. Если вы его не используете зачем вообще объявили?