เนื้อหาตอนต่อไปนี้ จะนำตัาอย่างแนวทางการจัดรูปแบบการแสดง
รายการสินค้าในแบบต่างๆ ให้สามารถนำไปใช้งานหรือประยุกต์ต่อได้
จะเป็นเนื้อหาที่ต่อยอดจากบทความ ตามลิ้งค์ด้านล่าง
การ 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(); }
ตัวอย่างและแนวทางโค้ดทั้งหมดนี้ สามารถนำไปประยุกต์ใช้งานได้ทันที และปรับแต่งได้ตามต้องการ
ในโค้ดจะมีแนวทางความรู้ต่างๆ ผสมปะปนอยู่ หวังว่าเนื้อหานี้จะสามารถนำไปต่อยอดไม่มากก็น้อย