3bugs logo

Flutter Projects EP.2 เกมทายเลข - Flutter App

header-image

CAUTION

บทนี้อยู่ระหว่างการจัดทำ ดังนั้นเนื้อหา, รูปภาพ และการจัดรูปแบบจึงยังไม่สมบูรณ์

ถึงแม้การสร้างเกมทายเลขในบทที่ผ่านมา จะทำให้คุณสนุกกับการเรียนรู้และเขียนภาษา Dart มากแค่ไหน แต่ทว่าตัวแอพที่สร้างขึ้นก็เป็นเพียง console application ที่ไม่น่าสนใจ อันที่จริงแล้วเราไม่สามารถแจกจ่ายแอพให้ใครๆนำไปลองเล่นดูเพื่อที่จะบอกว่าชอบหรือไม่ชอบได้ด้วยซ้ำ

ในบทนี้เราจะใช้โค้ดบางส่วนจากบทที่แล้ว (ระบุให้ชัดเจนก็คือไฟล์ game.dart) เพื่อสร้างเกมทายเลขในรูปแบบของ Flutter application ซึ่งเราสามารถใส่ภาพกราฟิกและตกแต่ง UI ให้สวยงามได้ตามที่ต้องการ และสามารถ build project เป็นแอพพลิเคชันที่รันได้บน platform ต่างๆ เช่น iOS, Android หรือแม้แต่รันเป็น web app

เนื้อหาในบทนี้จะทำให้คุณเข้าใจหลักการพื้นฐานของการสร้างแอพใน Flutter และเรียนรู้การใช้งาน widget ต่างๆที่สำคัญ

TIP

สามารถดูหรือดาวน์โหลด source code ที่สมบูรณ์ของแอพในบทนี้ได้ที่ xxxxxxxxxx

สร้างโปรเจค

สร้าง Flutter project ใหม่ โดยตั้งชื่อว่า guess_the_number_flutter

จากรูป ในโฟลเดอร์หลักของโปรเจคจะมีไฟล์ pubspec.yaml และโฟลเดอร์ lib เช่นเดียวกับ Dart project ในบทที่แล้ว แต่ไม่มีโฟลเดอร์ bin

ภาพ 2-1

เมื่อดูในโฟลเดอร์ lib จะเห็นไฟล์ main.dart ที่เป็น entry point ของแอพเพียงไฟล์เดียว แต่เราสามารถสร้างไฟล์ .dart เพิ่ม เพื่อแยกเก็บโค้ดของส่วนต่างๆของแอพ เช่น ส่วนของ UI, ส่วนของการคำนวณ, ส่วนของการเก็บข้อมูล เป็นต้น และสามารถสร้างโฟลเดอร์ย่อยใน lib เพื่อจัดกลุ่มให้กับไฟล์ .dart เหล่านี้ได้ แต่ทั้งนี้ไฟล์ .dart ทั้งหมดต้องอยู่ภายใต้โฟลเดอร์ lib เท่านั้น จะอยู่นอกโฟลเดอร์ lib ไม่ได้

โฟลเดอร์หลักของโปรเจคมีโฟลเดอร์ย่อยที่เพิ่มมาจาก Dart project ในบทที่แล้วคือ android, ios, linux, macos, web และ windows ซึ่งเป็นโฟลเดอร์ที่ใช้เก็บ native projects ของแต่ละ platform ที่เราสามารถ build แอพได้

Native projects เหล่านี้จะถูกสร้างและอัพเดทโดยอัตโนมัติ เมื่อเราเขียนโค้ดในไฟล์ .dart แล้วสั่งรันหรือ build แอพ ปกติเราจึงไม่ต้องเข้าไปแก้ไขโค้ดหรือดำเนินการใดๆใน native projects เองโดยตรง ยกเว้นบางกรณีที่จำเป็น

ติดตั้ง Dependencies

โปรเจคนี้จะมีการใช้งานแพคเกจ google_fonts และ intl ดังนั้นให้ทำการติดตั้งแพคเกจทั้งสอง โดยเปิด Terminal ใน VS Code ขึ้นมา แล้วพิมพ์คำสั่งต่อไปนี้ที่โฟลเดอร์หลักของโปรเจค

flutter pub add google_fonts
flutter pub add intl

แพคเกจ google_fonts ช่วยให้เราแสดงข้อความในหน้าจอแอพโดยใช้ฟอนต์จาก Google Fonts ได้อย่างง่ายดาย ส่วนแพคเกจ intl มีฟังก์ชันที่ใช้จัดรูปแบบวันที่, ตัวเลข และอื่นๆที่เกี่ยวกับภาษาและวัฒนธรรมของแต่ละประเทศ

เพิ่มไฟล์รูปภาพเข้ามาในโปรเจค

  1. ให้สร้างโฟลเดอร์ assets ขึ้นมาที่โฟลเดอร์หลักของโปรเจค (จะเป็นโฟลเดอร์ในระดับเดียวกับโฟลเดอร์ lib)

    ในหนังสือเล่มนี้ เราจะเก็บไฟล์ต่างๆที่เป็น resource ของแอพ เช่น รูปภาพ, ไฟล์เสียง, ไฟล์วิดีโอ ฯลฯ ไว้ในโฟลเดอร์ assets นี้ แต่สำหรับแอพในบทนี้จะมีแค่รูปภาพเท่านั้น

  2. สร้างโฟลเดอร์ images ภายในโฟลเดอร์ assets อีกที แล้วดาวน์โหลดไฟล์รูปภาพจาก https://bit.ly/43gfqFz มาใส่ลงในโฟลเดอร์ images

  3. แก้ไขไฟล์ pubspec.yaml โดยเพิ่มเซคชัน assets ภายใต้เซคชัน flutter และภายใต้เซคชัน assets ให้เพิ่มการระบุไปยังโฟลเดอร์ assets/images/ (ต้องมี / ปิดท้าย) เพื่อบอก Flutter ว่าแอพของเราต้องการใช้ไฟล์รูปภาพที่อยู่ในโฟลเดอร์นี้

flutter:
  assets:
    - assets/images/

NOTE

คำสั่งในไฟล์ pubspec.yaml เขียนด้วยภาษา YAML ซึ่งเป็นภาษาที่ใช้กำหนดข้อมูลในลักษณะ key-value และสามารถกำหนดข้อมูลที่ซ้อนกันเป็นชั้นๆได้โดยใช้การเว้นวรรคข้างหน้าบรรทัด ดังนั้นระยะการเว้นวรรคข้างหน้าบรรทัดจึงมีความสำคัญ

สร้างไฟล์ constants.dart

เราจะเริ่มเขียนโค้ดโดยการสร้างไฟล์ constants.dart ในโฟลเดอร์ lib เพื่อเก็บค่าคงที่ต่างๆที่ใช้ในโปรเจค เช่น ค่าสตริง, ค่าสี ดังนี้

import 'package:flutter/material.dart';

