เนื้อหาตอนต่อไปนี้ เราจะกลับมาทบทวนกระบวนการ
หรือขั้นตอนในการโหลดข้อมูลหน้าๆ หนึ่งในแอป ที่เราอาจจะ
ได้ใช้งานบ่อยๆ ไม่ว่าจะเป็นการโหลดข้อมูลจาก 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) การทำ
เช่นนี้ช่วยป้องกันการรั่วไหลของหน่วยความจำและปัญหาอื่น ๆ ที่เกี่ยวข้องกับการจัดการทรัพยากร
หวังว่าแนวทางตัวอย่างเนื้อหานี้ จะสามารถนำไปศึกษาปรับใช้งานได้ต่อไปไม่มากก็น้อย