เนื้อหาตอนต่อไปนี้ จะนำตัาอย่างแนวทางการจัดรูปแบบการแสดง
รายการสินค้าในแบบต่างๆ ให้สามารถนำไปใช้งานหรือประยุกต์ต่อได้
จะเป็นเนื้อหาที่ต่อยอดจากบทความ ตามลิ้งค์ด้านล่าง
การ Cache ข้อมูลเพิ่มความเร็วสำหรับการโหลดข้อมูล Server http://niik.in/1106
เนื้อหานี้จะโค้ดตัวอย่างในตอนท้ายบทความสามารถดาวน์โหลดไปดูเป็นทางได้
โดยในโค้ดตัวอย่างจะมีการใช้งาน package ต่างๆ ที่เกี่ยวข้อง รวมถึงตัวอย่างการนำ Riverpod
มาใช้งานร่วมกับ Provider ในโปรเจ็คเดียวกัน ซึ่งเป็นตัวอย่างกรณีที่เราอาจจะต้องใช้งานร่วมกัน
แต่จริงๆ แล้วควรเลือกอย่างใดอย่างหนึ่ง
ในการจัดรูปแบบ layout ตัวอย่างนี้ จะมีด้วยกัน 4 รูปแบบ ดังนี้คือ
- แบบ ListView ใช้ ListTile จัดรูปแบบ
- แบบ ListView ไม่ใช้ ListTile จัดรูปแบบ แต่กำหนดเอง
- แบบ GridView
- ใช้ MasonryGridView ที่สามารถกำหนดให้ความสูงแต่ละ Grid แตกต่างกันได้
ตัวอย่างผลลัพธ์แต่ละแบบ