const String kAppName = 'Guess the Number';
const String kPlayGameLabel = 'PLAY GAME';

const double kPageMargin = 24.0;

const Color kColorYellow = Color(0xFFF5B52D);
const Color kColorOrange = Color(0xFFF1764A);
const Color kColorBlue = Color(0xFF14B2CF);

const Color kColorGradientStart = Color(0xFFFEF4E8);
const Color kColorGradientEnd = Color(0xFFFFD2CF);

const List<Color> kButtonColorList = [
  Color(0xFFE2D838),
  Color(0xFFFE999F),
  Color(0xFFFED77A),
  Color(0xFF89FDB4),
  Color(0xFFC3E2FE),
  Color(0xFFFD9FFD),
  Color(0xFFFEA628),
  Color(0xFFAAFA75),
  Color(0xFFB98DEF),
  Color(0xFF7A98F8),
];

ค่าคงที่เหล่านี้เป็น global constants ที่เราสามารถเข้าถึงได้จากทุกที่ในโปรเจค

TIP

คู่มือ Flutter style guide แนะนำให้ตั้งชื่อ global constants โดยขึ้นต้นด้วยตัวอักษร k

เรากำหนดสีเหลือง, สีส้ม และสีฟ้าแบบ custom ขึ้นมาเอง (ค่าคงที่ kColorYellow, kColorOrange, kColorBlue) โดยใช้การระบุค่าสีในรูปแบบเลขฐานสิบหก (hexadecimal) โดยที่ 0x คือการระบุว่าเป็นเลขฐานสิบหก ถัดมาคือเลขฐานสิบหก 8 ตัว แบ่งออกเป็น 4 ชุด ชุดละ 2 ตัว แต่ละชุดมีค่าได้ตั้งแต่ 00 ถึง FF ซึ่งเท่ากับ 0 ถึง 255 ในระบบเลขฐานสิบ

ค่าสีในรูปแบบเลขฐานสิบหก

จากรูป

  • 0x ระบุว่าเป็นเลขฐานสิบหก
  • Alpha คือค่าความโปร่งใส โดย 00 หมายถึงโปร่งใสสูงสุด (เหมือนกระจกใส) และ FF หมายถึงไม่มีความโปร่งใสเลย (เป็นสีทึบ)
  • Red คือค่าสีแดง
  • Green คือค่าสีเขียว
  • Blue คือค่าสีน้ำเงิน

🚀 Colors Playground ลองปรับค่าสีดูครับ!

TIP

ถ้าหากต้องการใช้สีที่ Flutter กำหนดไว้ให้แล้ว ให้เรียกใช้ชื่อสีที่เป็น static constant ของคลาส Colors (ไม่ใช่ Color) เช่น Colors.yellow, Colors.orange, Colors.blue ฯลฯ

สำหรับค่าคงที่ kColorGradientStart และ kColorGradientEnd เป็นสีที่ใช้ในการสร้างสีแบบไล่ระดับ (gradient) ซึ่งจะนำไปกำหนดเป็นพื้นหลัง (background) ให้กับหน้าจอต่างๆในแอพนี้

สุดท้ายค่าคงที่ kButtonColorList คือ List ของสีที่จะนำไปกำหนดเป็นสีของปุ่มตัวเลข 0-9 ในหน้าจอเล่นเกม

กำหนดธีมของแอพ

สร้างไฟล์ theme.dart ในโฟลเดอร์ lib เพื่อกำหนดธีมของแอพ ดังนี้

import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';

import 'constants.dart';

final appTheme = ThemeData(
  useMaterial3: true,
  fontFamily: GoogleFonts.oswald().fontFamily,
  colorScheme: ColorScheme.fromSeed(seedColor: kColorBlue),
  textTheme: const TextTheme(
    titleLarge: TextStyle(
      fontSize: 60.0,
      fontWeight: FontWeight.bold,
      height: 1.2,
    ),
    titleMedium: TextStyle(
      fontSize: 44.0,
      fontWeight: FontWeight.bold,
      height: 1.2,
    ),
    labelLarge: TextStyle(
      color: Colors.white,
      fontSize: 22.0,
    ),
    headlineMedium: TextStyle(
      fontSize: 30.0,
    ),
    headlineSmall: TextStyle(
      fontSize: 26.0,
    ),
    bodyLarge: TextStyle(
      fontSize: 20.0,
    ),
    bodyMedium: TextStyle(
      fontSize: 16.0,
    ),
    bodySmall: TextStyle(
      fontSize: 12.0,
      color: Colors.black54,
    ),
  ),
  elevatedButtonTheme: ElevatedButtonThemeData(
    style: ElevatedButton.styleFrom(
      foregroundColor: Colors.white,
      backgroundColor: kColorBlue,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12.0),
      ),
      padding: const EdgeInsets.symmetric(
        horizontal: 24.0,
        vertical: 12.0,
      ),
    ),
  ),
  textButtonTheme: TextButtonThemeData(
    style: TextButton.styleFrom(
      foregroundColor: kColorBlue,
      padding: const EdgeInsets.symmetric(
        horizontal: 24.0,
        vertical: 12.0,
      ),
    ),
  ),
);

โค้ดในไฟล์นี้เป็นการสร้างออบเจค ThemeData เก็บลงตัวแปรชื่อ appTheme ที่เป็น top-level variable เราจะนำออบเจค ThemeData นี้ไปกำหนดเป็นธีมของแอพในหัวข้อถัดไป

ในการสร้างออบเจค ThemeData มีการกำหนดรายละเอียดต่างๆ ดังนี้

  • กำหนดให้ใช้ฟอนต์ Oswald จาก Google Fonts

  • กำหนด color scheme โดยใช้ออบเจค ColorScheme ที่สร้างจากเมธอด ColorScheme.fromSeed() เมธอดนี้จะสร้างชุดสี (color palette) ที่เป็นเฉดสีต่างๆของสีที่ระบุ ในที่นี้คือสีฟ้า kColorBlue

  • กำหนด text theme โดยใช้ออบเจค TextTheme ที่กำหนดสไตล์ตัวอักษรให้กับ role ต่างๆของ text (body, label, headline, title)

NOTE

เมื่อนำ ThemeData ไปกำหนดเป็นธีมของแอพ จะทำให้ color scheme และ text theme ที่กำหนดภายใน ThemeData นี้ถูกนำไปใช้กับ Material components ต่างๆใน UI ของแอพ ไม่ว่าจะเป็นปุ่ม, ข้อความ, พื้นหลัง ฯลฯ ยกตัวอย่างเช่น ข้อความบนปุ่มจะใช้สไตล์ตัวอักษรจาก labelLarge ใน TextTheme เป็นต้น

  • กำหนดธีมให้กับวิดเจ็ต ElevatedButton และ TextButton

อัพเดทไฟล์ main.dart

  1. อัพเดทโค้ดในไฟล์ main.dart ดังนี้
import 'package:flutter/material.dart';

