Widgets

Building blocks ของ UI ใน Flutter — ทุกอย่างที่เห็นบนหน้าจอคือ Widget เหมือนกล่องซ้อนกล่อง (Widget Tree)

Widget Tree

Widget ซ้อนกันเป็นชั้นๆ — child: มีลูก 1 ตัว / children: มีลูกหลายตัว:

graph TD
  MaterialApp["MaterialApp — root ของแอป"] --> Scaffold["Scaffold — โครงหน้าจอ"]
  Scaffold --> Column["Column — จัดลูกแนวตั้ง"]
  Column --> Image["Image — แสดงรูป"]
  Column --> SizedBox["SizedBox — เว้นระยะ"]
  Column --> TextButton["TextButton — ปุ่มกดได้"]

StatelessWidget vs StatefulWidget

StatelessWidgetStatefulWidget
เปลี่ยนแปลงได้ไหมไม่ได้ สร้างแล้วคงที่เปลี่ยนได้ตลอด
จำนวน class1 class (มี build())2 class (Widget + State)
update จอต่อเมื่อ parent re-renderเมื่อ setState() ถูกเรียก
ตัวอย่างข้อความ, ไอคอน, gradientกดปุ่มเปลี่ยนข้อความ, toggle
เปรียบเทียบโปสเตอร์ติดผนังป้ายไฟ LED เปลี่ยนได้

วิธีเลือก: ถามตัวเองว่า “widget นี้ต้องเปลี่ยนหน้าตาหลังจากสร้างไหม?” ไม่ → Stateless / ใช่ → Stateful

ทำไม StatefulWidget ถึงต้องมี 2 class?

ลองนึกภาพ ป้ายราคาในร้านกาแฟ:

  • Widget class = ตัวป้าย — ถ้าเก่าหรือเปื้อน ร้านพิมพ์ใหม่แล้วเอาอันเก่าทิ้ง (Flutter ทำแบบนี้ทุกครั้งที่ rebuild)
  • State class = สมุดบัญชีหลังร้าน — เก็บข้อมูลจริงๆ ว่าราคาเท่าไหร่ กี่แก้ว ไม่ถูกทิ้งไปกับป้าย

ถ้ารวมเป็น class เดียว ทุกครั้งที่พิมพ์ป้ายใหม่ ข้อมูลในสมุดบัญชีก็หายไปด้วย — เลยต้อง แยกกัน ให้ข้อมูลรอดจากการพิมพ์ป้ายใหม่

BuildContext — metadata ของ widget

ทุก widget มี context ของตัวเอง เก็บข้อมูล 2 อย่าง:

  • ตัวเองเป็นใคร — widget meta information
  • อยู่ตรงไหนใน widget tree — parent เป็นใคร, Theme/MediaQuery ที่ครอบอยู่คืออะไร
// ดึง theme ที่ครอบอยู่
Theme.of(context).colorScheme.primary
 
// ดึงข้อมูล device
MediaQuery.of(context).platformBrightness
 
// แสดง overlay — Flutter ต้องรู้ว่าเปิดจาก widget ไหน
showModalBottomSheet(context: context, builder: ...);
ScaffoldMessenger.of(context).showSnackBar(...);

ใน State class ใช้ context ได้เลยแม้อยู่นอก build() เพราะ Flutter ให้มาผ่าน parent class

สร้าง StatelessWidget

class GradientContainer extends StatelessWidget {
  const GradientContainer({super.key});
 
  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: const BoxDecoration(
        gradient: LinearGradient(colors: [Colors.purple, Colors.blue]),
      ),
      child: const Center(child: Text('Hello!')),
    );
  }
}

สร้าง StatefulWidget (2 classes)

class DiceRoller extends StatefulWidget {
  const DiceRoller({super.key});
  @override
  State<DiceRoller> createState() => _DiceRollerState();
}
 
