web analytics

บันทึกการเรียน Flutter ของผม ตอนที่ 4

สวัสดีครับ บทความนี้เป็นบันทึกการเรียน Flutter + ภาษา Dart ของผม ตอนนี้พอจะเริ่มเข้าใจหลักการแล้วละ บทความนี้ก็เลยมาบันทึกการเรียน และการทำความเข้าใจเกี่ยวกับ Flutter ของผมครับ ซึ่งบทความนี้จะลองทำความเข้าใจกับ Codelabs ของ Flutter ที่หัวข้อ Building Beautiful UIs with Flutter ครับ โดยตัว Codelabs จะพาทำแอปแชท แล้วก็สอนการตกแต่ง UI และการ Custom UI ตาม Platform ครับ อ้อแล้วก็รอบนี้ผมจะลองรันใน iOS ด้วยเลย

สามารถดู Codelabs ของ Flutter ได้ที่ลิงค์นี้เลย

Building Beautiful UIs with Flutter
https://codelabs.developers.google.com/codelabs/flutter/#0

แอปที่เราจะทำ

แอปนี้เป็นแอทหน้าจอแชทแบบง่ายๆ เพื่อฝึกการทำ UI ครับ สังเกตว่า UI ของ Android จะแตกต่างกับ iOS ทั้งสีธีม แล้วก็ไอคอนครับ
เดี๋ยวเราลองมาทำ Codelab กัน

ภาพจาก Flutter

1

เริ่มต้น

ที่ main.dart สร้าง Widget สำหรับ app ของเราอันนึง คือ FriendlychatApp โดยภายในจะใช้งาน MaterialApp
ซึ่งใน MaterialApp ตรงพารามิเตอร์ home ก็ค่อยเรียกใช้ Widget ChatScreen ที่ทำแบบนี้ก็เพราะว่าจะได้เปลี่ยนธีมของแอปง่ายๆ คือใช้ความสามารถของ MaterialApp นั่นเอง
ส่วนที่ ChatScreen ก็ใส่แค่ AppBar ไว้ก่อน

import 'package:flutter/material.dart';

void main() {
  runApp(new FriendlychatApp());
}

class FriendlychatApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: "Friendlychat",
      home: new ChatScreen(),
    );
  }
}

class ChatScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(title: new Text("Friendlychat")),
    );
  }
}

ลองรัน จะได้หน้าเปล่าๆกับ AppBar

2

ทำช่องพิมพ์ข้อความ

ต่อมาก็มาทำช่องพิมพ์ข้อความ หรือเรียกว่า TextField (ใน Android จะเรียก EditText) โดย UI ตรงนี้จะกำหนดให้มีไอคอนรูปส่งจดหมาย ด้านขวาด้วย

3a

พอมาถึงตรงจุดนี้ เราก็จะคิดขึ้นได้ว่า หน้า Chat มันก็ต้องมีการเปลี่ยนแปลง เช่นพิมพ์ข้อความไปหน้าจอก็ต้องอัพเดท ดังนั้นก็ต้องใช้ StatefulWidget แทน StatelessWidget
พอใช้ StatefulWidget ก็ต้องมี State และมี createState()

class ChatScreen extends StatefulWidget {                     //modified
  @override                                                        //new
  State createState() => new ChatScreenState();                    //new
}

ดังนั้นต้องทำคลาส ChatScreenState แล้วย้ายส่วนของ build ใน ChatScreen มาใน ChatScreenState  แทน
จากนั้นก็เพิ่มส่วนของช่องกรอกข้อความ ใน Flutter เรียกว่า TextField โดยมันจะมาคู่กับ TextEditingController
ก่อนอื่นประกาศตัวแปร TextEditingController ก่อน แล้ว เขียน method สำหรับแสดง TextField ใน Flutter เรียกกระบวนการนี้ว่า TextComposer
โดยใน method ก็มีการสร้าง container แล้ว ข้างในก็ค่อยใส่ TextField เพื่อจะได้กำหนด margin ได้ จะได้สวยขึ้น

class ChatScreenState extends State<ChatScreen> {                  //new
  final TextEditingController _textController = new TextEditingController();
  @override                                                        //new
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(title: new Text("Friendlychat")),
      body: _buildTextComposer()
    );
  }

  void _handleSubmitted(String text) {
    _textController.clear();
  }

  Widget _buildTextComposer() {
    return new Container(
      margin: const EdgeInsets.symmetric(horizontal: 8.0),
      child: new TextField(
        controller: _textController,
        onSubmitted: _handleSubmitted,
        decoration: new InputDecoration.collapsed(
            hintText: "Send a message"),
      ),
    );
  }
}