import 'constants.dart';
import 'theme.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: kAppName,
      theme: appTheme,
      home: Scaffold(
        body: Center(
          child: Text('Hello, Flutter!'),
        ),
      ),
    );
  }
}
  1. สั่งรันแอพ จะได้ผลลัพธ์ดังรูป
ภาพ 2-3

ใน Flutter การสร้าง UI ของแอพจะใช้สิ่งที่เรียกว่า วิดเจ็ต (widget) ซึ่งก็คือออบเจคที่เป็นตัวแทนของชิ้นส่วน UI (UI components) ของแอพ เช่น ปุ่ม, ข้อความ, รูปภาพ, ลิสต์, เท็กซ์ฟีลด์, เช็คบ็อกซ์ ฯลฯ วิดเจ็ตที่ประกอบกันเป็น UI ของแอพเหล่านี้มีการเชื่อมโยงกันด้วยโครงสร้างที่เป็นลำดับชั้น เรียกว่า widget tree

จากโค้ดใน main.dart วิดเจ็ตที่เป็นรูท (root widget) ของแอพคือ MyApp เนื่องจากวิดเจ็ตนี้ (ออบเจคของคลาส MyApp) ถูกระบุให้กับฟังก์ชัน runApp() ภายในฟังก์ชัน main() ซึ่งรายละเอียดของคลาส MyApp ก็อยู่ในไฟล์ main.dart เช่นกัน คือในบรรทัดที่ 10-25

วิดเจ็ตใน Flutter แบ่งออกเป็น 2 ประเภทใหญ่ๆ คือ stateless widget กับ stateful widget แปลตรงตัวก็คือ "วิดเจ็ตที่ไม่มีสถานะ" กับ "วิดเจ็ตที่มีสถานะ" ตามลำดับ คำว่า "สถานะ" (state) ในที่นี้หมายถึง "ข้อมูล" ที่เปลี่ยนแปลงได้ของวิดเจ็ต และการเปลี่ยนแปลงของมันส่งผลต่อ "การแสดงผล" ของวิดเจ็ตนั้นๆ

ในที่นี้คลาส MyApp เป็น stateless widget เนื่องจากสืบทอด (extends หรือ inherits) มาจากคลาส StatelessWidget

เมธอด build() ของคลาส MyApp ในบรรทัดที่ 14-24 คือการ override เมธอด build() ของคลาส StatelessWidget ที่ MyApp สืบทอดมา, เมื่อออบเจค MyApp ถูกสร้างขึ้น Flutter จะเรียกมายังเมธอดนี้ เพื่อให้เราสร้าง UI ของแอพด้วยการ return วิดเจ็ตที่เราต้องการให้เป็น UI ของแอพออกไป ในที่นี้คือวิตเจ็ต MaterialApp

NOTE

ดังนั้นจะว่าไปแล้ว root widget ที่แท้จริงของแอพนี้ก็คือ MaterialApp ส่วน MyApp เป็นเพียงวิดเจ็ตที่เราสร้างขึ้นมาครอบ MaterialApp ไว้อีกที เพื่อแยกออกมาเป็นคลาสใหม่ จะได้ไม่ต้องระบุ MaterialApp ให้กับฟังก์ชัน runApp() โดยตรง

เราสามารถกำหนดชื่อและธีมของแอพได้ที่พร็อพเพอร์ตี title และ theme ของ MaterialApp ตามลำดับ ในที่นี้กำหนดชื่อแอพด้วยค่าคงที่ kAppName ที่กำหนดไว้ในไฟล์ constants.dart และกำหนดธีมของแอพด้วยตัวแปร appTheme ที่กำหนดไว้ในไฟล์ theme.dart (และนี่คือเหตุผลว่าทำไมเราต้อง import ไฟล์ทั้งสองเข้ามาในไฟล์ main.dart ที่บรรทัด 3-4)

พร็อพเพอร์ตี home ของ MaterialApp ใช้กำหนดวิดเจ็ตที่เป็นหน้าจอแรกของแอพ ในที่นี้กำหนดเป็น Scaffold ซึ่งมีพร็อพเพอร์ตีที่สำคัญ 2 ตัวคือ

  • appBar ใช้กำหนด app bar (แถบด้านบนที่มักจะแสดงชื่อแอพ)
  • body ใช้กำหนดการแสดงผลในส่วนที่เหลือทั้งหมดของหน้าจอ

ในที่นี้กำหนดเฉพาะ body แต่ไม่ได้กำหนด appBar ทำให้หน้าจอของแอพที่รันขึ้นมาไม่มีส่วน app bar

Scaffold ยังมีพร็อพเพอร์ตีเฉพาะสำหรับกำหนด UI ที่เป็น pattern มาตรฐานของ mobile app เช่น floatingActionButton (ปุ่มที่ลอยอยู่บริเวณมุมล่างขวาของหน้าจอ), drawer (แถบเมนูที่เลื่อนเข้ามาจากด้านซ้ายของหน้าจอ) และ bottomNavigationBar (แถบเมนูด้านล่างของหน้าจอ) เป็นต้น

เรากำหนด Scaffold ให้มี body เป็น Center ซึ่งเป็นวิดเจ็ตที่จะจัดวางวิดเจ็ตลูก (child widget) ของมันให้อยู่ตรงกลางพื้นที่ที่มีอยู่ ในที่นี้ child widget ของ Center ก็คือวิดเจ็ต Text ที่แสดงข้อความ "Hello, Flutter!" จึงทำให้ข้อความนี้ถูกแสดงออกมาตรงกลางจอ

ตอนนี้ แผนผังของ widget tree ของแอพเป็นดังนี้

แผนผัง widget tree

NOTE

จะเห็นว่านอกจากวิดเจ็ตที่เป็น UI component แล้ว Flutter ยังมีวิดเจ็ตที่ไม่มีรูปร่างหน้าตา แต่ใช้ในการ "จัดเรียง" หรือ "จัดตำแหน่ง" ให้กับวิดเจ็ตอื่นๆ ดังเช่น Center ในตัวอย่างนี้ และรวมถึง Column, Row, Stack, ListView ที่จะได้เรียนรู้ต่อไปด้วย วิดเจ็ตเหล่านี้เรียกว่า layout widgets

รู้จักกับ Constant Constructor

คอนสตรัคเตอร์ของคลาส MyApp ในบรรทัดที่ 11 จัดว่าเป็น constant constructor เนื่องจากมีการระบุ const ไว้ข้างหน้า

ในภาษา Dart, constant constructor คือคอนสตรัคเตอร์ที่ใช้สร้างออบเจค (instance ของคลาส) ที่เปลี่ยนแปลงแก้ไขไม่ได้ (immutable) ซึ่งคลาสที่จะมี constant constructor ได้นั้น ฟีลด์ทั้งหมดของคลาสต้องถูกระบุเป็น final เช่น

class MyClass {
  final int x;

  const MyClass(this.x); // constant constructor
}

