ทำ 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
1 2 3 4 5 6 7 8 9 10 11 12 |
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 ตามต้องการ จะได้ประมาณนี้ พร้อมกับผมสมมุติว่า ช่องแรกถูกเลือกไว้ เลยกำหนดสีให้มันแตกต่าง
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 |
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 ละกัน)
1 2 3 4 5 6 7 |
class _MyHomePageState extends State<MyHomePage> { int _indexSegmentSelected= 1; ... |
จากนั้น refactor code นิดหน่อย เพราะว่าส่วนของ Container สำหรับ segment แต่ละอัน มันคล้ายกัน ต่างกันแค่สถานะและข้อความเท่านั้น
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 |
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
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 |
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
1 2 3 4 5 6 7 |
class _MyHomePageState extends State<MyHomePage> { int _indexSegmentSelected = 1; PageController _pageController = PageController(); ... |
จากนั้นเพิ่ม Page View และกำหนด Page Controller ให้มัน โดยผมจะให้ Page View อยู่ด้านล่างของ Segmented Control และขยายเต็มพื้นที่โดยใช้ Expanded
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 |
@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 ไปที่หน้านั้น
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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
1 2 3 4 5 6 7 8 9 10 |
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 เท่านั้น
1 2 3 4 5 6 7 8 9 10 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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
1 2 3 4 5 6 7 8 9 10 |
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 อื่นๆเพิ่มเข้ามาได้
1 2 3 |
PageController _pageController = PageController(viewportFraction: 0.8); |

Animated
ลองเพิ่ม Animated ให้กับ Page ของเราอีกสักหน่อย โดยใช้ Animated Widget ในตัวอย่างนี้ใช้ AnimatedPadding กับ AnimatedOpacity หรือก็คือ page จะค่อยๆขยาย และค่อยๆ fade นั่นเอง
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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