ลองรัน ก็จะได้ช่องพิมพ์ข้อความแล้ว และก็มี margin ด้านซ้ายขวาเล็กน้อย

4

ต่อมาก็เพิ่มไอคอน รูปส่ง ไปด้านขวาของช่องกรอกข้อความ วิธีก็คือการใช้ Row
ซึ่งมันก็เหมือนการเอา View มาต่อกัน ส่วนช่องข้อความก็ใช้คลาส Flexible เพื่อให้มันแสดงยาวเต็มจอ

อ้อแล้วก็กำหนด controller , submit ให้กับ TextField ด้วย

Widget _buildTextComposer() {
  return new Container(
    margin: const EdgeInsets.symmetric(horizontal: 8.0),
    child: new Row(
      children: <Widget>[
        new Flexible(
          child: new TextField(
            controller: _textController,
            onSubmitted: _handleSubmitted,

            decoration: new InputDecoration.collapsed(
              hintText: "Send a message"),
          ),
        ),
        new Container(                                                 //new
          margin: new EdgeInsets.symmetric(horizontal: 4.0),           //new
          child: new IconButton(                                       //new
            icon: new Icon(Icons.send),                                //new
            onPressed: () => _handleSubmitted(_textController.text)),  //new
        ),                                                             //new
      ],
    ),
  );
}

ลองรัน จะมีไอคอนเพิ่มมาแล้ว และอยู่ด้านขวาสุด

5

เปลี่ยนสีไอคอน

ทำการปรับแต่งสีของไอคอน ตรงนี้จะใช้ IconThemeData
โดยเอา container ทั้งหมดของ view ยัดมาเป็น child ซึ่งมันจะเหมือนกันเปลี่ยนไอคอนทั้งหมดใน child เป็นสีนั้น
แบบนี้ก็สะดวกดี

Widget _buildTextComposer() {
  return new IconTheme(                                            //new
    data: new IconThemeData(color: Theme.of(context).accentColor), //new
    child: new Container(                                     //modified
      margin: const EdgeInsets.symmetric(horizontal: 8.0),
      child: new Row(
        children: <Widget>[
          new Flexible(
            child: new TextField(
              controller: _textController,
              onSubmitted: _handleSubmitted,
              decoration: new InputDecoration.collapsed(
                hintText: "Send a message"),
            ),
          ),
          new Container(
            margin: new EdgeInsets.symmetric(horizontal: 4.0),
            child: new IconButton(
              icon: new Icon(Icons.send),
              onPressed: () => _handleSubmitted(_textController.text)),
          ),
        ],
      ),
    ),                                                             //new
  );
}

ลองรัน ไอคอนเปลี่ยนสีแล้ว

6

ทำส่วนแถวแสดงข้อความ

ออกแบบแถวสำหรับแสดงข้อความแชทประมาณนี้

7

ทำ class สำหรับแสดงข้อความชื่อ ChatMessage
ภายใน build ก็ใส่ Widget ต่างๆ แล้วเราสามารถวาดวงกลมโดยใช้ CircleAvatar ได้เลย สะดวกดี
ถามว่า การเขียนโค้ดตรงนี้พวก Widget ให้มันเป็นไปตามที่ ออกแบบยากไหม ผมว่ายาก 5555
คงต้องจำ attr แล้วก็ Widget สักหน่อย อันนี้อยู่ที่การฝึกฝนละนะ