ส่วนคลาสนี้จะเกิด compile-time error (Can't define a const constructor for a class with non-final fields.) เนื่องจาก y ไม่ใช่ final field คลาสจึงไม่สามารถมี constant constructor ได้

class MyClass2 {
  final int x;
  double y = 5; // non-final field

  const MyClass2(this.x, this.y); // constant constructor
}

สำหรับคลาสที่มี constant constructor เราสามารถสร้างออบเจคของคลาสนั้นด้วยคำสั่ง const ได้ ซึ่งจะทำให้ออบเจคถูกสร้างขึ้นตั้งแต่ช่วง compile time และถูก build ลงใน binary ของแอพ (ไฟล์ที่ได้จากการ build แอพ) ผลก็คือ ถ้าหากในโค้ดมีการสร้างออบเจคมากกว่า 1 ครั้งด้วยค่าอาร์กิวเมนต์เดียวกัน ออบเจคจะถูกสร้างขึ้นเพียงออบเจคเดียว และถูกใช้ซ้ำทุกที่ที่มีการสร้างออบเจคด้วยค่าอาร์กิวเมนต์นั้น

เช่นตัวอย่างนี้ ตัวแปร a และ b จะชี้ไปที่ออบเจคเดียวกัน ถึงแม้จะดูเหมือนเป็นการสร้างออบเจค MyClass 2 ครั้ง (2 ออบเจค) ก็ตาม แต่จริงๆแล้วมีออบเจค MyClass เพียง 1 ออบเจคเท่านั้น

void main() {
  var a = const MyClass(5);
  var b = const MyClass(5);

  print(identical(a, b)); // true
}

Constant constructor และการสร้างออบเจคด้วย const จะช่วยลดการใช้หน่วยความจำของแอพ และช่วยให้แอพทำงานได้เร็วขึ้นเนื่องจากจำนวนออบเจคที่ถูกสร้างน้อยลง และออบเจคถูกสร้างตั้งแต่ช่วง compile time แทนที่จะเป็น runtime

สร้าง Home Page

ให้สร้างหน้า home page ของแอพ ตามขั้นตอนต่อไปนี้

  1. สร้างโฟลเดอร์ ui ในโฟลเดอร์ lib

  2. สร้างไฟล์ home_page.dart ในโฟลเดอร์ lib/ui และพิมพ์โค้ดดังนี้

import 'package:flutter/material.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text('Hello, Flutter!'),
      ),
    );
  }
}
  1. อัพเดทไฟล์ main.dart โดยเพิ่มการอิมพอร์ตไฟล์ home_page.dart ที่ตอนต้นไฟล์ และเปลี่ยนพร็อพเพอร์ตี home ของ MaterialApp เป็น HomePage แทน
import 'ui/home_page.dart';

...

return MaterialApp(
  title: kAppName,
  theme: appTheme,
  home: const HomePage(), // อัพเดทตรงนี้
);

...

เราย้าย widget tree ตั้งแต่ Scaffold ลงไป (Scaffold -> Center -> Text) จากคลาส MyApp ไปยังคลาส HomePage

  1. สั่ง hot reload หน้าจอแอพจะยังคงเหมือนเดิม คือมีข้อความ "Hello, Flutter!" อยู่ตรงกลางจอ

กำหนดพื้นหลังของหน้าจอ

ถัดไปจะกำหนดพื้นหลังของหน้า home page ให้เป็นสีไล่ระดับ (gradient) โดยใช้สีที่กำหนดไว้ในไฟล์ constants.dart

Flutter มีวิดเจ็ต Container ที่ใช้ครอบหรือบรรจุวิดเจ็ตอื่น ซึ่ง Container มีพร็อพเพอร์ตีให้เราปรับแต่งการแสดงผลของมัน เช่น ขนาด (ความกว้าง/ความสูง), padding, margin, เส้นกรอบ, พื้นหลัง, เงา ฯลฯ

เราจะใช้ Container ในการกำหนดพื้นหลังของหน้า home page โดยให้ทำการอิมพอร์ตไฟล์ constants.dart และเพิ่มวิดเจ็ต Container แทรกเข้าไประหว่าง Scaffold กับ Center ดังนี้

import 'package:flutter/material.dart';

import '../constants.dart';

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        width: double.infinity,
        height: double.infinity,
        decoration: const BoxDecoration(
          gradient: LinearGradient(
            colors: [kColorGradientStart, kColorGradientEnd],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
          ),
        ),
        child: Center(
          child: Text(
            'Hello, Flutter!',
          ),
        ),
      ),
    );
  }
}

TIP

ใน VS Code เราสามารถวาง cursor ที่ชื่อวิดเจ็ต Center แล้วกด Ctrl + . (Windows) หรือ Cmd + . (Mac) แล้วเลือก "Wrap with Container" เพื่อเพิ่มวิดเจ็ต Container ขึ้นมาครอบวิดเจ็ต Center ไว้ จากนั้นก็ค่อยพิมพ์พร็อพเพอร์ตี width, height, decoration ให้กับ Container ตามโค้ดด้านบน

ตอนนี้ Container ถูกกำหนดเป็น child ของ Scaffold (กำหนดที่พร็อพเพอร์ตี body) และ Center ถูกกำหนดเป็น child ของ Container อีกที ดังนั้น widget tree ของหน้า home page จะเป็นดังนี้

แผนผัง widget tree

เรากำหนดความกว้างและความสูงของ Container เป็นค่า double.infinity เพื่อยืด Container ทั้งแนวนอนและแนวตั้งให้เต็มพื้นที่ของ parent widget ซึ่งในที่นี้ก็คือ Scaffold และสำหรับตัวอย่างนี้ Scaffold จะใช้พื้นที่ทั้งหมดของหน้าจอ ครอบคลุมส่วนของ status bar, navigation bar และส่วนที่เป็นรอยบาก (notch) ของจอด้วย

สำหรับพร็อพเพอร์ตี decoration ใช้ตกแต่งหน้าตาของ Container เราตกแต่งโดยใช้ออบเจค BoxDecoration ที่มีการระบายพื้นหลังเป็น gradient ไล่จากสี kColorGradientStart ที่ตำแหน่งกึ่งกลางด้านบน (Alignment.topCenter) ไปยังสี kColorGradientEnd ที่ตำแหน่งกึ่งกลางด้านล่าง (Alignment.bottomCenter) ของ Container

สั่ง hot reload จะได้หน้าจอแอพดังรูป

ภาพ 2-4

จัดเรียงวิดเจ็ตด้วย Column และ Row

