Flutter Infinite ListView
How to implement infinite ListView in Flutter
Pengenalan
Sebuah aplikasi pada umumnya pasti terdapat fitur untuk menampilkan sekumpulan data. Data-data tersebut biasanya ditampilkan sesuai dengan platform-nya masing-masing. Kalau di desktop/web umumnya menggunakan table pagination.
Lalu, untuk platform mobile biasanya kita menampilkan datanya dalam bentuk list.
Untuk mobile, implementasi pagination-nya kita bisa menggunakan teknik Infinite Scrolling atau Infinite ListView. Jadi, konsepnya itu adalah app-nya menampilkan list datanya dalam bentuk ListView lalu, ketika berada di paling bawah maka, app-nya akan memuat lagi ke API atau database untuk menampilkan data berikutnya.
Cara Pembuatan
Buat Projek
Langkah pertamanya, silakan kita buat projek baru dengan nama flutter-swapi.
Atur pubspec.yaml
Untuk membuatnya ada beberapa plugin yang perlu kita tambahkan kedalam projek kita. Silakan buka file pubspec.yaml dan tambahkan beberapa plugin berikut.
- dio
- json_annotation
- json_serializable
- build_runner
Catatan: Untuk versinya silakan pakai yang versi terbaru ya.
...
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
dio: ^4.0.4
json_annotation: ^4.4.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^1.0.0
json_serializable: ^6.1.4
build_runner: ^2.1.7
...
Buat Class Model
Langkah berikutnya, kita perlu membuat class model dari respon endpoint yang bakalan kita pakai. Berikut adalah endpoint yang akan kita pakai.
https://swapi.dev/api/people?page=:page
Dan berikut adalah format respon dari endpoint tersebut.
{
"count": 82,
"next": "https://swapi.dev/api/people/?page=2",
"previous": null,
"results": [
{
"name": "Luke Skywalker",
"height": "172",
"mass": "77",
"hair_color": "blond",
"skin_color": "fair",
"eye_color": "blue",
"birth_year": "19BBY",
"gender": "male",
"homeworld": "https://swapi.dev/api/planets/1/",
"films": [
"https://swapi.dev/api/films/1/",
"https://swapi.dev/api/films/2/",
"https://swapi.dev/api/films/3/",
"https://swapi.dev/api/films/6/"
],
"species": [],
"vehicles": [
"https://swapi.dev/api/vehicles/14/",
"https://swapi.dev/api/vehicles/30/"
],
"starships": [
"https://swapi.dev/api/starships/12/",
"https://swapi.dev/api/starships/22/"
],
"created": "2014-12-09T13:50:51.644000Z",
"edited": "2014-12-20T21:17:56.891000Z",
"url": "https://swapi.dev/api/people/1/"
},
...
]
}
Nah, karena kita sudah tahu format respon JSON-nya. Maka, sekarang kita buat class modelnya. Caranya silakan buat file baru dengan nama people_response.dart. Dan isikan dengan kode berikut.
import 'package:json_annotation/json_annotation.dart';
part 'people_response.g.dart';
@JsonSerializable()
class PeopleResponse {
@JsonKey(name: 'count')
final int count;
@JsonKey(name: 'next')
final String? nextUrl;
@JsonKey(name: 'previous')
final String? previousUrl;
@JsonKey(name: 'results')
final List<ItemPeopleResponse> results;
PeopleResponse({
required this.count,
required this.nextUrl,
required this.previousUrl,
required this.results,
});
factory PeopleResponse.fromJson(Map<String, dynamic> json) => _$PeopleResponseFromJson(json);
Map<String, dynamic> toJson() => _$PeopleResponseToJson(this);
}
@JsonSerializable()
class ItemPeopleResponse {
@JsonKey(name: 'name')
final String? name;
@JsonKey(name: 'birth_year')
final String? birthYear;
@JsonKey(name: 'gender')
final String? gender;
ItemPeopleResponse({
required this.name,
required this.birthYear,
required this.gender,
});
factory ItemPeopleResponse.fromJson(Map<String, dynamic> json) => _$ItemPeopleResponseFromJson(json);
Map<String, dynamic> toJson() => _$ItemPeopleResponseToJson(this);
}
Untuk menghilangkan tanda error-nya. Kita perlu jalankan build runner-nya untuk membuat file generator-nya. Untuk membuat file generator-nya silakan jalankan perintah berikut.
flutter pub run build_runner build
Buat UI
Langkah berikutnya kita perlu membuat tampilannya. Silakan buka file main.dart dan ubah kode didalamnya menjadi seperti berikut.
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_swapi/people_response.dart';
enum ItemType {
data,
loading,
}
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Swapi',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Swapi'),
);
}
}
class MyHomePage extends StatefulWidget {
final String title;
const MyHomePage({Key? key, required this.title}) : super(key: key);
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final _scrollController = ScrollController();
final _listData = <Data>[];
final _dio = Dio();
String? _nextUrl;
var _isLoading = false;
var _paddingBottom = 0.0;
var _errorMessage = '';
@override
void initState() {
_doLoadData();
// TODO: Buat fitur load more
super.initState();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final mediaQueryData = MediaQuery.of(context);
_paddingBottom = mediaQueryData.padding.bottom;
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: _buildWidgetBody(),
);
}
Widget _buildWidgetBody() {
if (_errorMessage.isNotEmpty) {
return _buildWidgetInfoTryAgain(_errorMessage);
}
if (_listData.isEmpty) {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
} else {
return _buildWidgetInfoTryAgain('Data not available');
}
} else {
return ListView.separated(
padding: EdgeInsets.only(
left: 16,
top: 16,
right: 16,
bottom: _paddingBottom > 0 ? _paddingBottom : 16,
),
itemBuilder: (context, index) {
final itemPeople = _listData[index];
final typeData = itemPeople.itemType;
if (typeData == ItemType.loading) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
} else if (typeData == ItemType.data) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
itemPeople.people?.name ?? '-',
style: Theme.of(context).textTheme.headline6,
),
Text(
itemPeople.people?.gender ?? '-',
style: Theme.of(context).textTheme.bodyText2,
),
Text(
itemPeople.people?.birthYear ?? '-',
style: Theme.of(context).textTheme.caption,
),
],
),
),
);
} else {
return Container();
}
},
separatorBuilder: (context, index) => Container(
height: 8,
),
itemCount: _listData.length,
);
}
}
Widget _buildWidgetInfoTryAgain(String text) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(text),
ElevatedButton(
child: const Text('Try Again'),
onPressed: () {
_doLoadData();
},
),
],
),
);
}
Future<void> _doLoadData() async {
_errorMessage = '';
var url = '';
if (_listData.isEmpty) {
url = 'https://swapi.dev/api/people?page=1';
} else {
url = _nextUrl ?? '';
}
if (url.isEmpty) {
return;
}
setState(() => _isLoading = true);
try {
final response = await _dio.get(url);
if (response.statusCode.toString().startsWith('2')) {
final peopleResponse = PeopleResponse.fromJson(response.data);
if (_listData.isNotEmpty) {
/// Hapus widget elemen loading
final lastItem = _listData.last;
if (lastItem.itemType == ItemType.loading) {
_listData.removeLast();
}
}
/// Masukkan data baru kedalam _listData
_listData.addAll(
peopleResponse.results.map(
(element) {
return Data(
people: element,
itemType: ItemType.data,
);
},
),
);
/// Jika nilai _nextUrl != null maka, tambahkan widget elemen loading
_nextUrl = peopleResponse.nextUrl;
if (_nextUrl != null) {
_listData.add(
Data(
people: null,
itemType: ItemType.loading,
),
);
}
} else {
/// Munculkan pesan error
_listData.clear();
_errorMessage = "Failed to get data";
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(_errorMessage)));
}
} catch (error) {
/// Munculkan pesan error
_errorMessage = 'Failed to get data';
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(_errorMessage)));
} finally {
setState(() => _isLoading = false);
}
}
}
class Data {
final ItemPeopleResponse? people;
final ItemType itemType;
Data({
required this.people,
required this.itemType,
});
}
Adapun output dari kode UI diatas adalah sebagai berikut.
Buat Fitur Infinite List
Di langkah sebelumnya, kita sudah berhasil menampilkan datanya dari endpoint. Tapi, fitur infinite list-nya belum bisa berfungsi. Nah, di langkah ini kita akan membuatnya. Silakan buka kembali file main.dart dan ubah kode berikut.
// TODO: Buat fitur load more
Menjadi seperti berikut.
_scrollController.addListener(() {
if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
_doLoadData();
}
});
Kemudian, kita set property controller
dari widget ListView.separated
.
return ListView.separated(
controller: _scrollController,
padding: EdgeInsets.only(
left: 16,
Berikut adalah video hasilnya.
Kesimpulan
Jadi, di tulisan ini kita telah berhasil membuat Infinite ListView di Flutter. Caranya cukup mudah kan. Daripada kita pakai plugin spesial untuk fitur tersebut mending kita buat sendiri dan lebih fully customize. Untuk source code lengkapnya bisa dilihat di Github berikut ya.