class ChatMessage extends StatelessWidget {
  ChatMessage({this.text});
  final String text;
  final String _name = "Benznest";
  @override
  Widget build(BuildContext context) {
    return new Container(
      margin: const EdgeInsets.symmetric(vertical: 10.0),
      child: new Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          new Container(
            margin: const EdgeInsets.only(right: 16.0),
            child: new CircleAvatar(child: new Text(_name[0])),
          ),
          new Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              new Text(_name, style: Theme.of(context).textTheme.subhead),
              new Container(
                margin: const EdgeInsets.only(top: 5.0),
                child: new Text(text),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

เพิ่มตัวแปร list สำหรับเก็บ ChatMessage ใน ChatScreenState

class ChatScreenState extends State<ChatScreen> {
  final List<ChatMessage> _messages = <ChatMessage>[];             // new

ปรับแต่ง method การ submit ของ TextField ให้มีการเพิ่มข้อความลงไปใน list
แล้วก็ทำใน setState เพราะว่าจะได้อัพเดทหน้าจอ

void _handleSubmitted(String text) {
  _textController.clear();
    ChatMessage message = new ChatMessage(                         //new
      text: text,                                                  //new
    );                                                             //new
    setState(() {                                                  //new
      _messages.insert(0, message);                                //new
    });                                                            //new
 }

ทำ ListView แสดงข้อความ

ทำการเพิ่ม ListView สำหรับแสดงข้อความแชท
สังเกตว่ามีการใช้ Flexible ครอบ LsitView เพราะจะได้แผ่ให้ ListView บีบ TextField ไปอยู่ด้านล่างจอนั่นเอง
แล้วก็ใช้ ListView reverse = true เพราะ ให้แสดงข้อความจากด้านล่าง

class ChatScreenState extends State<ChatScreen> {                  //new
  ...
  @override                                                        //new
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(title: new Text("Friendlychat")),
      body: new Column(                                        //modified
        children: <Widget>[                                         //new
          new Flexible(                                             //new
            child: new ListView.builder(                            //new 
              padding: new EdgeInsets.all(8.0),                     //new
              reverse: true,                                        //new
              itemBuilder: (_, int index) => _messages[index],      //new
              itemCount: _messages.length,                          //new
            ),                                                      //new
          ),                                                        //new
          new Divider(height: 1.0),                                 //new
          new Container(                                            //new
            decoration: new BoxDecoration(
                color: Theme.of(context).cardColor),                  //new
            child: _buildTextComposer(),                       //modified
          ),                                                        //new
        ],                                                          //new
      ),                                                            //new
    );
  }

ลองรัน แล้วพิมพ์ข้อความ

8

ทำ Animation

ต่อมา ลองทำ animation ให้กับ ข้อความแชทกันครับ
เริ่มจาก นำ TickerProviderStateMixin โดยใช้ with

class ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin  { //modified
   ...
}

สำหรับ keyword “with” นี้ ผมก็สงสัยว่าคืออะไร
https://stackoverflow.com/questions/21682714/with-keyword-in-dart

A mixin refers to the ability to add the capabilities of another class or classes to your own class, without inheriting from those classes. The methods of those classes can now be called on your class, and the code within those classes will execute. Dart does not have multiple inheritances, but the use of mixins allows you to fold in other classes to achieve code reuse while avoiding the issues that multiple inheritances would cause.

With คือการเรียกใช้คลาสอื่นเพิ่มเข้ามาเป็นเพิ่มความสามารถของคลาสอื่นมาที่คลาสของเรา โดยไม่มีการสืบทอด ทำให้ method ของคลาสนั้นถูกเรียกได้ในคลาสของเรา ซึ่งใน Dart ไม่มีการสืบทอดแบบหลายคลาสนะ (มีใน C++) แต่ว่าการใช้ mixin จะอนุญาตเราทำสิ่งที่คล้ายๆ การสืบทอดแบบหลายทำได้ เรียกคลาสที่ถูกเพิ่มความสามารถว่า  Mixin

โอเค พอเราเพิ่ม TickerProviderStateMixin เข้ามาให้กับ ChatScreenState ผ่าน with แล้ว
ทำให้ ChatScreenState สามารถเรียกใช้ method และมี context ของ TickerProviderStateMixin ด้วย

จากนั้นประกาศตัวแปร AnimationController ให้กับ ChatMessage และเพิ่มให้ constuctor ด้วย
AnimationController จะดูแลพวกเวลา animate , ทิศทางของ animation

// Modify the ChatMessage class definition as follows.

class ChatMessage extends StatelessWidget {
  ChatMessage({this.text, this.animationController});         //modified
  final String text;
  final AnimationController animationController;                   //new
  ...

ที่คำสั่งของปุ่ม submit ก็แก้ไขให้ ChatMessage รองรับ AnimationController ให้กำหนดพวกเวลาของ animation
และตรงนี้เองที่พารามิเตอร์ vsync ต้องการ  TickerProviderStateMixin เราก็ใช้ this ได้เลย

void _handleSubmitted(String text) {
  _textController.clear();
  ChatMessage message = new ChatMessage(
    text: text,
    animationController: new AnimationController(                  //new
      duration: new Duration(milliseconds: 700),                   //new
      vsync: this,                                                 //new
    ),                                                             //new
  );                                                               //new
  setState(() {
    _messages.insert(0, message);
  });
  message.animationController.forward();                           //new
}

ถ้าไม่ใส่ with TickerProviderStateMixin ก็จะมาแดงตรง vsync

9

ต่อมาก็มากำหนด animation ว่าต้องการแสดงกับ Widget ไหน ซึ่งในที่นี้คือ ทั้ง container ใน ChatMessage
จะใช้ SizeTransition ครอบ container ทั้งหมด แล้วก็กำหนด sizeFactor ว่าต้องการ curve แบบไหน

นอกจาก SizeTransition ที่มันจะทำการขยาย หด แล้วอีกตัวที่ใช้ได้คือ FadeTransition มันจะเล่นกับความจาง เข้ม เล่นกับพวก opacity แทน

class ChatMessage extends StatelessWidget {
  ...
  @override
  Widget build(BuildContext context) {
    return new SizeTransition( //new
        sizeFactor: new CurvedAnimation( //new
            parent: animationController, curve: Curves.easeOut), //new
        axisAlignment: 0.0,
        child: new Container(
          margin: const EdgeInsets.symmetric(vertical: 10.0),
          child: new Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              new Container(
                margin: const EdgeInsets.only(right: 16.0),
                child: new CircleAvatar(child: new Text(_name[0])),
              ),
              new Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  new Text(_name, style: Theme
                      .of(context)
                      .textTheme
                      .subhead),
                  new Container(
                    margin: const EdgeInsets.only(top: 5.0),
                    child: new Text(text),
                  ),
                ],
              ),
            ],
          ),
        )
    );
  }
}

มีให้เลือก curve หลายแบบ

10

จากนั้นเพื่อสุขลักษณะที่ดี ทาง Flutter บอกว่าใช้เสร็จแล้วให้ คืน memmory ด้วย
ก็ใช้ override dispose() แล้วก็วนลูป animationController ทำการ dispose ให้หมด

class ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
  ...
  @override
  void dispose() {                                                   //new
    for (ChatMessage message in _messages)                           //new
      message.animationController.dispose();                         //new
    super.dispose();                                                 //new
  }

ลองรัน ก็จะได้ animation แล้ว

a1

ป้องกันการส่งข้อความเปล่า

เพิ่มตัวแปร boolean เพื่อบอกว่าตอนนี้มีคำในช่องกรอกข้อความอยู่มัย

class ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
  final List<ChatMessage> _messages = <ChatMessage>[];
  final TextEditingController _textController = new TextEditingController();
  bool _isComposing = false;                                      //new

เพิ่ม onChange ของ TextField ถ้ามีข้อความอยู่ก็ให้ _isCompsing เป็น true
แล้วแก้ไข onPressed ของ Icon ส่ง ถ้า _isCompsing = true ค่อยเรียกใช้ submit

ดังนั้นต้องจะเห็นว่า ใน onChange ตัว _isComposing ต้องทำใน setState เพราะว่า ต้องการอัพเดท onPressed ใหม่ให้เป้นไปตมค่า _isCompsing นั่นเอง