Flutter มี layout widgets ที่ใช้ในการจัดเรียงวิดเจ็ตในลักษณะต่างๆ ดังนี้

  • Column ใช้จัดเรียงวิดเจ็ตลูกในแนวตั้ง (vertical) จากบนลงล่าง
  • Row ใช้จัดเรียงวิดเจ็ตลูกในแนวนอน (horizontal) จากซ้ายไปขวา
  • Stack ใช้จัดเรียงวิดเจ็ตลูกในลักษณะซ้อนทับกัน
  • ListView ใช้จัดเรียงวิดเจ็ตลูกในแนวตั้งหรือแนวนอน และสามารถเลื่อน (scroll) ได้ถ้าหากขนาดของวิดเจ็ตลูกเกินขอบเขตของ ListView

ในหัวข้อนี้จะขออธิบายเฉพาะวิดเจ็ต Column และ Row ให้ทำตามขั้นตอนต่อไปนี้

  1. ลบวิดเจ็ต Center รวมถึง Text ที่เป็น child แล้วแทนที่ด้วยวิดเจ็ต SafeArea ที่มี child เป็นวิดเจ็ต Column ดังนี้
Container(
  ...
  child: SafeArea(
    child: Column(
      children: [
        _buildBox(color: kColorYellow),
        _buildBox(color: kColorOrange),
        _buildBox(color: kColorBlue),
      ],
    ),
  ),
),

วิดเจ็ต SafeArea ช่วยให้เราแน่ใจได้ว่า การแสดงผลของวิตเจ็ตทั้งหมดภายใต้ SafeArea จะอยู่ในพื้นที่ที่ "ปลอดภัย" คือไม่ไปซ้อนทับกับ status bar, navigation bar หรือ notch ของจอเครื่อง

เรากำหนด SafeArea เป็น child ของ Container (ที่มีพื้นหลังเป็น gradient) เพราะต้องการให้พื้นหลัง gradient ครอบคลุมพื้นที่ทั้งหมดของหน้าจอ โดยไม่ยกเว้นส่วนใด แต่กลับกันหากนำ SafeArea ไปครอบ Container จะทำให้พื้นหลัง gradient ไม่ถูกแสดงในส่วนของ status bar, navigation bar และ notch ซึ่งไม่ใช่สิ่งที่เราต้องการ

  1. เพิ่มเมธอด _buildBox() ในคลาส HomePage ดังนี้
Widget _buildBox({Color? color}) {
  return Container(
    width: 160.0,
    height: 160.0,
    decoration: BoxDecoration(
      color: color,
      borderRadius: BorderRadius.circular(16.0),
    ),
  );
}
  1. สั่ง hot reload จะได้หน้าจอแอพดังรูป
ภาพ 2-5

วิดเจ็ต Column สามารถมีวิดเจ็ตลูกได้มากกว่า 1 วิดเจ็ต มันจึงมีพร็อพเพอร์ตี children (แทนที่จะเป็น child) สำหรับระบุวิดเจ็ตลูกที่เราต้องการจัดเรียงในแนวตั้งจากบนลงล่าง โดยค่าที่กำหนดให้พร็อพเพอร์ตีนี้จะต้องเป็น List ของวิดเจ็ต

ในที่นี้กำหนด List ที่เป็นค่า literal (ใช้วงเล็บ []) ซึ่งภายใน List มีวิดเจ็ต Container 3 ชิ้นที่สร้างจากเมธอด _buildBox() และถูกกำหนดสีพื้นหลังแตกต่างกันไป

เราสามารถกำหนดรูปแบบการจัดเรียงวิดเจ็ตลูกของ Column ได้ทั้งในแนวตั้งและแนวนอน โดยใช้พร็อพเพอร์ตี mainAxisAlignment และ crossAxisAlignment ตามลำดับ

แกนหลัก (Main Axis) และแกนขวาง (Cross Axis)

เนื่องจากวิดเจ็ต Column ใช้จัดเรียงวิดเจ็ตลูกในแนวตั้ง ดังนั้นแกนหลัก (main axis) ของมันจึงหมายถึงแนวตั้ง ส่วนแกนรองหรือแกนขวาง (cross axis) หมายถึงแนวนอน ในขณะที่วิดเจ็ต Row จะตรงกันข้าม กล่าวคือ แกนหลักของ Row หมายถึงแนวนอน ส่วนแกนรองหรือแกนขวางของ Row หมายถึงแนวตั้ง ดังรูป

แกนหลักและแกนขวาง

ค่าที่เป็นไปได้สำหรับพร็อพเพอร์ตี mainAxisAlignment

  • MainAxisAlignment.start (ค่าดีฟอลต์) จัดเรียงวิดเจ็ตลูกให้อยู่ทางด้านบนของ Column (หรือทางซ้ายของ Row)

  • MainAxisAlignment.end จัดเรียงวิดเจ็ตลูกให้อยู่ทางด้านล่างของ Column (หรือทางขวาของ Row)

  • MainAxisAlignment.center จัดเรียงวิดเจ็ตลูกให้อยู่ตรงกลางของ Column ในแนวตั้ง (หรือตรงกลางของ Row ในแนวนอน)

  • MainAxisAlignment.spaceBetween จัดเรียงวิดเจ็ตลูกให้มีระยะห่างระหว่างวิดเจ็ตเท่ากัน โดยวิดเจ็ตแรกอยู่ด้านบนของ Column (หรือด้านซ้ายของ Row) และวิดเจ็ตสุดท้ายอยู่ด้านล่างของ Column (หรือด้านขวาของ Row)

  • MainAxisAlignment.spaceEvenly เพิ่มเติมจาก spaceBetween คือจะมีระยะห่างก่อนวิดเจ็ตแรก และระยะห่างหลังวิดเจ็ตสุดท้ายด้วย ซึ่งเป็นระยะที่เท่ากับระยะห่างระหว่างวิดเจ็ต

  • MainAxisAlignment.spaceAround คล้ายกับ spaceEvenly แต่ระยะห่างก่อนวิดเจ็ตแรกและหลังวิดเจ็ตสุดท้าย จะเป็นครึ่งหนึ่งของระยะห่างระหว่างวิดเจ็ต

MainAxisAlignment

ค่าที่เป็นไปได้สำหรับพร็อพเพอร์ตี crossAxisAlignment

  • CrossAxisAlignment.center (ค่าดีฟอลต์) จัดเรียงวิดเจ็ตลูกให้อยู่ตรงกลางของ Column ในแนวนอน (หรือตรงกลางของ Row ในแนวตั้ง)

  • CrossAxisAlignment.start จัดเรียงวิดเจ็ตลูกให้อยู่ทางซ้ายของ Column (หรือด้านบนของ Row)

  • CrossAxisAlignment.end จัดเรียงวิดเจ็ตลูกให้อยู่ทางขวาของ Column (หรือด้านล่างของ Row)

  • CrossAxisAlignment.stretch บังคับวิดเจ็ตลูกให้ยืดเต็มแกนขวาง (แนวนอนของ Column หรือแนวตั้งของ Row)

  • CrossAxisAlignment.baseline (ใช้สำหรับ Row กรณีวิดเจ็ตลูกมีข้อความ) จัดเรียงวิดเจ็ตลูกในแนวแกนขวาง โดยให้ "เส้นฐาน" หรือ baseline ของข้อความในวิดเจ็ตลูกตรงกัน ทั้งนี้ต้องกำหนดประเภทของ baseline ที่พร็อพเพอร์ตี textBaseline ด้วย