class _DiceRollerState extends State<DiceRoller> {
  var currentDiceRoll = 2;
 
  void rollDice() {
    setState(() { currentDiceRoll = Random().nextInt(6) + 1; });
  }
 
  @override
  Widget build(BuildContext context) {
    return Column(children: [
      Image.asset('assets/images/dice-$currentDiceRoll.png'),
      TextButton(onPressed: rollDice, child: const Text('Roll Dice')),
    ]);
  }
}

ทำไมต้องแยก 2 class? Flutter จัดการ Widget กับ State แยกกัน — Widget ถูกสร้างใหม่ได้ทุกเมื่อ แต่ State อยู่ถาวรเก็บข้อมูลที่เปลี่ยนได้

ConsumerWidget (Riverpod)

เมื่อใช้ Riverpod → เปลี่ยน StatelessWidget เป็น ConsumerWidget:

class MyScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final data = ref.watch(myProvider);  // อ่านค่า + rebuild อัตโนมัติ
    return Text(data.toString());
  }
}

setState() Flow

graph LR
  A[กดปุ่ม] --> B["onPressed เรียก function"]
  B --> C["setState()"]
  C --> D["Flutter เรียก build() ใหม่"]
  D --> E[จอ update]

ถ้าไม่ใส่ setState() → ค่าเปลี่ยนใน memory แต่ Flutter ไม่เรียก build() ใหม่ → จอเดิม

Configuration Objects

ไม่ใช่ทุกอย่างใน Widget Tree ที่เป็น Widget — บาง object เป็น configuration ที่ตั้งค่าให้ widget:

Configuration Objectตั้งค่าอะไร
BoxDecorationตกแต่ง Container (gradient, border)
LinearGradientไล่สี
TextStylefont size, weight, color
EdgeInsetspadding/margin
Colorสี
Alignmentตำแหน่ง (topLeft, center, …)
InputDecorationตกแต่ง TextField (label, hint, border)

Built-in Widgets ที่ใช้บ่อย

Layout & Structure

Widgetหน้าที่Section
MaterialApproot ของแอป ให้ Material Design theme1-2
Scaffoldโครงร่างหน้าจอ (AppBar + Body + FAB)1-2
Centerจัดลูกไว้ตรงกลาง1-2
Columnจัดลูกแนวตั้ง ↕1-2
Rowจัดลูกแนวนอน ↔5
Containerกล่องตกแต่งได้ (gradient, border, padding)1-2
SizedBoxกำหนดขนาด / เว้นระยะ1-2
Expandedใช้พื้นที่ที่เหลือทั้งหมด3
Stackซ้อน widget ทับกัน5
Cardกรอบมีเงา สำหรับแสดงข้อมูลเป็นกลุ่ม5
SafeAreaหลีก notch/status bar6

Expanded — กินพื้นที่ที่เหลือ

ใช้ใน Row หรือ Column เพื่อบอกว่า child ตัวนี้ให้ยืดเต็มพื้นที่ที่เหลือ — ถ้าไม่ใส่ child อาจยืดไม่สิ้นสุดแล้ว error overflow

Row
├── Text("1")          ← กว้างตาม content
└── Expanded           ← กินพื้นที่ที่เหลือทั้งหมด
    └── Column
        ├── Text("คำถาม...")
        ├── Text("คำตอบ user")
        └── Text("คำตอบถูก")
Row(
  children: [
    Text('1'),
    Expanded(           // ❌ ถ้าไม่ใส่ Expanded → Column ไม่รู้ว่าควรกว้างเท่าไหร่ → overflow
      child: Column(
        children: [
          Text('What are the main building blocks of Flutter?'),
          Text('Widgets'),
          Text('Components'),
        ],
      ),
    ),
  ],
)

ทำไมต้องใส่?Column ข้างใน Row ไม่รู้ว่าตัวเองควรกว้างเท่าไหร่ Expanded บอกให้มันใช้พื้นที่ที่เหลือหลังจาก widget อื่นๆ ใน Row จองไปแล้ว

