Di Flutter #17 kita ambil data dari API pakai http, dan di #18 kita bungkus data JSON ke dalam Model Class yang rapi. Tapi semua data masih kita print() ke console — nggak ada yang muncul di layar HP.
Hari ini kita akan gabungkan semuanya: fetch data REST API → parse ke Model Class → tampilkan dalam ListView yang bisa di-scroll. Ini adalah pola paling umum di hampir semua app production: news feed, product list, chat messages, dll.
1. Goal Hari Ini
Kita akan bikin app yang:
- Fetch 20 post dari JSONPlaceholder API saat pertama dibuka
- Tampilkan dalam
ListViewcard-card yang rapi - Tap card → detail post
- Handle loading state & error state
API yang dipakai: https://jsonplaceholder.typicode.com/posts (gratis, butuh setup, cocok untuk belajar).
2. Persiapan: Model & Service
Kalau kamu masih punya code dari Flutter #18, tinggal pakai. Kalau belum, ini ringkasan model dan service-nya:
// models/post.dart
class Post {
final int id;
final int userId;
final String title;
final String body;
const Post({
required this.id,
required this.userId,
required this.title,
required this.body,
});
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
id: json['id'] as int,
userId: json['userId'] as int,
title: json['title'] as String,
body: json['body'] as String,
);
}
}
// services/api_service.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/post.dart';
class ApiService {
static const String baseUrl = 'https://jsonplaceholder.typicode.com';
static Future<List<Post>> getPosts() async {
final response = await http.get(Uri.parse('$baseUrl/posts'));
if (response.statusCode == 200) {
final List<dynamic> jsonList = jsonDecode(response.body);
return jsonList
.map((json) => Post.fromJson(json as Map<String, dynamic>))
.toList();
} else {
throw Exception('Gagal load posts: ${response.statusCode}');
}
}
static Future<Post> getPostDetail(int id) async {
final response = await http.get(Uri.parse('$baseUrl/posts/$id'));
if (response.statusCode == 200) {
return Post.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
} else {
throw Exception('Gagal load post: ${response.statusCode}');
}
}
}
Perhatikan kita tambah getPostDetail(id) — biar bisa fetch satu post untuk halaman detail.
3. Tampilkan dalam ListView — Versi Dasar
import 'package:flutter/material.dart';
import '../models/post.dart';
import '../services/api_service.dart';
class PostListScreen extends StatefulWidget {
const PostListScreen({super.key});
@override
State<PostListScreen> createState() => _PostListScreenState();
}
class _PostListScreenState extends State<PostListScreen> {
late Future<List<Post>> _futurePosts;
@override
void initState() {
super.initState();
_futurePosts = ApiService.getPosts();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Blog Posts')),
body: FutureBuilder<List<Post>>(
future: _futurePosts,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text('Error: ${snapshot.error}'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () {
setState(() {
_futurePosts = ApiService.getPosts();
});
},
child: const Text('Coba Lagi'),
),
],
),
);
}
final posts = snapshot.data!;
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return ListTile(
title: Text(post.title),
subtitle: Text(
post.body,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: const Icon(Icons.chevron_right),
);
},
);
},
),
);
}
}
Beberapa hal penting di kode ini:
FutureBuilder— widget yang listen keFuturedan rebuild UI berdasarkan state-nya:waiting,hasData, atauhasError.ListView.builder— list yang efficient: widget cuma dibuat untuk item yang visible di layar. Beda denganListView(children: [...])yang render semua item sekaligus (jelek untuk list panjang).initState()— fetch data di sini, bukan dibuild(). Kalau fetch dibuild(), tiap rebuild akan fetch ulang terus-menerus.- Retry button — kalau error, user bisa tap “Coba Lagi” tanpa restart app.
4. Versi Card — Tampilan Lebih Menarik
ListItem terlalu plain. Mari upgrade ke Card dengan layout yang lebih informatif:
ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
elevation: 2,
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PostDetailScreen(post: post),
),
);
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: avatar + user ID
Row(
children: [
CircleAvatar(
child: Text('U${post.userId}'),
),
const SizedBox(width: 12),
Text(
'User ${post.userId}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
const SizedBox(height: 12),
// Title
Text(
post.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// Body preview
Text(
post.body,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
);
},
)
Sekarang tiap item tampil sebagai card dengan avatar user ID, title bold, dan body preview max 3 baris. Tap card → pindah ke detail screen.
5. Halaman Detail Post
class PostDetailScreen extends StatelessWidget {
final Post post;
const PostDetailScreen({super.key, required this.post});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Post #${post.id}'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Chip(
avatar: const Icon(Icons.person, size: 18),
label: Text('User ${post.userId}'),
),
const SizedBox(height: 12),
// Title
Text(
post.title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
// Body
Text(
post.body,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
height: 1.6,
),
),
],
),
),
);
}
}
Karena body post bisa panjang, kita pakai SingleChildScrollView supaya bisa di-scroll. Jangan lupa CrossAxisAlignment.start biar text align ke kiri.
6. Pull to Refresh
User expect kalau list bisa di-refresh dengan swipe ke bawah. Tambahin RefreshIndicator:
RefreshIndicator(
onRefresh: () async {
setState(() {
_futurePosts = ApiService.getPosts();
});
await _futurePosts;
},
child: FutureBuilder<List<Post>>(
future: _futurePosts,
builder: (context, snapshot) {
// ... kode FutureBuilder seperti biasa
},
),
)
RefreshIndicator wrap FutureBuilder kita. Saat user swipe-down, onRefresh dipanggil, data di-fetch ulang, dan FutureBuilder rebuild otomatis.
7. Pola Error & Loading di Production
Di dunia nyata, kamu sering butuh tiga state yang ditampilkan ke user:
enum ViewState { idle, loading, loaded, error }
// ... di dalam State class:
ViewState _viewState = ViewState.idle;
List<Post> _posts = [];
String _errorMessage = '';
Future<void> _loadPosts() async {
setState(() {
_viewState = ViewState.loading;
});
try {
final posts = await ApiService.getPosts();
setState(() {
_posts = posts;
_viewState = ViewState.loaded;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_viewState = ViewState.error;
});
}
}
// Di build():
Widget _buildBody() {
switch (_viewState) {
case ViewState.loading:
return const Center(child: CircularProgressIndicator());
case ViewState.error:
return Center(
child: Text('Error: $_errorMessage'),
);
case ViewState.loaded:
return RefreshIndicator(
onRefresh: _loadPosts,
child: ListView.builder(
itemCount: _posts.length,
itemBuilder: (context, index) => _buildPostCard(_posts[index]),
),
);
default:
return const SizedBox.shrink();
}
}
Pola enum ViewState ini lebih eksplisit daripada cuma andalkan FutureBuilder. Di tutorial selanjutnya (Provider/BLoC), pattern ini bakal jadi fondasi state management yang lebih canggih.
8. Checklist & Best Practice
- Fetch di
initState(), bukan dibuild()— mencegah fetch berulang tiap rebuild. - Pakai
ListView.builder()— lazy loading, cuma render item yang visible. - Tangani tiga state: loading, error, loaded — user harus selalu tau apa yang terjadi.
- Tombol retry — kalau error, user bisa coba lagi tanpa restart app.
- Pull to refresh — wrap list dengan
RefreshIndicator. - Data di-pass ke detail screen — nggak perlu fetch ulang kalau data udah ada di memori.
constconstructor — hemat rebuild, optimize performance.
Ringkasan
FutureBuilderadalah widget utama untuk menampilkan data async di Flutter.ListView.builder— list efficient yang cuma render item visible.- Tiga state — loading (spinner), error (message + retry), loaded (list).
RefreshIndicator— pull-to-refresh native yang user expect di mobile.- Model Class dari #18 bikin akses data jadi type-safe:
post.title, bukanpost['title'].
Sekarang app kamu udah bisa tampilin data dari internet secara real-time! 🌐
Coba sendiri! Tambahkan search bar di atas list — filter post berdasarkan title pakai TextField + setState. Share hasilnya ke sosial media dan tag @ahsai001! 🚀