CrossAxisAlignment

เราสามารถใช้ Column ร่วมกับ Row เพื่อสร้าง layout ที่ซับซ้อนขึ้นได้ เช่น

Column(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  children: [
    Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        _buildBox(color: kColorYellow),
        _buildBox(color: kColorOrange),
      ],
    ),
    Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        _buildBox(color: kColorBlue),
        _buildBox(color: kColorYellow),
      ],
    ),
  ],
),
ภาพ 2-6

จากโค้ด Column มี children เป็น Row 2 ออบเจค และ mainAxisAlignment ของ Column ถูกกำหนดเป็น spaceBetween ทำให้ Row ทั้งสองถูกจัดเรียงไว้ด้านบนและด้านล่างของ Column

แต่ละ Row มี children เป็น Container ที่สร้างจากเมธอด _buildBox() 2 ออบเจค และ mainAxisAlignment ของ Row ถูกกำหนดเป็น spaceBetween ทำให้ Container ทั้งสองถูกจัดเรียงไว้ด้านซ้ายและด้านขวาของ Row

NOTE

Flutter ยังมีวิดเจ็ต Flex ที่มีพร็อพเพอร์ตีและการทำงานเหมือนกับวิดเจ็ต Row กับ Column แต่จะมีพร็อพเพอร์ตีเพิ่มมาคือ direction เอาไว้กำหนดว่าจะจัดเรียงวิดเจ็ตลูกในแนวตั้งหรือแนวนอน ดังนั้นกรณีที่เราไม่รู้ในตอนเขียนโค้ดว่าจะจัดเรียงแนวใด ก็สามารถใช้วิดเจ็ต Flex แล้วไปกำหนดทิศทางในตอน runtime ได้

จริงๆแล้ววิดเจ็ต Row และ Column ก็คือวิดเจ็ต Flex ที่ถูกกำหนด direction เป็นแนวนอนและแนวตั้งตามลำดับ นั่นเอง

สร้าง UI หน้า Home Page ให้สมบูรณ์

ทำตามขั้นตอนต่อไปนี้ เพื่อสร้าง UI ของหน้า home page ที่สมบูรณ์ ดังรูป

ภาพ 2-7
  1. ที่เมธอด build() ของคลาส HomePage ให้เปลี่ยน child ของวิดเจ็ต SafeArea เป็น Padding ที่มี child เป็นวิดเจ็ต Column ดังนี้
SafeArea(
  child: Padding(
    padding: const EdgeInsets.all(kPageMargin),
    child: Column(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        _buildTitle(context),
        _buildImage(),
        _buildButton(context),
      ],
    ),
  ),
),

วิดเจ็ต Padding ใช้เพิ่มระยะห่างรอบๆวิดเจ็ตลูกของมัน เรานำวิดเจ็ตนี้มาครอบ Column เพื่อให้การแสดงผลในส่วนของ Column ไม่ติดกับขอบจอ แต่จะเว้นห่างเข้ามาเป็นระยะ kPageMargin ทั้ง 4 ด้านของจอ

วิดเจ็ต Column มีวิดเจ็ตลูก 3 ออบเจค คือ

  • วิดเจ็ตที่สร้างจากเมธอด _buildTitle() (ข้อความ "GUESS THE" และ "NUMBER")
  • วิดเจ็ตที่สร้างจากเมธอด _buildImage() (รูปภาพ)
  • วิดเจ็ตที่สร้างจากเมธอด _buildButton() (ปุ่ม PLAY GAME)

พร็อพเพอร์ตี mainAxisAlignment ของ Column ถูกกำหนดเป็น spaceBetween ทำให้วิดเจ็ตลูกตัวแรกและตัวที่ 3 (ตัวสุดท้าย) ของ Column อยู่ชิดด้านบนและด้านล่างของ Column ตามลำดับ ส่วนวิดเจ็ตลูกตัวที่ 2 อยู่ตรงกลางโดยห่างจากวิดเจ็ตลูกตัวแรกและตัวที่ 3 เป็นระยะเท่ากัน

  1. เพิ่มเมธอด _buildTitle() ในคลาส HomePage
Widget _buildTitle(BuildContext context) {
  var textTheme = Theme.of(context).textTheme;

  return Column(
    children: [
      Text(
        'GUESS  THE',
        style: textTheme.titleMedium!.copyWith(
          color: kColorYellow,
        ),
      ),
      Text(
        'NUMBER',
        style: textTheme.titleLarge!.copyWith(
          color: kColorOrange,
        ),
      ),
    ],
  );
}

ส่วนนี้เป็นการสร้างข้อความ "GUESS THE" และ "NUMBER" โดยใช้วิดเจ็ต Text 2 ออบเจค และกำหนดรูปแบบตัวอักษรของข้อความโดยนำสไตล์ titleMedium และ titleLarge จากธีม (ตามที่กำหนดไว้ในไฟล์ theme.dart ก่อนหน้านี้) มาใช้ แต่เปลี่ยนสีตัวอักษรเป็นสี kColorYellow และ kColorOrange ตามลำดับ

วิดเจ็ต Text ทั้งสองถูกกำหนดเป็น children ของ Column มันจึงถูกจัดเรียงต่อกันในแนวตั้ง โดยอยู่ชิดด้านบน (เนื่องจากค่าดีฟอลต์ของ mainAxisAlignment ของ Column คือ start) และอยู่กึ่งกลางของ Column ในแนวนอน (เนื่องจากค่าดีฟอลต์ของ crossAxisAlignment ของ Column คือ center)

พารามิเตอร์ context ของเมธอด _buildTitle() นี้ คือออบเจค BuildContext ที่มาจากพารามิเตอร์ context ของเมธอด build() อีกที ซึ่งเราใช้ BuildContext นี้ในการเข้าถึงธีมของแอพ (Theme.of(context)) และนำสไตล์ของข้อความมาใช้

  1. เพิ่มเมธอด _buildImage() ในคลาส HomePage
Widget _buildImage() {
  return Container(
    width: 260.0,
    height: 260.0,
    decoration: BoxDecoration(
      shape: BoxShape.circle,
      image: const DecorationImage(
        image: AssetImage('assets/images/home.jpg'),
      ),
      boxShadow: [
        BoxShadow(
          color: Colors.black.withOpacity(0.25),
          blurRadius: 16.0,
          offset: const Offset(0.0, 8.0),
          spreadRadius: 0.0,
        )
      ],
    ),
  );
}

ส่วนนี้จะสร้าง Container ขนาด 260x260 ที่มีรูปภาพเป็นพื้นหลัง และกำหนด shape ของ Container เป็นวงกลม รวมถึงกำหนดเงาให้กับ Container ด้วย โดยใช้สีดำที่มีความทึบ 25% (โปร่งใส 75%)

