เนื้อหาตอนต่อไปนี้ เราจะกลับมาทบทวนกระบวนการ
หรือขั้นตอนในการโหลดข้อมูลหน้าๆ หนึ่งในแอป ที่เราอาจจะ
ได้ใช้งานบ่อยๆ ไม่ว่าจะเป็นการโหลดข้อมูลจาก server ผ่าน
http แล้วนำรายาการมาแสดง ซึ่งเนื้อหาเหล่านี้ เราได้เคยอธิบาย
ไว้แล้วในบทความต่างๆ ที่ผ่านมา ตามตัวอย่างลิ้งค์ด้านล่าง
- การใช้งาน FutureBuilder ที่เป็น Async widgets ใน Flutter http://niik.in/1036
- การใช้งาน RefreshIndicator ปัดเพื่อรีเฟรชข้อมูล ใน Flutter http://niik.in/1040
- การใช้งาน Http ดึงข้อมูลจาก Server มาแสดงใน Flutter http://niik.in/1038
เนื้อหานี้ใช้โค้ดตัวอย่างเริ่มต้น จากบทความ ตามลิ้งค์นี้ http://niik.in/961
โดยใช้ โค้ดตัวอย่างจากส่วน เพิ่มเติมเนื้อหา ครั้งที่ 2
เนื้อหาต่างๆ เหล่านี้ล้วนเป็นแนวทางที่เราสามารถนำไปปรับประยุกต์ใช้งานได้
อย่างไรก็ดี เพื่อให้เราสามารถเข้าใจ การทำงานที่ชัดเจน และประยุกต์ได้ง่ายขึ้น จึงจะนำมาอธิบาย
พร้อมกับเพิ่มเติมการทำงานให้ครอบคลุมมากยิ่งขึ้น
สิ่งที่เราจะทำและเรียนรู้ในบทความนี้
- รู้จักการใช้งานตัวแปร ValueNotifier สำหรับซ่อนหรือแสดง widget
- การเรียกใช้งาน WidgetsBinding.instance.addPostFrameCallback()
- การใช้งาน ValueListenableBuilder widget
- การจัดการกระบวนการโหลดข้อมูลด้วย FutureBuilder
- สามารถกำหนดให้ปัดลงเพื่อโหลดข้อมูลใหม่ หรือกดปุ่มที่ตำแหน่งต่างๆ เพื่อโหลดข้อมูล
การใช้งาน ValueNotifier
ValueNotifier เป็นคลาสใน Flutter ที่ใช้สำหรับการจัดการค่าที่สามารถเปลี่ยนแปลงได้
และแจ้งเตือนเมื่อค่ามีการเปลี่ยนแปลง ValueNotifier เป็นส่วนหนึ่งของแพ็กเกจ flutter และ
มีความเกี่ยวข้องกับการจัดการสถานะ (state management) ในแอปพลิเคชัน Flutter
คุณสมบัติหลักของ ValueNotifier:
เก็บค่าและแจ้งเตือน: ValueNotifier ใช้เก็บค่าหนึ่งค่า (value) และสามารถแจ้งเตือนไป
ยัง (listeners) เมื่อค่าของมันเปลี่ยนแปลง
เชื่อมต่อกับ UI: มักใช้ร่วมกับ ValueListenableBuilder เพื่อสร้าง UI ที่ตอบสนองต่อ
การเปลี่ยนแปลงค่า โดยจะทำการ build เฉพราะส่วนที่มีการเรียกใช้งาน ไม่ได้ build ทั้งหมด
วิธีการใช้งาน
// สร้าง ValueNotifier final ValueNotifier<int> _counter = ValueNotifier<int>(0); // ValueNotifier<ชนิดตัวแปร> _counter = ValueNotifier<ชนิดตัวแปร>(ค่าเริ่มต้น); // เปลี่ยนค่า: _counter.value = _counter.value + 1; // การนำไปใช้งานกับ ValueListenableBuilder widget // ถ้าค่า _counter จะทำการ build เฉพราะส่วนที่เรียกใช้งานเท่านั้น ValueListenableBuilder<int>( valueListenable: _counter, builder: (context, value, child) { return Text('Counter value: $value'); }, );
ข้อมูล Future และการบวนการใช้งาน FutureBuilder
ในกระบวนการหรือขั้นตอนการดึงข้อมูลจาก server มาแสดงหรือข้อมูล future ใดๆ นั้น โดย
ทั่วไป เราจะจัดรูปแบบในลักษณะดังนี้คือ เมื่อเริ่มต้น เราจะต้องแสดงตัว loading ก่อน ซึ่งเราจะใช้
ValueNotifier สร้างตัวแปรกำหนดสถานะ การซ่อนหรือแสดงตัว loading จากนั้นต่อมา เราต้อง
มีตัวแปร สำหรับเก็บข้อมูล Future ที่จะแสดง จะต้องมีเสมอ
// สร้างตัวแปรที่สามารถแจ้งเตือนการเปลี่ยนแปลงค่า final ValueNotifier<bool> _visible = ValueNotifier<bool>(false); // ข้อมูลใน future Future<String?> _dataFuture = Future.value(null);
ในตัวอย่างจำลองนี้ เราจะจำลองข้อมูล future เป็น string โดยกำหนดค่าเริ่มต้นเป็น null
ซึ่งจริงๆ แล้วโดยทั่วไปเวลาใช้งานในรูปแบบจริง เรามักจะใช้ในรูปภาพ List<Object> ข้อมูลต่างๆ
ตัวอย่างเช่น สมมติเราไปดึงข้อมูล Article Object จากไฟล์ json บน server มา ก็อาจจะใช้เป็น
รูปแบบดังนี้ ในการกำหนดค่าเริ่มต้น
// ตัวอย่างกำหนดตัวแปร future สำหรับรับค่าและแสดง Future<List<Article>> _articles = Future.value([]);
ในขั้นตอนต่อเรา เมื่อเราสร้างตัวแปรสำหรับรับค่าแล้ว เราจะต้องมีฟังก์ชั่นสำหรับ ทำการดึงข้อมูลนั้น
มาใส่ตัวแปรที่เรากำหนด ในที่นี้เราจำลองการดึงข้อมูลโดยหน่วงเวลา 2 วินาทีเป็นดังนี้
// จำลองใช้เป็นแบบฟังก์ชั่น ให้เสมือนดึงข้อมูลจาก 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<String>
ตอนนี้เรามีตัวแปรสำหรับรับค่า และมีฟังก์ชั่นสำหรับดึงข้อมูลมาเก็บในตัวแปรแล้ว ต่อไป ขั้นตอน
การทำงานของโปรแกรม เมื่อโหลดหน้าแอป เราต้องทำการไปดึงข้อมูลแล้วนำมาเก็บไว้ในตัวแปร
โดยจะทำงานในส่วนของ initState ดังนี้
@override void initState() { print("debug: Init"); super.initState(); _dataFuture = fetchData(); // ดึงข้อมูลค่าเริ่มต้น }
นั่นหมายความว่า เมื่อโหลดมาครั้งแรก จะไปทำการเรียกใช้งานฟังก์ชั่น fetchData() เพื่อดึงข้อมูล
มาเก็บไว้ในตัวแปร _dataFuture ซึ่งเป็นข้อมูล future
ต่อไปเข้าสู่กระบวนการ buid ดึงข้อมูล future มาแสดง
FutureBuilder<String?>( // การคืนค่าต้องตรงกัน ในที่นี่ เป็น string หรือ null future: _dataFuture, // ตัวแปร future 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; // ตัวไว้สำหรับซ่อนหรือแสดงสถานะตัว loading }); } if (snapshot.hasData) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${snapshot.data}', style: TextStyle( fontSize: 20, ), ), ], ); } else if (snapshot.hasError) { return Center(child: Text('${snapshot.error}')); } return const Center(child: CircularProgressIndicator()); }, ),
ในตัวอย่าง เมื่อเข้ามาครั้งแรก ขณะที่ตัวฟังก์ชั่น fetchData() ทำงาน เงื่อนไขการ build
ของ FutureBuilder จะไปทำตัวสุดท้าย คือ
return const Center(child: CircularProgressIndicator());
จะแสดงตัว loading ในรูปแบบวงกลมหมุน ในขณะที่ snapshot.connectionState ==
ConnectionState.waiting และถ้ากรณีเกิด error ขึ้นก็จะแสดงในส่วนนี้
return Center(child: Text('${snapshot.error}'));
และสุดท้ายหากมีข้อมูลสุดท้ายถูกส่งกลับมา if (snapshot.hasData) { ก็จะทำการแสดงข้อความ
ที่เรากำหนดไว้ในตัวอย่างออกมา เป็นข้อความสุดท้ายของผลลัพธ์จากตัวแปร future
ในสถานะหรือขั้นตอนสุดท้ายของการได้ข้อมูลมา เราสามารถจัดการการทำงานเพิ่มเติม โดยใช้รูปแบบ
ดังต่อไปนี้ได้
if (snapshot.connectionState == ConnectionState.done) { WidgetsBinding.instance.addPostFrameCallback((_) { // Change state after the build is complete _visible.value = false; // ตัวไว้สำหรับซ่อนหรือแสดงสถานะตัว loading }); }
ข้างต้นคือสถานะที่ได้ข้อมูลมาแล้ว และ เราต้องการกำหนดค่า _visible.value = false; หรือ
เพื่อให้ซ่อนตัว loading แต่ในกระบวนการ build เราจะไม่สามารถ ทำการกำหนดการเปลี่ยนแปลง
ค่าใดๆ ได้ เพราะจะกลายเป็นการวนลูป build ไปเรื่อยๆ เราจึงมีการใช้ตัว
WidgetsBinding.instance.addPostFrameCallback((_) { });
คลุมการทำงานอีกทีหนึ่ง ก็เพื่อบอกว่า ให้ทำงานหลักจาก การ build เสร็จเรียบร้อยแล้ว ซึ่งวิธีนี้จะทำ
ให้เราสามารถกำหนดหรือเปลี่ยนแปลงค่า state ใน การ build ได้แบบไม่มีปัญหา
ต่อไป เรามาดูต่อในส่วนของการ refresh ทั้งแบบเรียกฟังก์ชั่นผ่านปุ่มจากตำแหน่งต่างๆ และจากการ
ใช้งาน pull to refresh หรือปัดลงเพื่อโหลดใหม่
สิ่งที่เราต้องมีคือฟังก์ชั่นสำหรับการโหลดใหม่ เราจะสร้างขึ้นมาดังนี้
Future<void> _refresh() async { setState(() { _dataFuture = fetchData(); }); }
ฟังก์ชั่น _refresh() ที่เราสร้างขึ้น จะทำการไปเรียกคำสั่ง fetchData() ใหม่เพื่อไปดึงข้อมูลมา
เก็บไว้ในตัวแปร _dataFuture และเราต้องทำการกำหนด setState เพื่อให้เกิดการ build ใหม่
อีกครั้ง
ในที่นี้ เรามีรูปแบบการโหลดข้อมูลใหม่อยู่ด้วยกัน 3 จุด คือ จากปุ่มเมนูบนขวา ตรง action
appBar: AppBar( title: Text('Home'), actions: [ IconButton( onPressed: () { _visible.value = true; _refresh(); }, icon: const Icon(Icons.refresh_outlined), ) ], ),
ส่วนที่สองส่วนที่อยู่ตรง floatingActionButton
floatingActionButton: ValueListenableBuilder<bool>( valueListenable: _visible, builder: (context, visible, child) { return (visible == false) ? FloatingActionButton( onPressed: () { _visible.value = true; _refresh(); }, shape: const CircleBorder(), child: const Icon(Icons.refresh), ) : SizedBox.shrink(); }, ),
และส่วนสุดท้ายส่วนที่กดปัดลง แล้วทำการ refresh หรือส่วนที่ใช้งาน RefreshIndicator
body: RefreshIndicator( onRefresh: () async { _visible.value = true; _refresh(); }, // Function to call when the user pulls to refresh child: ListView(
จะเห็นว่า ทั้ง 3 จุด การที่จะเรียกใช้งานฟังก์ชั่น _refresh() เราจะทำการกำหนดค่า _visible.value
ให้มีค่าเป็น true เพื่อใช้เป็นค่าสำหรับ สร้างการซ่อนหรือแสดงตัว loading ต่างๆ ซึ่งในตัวอย่าง
เรามึอยู่ 2 จุด จุดแรกก็ในตัว FutureBuilder ที่อธิบายไปแล้วด้านบน และอีกจุด เราใช้เป็นแบบ
เส้นแถบ ใต้ด appBar เรียกว่า LinearProgressIndicator()
ValueListenableBuilder<bool>( valueListenable: _visible, builder: (context, visible, child) { return Visibility( visible: visible, child: const LinearProgressIndicator( backgroundColor: Colors.white60, ), ); }, ),
การแสดงในลักษณะเช่นนี้ก็เพราะว่า เราไม่ต้องการล้างหน้าข้อมูลเดิมในขณะที่โหลดข้อมูลใหม่
แต่จะแสดงข้อมูลเดิมไว้ และมีสถานะกำลังโหลดข้อมูลใหม่เป็นแถบเส้นด้านบนแทน และถ้าข้อมูล
โหลดเรียบร้อยแล้ว ก็จะไปแทนที่ข้อมูลเก่าได้เลย
ตัวอย่างการทำงาน
โค้ดตัวอย่างทั้งหมด
ไฟล์ home.dart
import 'package:flutter/material.dart'; class Home extends StatefulWidget { static const routeName = '/home'; const Home({Key? key}) : super(key: key); @override State<StatefulWidget> createState() { return _HomeState(); } } class _HomeState extends State<Home> { // สร้างตัวแปรที่สามารถแจ้งเตือนการเปลี่ยนแปลงค่า final ValueNotifier<bool> _visible = ValueNotifier<bool>(false); // ข้อมูลใน future Future<String?> _dataFuture = Future.value(null); // จำลองใช้เป็นแบบฟังก์ชั่น ให้เสมือนดึงข้อมูลจาก 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 { setState(() { _dataFuture = fetchData(); }); } @override void initState() { print("debug: Init"); super.initState(); _dataFuture = fetchData(); } @override void dispose() { _visible.dispose(); // Dispose the ValueNotifier super.dispose(); } @override Widget build(BuildContext context) { print("debug: build"); return Scaffold( appBar: AppBar( title: Text('Home'), actions: [ IconButton( onPressed: () { _visible.value = true; _refresh(); }, icon: const Icon(Icons.refresh_outlined), ) ], ), body: RefreshIndicator( onRefresh: () async { _visible.value = true; _refresh(); }, // Function to call when the user pulls to refresh child: ListView( padding: const EdgeInsets.all(8.0), children: [ ValueListenableBuilder<bool>( valueListenable: _visible, builder: (context, visible, child) { return Visibility( visible: visible, child: const LinearProgressIndicator( backgroundColor: Colors.white60, ), ); }, ), FutureBuilder<String?>( future: _dataFuture, 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 (snapshot.hasData) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${snapshot.data}', style: TextStyle( fontSize: 20, ), ), ], ); } 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: () { _visible.value = true; _refresh(); }, shape: const CircleBorder(), child: const Icon(Icons.refresh), ) : SizedBox.shrink(); }, ), ); } }
สิ่งสำคัญอีกประการที่ห้ามลืม คือ
@override void dispose() { _visible.dispose(); // Dispose the ValueNotifier super.dispose(); }
เมธอด dispose() ใช้เพื่อจัดการกับการทำความสะอาดทรัพยากรที่ใช้งานใน StatefulWidget
เมื่อวิดเจ็ตไม่ถูกใช้งานอีกต่อไป หรือถูกลบออกจากโครงสร้างของวิดเจ็ต (widget tree) การทำ
เช่นนี้ช่วยป้องกันการรั่วไหลของหน่วยความจำและปัญหาอื่น ๆ ที่เกี่ยวข้องกับการจัดการทรัพยากร
หวังว่าแนวทางตัวอย่างเนื้อหานี้ จะสามารถนำไปศึกษาปรับใช้งานได้ต่อไปไม่มากก็น้อย