  Widget _buildTextComposer() {
    return new IconTheme( //new
      data: new IconThemeData(color: Theme
          .of(context)
          .accentColor), //new
      child: new Container( //modified
        margin: const EdgeInsets.symmetric(horizontal: 8.0),
        child: new Row(
          children: <Widget>[
            new Flexible(
              child: new TextField(
                controller: _textController,
                onSubmitted: _handleSubmitted,
                onChanged: (String text) {          //new
                  setState(() {                     //new
                    _isComposing = text.length > 0; //new
                  });                               //new
                },
                decoration: new InputDecoration.collapsed(
                    hintText: "Send a message"),
              ),
            ),
            new Container(
              margin: new EdgeInsets.symmetric(horizontal: 4.0),
              child: new IconButton(
                  icon: new Icon(Icons.send),
                  onPressed: _isComposing
                    ? () => _handleSubmitted(_textController.text)    //modified
                    : null,
              ),
            )
          ],
        ),
      ), //new
    );
  }
}

หลังจาก submit ก็คืนค่า _isComposing เป็น false อีกครั้ง

  void _handleSubmitted(String text) {
    _textController.clear();
    setState(() {                                                    //new
      _isComposing = false;                                          //new
    });                                                              //new
    ChatMessage message = new ChatMessage(
      text: text,
      animationController: new AnimationController( //new
        duration: new Duration(milliseconds: 1000), //new
        vsync: this, //new
      ), //new
    ); //new
    setState(() {
      _messages.insert(0, message);
    });
    message.animationController.forward(); //new
  }