NOTE

ใน Flutter ตัวเลขที่ระบุขนาด เช่น ความกว้าง, ความสูง, margin, padding มีหน่วยเป็น logical pixel หรือเรียกอีกอย่างว่า device-independent pixel (dip หรือ dp) ซึ่งเป็นหน่วยที่ไม่ขึ้นกับความละเอียดหน้าจอของ device กล่าวคือ 1 logical pixel จะมีขนาดเท่าๆกันบนทุก device แม้ว่าจอของ device เหล่านั้นจะมีความละเอียดต่างกัน

logical pixel จะถูกคำนวณเป็น physical pixel ตอนที่ Flutter วาดวิดเจ็ตลงบนจอ, ยิ่งจอมีความละเอียดสูง จำนวน physical pixel ที่จะถูกวาดต่อ 1 logical pixel ก็จะยิ่งสูง ทั้งนี้เพื่อให้ logical pixel มีขนาดเท่าๆกันบนทุก device นั่นเอง

  1. เพิ่มเมธอด _buildButton() ในคลาส HomePage
Widget _buildButton(BuildContext context) {
  return ElevatedButton.icon(
    label: const Text(kPlayGameLabel),
    icon: const Icon(Icons.play_arrow, size: 32.0),
    onPressed: () {
      // TODO: handle button press
    },
  );
}

ส่วนนี้เป็นการสร้างปุ่มชนิด ElevatedButton โดยใช้ ElevatedButton.icon ซึ่งเป็น named constructor ของคลาส ElevatedButton, named constructor นี้ช่วยให้เราสร้างปุ่มที่มีทั้งข้อความและรูปไอคอนได้อย่างง่ายดาย โดยกำหนดข้อความที่พร็อพเพอร์ตี label และกำหนดรูปไอคอนที่พร็อพเพอร์ตี icon (พร็อพเพอร์ตีทั้งสองเป็น type Widget ทั้งคู่ นั่นหมายความว่าจริงๆแล้วเราสามารถกำหนดวิดเจ็ตใดให้กับมันก็ได้ อย่างไรก็ตาม มันถูกออกแบบมาให้กำหนดส่วนที่เป็นข้อความและส่วนที่เป็นรูปไอคอนของปุ่ม ตามลำดับ ในที่นี้จึงกำหนดวิดเจ็ต Text และ Icon ให้กับพร็อพเพอร์ตี label และ icon ตามลำดับ)

สำหรับพร็อพเพอร์ตี onPressed ของปุ่ม ตอนนี้เรากำหนดเป็นฟังก์ชันว่างๆไว้ก่อน ซึ่งทำให้ปุ่มสามารถกดได้ แต่เมื่อกดจะยังไม่มีอะไรเกิดขึ้น แต่หากกำหนด onPressed เป็น null จะกลายเป็นปุ่มที่ไม่สามารถกดได้ (disabled)

NOTE

Named Constructor

ในภาษา Dart นอกจาก default constructor ซึ่งเป็นเมธอดที่มีชื่อเดียวกับคลาสแล้ว เรายังสามารถสร้าง named constructor (constructor ที่มีชื่อ) ขึ้นมาในคลาสได้ด้วย เช่น

class Point {
  int x, y;

  // Default constructor
  Point(this.x, this.y);

  // Named constructor
  Point.origin() {
    x = 0;
    y = 0;
  }

  // Named constructor with parameters
  Point.fromJson(Map<String, int> json) {
    x = json['x'];
    y = json['y'];
  }
}

void main() {
  var p1 = Point(10, 20); // ใช้ default constructor
  var p2 = Point.origin(); // ใช้ named constructor
  var p3 = Point.fromJson({'x': 30, 'y': 40}); // ใช้ named constructor
}

ตัวอย่างนี้ Point.origin และ Point.fromJson คือ named constructor ที่ใช้ในการสร้างออบเจคของคลาส Point โดย Point.origin จะสร้างออบเจค Point ที่มีค่า x และ y เป็น 0 ส่วน Point.fromJson จะสร้างออบเจค Point จาก Map ที่รับเข้ามา โดยกำหนด x และ y ด้วยค่าที่มีคีย์เป็น 'x' และ 'y' ใน Map นั้น

ข้อดีของ named constructor คือเราสามารถสร้างหลายๆ constructor ไว้ในคลาสเดียวกันได้ ซึ่งแต่ละ constructor อาจมีพารามิเตอร์แตกต่างกัน นอกจากนี้ named constructor ยังทำให้เราสามารถกำหนดชื่อที่สื่อความหมายได้ดีกว่า default constructor

  1. สั่ง hot reload จะได้หน้าจอแอพดังรูป
ภาพ 2-8

จะสังเกตว่ารูปภาพไม่ได้อยู่กึ่งกลางจอในแนวตั้งพอดี สาเหตุที่เป็นเช่นนี้เพราะมันถูกจัดให้อยู่กึ่งกลางระหว่างส่วนของข้อความ title กับปุ่ม แต่ส่วนของ title มีความสูงมากกว่าปุ่ม จึงทำให้รูปภาพค่อนมาทางด้านล่างเมื่อเทียบกับทั้งจอ ซึ่งเราจะแก้ปัญหานี้ในหัวข้อถัดไป

วิดเจ็ต Expanded และ Flexible

วิดเจ็ต Expanded จะใช้เมื่อเราต้องการให้วิดเจ็ตที่นำมาจัดเรียงใน Row หรือ Column ยืดออกตามแนวแกนหลัก โดยใช้พื้นที่ในแนวแกนหลักมากที่สุดเท่าที่เป็นไปได้ เช่น

Column(
  children: [
    _buildBox(color: kColorYellow), // Box1
    Expanded(
      child: _buildBox(color: kColorOrange), // Box2
    ),
    _buildBox(color: kColorBlue), // Box3
  ],
),
ภาพ 2-9

จากโค้ด เราครอบ Box2 ด้วยวิดเจ็ต Expanded ทำให้ Box2 ถูกยืดออกตามแนวแกนหลักของ Column ซึ่งก็คือแนวตั้ง โดยใช้พื้นที่ในแนวตั้งที่เหลือทั้งหมด หลังจากถูกใช้ไปโดย Box1 และ Box3 แล้ว

ถ้าหากมีการใช้ Expanded มากกว่า 1 ออบเจคใน Column หรือ Row พื้นที่ที่เหลือจะถูกแบ่งให้แต่ละ Expanded ตามอัตราส่วนของ flex ที่กำหนดใน Expanded เหล่านั้น โดยค่าดีฟอลต์ของ flex คือ 1 (หมายถึงถ้าไม่ได้ระบุ flex จะมีค่าเป็น 1) เช่น

Column(
  children: [
    Expanded(
      child: _buildBox(color: kColorYellow), // Box1
    ),
    Expanded(
      child: _buildBox(color: kColorOrange), // Box2
    ),
    _buildBox(color: kColorBlue), // Box3
  ],
),