Spacer — ดันของไปชิดขอบ

ใช้ใน Row หรือ Column — กินพื้นที่ว่างทั้งหมดระหว่าง widget สองฝั่ง

Row(children: [
  Text('\$19.99'),
  const Spacer(),    // ← ดัน amount ไปซ้าย, icon+date ไปขวา
  Row(children: [Icon(Icons.work), Text('2026-05-14')]),
])

ต่างจาก Expanded ยังไง? — Spacer เป็นพื้นที่ว่างเปล่า ไม่มี child / Expanded ครอบ widget ให้ยืดเต็มที่

Lists & Scrolling

Widgetหน้าที่Section
ListViewlist เลื่อนได้5
ListView.builderlist ที่สร้าง item เฉพาะที่เห็น (ประหยัด memory)5
SingleChildScrollViewscroll ได้เมื่อเนื้อหาเกินจอ3
GridViewแสดง items เป็นตาราง8

Content

Widgetหน้าที่Section
Textแสดงข้อความ1-2
Image.asset()แสดงรูปจาก assets1-2
Image.network()แสดงรูปจาก URL8
Iconแสดงไอคอน (Icons.xxx)3

Buttons

ปุ่มหน้าตาSection
ElevatedButtonมีสีพื้นหลัง + เงา5
OutlinedButtonไม่มีพื้นหลัง มีแค่ขอบ3
OutlinedButton.icon()ปุ่ม outline + icon3
TextButtonแค่ข้อความกดได้1-2
IconButtonปุ่มเป็น icon5

ทุกปุ่มต้องมี child: (widget บนปุ่ม) และ onPressed: (function เมื่อกด)

Input & Forms

Widgetหน้าที่Section
TextFieldช่องกรอกข้อความ (ใช้คู่กับ TextEditingController)5
Formจัดกลุ่ม input + validate รวม11
TextFormFieldTextField ที่มี validator ในตัว11
DropdownButtonเลือกจากรายการ dropdown5

2 วิธีจัดการ user input

วิธี 1: onChanged — จัดการเอง

var _enteredTitle = '';
 
TextField(
  onChanged: (value) {
    _enteredTitle = value;   // เก็บค่าเองทุก key press
  },
);

วิธี 2: TextEditingController — Flutter จัดการให้

final _titleController = TextEditingController();
 
TextField(
  controller: _titleController,
  maxLength: 50,
  decoration: const InputDecoration(label: Text('Title')),
);
 
// อ่านค่า
_titleController.text
 
// ⚠️ ต้อง dispose เสมอ!
@override
void dispose() {
  _titleController.dispose();
  super.dispose();
}

เลือกอันไหนดี?

  • input ไม่กี่ตัว → onChanged ก็พอ
  • input หลายตัว → TextEditingController สะดวกกว่า ไม่ต้องสร้าง variable + function เอง

// Form + Validation final _formKey = GlobalKey(); Form( key: _formKey, child: TextFormField( validator: (value) { if (value == null || value.isEmpty) return ‘กรุณากรอกข้อมูล’; return null; }, ), ); if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); }


### Navigation & Structure

| Widget | หน้าที่ | Section |
|--------|---------|---------|
| `AppBar` | แถบด้านบน (title + actions) | 5 |
| `TabBar` + `TabBarView` | แท็บสลับหน้า | 8 |
| `DefaultTabController` | ควบคุม TabBar | 8 |
| `Drawer` | เมนูด้านข้างเลื่อนเข้า-ออก | 8 |
| `BottomNavigationBar` | แถบเมนูด้านล่าง | 8 |

ดูเพิ่มเติม: [[Navigation]]

### Interaction

