เนื้อหาตอนต่อไปนี้ เราจะกลับมาทบทวนกระบวนการ
หรือขั้นตอนในการโหลดข้อมูลหน้าๆ หนึ่งในแอป ที่เราอาจจะ
ได้ใช้งานบ่อยๆ ไม่ว่าจะเป็นการโหลดข้อมูลจาก 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 ทั้งหมด
วิธีการใช้งาน
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | // สร้าง 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 ที่จะแสดง จะต้องมีเสมอ
1 2 3 4 | // สร้างตัวแปรที่สามารถแจ้งเตือนการเปลี่ยนแปลงค่า final ValueNotifier<bool> _visible = ValueNotifier<bool>( false ); // ข้อมูลใน future Future<String?> _dataFuture = Future.value( null ); |
ในตัวอย่างจำลองนี้ เราจะจำลองข้อมูล future เป็น string โดยกำหนดค่าเริ่มต้นเป็น null
ซึ่งจริงๆ แล้วโดยทั่วไปเวลาใช้งานในรูปแบบจริง เรามักจะใช้ในรูปภาพ List<Object> ข้อมูลต่างๆ
ตัวอย่างเช่น สมมติเราไปดึงข้อมูล Article Object จากไฟล์ json บน server มา ก็อาจจะใช้เป็น
รูปแบบดังนี้ ในการกำหนดค่าเริ่มต้น
1 2 | // ตัวอย่างกำหนดตัวแปร future สำหรับรับค่าและแสดง Future<List<Article>> _articles = Future.value([]); |
ในขั้นตอนต่อเรา เมื่อเราสร้างตัวแปรสำหรับรับค่าแล้ว เราจะต้องมีฟังก์ชั่นสำหรับ ทำการดึงข้อมูลนั้น
มาใส่ตัวแปรที่เรากำหนด ในที่นี้เราจำลองการดึงข้อมูลโดยหน่วงเวลา 2 วินาทีเป็นดังนี้
1 2 3 4 5 6 7 8 9 10 11 | // จำลองใช้เป็นแบบฟังก์ชั่น ให้เสมือนดึงข้อมูลจาก 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 ดังนี้
1 2 3 4 5 6 | @override void initState() { print( "debug: Init" ); super .initState(); _dataFuture = fetchData(); // ดึงข้อมูลค่าเริ่มต้น } |
นั่นหมายความว่า เมื่อโหลดมาครั้งแรก จะไปทำการเรียกใช้งานฟังก์ชั่น fetchData() เพื่อดึงข้อมูล
มาเก็บไว้ในตัวแปร _dataFuture ซึ่งเป็นข้อมูล future
ต่อไปเข้าสู่กระบวนการ buid ดึงข้อมูล future มาแสดง
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | 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 จะไปทำตัวสุดท้าย คือ
1 | return const Center(child: CircularProgressIndicator()); |
จะแสดงตัว loading ในรูปแบบวงกลมหมุน ในขณะที่ snapshot.connectionState ==
ConnectionState.waiting และถ้ากรณีเกิด error ขึ้นก็จะแสดงในส่วนนี้
1 | return Center(child: Text( '${snapshot.error}' )); |
และสุดท้ายหากมีข้อมูลสุดท้ายถูกส่งกลับมา if (snapshot.hasData) { ก็จะทำการแสดงข้อความ
ที่เรากำหนดไว้ในตัวอย่างออกมา เป็นข้อความสุดท้ายของผลลัพธ์จากตัวแปร future
ในสถานะหรือขั้นตอนสุดท้ายของการได้ข้อมูลมา เราสามารถจัดการการทำงานเพิ่มเติม โดยใช้รูปแบบ
ดังต่อไปนี้ได้
1 2 3 4 5 6 | 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 ไปเรื่อยๆ เราจึงมีการใช้ตัว
1 | WidgetsBinding.instance.addPostFrameCallback((_) { }); |
คลุมการทำงานอีกทีหนึ่ง ก็เพื่อบอกว่า ให้ทำงานหลักจาก การ build เสร็จเรียบร้อยแล้ว ซึ่งวิธีนี้จะทำ
ให้เราสามารถกำหนดหรือเปลี่ยนแปลงค่า state ใน การ build ได้แบบไม่มีปัญหา
ต่อไป เรามาดูต่อในส่วนของการ refresh ทั้งแบบเรียกฟังก์ชั่นผ่านปุ่มจากตำแหน่งต่างๆ และจากการ
ใช้งาน pull to refresh หรือปัดลงเพื่อโหลดใหม่
สิ่งที่เราต้องมีคือฟังก์ชั่นสำหรับการโหลดใหม่ เราจะสร้างขึ้นมาดังนี้
1 2 3 4 5 | Future<void> _refresh() async { setState(() { _dataFuture = fetchData(); }); } |
ฟังก์ชั่น _refresh() ที่เราสร้างขึ้น จะทำการไปเรียกคำสั่ง fetchData() ใหม่เพื่อไปดึงข้อมูลมา
เก็บไว้ในตัวแปร _dataFuture และเราต้องทำการกำหนด setState เพื่อให้เกิดการ build ใหม่
อีกครั้ง
ในที่นี้ เรามีรูปแบบการโหลดข้อมูลใหม่อยู่ด้วยกัน 3 จุด คือ จากปุ่มเมนูบนขวา ตรง action
1 2 3 4 5 6 7 8 9 10 11 12 | appBar: AppBar( title: Text( 'Home' ), actions: [ IconButton( onPressed: () { _visible.value = true ; _refresh(); }, icon: const Icon(Icons.refresh_outlined), ) ], ), |
ส่วนที่สองส่วนที่อยู่ตรง floatingActionButton
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | 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
1 2 3 4 5 6 | 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()
1 2 3 4 5 6 7 8 9 10 11 | ValueListenableBuilder<bool>( valueListenable: _visible, builder: (context, visible, child) { return Visibility( visible: visible, child: const LinearProgressIndicator( backgroundColor: Colors.white60, ), ); }, ), |
การแสดงในลักษณะเช่นนี้ก็เพราะว่า เราไม่ต้องการล้างหน้าข้อมูลเดิมในขณะที่โหลดข้อมูลใหม่
แต่จะแสดงข้อมูลเดิมไว้ และมีสถานะกำลังโหลดข้อมูลใหม่เป็นแถบเส้นด้านบนแทน และถ้าข้อมูล
โหลดเรียบร้อยแล้ว ก็จะไปแทนที่ข้อมูลเก่าได้เลย
ตัวอย่างการทำงาน

โค้ดตัวอย่างทั้งหมด
ไฟล์ home.dart
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | 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(); }, ), ); } } |
สิ่งสำคัญอีกประการที่ห้ามลืม คือ
1 2 3 4 5 | @override void dispose() { _visible.dispose(); // Dispose the ValueNotifier super .dispose(); } |
เมธอด dispose() ใช้เพื่อจัดการกับการทำความสะอาดทรัพยากรที่ใช้งานใน StatefulWidget
เมื่อวิดเจ็ตไม่ถูกใช้งานอีกต่อไป หรือถูกลบออกจากโครงสร้างของวิดเจ็ต (widget tree) การทำ
เช่นนี้ช่วยป้องกันการรั่วไหลของหน่วยความจำและปัญหาอื่น ๆ ที่เกี่ยวข้องกับการจัดการทรัพยากร
หวังว่าแนวทางตัวอย่างเนื้อหานี้ จะสามารถนำไปศึกษาปรับใช้งานได้ต่อไปไม่มากก็น้อย