Box1 และ Box2 ถูกครอบด้วย Expanded แต่ไม่ได้ระบุ flex หรือก็คือ flex เป็น 1 ทั้งคู่ ดังนั้นทั้งสองจึงแบ่งพื้นที่แนวตั้งไปในอัตราส่วน 1:1 หรือก็คือแบ่งให้เท่ากัน (คนละครึ่ง) โดยพื้นที่ที่นำมาแบ่ง คือพื้นที่แนวตั้งที่เหลือจากที่ Box3 ใช้ไป

ภาพ 2-10
Column(
  children: [
    Expanded(
      flex: 2,
      child: _buildBox(color: kColorYellow), // Box1
    ),
    Expanded(
      child: _buildBox(color: kColorOrange), // Box2
    ),
    _buildBox(color: kColorBlue), // Box3
  ],
),

Box1 และ Box2 ถูกครอบด้วย Expanded โดย Box1 ถูกกำหนด flex เป็น 2 ทำให้มันได้พื้นที่ในแนวตั้งเป็น 2 เท่าของพื้นที่ที่ Box2 ได้

ภาพ 2-11

สำหรับวิดเจ็ต Flexible จะมีหลักการเรื่อง flex เหมือนกับ Expanded แต่มันจะไม่ทำให้วิดเจ็ตลูกถูกยืดออกตามแนวแกนหลักเหมือนอย่าง Expanded เช่น

Column(
  children: [
    _buildBox(color: kColorYellow), // Box1
    Expanded(
      child: _buildBox(color: kColorOrange), // Box2
    ),
    Flexible(
      child: _buildBox(color: kColorBlue), // Box3
    ),
  ],
),
ภาพ 2-12

จะเห็นว่า Box3 ที่ถูกครอบด้วย Flexible ไม่ได้ถูกยืดออกตามแนวตั้ง แต่ก็ถูกกันพื้นที่ในแนวตั้งไว้เท่ากับส่วนของ Box2 เนื่องจาก Expanded ที่ครอบ Box2 กับ Flexible ที่ครอบ Box3 มี flex เป็น 1 เท่ากัน

ถ้าหากเพิ่ม Center ครอบ Box3 จะช่วยให้เข้าใจมากยิ่งขึ้น

Column(
  children: [
    _buildBox(color: kColorYellow), // Box1
    Expanded(
      child: _buildBox(color: kColorOrange), // Box2
    ),
    Flexible(
      child: Center(
        child: _buildBox(color: kColorBlue), // Box3
      ),
    ),
  ],
),
ภาพ 2-13

Center ที่เพิ่มเข้าไป ทำให้ Box3 ถูกจัดวางไว้ตรงกึ่งกลางของพื้นที่ Flexible นั่นเอง

หรือหากใช้วิดเจ็ต Align แทน Center ก็ทำให้เราสามารถเลือกวางตำแหน่งของ Box3 ภายในพื้นที่ Flexible ได้ตามต้องการ

Column(
  children: [
    _buildBox(color: kColorYellow), // Box1
    Expanded(
      child: _buildBox(color: kColorOrange), // Box2
    ),
    Flexible(
      child: Align(
        alignment: Alignment.bottomRight,
        child: _buildBox(color: kColorBlue), // Box3
      ),
    ),
  ],
),
ภาพ 2-14

กลับมาที่หน้า home page ของแอพ เราจะแก้ปัญหากรณีรูปภาพไม่ได้อยู่กึ่งกลางจอในแนวตั้ง โดยใช้ Flexible ครอบส่วนของข้อความ (เมธอด _buildTitle()) และครอบส่วนของปุ่ม (เมธอด _buildButton()) และลบพร็อพเพอร์ตี mainAxisAlignment ที่กำหนดให้กับ Column ออกไป

Column(
  crossAxisAlignment: CrossAxisAlignment.stretch,
  children: [
    Flexible(
      child: _buildTitle(context),
    ),
    _buildImage(),
    Flexible(
      child: _buildButton(context),
    ),
  ],
),

เมื่อทำการ hot reload จะได้หน้าจอแอพดังรูป

ภาพ 2-15

ตอนนี้รูปภาพอยู่กึ่งกลางจอในแนวตั้งแล้ว แต่มีปัญหาใหม่เกิดขึ้นคือ ปุ่มไม่ได้อยู่ชิดด้านล่าง เราสามารถแก้ปัญหานี้ได้โดยนำ Align มาครอบส่วนของปุ่ม และกำหนด alignment เป็น Alignment.bottomCenter

Column(
  crossAxisAlignment: CrossAxisAlignment.stretch,
  children: [
    Flexible(
      child: _buildTitle(context),
    ),
    _buildImage(),
    Flexible(
      child: Align(
        alignment: Alignment.bottomCenter,
        child: _buildButton(context),
      ),
    ),
  ],
),

เมื่อทำการ hot reload จะได้หน้าจอแอพดังรูป

ภาพ 2-16

ปุ่มถูกวางไว้ด้านล่างตามที่เราต้องการ แต่ความกว้างของปุ่มกลับไม่ได้ยืดเต็มความกว้างจอ เพราะการกำหนด crossAxisAlignment ของ Column เป็น stretch ไม่มีผลต่อวิดเจ็ตลูกของ Align

วิดเจ็ตที่ใช้กำหนด "ตำแหน่ง" อย่างเช่น Center และ Align นั้น มันจะพยายามยืดตัวเองให้ครอบคลุม parent widget ให้มากที่สุดเท่าที่เป็นไปได้ทั้งแนวตั้งและแนวนอน แล้ววางวิดเจ็ตลูกตามตำแหน่งที่กำหนด โดยไม่ได้ยืดขนาดของวิดเจ็ตลูก

เราสามารถยืดความกว้างปุ่มให้เต็มวิดเจ็ต Align ได้โดยนำวิดเจ็ต SizedBox มาครอบปุ่ม และกำหนด width ของ SizedBox เป็น double.infinity (และตอนนี้ไม่จำเป็นต้องกำหนด crossAxisAlignment ของ Column เป็น stretch แล้ว)

Column(
  children: [
    Flexible(
      child: _buildTitle(context),
    ),
    _buildImage(),
    Flexible(
      child: Align(
        alignment: Alignment.bottomCenter,
        child: SizedBox(
          width: double.infinity,
          child: _buildButton(context),
        ),
      ),
    ),
  ],
),

ตอนนี้เราได้ UI ของหน้า home page ที่สมบูรณ์แล้ว ดังรูปที่แสดงก่อนหน้านี้ ซึ่งแผนผังของ widget tree หน้า home page จะเป็นดังนี้

แผนผัง widget tree

CAUTION

บทนี้อยู่ระหว่างการจัดทำ ดังนั้นเนื้อหา, รูปภาพ และการจัดรูปแบบจึงยังไม่สมบูรณ์