| Widget | หน้าที่ | Section |
|--------|---------|---------|
| `InkWell` | จับ tap บน widget ที่ไม่ใช่ปุ่ม + ripple effect | 8 |
| `GestureDetector` | จับ gesture ทุกประเภท (tap, swipe, drag) | 8 |
| `Dismissible` | ปัดลบ item (swipe to dismiss) | 5 |

#### Dismissible — ปัดลบ item ออกจาก list

```dart
Dismissible(
  key: ValueKey(expense.id),
  onDismissed: (direction) {
    removeExpense(expense);
  },
  child: ExpenseItem(expense),
)
  • ต้องมี key — ใช้ ValueKey ระบุตัวตน widget ให้ Flutter รู้ว่าจะลบตัวไหน
  • onDismissed — ทำงานหลังปัดเสร็จ ต้องลบ data จริงด้วย ไม่งั้น UI กับ data ไม่ตรงกัน

Dialogs & Overlays

Widget / Functionหน้าที่Section
showModalBottomSheet()overlay จากด้านล่าง5
showDialog() + AlertDialogpopup ตรงกลางจอ5
showDatePicker()เลือกวันที่5
ScaffoldMessenger + SnackBarแจ้งเตือนด้านล่าง + Undo5
ScaffoldMessenger.of(context).showSnackBar(
  SnackBar(
    persist: false,  // ⚠️ Flutter 3.x+ ต้องใส่ ไม่งั้น SnackBar ไม่หายเอง
    content: const Text('Expense deleted'),
    action: SnackBarAction(label: 'Undo', onPressed: () { ... }),
  ),
);

Animation

Widgetหน้าที่Section
AnimatedSwitcheranimate เมื่อเปลี่ยน child10
AnimatedContaineranimate เมื่อ properties เปลี่ยน10
AnimatedOpacityanimate ความโปร่งใส10
Heroanimate ระหว่าง 2 หน้าจอ (ใช้ tag เดียวกัน)10

ดูเพิ่มเติม: Animation

Async & Data

Widgetหน้าที่Section
FutureBuilderแสดง UI ตามสถานะ loading (waiting/done/error)12
StreamBuilderแสดง UI ตาม stream data (real-time)14
CircularProgressIndicatorloading spinner12
FutureBuilder(
  future: _loadedItems,
  builder: (ctx, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return const CircularProgressIndicator();
    }
    if (snapshot.hasError) return Text('Error: ${snapshot.error}');
    return ListView(...);
  },
);

Responsive

Widgetหน้าที่Section
LayoutBuilderรู้ constraints ของ parent → เลือก layout6
MediaQueryรู้ขนาดจอ, dark mode, orientation5-6

MediaQuery — เช็ค dark mode

final isDarkMode =
    MediaQuery.of(context).platformBrightness == Brightness.dark;
 
// ใช้เลือกสีต่างกันระหว่าง dark/light
color: isDarkMode
    ? Theme.of(context).colorScheme.secondary
    : Theme.of(context).colorScheme.primary,

ดูเพิ่มเติม: Responsive Design

Spacing: Margin, Padding, Content

Widget ใน Flutter มี 3 ชั้นเหมือน box model:

┌─────────── Margin ────────────┐
│  ┌─────── Padding ─────────┐  │
│  │  ┌──── Content ──────┐  │  │
│  │  │     Hi there      │  │  │
│  │  └───────────────────┘  │  │
│  └─────────────────────────┘  │
└───────────────────────────────┘
ชั้นคืออะไรใช้กับ
Contentเนื้อหาจริงของ widget (text, icon)
Paddingช่องว่างระหว่าง content กับขอบ widgetPadding, EdgeInsets, Container(padding:)
Marginช่องว่างระหว่าง widget กับ widget ข้างๆContainer(margin:), SizedBox
Container(
  margin: const EdgeInsets.all(40),
  padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
  child: Text('Hello World'),
)

SizedBox vs Padding

SizedBoxEdgeInsets (padding)
ทำอะไรเว้นระยะ ระหว่าง widget 2 ตัวเว้นระยะ ภายใน widget ตัวเดียว
เปรียบเทียบเว้นบรรทัดระหว่างย่อหน้าเว้นขอบกระดาษรอบข้อความ

Widget Size Constraints — widget ถูกกำหนดขนาดยังไง

Widget ทุกตัวมี 2 ปัจจัยกำหนดขนาด:

  • Size preferences — widget อยากได้ขนาดเท่าไหร่
  • Parent constraints — parent อนุญาตขนาดเท่าไหร่ (constraints ชนะเสมอ)
graph LR
  A["Scaffold<br/>constraints: max device size"] --> B["Column<br/>preferences: height=infinity, width=ตาม children"]
  B --> C["Result<br/>height=max device, width=ตาม children"]
WidgetHeight preferenceWidth preference
Columninfinity (เอาเท่าที่ได้)ตาม children
Rowตาม childreninfinity
ListViewinfinityinfinity
Containerตาม childตาม child

ปัญหา: unconstrained ซ้อน unconstrained

Column ไม่ส่ง height constraint ให้ลูก → ถ้าลูกคือ ListView ที่อยากได้ height=infinity → ล้นจอ!

Column(children: [
  Chart(...),
  ListView(...)   // height=infinity ใน Column ที่ไม่จำกัด → overflow
])

Expanded แก้ปัญหานี้

Expanded เปลี่ยนจาก “infinity” เป็น “เท่าที่เหลือ (available)” — จำกัดขนาดให้พอดี

Column(children: [
  Chart(...),
  Expanded(child: ListView(...))   // height=เท่าที่เหลือหลัง Chart
])

ใช้ DevTools ดู constraints ได้: Cmd+Shift+PDart: Open DevToolsFlutter Inspector → เลือก widget → Layout Explorer จะแสดง constraints + ขนาดจริง

Keys — ช่วย Flutter จับคู่ widget กับ element ถูกต้อง

// ใช้เมื่อ list items สลับตำแหน่ง/เพิ่ม/ลบ
ListView(
  children: items.map((item) =>
    TodoItem(key: ValueKey(item.id), item: item)
  ).toList(),
);

ถ้าไม่ใส่ Key → Flutter อาจจับคู่ widget เก่ากับ element ผิดตัว → UI แสดงข้อมูลผิด ดูเพิ่มเติม: Three Trees

const ประหยัด Memory

const Text('Hello!')  // ใช้ที่แรก → สร้างใน memory
const Text('Hello!')  // ใช้ที่สอง → ชี้ไปก้อนเดิม ไม่สร้างใหม่

ใส่ const หน้า widget ที่ค่าไม่เปลี่ยน → Dart cache ใช้ซ้ำ ประหยัด memory

Key Points

  • ทุกอย่างบนจอคือ Widget ซ้อนกันเป็น Widget Tree
  • StatelessWidget = คงที่ / StatefulWidget = เปลี่ยนได้ด้วย setState()
  • ListView.builder สร้าง item เฉพาะที่เห็นบนจอ → ประหยัด memory สำหรับ list ยาว
  • Form + TextFormField จัดการ validation ได้ในที่เดียว
  • FutureBuilder / StreamBuilder แสดง UI ตามสถานะ async data
  • Key สำคัญเมื่อ list items มีการสลับ/เพิ่ม/ลบ
  • Flutter — framework ที่ Widgets อยู่
  • Dart — ภาษาที่ใช้เขียน Widgets
  • Navigation — widgets สำหรับเปลี่ยนหน้าจอ
  • Animation — widgets สำหรับ animation
  • Riverpod — ConsumerWidget สำหรับ state management
  • Responsive Design — LayoutBuilder, MediaQuery
  • Three Trees — Widget/Element/Render tree + Keys
  • OOP — Widget คือ Object สร้างจาก Class
  • Shared Preferences — เก็บข้อมูลบน local device