หลายคนอาจสงสัยเหมือนผมตอนแรกว่า ทำไม ไม่ทำแค่เช็ค if เช็ค _isComposing ใน submit ถ้าเป็น true ก็ค่อยไปสร้าง ChatMessage ถ้า false ก็ไม่ต้องทำอะไร แต่จริงๆที่แล้วที่ต้องใช้ setState และการกำหนด null ให้ onPressed เมื่อไม่มีข้อความ
เป็นเพราะว่าต้องการให้ ปุ่มไอคอนส่งเป็นสีเทาเมื่อใช้งานไม่ได้ และเมื่อมีข้อความค่อยมีสีฟ้า

a2

ปัญหาข้อความไม่ขึ้นบรรทัดใหม่

ตอนนี้เมื่อเราพิมพ์ข้อความยาวๆ มันจะแสดงได้แค่บรรทัดเดียว

11

ให้เพิ่ม Expanded ครอบ Column

  @override
  Widget build(BuildContext context) {
    return new SizeTransition( //new
        sizeFactor: new CurvedAnimation( //new
            parent: animationController, curve: Curves.ease), //new
        axisAlignment: 0.0,
        child: new Container(
          margin: const EdgeInsets.symmetric(vertical: 10.0),
          child: new Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              new Container(
                margin: const EdgeInsets.only(right: 16.0),
                child: new CircleAvatar(child: new Text(_name[0])),
              ),
              new Expanded(
                child: new Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  new Text(_name, style: Theme
                      .of(context)
                      .textTheme
                      .subhead),
                  new Container(
                    margin: const EdgeInsets.only(top: 5.0),
                    child: new Text(text),
                  ),
                ],
               ),
              )
            ],
          ),
        )
    );
  }
}

13

ลองรัน พิมพ์ข้อความยาวๆอีกครั้ง

12

การปรับแต่งตาม Platform

ทีนี้จะ แยกว่า Android – iOS ให้คนละ ธีมกัน เพื่อแสดงให้เหมาะกับ Platform นั้น

ให้ import foundation.dart เข้ามา

import 'package:flutter/foundation.dart';

ประการตัวแปร themeData กำหนดสี
แล้วเอาธีมไปใส่ที่ พารามิเตอร์ theme ของ MaterialApp นี่แหละคือสิ่งที่ Widget MaterialApp ช่วยให้เป็นเรื่องง่าย
การแยก Platform ก็ใช้ผ่านตัวแปร defaultTargetPlatform ได้เลย ซึ่งตัวนี้จะมากับ foundation.dart นั่นเอง

final ThemeData kIOSTheme = new ThemeData(
  primarySwatch: Colors.orange,
  primaryColor: Colors.grey[100],
  primaryColorBrightness: Brightness.light,
);

final ThemeData kDefaultTheme = new ThemeData(
  primarySwatch: Colors.green,
  accentColor: Colors.greenAccent[400],
);

class FriendlychatApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: "Friendlychat",
      theme: defaultTargetPlatform == TargetPlatform.iOS         //new
          ? kIOSTheme                                            //new
          : kDefaultTheme,                                       //new
      home: new ChatScreen(),
    );
  }
}

หรือพิมพ์แค่ defaultTargetPlatform แล้วจะให้มัน import ให้ก็ได้

14

ลองรันบน Android

15

เดี๋ยวของ iOS ต้องไปลองใน Macbook แล้วจะมาเขียนเพิ่ม

โค้ดของโปรเจคนี้

https://gist.github.com/benznest/7373b7376c1c8df6a6e0c5cc23c51499

สรุป

บทความนี้ทำให้ผมได้เรียนรู้เยอะมาก อย่างแรกเลยคือการทำ UI , การใช้งาน TextField , การ custom ListView , การทำ Animation และการแยก UI ตาม Platform ครับ

ตอนนี้จากทั้งหมด 4 ตอนจนถึงตอนนี้ ผมเห็นภาพการพัฒนาแอปด้วย Flutter มากขึ้นมากๆ บทความหน้าจะเรียนเรื่องอะไรอีก จะมาบันทึกต่อครับ