web analytics

ทำ 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

อ่านต่อ ตอนที่ 2