ทำ Custom Segmented Control ใน Flutter ตอนที่ 1
สวัสดีครับ บล็อกนี้ขอกลับมาเขียนเรื่องพื้นฐานง่ายๆ สนุกๆกันครับ นั่นก็คือ Segmented Control หรือก็คือ ตัวเลือกเมนู คล้ายๆกับแท็บนั่นเอง สามารถนำไปปรับใช้ได้หลายๆแบบ เช่น ทำเป็น Bottom navigation menu ก็ได้เช่นกัน
Cupertino Segmented
Segmented Control ใน Flutter Widget สำเร็จรูปให้ใช้งานอยู่ 1 ตัว นั่นคือ CupertinoSegmentedControl หน้าตาเหมือนกับใน iOS ที่เราอาจจะเห็นบ่อยๆ โดยเจ้าตัวนี้จะอยู่ใน package cupertino
วิธีการใช้งานง่ายมาก คือกำหนด groupValue (ค่าปัจจุบัน) ให้มัน และ chrilden คือ Map ของค่าแต่ละ segment กับ Widget
CupertinoSegmentedControl(
groupValue: 1,
children: {
1: Text("One"),
2: Text("Two"),
3: Text("Three")},
onValueChanged: (v) {
//
},
)
Custom Segmented
แต่ทว่า ตัว CupertinoSegmentedControl นั้นปรับแต่งได้น้อย ได้แค่กำหนดสีนิดหน่อย ดังนั้นผมจึงแนะนำว่า ให้เราสร้าง Segmented Control ของเราเองเลยดีกว่า ซึ่งวิธีการทำนั้นไม่ได้ยากเลย
โดยเราจะสร้าง Segmented Control แบบ 3 segment วิธีการคือ สร้าง Container ตัวนอกเป็นกรอบใหญ่ จากนั้นกำหนด Row ข้างใน Row กำหนด Container 3 ตัวให้ขนาดความกว้างเท่าๆกัน นั่นก็คือใช้ Expanded ครอบ Container แต่ละตัว
แล้วก็กำหนด border radius ตามต้องการ จะได้ประมาณนี้ พร้อมกับผมสมมุติว่า ช่องแรกถูกเลือกไว้ เลยกำหนดสีให้มันแตกต่าง
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("App"),
),
body: Container(height: 100,
child: Center(
child: buildSegmentedControl(),
),
));
}
Widget buildSegmentedControl() {
return Container(padding: EdgeInsets.all(4),
decoration: BoxDecoration(color: Colors.pink[50],borderRadius: BorderRadius.circular(16)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Segment 1
Container(
decoration: BoxDecoration(color: Colors.pink[400],borderRadius: BorderRadius.circular(16)),
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Text(
"One",
style: TextStyle(fontSize: 20, color: Colors.white),
)),
// Segment 2
Container(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Text("Two", style: TextStyle(fontSize: 20, color: Colors.pink[400])),
),
// Segment 3
Container(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Text("Three", style: TextStyle(fontSize: 20, color: Colors.pink[400])),
),
],
));
}
}
ได้ segment control หน้าตาประมาณนี้
ต่อไปเราจะกำหนด ให้ Segment แต่ละตัวสามารถถูกเลือกได้ นั่นก็คือจะมี State มาเกี่ยวข้อง ผมประกาศให้ indexSegmentSelected = 1 ก็คือให้ ช่องที่ถูกเลือกคือช่องที่ 2 (ให้ index เริ่มที่ 0 ละกัน)
class _MyHomePageState extends State<MyHomePage> {
int _indexSegmentSelected= 1;
...
จากนั้น refactor code นิดหน่อย เพราะว่าส่วนของ Container สำหรับ segment แต่ละอัน มันคล้ายกัน ต่างกันแค่สถานะและข้อความเท่านั้น
Widget buildSegmentedControl() {
return Container(
padding: EdgeInsets.all(4),
decoration: BoxDecoration(color: Colors.pink[50], borderRadius: BorderRadius.circular(16)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
buildSegmentItem("One", selected: _indexSegmentSelected == 0),
buildSegmentItem("Two", selected: _indexSegmentSelected == 1),
buildSegmentItem("Three", selected: _indexSegmentSelected == 2)
],
));
}
Widget buildSegmentItem(String title, {bool selected = false}) {
return Container(
decoration: selected ? BoxDecoration(color: Colors.pink[400], borderRadius: BorderRadius.circular(16)) : null,
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Text(title, style: TextStyle(fontSize: 20, color: selected ? Colors.white : Colors.pink[400])),
);
}
}
ตอนนี้ segment ของเราจะขยับตามค่า _indexSegmentSelected แล้ว
จากนั้นเพิ่ม GestureDetector เพื่อเพิ่ม onTap ให้ segment เมื่อกดแล้วก็จะ setState กำหนดค่า _indexSegmentSelected
Widget buildSegmentedControl() {
return Container(
padding: EdgeInsets.all(4),
decoration: BoxDecoration(color: Colors.pink[50], borderRadius: BorderRadius.circular(16)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
buildSegmentItem("One", index: 0),
buildSegmentItem("Two", index: 1),
buildSegmentItem("Three", index: 2)],
));
}
Widget buildSegmentItem(String title, {int index = 0}) {
bool selected = _indexSegmentSelected== index;
return GestureDetector(
onTap: () {
setState(() {
_indexSegmentSelected= index;
});
},
child: Container(
decoration: selected ? BoxDecoration(color: Colors.pink[400], borderRadius: BorderRadius.circular(16)) : null,
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Text(title, style: TextStyle(fontSize: 20, color: selected ? Colors.white : Colors.pink[400])),
),
);
}
ผลคือสามารถกดที่ segment และขยับไปตามที่ต้องการได้แล้ว
Segmented Control + PageView
มาลองประยุกต์ใช้งานเพิ่มเติมกันครับ เช่นใช้งานร่วมกับ PageView + PageController ก็คือการกดที่ segment แล้วให้ page view เปลี่ยนหน้าไปตาม segment
สร้างตัวแปร PageController
class _MyHomePageState extends State<MyHomePage> {
int _indexSegmentSelected = 1;
PageController _pageController = PageController();
...
จากนั้นเพิ่ม Page View และกำหนด Page Controller ให้มัน โดยผมจะให้ Page View อยู่ด้านล่างของ Segmented Control และขยายเต็มพื้นที่โดยใช้ Expanded
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("App"),
),
body: Container(
child: Column(
children: [
// Segmented Control
Container(padding: EdgeInsets.all(16), child: buildSegmentedControl()),
// Page View
Expanded(
child: PageView(
controller: _pageController,
children: [
buildPage("Page One"),
buildPage("Page Two"),
buildPage("Page Three"),
],
))
],
),
));
}
Widget buildPage(String title) {
return Container(
margin: EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.pink[200], borderRadius: BorderRadius.circular(16)),
child: Center(
child: Text(title, style: TextStyle(fontSize: 20, color: Colors.white)),
),
);
}
...
ลองรัน ก็จะได้ PageView และสามารถเลื่อนหน้าได้ตามที่เรากำหนด children ของ PageView ไว้
แต่ว่าตอนนี้ยังไม่สามารถกดเปลี่ยน page จาก segment ได้ ดังนั้นต้องเพิ่มให้ onTap ของ segment เมื่อกดแล้วให้ PageController animateTo ไปที่หน้านั้น
Widget buildSegmentItem(String title, {int index = 0}) {
bool selected = _indexSegmentSelected == index;
return GestureDetector(
onTap: () {
setState(() {
_indexSegmentSelected = index;
_pageController.animateToPage(_indexSegmentSelected, duration: Duration(milliseconds: 300), curve: Curves.decelerate);
});
},
child: Container(
...
),
);
}
จากนั้น ปัญหาต่อมาที่เจอคือ เมื่อเราเลื่อนที่ PageView ซ้าน-ขวา ตัว Segment ไม่ได้อัพเดทตาม นั่นก็เพราะเรายังไม่ได้เพิ่มคำสั่งในฝั่งของ PageView นั่นเอง
ซึ่ง PageView จะมีคำสั่ง onPageChanged อยู่ โดยคือคำสั่งนี้จะถูกเรียกเมื่อ page ถูกเปลี่ยน ดังนั้นเราก็เพิ่ม setState ให้อัพเดท ค่า _indexSegmentSelected ให้ถูกต้องตาม page
PageView(
controller: _pageController,
onPageChanged: (index) {
setState(() {
_indexSegmentSelected = index;
});
},
...
ปัญหา Animate page
มาเก็บตกปัญหากันครับ จาก code ที่เราเขียน เราได้เชื่อม Segment กับ PageView ทั้ง 2 ฝั่ง คือ onTap ของ segment และ onPageChanged ของ PageView
ปัญหาตอนนี้ที่เจอก็คือเมื่อกด segment ข้ามจาก 1 ไป 3 จะพบว่ามันแสดง Animate ซ้ำซ้อน นั่นก็เพราะ เมื่อเรากดเปลี่ยน segment ก็จะทำการ animateTo ไปยัง page ปลายทาง และนั่นก็ทำให้มีการเรียก onPageChanged ของ PageView ด้วย ทำให้ มันมีการพยายามเปลี่ยนหน้าซ้ำกันถึง 2 ครั้ง
ดังนั้น วิธีแก้แบบง่ายๆ ก็คือ เราจะสร้าง method กลางอันนึง ให้ onTap ของ Segment และ onPageChnaged เรียก method นี้ และ กรณีของ onPageChanged เราจะไม่ทำ animate เราจะทำ animateTo เฉพาะ กรณีที่กดที่ segment เท่านั้น
changePage(int index, {bool animate = true}) async {
if (animate) {
await _pageController.animateToPage(index, duration: Duration(milliseconds: 300), curve: Curves.decelerate);
}
setState(() {
_indexSegmentSelected = index;
});
}
ที่ onTap ของ Segment ก็เรียก changePage(index) กำหนดให้ แสดง animate default เป็น true
Widget buildSegmentItem(String title, {int index = 0}) {
bool selected = _indexSegmentSelected == index;
return GestureDetector(
onTap: () {
changePage(index); // Add here
},
child: Container(
...
),
);
}
ส่วนที่ onPageChanged เรียก changePage(index, animate: false) คือเปลี่ยนหน้าเฉพาะไม่ต้อง animate
PageView(
controller: _pageController,
onPageChanged: (index) {
changePage(index, animate: false); // Add here
},
...
)
ลองดูผลงาน Animate ไม่ซ้ำแล้ว
Viewport Fraction
ลองเพิ่มลูกเล่นของ Page Controller โดยการกำหนด Viewport Fraction ซึ่งเราจะเห็นในการแสดงพวก banner เช่น กำหนด viewportFraction = 0.8 จะทำให้ page หลักที่แสดงของเรามีขนาด 80% ทำให้ที่ว่างที่เหลือจะแสดง page อื่นๆเพิ่มเข้ามาได้
PageController _pageController = PageController(viewportFraction: 0.8);
Animated
ลองเพิ่ม Animated ให้กับ Page ของเราอีกสักหน่อย โดยใช้ Animated Widget ในตัวอย่างนี้ใช้ AnimatedPadding กับ AnimatedOpacity หรือก็คือ page จะค่อยๆขยาย และค่อยๆ fade นั่นเอง
Widget buildPage(String title,{int index = 0}) {
return AnimatedPadding(
duration: Duration(milliseconds: 500),
padding: EdgeInsets.all(_indexSegmentSelected == index ? 0 : 28),
child: AnimatedOpacity(
duration: Duration(milliseconds: 500),
opacity: _indexSegmentSelected == index ? 1 : 0.3,
child: Container(
margin: EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.pink[200], borderRadius: BorderRadius.circular(16)),
child: Center(
child: Text(title, style: TextStyle(fontSize: 20, color: Colors.white)),
),
)),
);
}
หากสนใจเรื่อง Animated Widget สามารถอ่านเรื่อง Animation ใน Flutter ของผมได้ครับ ผมเขียนเอาไว้ 2 ตอน
สรุป
ในบทความนี้เราได้ลองเล่น Segmented Control ที่ลอง custom เอง และการนำมาใช้กับ PageView + PageController สามารถนำไปประยุกต์ใช้ได้หลายแบบ สำหรับตอนที่ 2 นั้น เราจะลองนำ Custom Paniter มาใช้งานร่วมกับ Segmented Control กันครับ จะช่วยให้ Segmented Control ของเราดูใหลลื่นมากขึ้น
หวังว่าบทความนี้จะมีประโยชน์กับผู้เริ่มต้นครับ ขอบพระคุณครับ สวัสดีครับ (:
สำหรับ source code สามารถลองเล่นได้ที่ DartPad ตามลิงค์ด้านล่างนี้เลยครับ
https://dartpad.dev/7a9b1708f5aac03640ecb996c6b96bca