ในแบบที่สี่หรือแบบสุดท้าย MasonryGridView เรามีการใช้งาน package ที่ชื่อว่า
flutter_staggered_grid_view เข้ามาช่วย ติดตั้งก่อนใช้งานในไฟล์ pubspec.yaml
flutter_staggered_grid_view: ^0.7.0
ในตัวอย่างแต่ละหัวข้อ จะนำเฉพาะส่วนของโค้ดที่กำหนดรูปแบบเท่านั้น มาให้ดูเป็นตัวอย่าง
โดยโค้ดสุดท้าย จะเป็นไฟล์รวม product.dart ที่รวมทั้งหมด และมีการคอมเม้นท์ปิดแต่ละรูปแบบ
ไว้และเปิดไว้อันเดียว ไฟล์ท้้งหมดมีในโค้ดท้ายบทความให้ด้วยโหลด
การจัด Layout ด้วย ListView ใช้ ListTile จัดรูปแบบ
รูปแบบการแสดงจะเป็นในรูปแบบ ListTile ที่เราคุ้นเคย สามารถปรับแต่งเพิ่มเติมได้ตามต้องการ
// ใช้งาน ListView
child: ListView.separated(
// กรณีมีรายการ แสดงปกติ
controller:
_scrollController, // กำนหนด controller ที่จะใช้งานร่วม
itemCount: items.length,
itemBuilder: (context, index) {
Product product = items[index];
Widget card; // สร้างเป็นตัวแปร
card = Card(
margin: const EdgeInsets.all(
5.0), // การเยื้องขอบ
child: Column(
children: [
ListTile(
leading: CachedNetworkImage(
imageUrl: product.image,
width: 100.0,
placeholder: (context, url) =>
Center(
child: SizedBox(
// Adjust the size as needed
width: 40.0,
height: 40.0,
child:
CircularProgressIndicator(), // Show loading indicator
),
),
errorWidget: (context, url,
error) =>
Icon(Icons
.error), // Show error icon if loading fails
),
title: Text(product.title),
subtitle: Text(
'Price: \$ ${product.price}'),
trailing: Icon(Icons.more_vert),
onTap: () {},
)
],
));
return card;
},
separatorBuilder:
(BuildContext context, int index) =>
const SizedBox(),
),
// ใช้งาน ListView
การจัด Layout ด้วย ListView ไม่ใช้ ListTile จัดรูปแบบ
เราสามารถจัดรูปแบบตามต้องการแทนการใช้งาน ListTile ได้ทำให้มีความหลากหลายมากขึ้น
// ใช้งาน ListView แบบกำหนดเอง ไม่ใช้ ListTile
child: ListView.separated(
// กรณีมีรายการ แสดงปกติ
controller:
_scrollController, // กำนหนด controller ที่จะใช้งานร่วม
itemCount: items.length,
itemBuilder: (context, index) {
Product product = items[index];
Widget card; // สร้างเป็นตัวแปร
card = Card(
margin: const EdgeInsets.all(5.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: CachedNetworkImage(
imageUrl: product.image,
height: 100.0,
width: 100.0,
fit: BoxFit.contain,
placeholder: (context, url) =>
Center(
child: SizedBox(
// Adjust the size as needed
width: 40.0,
height: 40.0,
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url,error) =>Icon(Icons.error),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(0.0),
child: Container(
color: Colors.grey[200],
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.title),
Text('Price: \$ ${product.price}'),
],
),
),
),
),
],
));
return card;
},
separatorBuilder:(BuildContext context, int index) =>const SizedBox(),
),
// ใช้งาน ListView แบบกำหนดเอง ไม่ใช้ ListTile
การจัด Layout ด้วย GridView
จัดรูปแบบในลักษณะ Grid ที่มีความสูงของรายการข้อมูลเท่าๆ กัน
// ใช้งาน GridView
child: GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(5.0),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // Number of columns
crossAxisSpacing: 0.0,
mainAxisSpacing: 0.0,
childAspectRatio: 3 / 3.8, // Adjust to control the size ratio
),
itemCount: items.length,
itemBuilder: (context, index) {
Product product = items[index];
Widget card; // สร้างเป็นตัวแปร
card = Card(
child: Padding(
padding: const EdgeInsets.all(3.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
child: CachedNetworkImage(
imageUrl: product.image,
height: 150,
fit: BoxFit.contain,
placeholder: (context, url) =>
const Center(
child: SizedBox(
width: 40.0,
height: 40.0,
child:CircularProgressIndicator(),
),
),
errorWidget: (context, url,
error) =>
const Icon(Icons.error),
),
),
Padding(
padding: const EdgeInsets.all(3.0),
child: Text(product.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,),
),
Padding(
padding: const EdgeInsets.all(3.0),
child: Text('Price: \$ ${product.price}'),
),
],
),
));
return card;
},
),
// ใช้งาน GridView
การจัด Layout ด้วย MasonryGridView
จัดรูปแบบในลักษณะ Grid ที่มีความสูงของรายการข้อมูลเป็นไปตามเนื้อหาภายในของแต่ละรายการ
ทำให้ให้รายการดูมีลักษณะเด่นพิเศษตามชนิดข้อมูลของรายการนั้นๆ เช่น ถ้ารูปรายการนั้นใหญ่ก็อาจจะ
แสดงเด่นกว่ารายการอื่น
// ใช้งาน MasonryGridView
child: MasonryGridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(5.0),
gridDelegate: SliverSimpleGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // Number of columns
),
itemCount: items.length,
itemBuilder: (context, index) {
Product product = items[index];
return Card(
child: Padding(
padding: const EdgeInsets.all(3.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CachedNetworkImage(
imageUrl: product.image,
fit: BoxFit.contain,
placeholder: (context, url) => const Center(
child: SizedBox(
width: 40.0,
height: 40.0,
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => const Icon(Icons.error),
),
Padding(
padding: const EdgeInsets.all(3.0),
child: Text(
product.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Padding(
padding: const EdgeInsets.all(3.0),
child: Text('Price: \$ ${product.price}'),
),
],
),
),
);
},
),
// ใช้งาน MasonryGridView
เพื่อให้เห็นภาพรวมของตัวอย่างโค้ด ให้ดูไฟล์ทั้งหมด ได้ดังนี้ ส่วนของโค้ด มีการจัดการต่างๆ เกี่ยวกับ
รายการสินค้า ในเนื้อหาที่ผ่านมา
ไฟล์ product.dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
// import 'package:intl/intl.dart'; // จัดรูปแบบวันทีและเวลา http://niik.in/1047
import 'package:cached_network_image/cached_network_image.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import '../models/product_model.dart';
class Products extends StatefulWidget {
static const routeName = '/product';
const Products({Key? key}) : super(key: key);
@override
State<StatefulWidget> createState() {
return _ProductsState();
}
}
class _ProductsState extends State<Products> {
// สร้างตัวแปรที่สามารถแจ้งเตือนการเปลี่ยนแปลงค่า
final ValueNotifier<bool> _visible = ValueNotifier<bool>(false);
// กำนหดตัวแปรข้อมูล products
Future<List<Product>> _products = Future.value([]);
// ตัว ScrollController สำหรับจัดการการ scroll ใน ListView
final ScrollController _scrollController = ScrollController();
// สำหรับป้องกันการเรียกโหลดข้อมูลซ้ำในทันที
bool _isLoading = false;
// จำลองใช้เป็นแบบฟังก์ชั่น ให้เสมือนดึงข้อมูลจาก server
Future<String> fetchData() async {
print("debug: do function");
final response = await Future<String>.delayed(
const Duration(seconds: 2),
() {
return 'Data Loaded \n${DateTime.now()}';
},
);
return response;
}
Future<void> _refresh() async {
if (_isLoading) return;
_visible.value = true;
try {
setState(() {
_isLoading = true;
_products = fetchProduct(reload: true);
});
} catch (e) {
throw Exception('error: ${e}');
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
void initState() {
print("debug: Init");
super.initState();
_products = fetchProduct();
}
@override
void dispose() {
_scrollController.dispose();
_visible.dispose(); // Dispose the ValueNotifier
super.dispose();
}
@override
Widget build(BuildContext context) {
print("debug: build");
return Scaffold(
appBar: AppBar(
title: Text('Product'),
actions: [
IconButton(
onPressed: () async {
if (!_isLoading && _visible.value == false) {
_refresh();
}
},
icon: const Icon(Icons.refresh_outlined),
)
],
),
body: ListView(
padding: const EdgeInsets.all(0.0),
children: [
ValueListenableBuilder<bool>(
valueListenable: _visible,
builder: (context, visible, child) {
return Visibility(
visible: visible,
child: const LinearProgressIndicator(
backgroundColor: Colors.white60,
),
);
},
),
FutureBuilder<List<Product>>(
// ชนิดของข้อมูล
future: _products,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {}
if (snapshot.connectionState == ConnectionState.done) {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Change state after the build is complete
_visible.value = false;
if (_scrollController.hasClients) {
//เช็คว่ามีตัว widget ที่ scroll ได้หรือไม่ ถ้ามี
// เลื่อน scroll มาด้านบนสุด
_scrollController.animateTo(0,
duration: Duration(milliseconds: 500),
curve: Curves.fastOutSlowIn);
}
});
}
if (snapshot.hasData) {
// แสดงทั้งหมด
final items = snapshot.data!.toList();
// แสดงแค่ 10 รายการ
// final items = snapshot.data!.take(10).toList();
double statusBarHeight = MediaQuery.of(context).padding.top;
double appBarHeight = kToolbarHeight; // Default height of the AppBar (56.0)
double availableHeight = MediaQuery.of(context).size.height - statusBarHeight - appBarHeight - 80;
print("debug: ${statusBarHeight+kToolbarHeight+80}");
return Column(
children: [
Container(
// สร้างส่วน header ของลิสรายการ
padding: const EdgeInsets.all(5.0),
decoration: BoxDecoration(
color: Colors.orange.withAlpha(100),
),
child: Row(
children: [
Text(
'Total ${items.length} items'), // แสดงจำนวนรายการ
],
),
),
SizedBox(
// ปรับความสูงขางรายการทั้งหมด การ ลบค่า เพื่อให้ข้อมูลแสดงเต็มพื้นที่
// หากมี appbar ควรลบ 100 ถ้ามีส่วนอื่นเพิ่มให้บวกเพิ่มเข้าไป ตามเหมาะสม
// หากไม่มี appbar ควรลบพื้นที่ที่เพิ่มเข้ามาค่าอื่นๆ ตามเหมาะสม
height: MediaQuery.of(context).size.height - 136,
child: snapshot.data!.isNotEmpty // กำหนดเงื่อนไขตรงนี้
? RefreshIndicator(
onRefresh: () async {
if (!_isLoading && _visible.value == false) {
_refresh();
}
},
// ใช้งาน MasonryGridView
child: MasonryGridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(5.0),
gridDelegate: SliverSimpleGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // Number of columns
),
itemCount: items.length,
itemBuilder: (context, index) {
Product product = items[index];
return Card(
child: Padding(
padding: const EdgeInsets.all(3.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CachedNetworkImage(
imageUrl: product.image,
fit: BoxFit.contain,
placeholder: (context, url) => const Center(
child: SizedBox(
width: 40.0,
height: 40.0,
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => const Icon(Icons.error),
),
Padding(
padding: const EdgeInsets.all(3.0),
child: Text(
product.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Padding(
padding: const EdgeInsets.all(3.0),
child: Text('Price: \$ ${product.price}'),
),
],
),
),
);
},
),
// ใช้งาน MasonryGridView
// ใช้งาน GridView
/* child: GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(5.0),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // Number of columns
crossAxisSpacing: 0.0,
mainAxisSpacing: 0.0,
childAspectRatio: 3 / 3.8, // Adjust to control the size ratio
),
itemCount: items.length,
itemBuilder: (context, index) {
Product product = items[index];
Widget card; // สร้างเป็นตัวแปร
card = Card(
child: Padding(
padding: const EdgeInsets.all(3.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
child: CachedNetworkImage(
imageUrl: product.image,
height: 150,
fit: BoxFit.contain,
placeholder: (context, url) =>
const Center(
child: SizedBox(
width: 40.0,
height: 40.0,
child:CircularProgressIndicator(),
),
),
errorWidget: (context, url,
error) =>
const Icon(Icons.error),
),
),
Padding(
padding: const EdgeInsets.all(3.0),
child: Text(product.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,),
),
Padding(
padding: const EdgeInsets.all(3.0),
child: Text('Price: \$ ${product.price}'),
),
],
),
));
return card;
},
), */
// ใช้งาน GridView
// ใช้งาน ListView แบบกำหนดเอง ไม่ใช้ ListTile
/* child: ListView.separated(
// กรณีมีรายการ แสดงปกติ
controller:
_scrollController, // กำนหนด controller ที่จะใช้งานร่วม
itemCount: items.length,
itemBuilder: (context, index) {
Product product = items[index];
Widget card; // สร้างเป็นตัวแปร
card = Card(
margin: const EdgeInsets.all(5.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: CachedNetworkImage(
imageUrl: product.image,
height: 100.0,
width: 100.0,
fit: BoxFit.contain,
placeholder: (context, url) =>
Center(
child: SizedBox(
// Adjust the size as needed
width: 40.0,
height: 40.0,
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url,error) =>Icon(Icons.error),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(0.0),
child: Container(
color: Colors.grey[200],
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.title),
Text('Price: \$ ${product.price}'),
],
),
),
),
),
],
));
return card;
},
separatorBuilder:(BuildContext context, int index) =>const SizedBox(),
), */
// ใช้งาน ListView แบบกำหนดเอง ไม่ใช้ ListTile
// ใช้งาน ListView
/* child: ListView.separated(
// กรณีมีรายการ แสดงปกติ
controller:
_scrollController, // กำนหนด controller ที่จะใช้งานร่วม
itemCount: items.length,
itemBuilder: (context, index) {
Product product = items[index];
Widget card; // สร้างเป็นตัวแปร
card = Card(
margin: const EdgeInsets.all(
5.0), // การเยื้องขอบ
child: Column(
children: [
ListTile(
leading: CachedNetworkImage(
imageUrl: product.image,
width: 100.0,
placeholder: (context, url) =>
Center(
child: SizedBox(
// Adjust the size as needed
width: 40.0,
height: 40.0,
child:
CircularProgressIndicator(), // Show loading indicator
),
),
errorWidget: (context, url,
error) =>
Icon(Icons
.error), // Show error icon if loading fails
),
title: Text(product.title),
subtitle: Text(
'Price: \$ ${product.price}'),
trailing: Icon(Icons.more_vert),
onTap: () {},
)
],
));
return card;
},
separatorBuilder:
(BuildContext context, int index) =>
const SizedBox(),
), */
// ใช้งาน ListView
)
: const Center(
child: Text('No items')), // กรณีไม่มีรายการ
),
],
);
} else if (snapshot.hasError) {
return Center(child: Text('${snapshot.error}'));
}
return const Center(child: CircularProgressIndicator());
},
),
],
),
floatingActionButton: ValueListenableBuilder<bool>(
valueListenable: _visible,
builder: (context, visible, child) {
return (visible == false)
? FloatingActionButton(
onPressed: () async {
if (!_isLoading && _visible.value == false) {
_refresh();
}
},
shape: const CircleBorder(),
child: const Icon(Icons.refresh),
)
: SizedBox.shrink();
},
),
);
}
}
// สรัางฟังก์ชั่นดึงข้อมูล คืนค่ากลับมาเป็นข้อมูล Future ประเภท List ของ Product
Future<List<Product>> fetchProduct({reload}) async {
String _currentPath = ''; // เก็บ path ปัจจุบัน
final appDocumentsDirectory = await getApplicationDocumentsDirectory();
_currentPath = appDocumentsDirectory.path;
String filename = "product_cache.json";
String readFile = "$_currentPath/$filename";
String _jsonData = '';
final _file = File(readFile);
final isExits = await _file.exists();
try {
if (isExits && reload == null) {
print("debug: read from file");
_jsonData = await _file.readAsString();
return compute(parseProducts, _jsonData);
} else {
// ทำการดึงข้อมูลจาก server ตาม url ที่กำหนด
String url = 'https://fakestoreapi.com/products';
final response = await http.get(Uri.parse(url));
// เมื่อมีข้อมูลกลับมา
if (response.statusCode == 200) {
print("debug: load form server");
final myfile = _file;
final isExits = await myfile.exists(); // เช็คว่ามีไฟล์หรือไม่
if (!isExits) {
// ถ้ายังไม่มีไฟล์
try {
await myfile.writeAsString(response.body);
} catch (e) {
throw Exception('error: ${e}');
}
} else {
try {
await myfile.writeAsString(response.body);
} catch (e) {
throw Exception('error: ${e}');
}
}
// ส่งข้อมูลที่เป็น JSON String data ไปทำการแปลง เป็นข้อมูล List<Product
// โดยใช้คำสั่ง compute ทำงานเบื้องหลัง เรียกใช้ฟังก์ชั่นชื่อ parseProducts
// ส่งข้อมูล JSON String data ผ่านตัวแปร response.body
return compute(parseProducts, response.body);
} else {
// กรณี error
throw Exception('Failed to load product');
}
}
} catch (e) {
throw Exception('error: ${e}');
}
}
// ฟังก์ชั่นแปลงข้อมูล JSON String data เป็น เป็นข้อมูล List<Product>
List<Product> parseProducts(String responseBody) {
final parsed = jsonDecode(responseBody).cast<Map<String, dynamic>>();
return parsed.map<Product>((json) => Product.fromJson(json)).toList();
}
ตัวอย่างและแนวทางโค้ดทั้งหมดนี้ สามารถนำไปประยุกต์ใช้งานได้ทันที และปรับแต่งได้ตามต้องการ
ในโค้ดจะมีแนวทางความรู้ต่างๆ ผสมปะปนอยู่ หวังว่าเนื้อหานี้จะสามารถนำไปต่อยอดไม่มากก็น้อย