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
| StatelessWidget | StatefulWidget | |
|---|---|---|
| เปลี่ยนแปลงได้ไหม | ไม่ได้ สร้างแล้วคงที่ | เปลี่ยนได้ตลอด |
| จำนวน class | 1 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 | ไล่สี |
TextStyle | font size, weight, color |
EdgeInsets | padding/margin |
Color | สี |
Alignment | ตำแหน่ง (topLeft, center, …) |
InputDecoration | ตกแต่ง TextField (label, hint, border) |
Built-in Widgets ที่ใช้บ่อย
Layout & Structure
| Widget | หน้าที่ | Section |
|---|---|---|
MaterialApp | root ของแอป ให้ Material Design theme | 1-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 bar | 6 |
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 |
|---|---|---|
ListView | list เลื่อนได้ | 5 |
ListView.builder | list ที่สร้าง item เฉพาะที่เห็น (ประหยัด memory) | 5 |
SingleChildScrollView | scroll ได้เมื่อเนื้อหาเกินจอ | 3 |
GridView | แสดง items เป็นตาราง | 8 |
Content
| Widget | หน้าที่ | Section |
|---|---|---|
Text | แสดงข้อความ | 1-2 |
Image.asset() | แสดงรูปจาก assets | 1-2 |
Image.network() | แสดงรูปจาก URL | 8 |
Icon | แสดงไอคอน (Icons.xxx) | 3 |
Buttons
| ปุ่ม | หน้าตา | Section |
|---|---|---|
ElevatedButton | มีสีพื้นหลัง + เงา | 5 |
OutlinedButton | ไม่มีพื้นหลัง มีแค่ขอบ | 3 |
OutlinedButton.icon() | ปุ่ม outline + icon | 3 |
TextButton | แค่ข้อความกดได้ | 1-2 |
IconButton | ปุ่มเป็น icon | 5 |
ทุกปุ่มต้องมี child: (widget บนปุ่ม) และ onPressed: (function เมื่อกด)
Input & Forms
| Widget | หน้าที่ | Section |
|---|---|---|
TextField | ช่องกรอกข้อความ (ใช้คู่กับ TextEditingController) | 5 |
Form | จัดกลุ่ม input + validate รวม | 11 |
TextFormField | TextField ที่มี validator ในตัว | 11 |
DropdownButton | เลือกจากรายการ dropdown | 5 |
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
### 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() + AlertDialog | popup ตรงกลางจอ | 5 |
showDatePicker() | เลือกวันที่ | 5 |
ScaffoldMessenger + SnackBar | แจ้งเตือนด้านล่าง + Undo | 5 |
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
persist: false, // ⚠️ Flutter 3.x+ ต้องใส่ ไม่งั้น SnackBar ไม่หายเอง
content: const Text('Expense deleted'),
action: SnackBarAction(label: 'Undo', onPressed: () { ... }),
),
);Animation
| Widget | หน้าที่ | Section |
|---|---|---|
AnimatedSwitcher | animate เมื่อเปลี่ยน child | 10 |
AnimatedContainer | animate เมื่อ properties เปลี่ยน | 10 |
AnimatedOpacity | animate ความโปร่งใส | 10 |
Hero | animate ระหว่าง 2 หน้าจอ (ใช้ tag เดียวกัน) | 10 |
ดูเพิ่มเติม: Animation
Async & Data
| Widget | หน้าที่ | Section |
|---|---|---|
FutureBuilder | แสดง UI ตามสถานะ loading (waiting/done/error) | 12 |
StreamBuilder | แสดง UI ตาม stream data (real-time) | 14 |
CircularProgressIndicator | loading spinner | 12 |
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 → เลือก layout | 6 |
MediaQuery | รู้ขนาดจอ, dark mode, orientation | 5-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 กับขอบ widget | Padding, 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
SizedBox | EdgeInsets (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"]
| Widget | Height preference | Width preference |
|---|---|---|
| Column | infinity (เอาเท่าที่ได้) | ตาม children |
| Row | ตาม children | infinity |
| ListView | infinity | infinity |
| 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+P → Dart: Open DevTools → Flutter 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 dataKeyสำคัญเมื่อ list items มีการสลับ/เพิ่ม/ลบ
Related
- 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