ตัว Flutter framework มีวิธีในการวาด widget บนหน้าจอผ่าน RenderBox class โดยเราจะไม่ได้ไปยุ่งในส่วนนี้ สำหรับวิธีการวาด widget จะมีโครงสร้างลำดับเรียก widget tree แต่ถึงจะเป็น tree คล้าย ๆ กับ HTML DOM แต่วิธีการคิดแตกต่างกัน ดังนั้นอย่าเอาวิธีคิดจากการเขียน HTML DOM มาใช้กับ Flutter
ข้อมูลต่าง ๆ ที่รวบรวมมาส่วนใหญ่นำมากจาก flutter.dev และ Flutter: The Advanced Layout Rule Even Beginners Must Know
หลักการทำงานของการวาด widget ถูกอธิบายไว้ในบทความ Understanding constraints หัวใจหลักในการทำงานคือ
Constraints go down. Sizes go up. Parent sets position.
หมายถึง ตัวข้อจำกัดพื้นที่ในการวาด widget จะถูกส่งจากด้านบนลงล่าง Constraints → Widget ส่วนขนาดของ Widget ที่ถูกวาดตามเงื่อนไขของ Constraints จะถูกส่งขึ้นไปข้างบน Widget → Parent ตัว Parent จะนำข้อมูลขนาดมาวางบนหน้าจอในพื้นที่และตำแหน่งที่กำหนด อธิบายรายละเอียดได้ดังนี้
จากลำดับการทำงาน ตัว framework จะแยกการวาง Layout ออกจากตัว widget อย่างชัดเจน จะไม่มีการมากำหนดค่าขนาด ตำแหน่งที่ตัว widget โดยตรง
เนื่องจาก framework ถูกออกแบบมาให้ทำงานเพียงรอบเดียว เน้นประสิทธิภาพที่ดี ทำให้มีข้อจำกัดดังนี้
โดยทั่วไป layout box จะมีอยู่ 3 ประเภท โดยขึ้นอยู่กับวิธีจัดการกับ Constraints
เป็น Layout ที่ใช้งานมากที่สุด เป็นพื้นที่สี่เหลี่ยมที่ใส่ widget อะไรก็ได้ 1 อัน โดยมีรูปแบบของ Constraints แตกต่างกันไป ที่ใช้กันบ่อย ๆ เช่น Container
ตัว Container เป็นกล่องที่มีความสามารถหลากหลาย เนื่องจากเป็นการรวม layout widget อื่น ๆ ที่ใช้บ่อย ๆ พฤติกรรมของ constraints จะขึ้นอยู่กับ constructor ว่าจะใส่ค่าอะไรเข้าไปบ้าง ซึ่งสะดวกในการใช้งานเป็นอย่างมาก แต่ก็มีข้อเสียคือ มันไม่สามารถประกาศแบบ const
ได้
ตัวอย่างการใช้งาน Container แสดงกล่องที่มีขนาด 48x48 และเทสี yellow ข้างใน มีช่องว่างระหว่างขอบ 10
import 'package:flutter/material.dart';
void main() {
runApp(MaterialApp(
home: Scaffold(
body: Container(
margin: const EdgeInsets.all(10.0),
color: Colors.yellow,
width: 48.0,
height: 48.0)),
));
}
Container ที่ลงสีเหลืองขนาด 48x48 และมีช่องว่างระหว่างขอบ 10px
หากต้องการหมุนตัว Container ก็กำหนดค่าที่ transform ตัวอย่าง ลองหมุนตามเข็มนาฬิกา 0.15 radian (ประมาณ 8.6 องศา) หากมี widget ใน child ตัว widget ข้างในก็จะถูกหมุนไปด้วย
Container(
margin: const EdgeInsets.all(10.0),
color: Colors.red,
width: 48.0,
height: 48.0,
transform: Matrix4.rotationZ(0.15),
child: const Text('XXXX'),
);
หมุน 0.15 radian ตัวหนังสือ XXXX ข้างในก็จะหมุนตามด้วย
ถ้าต้องการให้ตัว Container อยู่ตรงกลางจอ ก็เพียงครอบด้วย widget ที่ชื่อ Center อีกชั้น
Center(
child: Container(
margin: const EdgeInsets.all(10.0),
color: Colors.red,
width: 48.0,
height: 48.0,
transform: Matrix4.rotationZ(0.15),
child: const Text('XXXX'),
))
ย้ายไปอยู่ตรงกลางหน้าจอด้วย Center
เมื่อลองไปดูใน widget tree และเปิดดู Layout explorer จะพบว่าในแต่ละ widget จะมีค่า constraints ระบุอยู่ หากเขียน code แล้วพบปัญหา สามารถดูจากค่าเหล่านี้เพื่อหาสาเหตุที่เกิดขึ้นได้
Layout explorer กับค่า constraints
Container สามารถใช้งานได้หลากหลายเพราะเป็นการรวมเอา widget พื้นฐานหลายตัวมาใช้งาน โดยคำสั่งใน constructor จะทำการเลือกสร้าง widget ตามค่าส่งมา เช่น
ถ้ากำหนดค่า padding มา มันจะสร้าง Padding กลับมา
ถ้ากำหนดค่า decoration ก็จะสร้าง DecoratedBox กลับมา
คำแนะนำจากผู้ออกแบบ framework คือ ถ้าคุณใช้งานแล้วพอใจ รู้สึกสะดวกสบาย ไม่มีปัญหาเรื่องการดูแล code ก็สามารถใช้งานได้ตามต้องการ ไม่ต้องกังวลว่าต้องใช้ layout widget ที่มีคุณสมบัติเฉพาะถึงจะดี
ConstrainedBox สามารถระบุค่า constraints เพื่อให้ child ได้ใช้คำนวณขนาดของมัน ตาม layout ที่ต้องการ ตัวอย่าง หากกำหนดค่า BoxConstraints อยู่ในช่วง 70 ถึง 150 ตัว child จะใช้ค่านี้อ้างอิงในการคำนวณขนาดของมัน ซึ่ง Container ที่อยู่ใน child แม้จะระบุขนาดเป็น 10x10 แต่ขนาดจริงที่ปรากฎบนจอจะเป็น 70x70 แทน
Center(
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: 70,
minHeight: 70,
maxWidth: 150,
maxHeight: 150,
),
child: Container(color: Colors.red, width: 10, height: 10),
))
ผลจากการใช้ ConstrainedBox
อันนี้จะตรงข้ามกับ ConstrainedBox ตัว UnconstrainedBox จะไม่ส่งต่อค่า constraints จาก Parent ไปยัง child ของมัน ทำให้ตัว widget ไม่โดนจำกัดขนาด ในตัวอย่างจะเป็นการสร้าง Container มีขนาด 1000x10 ซึ่งโดยปกติหากไม่ใส่ไว้ใน UnconstrainedBox ตัว Constraints จะยาวได้แค่สุดขอบจอ แต่พอไม่จำกัด อาจจะเกิดเหตุการณ์ overflow ได้
Center(
child: UnconstrainedBox(
child: Container(
color: Colors.red,
width: 1000,
height: 10
)))
ผลจากการใช้ UnconstrainedBox
ในกรณีที่ตัว widget ไม่พบ constraints ที่ส่งมาจาก Parent อาจจะพบปัญหาว่าตัว widget พยายามกำหนดขนาดจะไม่สามารถวาดบนหน้าจอได้ ปัญหานี้่แก้ไขได้ด้วย LimitedBox ในกรณีปกติที่ Parent ส่ง constraints ตัว LimitedBox จะไม่ทำอะไร แต่หากไม่พบจะทำการส่งค่า constraints ไปให้
ในกรณีที่อนุญาติให้ widget มีขนาดจนล้นหน้าจอได้ และไม่ต้องการให้ตัว framework แจ้งเตือน สามารถใช้ OverflowBox ซึ่งหลักการจะคล้ายกับ UnconstrainedBox ตัว OverflowBox สามารถระบุค่า constraints ที่ต้องการได้ หากไม่ระบุตัวตัว OverflowBox จะมีค่า constraints เท่ากับ Parent
ตัวอย่าง OverflowBox ที่กำหนดค่า constraints ให้สามารถสร้างความกว้างได้ไม่จำกัด ผลที่ได้คือ ตัว Container สามารถมีขนาดได้ 1000px โดยที่จะถูก crop อัตโนมัติและไม่มีการแจ้งเตือนการ overflow
OverflowBox(
maxWidth: double.infinity, // width is unconstrained
child: Container(color: Colors.red, width: 1000, height: 10),
)
ผลจากการใช้ OverflowBox
หากต้องการกำหนดขนาดของ OverflowBox ให้ใช้ SizedOverflowBox แทน
ตัว FittedBox จะช่วยย่อขนาดของ widget ที่อยู่ใน child ให้เต็มตัวมันเองเสมอ หากขนาดของ widget ไม่เกินค่า constraints ก็จะแสดงผลตามปกติ แต่ในกรณีที่ใหญ่กว่า มันจะย่อ widget ให้ไม่เกินค่า constraints
ตัวอย่างเปรียบเทียบการแสดง Text ขนาดปกติ ไม่เกินหน้าจอ
FittedBox(
fit: BoxFit.contain,
child: Text('Hello World!!')
)
แสดงผลตามปกติ
ลองขยาย Text เป็น 10 เท่า เพื่อให้เห็นว่ามันไม่สามารถแสดงบนหน้าจอได้ทั้งหมด
Text(
'Hello World!!',
textScaler: TextScaler.linear(10)
)
ข้อความที่ขยาย 10 เท่า หน้าจอไม่สามารถแสดงข้อความได้ทั้งหมด
เมื่อเอา FittedBox มาครอบเอาไว้ ตัว FittedBox จะมีขนาดตาม constraints ของ Parent ถ้า Text ใหญ่เกิน มันจะทำการย่อขนาดลงให้พอดี
FittedBox(
fit: BoxFit.contain,
child: Text(
'Hello World!!',
textScaler: TextScaler.linear(10)
))
ข้อความที่ขยาย 10 เท่า ถูกย่อให้พอดีกับตัว FittedBox
เมื่อเอา Text ใส่ใน FittedBox จะพบว่าหากตั้งให้มีการตัดข้อความขึ้นแถวใหม่แบบอัตโนมัติ จะไม่สามารถทำได้ เนื่องจากตัว FittedBox จะย่อข้อความทั้งแถวให้พอดีเสมอ
Offstage จะทำงานเหมือน layout box ทั่วไป คือมันจะเอา widget ไปใส่ใน widget tree แต่ไม่แสดง widget บนจอ ประโยชน์ของมัน คือ สามารถเขียนคำสั่งให้เพื่อติดต่อกับ widget ได้ เช่น อ่านขนาดของ widget เพื่อทดลองหาขนาดจริงก่อนนำไปวาดใช้งาน หรือส่งข้อมูลไปให้ widget ทำงาน เป็นต้น
ตัวอย่าง การทดสอบอ่านค่าขนาดของ widget Text("Hello world!!", key: _widgetKey)
ที่ render จริงบนหน้าจอ แต่ถูกซ่อนเอาไว้ด้วย Offstage
หน้าจอจะเห็นเฉพาะปุ่มกด
import 'dart:developer';
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final GlobalKey _widgetKey = GlobalKey();
String _getWidgetSize() {
final RenderBox renderWidget = _widgetKey.currentContext!.findRenderObject()! as RenderBox;
return renderWidget.size.toString();
}
Widget get demo => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Offstage(
offstage: true,
child: Text("Hello world!!", key: _widgetKey),
),
ElevatedButton(
child: const Text('Get Text widget size'),
onPressed: () => log(_getWidgetSize())
)
]
);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(body: Center(child: demo))
);
}
}
เมื่อกดปุ่ม จะแสดงขนาดของ Text widget ใน log
หากต้องการกำหนดขนาดพื้นที่ที่จะใช้ render ตัว widget ที่มีขนาดแน่นอน SizedBox สามารถทำได้ดีในเรื่องนี้ ตัวอย่างการสร้างกล่องที่ใส่ Card ข้อความ Hello World!! ขนาด 200x300
const SizedBox(
width: 200.0,
height: 300.0,
child: Card(child: Text('Hello World!')),
)
constructors ของ SizedBox
width
height
โดยตรงAspectRatio จะพยายามขยายให้ใหญ่ที่สุดเท่าที่ Parent จะอนุญาต โดยคงอัตราส่วนกว้างและยาวไว้ตามที่ระบุเสมอ เช่น วาดกล่องขนาด 16:9 โดยข้างในมี Container เทสีเขียวเอาไว้
AspectRatio(
aspectRatio: 16 / 9,
child: Container(color: Colors.green)
)
ตัวอย่าง กล่อง 16:9 ที่จะอยู่กลางหน้าจอ โดยจะขยายจนเต็มด้านกว้างหรือยาวเสมอ
import 'package:flutter/material.dart';
void main() {
runApp(MaterialApp(
home: Scaffold(body:
Center(
child: AspectRatio(
aspectRatio: 16 / 9,
child: Container(color: Colors.green)
)))));
}
เมื่อเปลี่ยนขนาดแอป ตัว AspectRatio จะขยายจนเต็มโดยรักษาอัตราส่วนเอาไว้
ปกติเวลาจัดตำแหน่ง Text ตัว framework จะอ้างจากตำแหน่งทั้งชิ้นของ Text ซึ่งหาก Text มีขนาดแตกต่างกัน จะเกิดปัญหา Text เขย่ง ปัญหานี้สามารถใช้ Baseline เพื่อช่วยระบุตำแหน่งการวาง Text โดยใช้ค่า baseline ได้
ตัวอย่าง สร้าง SizedBox ความสูง 100 แล้ววาง Text ให้อยู่ด้านล่าง โดยเปรียบเทียบการใช้การ Align กับ Baseline ในการวางตำแหน่ง
SizedBox(
height: 100,
child: Align(
alignment: Alignment.bottomCenter,
child: Text('java')
))
SizedBox(
height: 100,
child: Baseline(
baseline: 100,
baselineType: TextBaseline.alphabetic,
child: Text('java')
))
การใช้ Baseline จะอ้างอิง baseline ของ Text ไม่ว่า Text จะมีขนาดเท่าไหร่ก็ตาม
Align มีความสามารถช่วยจัดการตำแหน่งของ widget ใน child ของมันโดยอ้างอิงจาก Parent โดยสามารถกำหนดเป็นมุม ขอบด้าน ที่ต้องการ หรือระยะจากมุมก็ได้ ซึ่งหากต้องการจัดกึ่งกลาง แนะนำว่าใช้ Center จะสะดวกกว่า
ค่า alignment ที่สามารถใช้งานได้
ข้อ 1 - 9 เป็นค่าคงที่ ที่ใชักันบ่อย ๆ คือชิดขอบ ชิดมุม หรืออยู่ตรงกลาง ในกรณีที่ต้องการระบุตำแหน่งที่ใด ๆ สามารถใช้ข้อ 10 และ 11 ในการกำหนดตำแหน่งเพิ่มเติมได้
Alignment() จะใช้ค่า (x, y) ในการระบุตำแหน่ง ค่าอยู่ในช่วง -1.0 ถึง 1.0 โดย 0.0 คือตรงกลาง
การระบุค่า x,y สำหรับ Alignment() เพื่อวางตำแหน่ง child
ในกรณีที่ใส่ค่าที่ไม่ใช่ 0.0 1.0 -1.0 แต่เป็นค่าระหว่างนั้น เช่นใช้ค่า 0.5, 0.5 จะได้ผลดังนี้
import 'package:flutter/material.dart';
void main() {
runApp(MaterialApp(
home: Scaffold(
body:
Container(
color: Colors.amber,
height: 300,
width: 300,
child: Align(
alignment: Alignment(0.5, 0.5),
child: Container(color: Colors.green, width: 30, height: 30),
)))));
}
ผลของการใช้ Alignment(0.5, 0.5) ระยะตำแหน่งคือ (202.5, 202.5)px
สำหรับวิธีการคำนวณตำแหน่ง logical pixel ของ Alignment()
คือ
([ขนาด Parent] - [ขนาด child]) * (x + 1) * 0.5 =
(300 - 30) * 1.5 * 0.5 =
270 * 0.75 =
202.5
FractionalOffset() จะใช้ค่า (x, y) ในการระบุตำแหน่ง ค่าจะอยู่ในช่วง 0.0 ถึง 1.0 โดย 0.5 คือตรงกลาง ดังนั้น Alignment(0.5, 0.5) = FractionalOffset(0.75, 0.75)
การระบุค่า x,y สำหรับ FractionalOffset() เพื่อวางตำแหน่ง child
สำหรับวิธีการคำนวณตำแหน่ง logical pixel ของ FractionalOffset()
คือ
([ขนาด Parent] - [ขนาด child]) * x =
(300 - 30) * 0.75 =
270 * 0.75 =
202.5
FractionallySizedBox ช่วยให้สามารถสร้างกล่องที่มีขนาดเป็นสัดส่วนตาม Parent แทนที่จะกำหนดเป็น logical pixel ค่าขนาดที่สามารถระบุได้คือ 0.0 ถึง 1.0 แทนขนาด 0% ถึง 100% ของ Parent ของมัน ตัวอย่างการสร้าง FractionallySizedBox กว้างเต็มหน้าจอ สูงแค่ครึ่งหนึ่งของจอ
import 'package:flutter/material.dart';
void main() {
runApp(MaterialApp(home: Scaffold(
body: FractionallySizedBox(
widthFactor: 1.0,
heightFactor: 0.5,
child: Container(color: Colors.amber),
))));
}
FractionallySizedBox() ขนาด 1.0x0.5
Transform มีความสามารถที่หลากหลายในการปรับเปลี่ยนรูปแบบของ widget ที่อยู่ใน child โดยจะมี constructor ที่นิยมใช้บ่อย ๆ ดังนี้
ตัวอย่าง หมุนข้อความ 45 องศา ตามเข็มนาฬิกา
import 'package:flutter/material.dart';
void main() {
var angle = 22 / 7 * 0.25; // 45 degree cw
runApp(MaterialApp(
home: Scaffold(
body: Center(
child: Container(
color: Colors.yellow,
child: Transform.rotate(angle: angle, child: const Text('Hello World')),
)))));
}
หมุน Text จะเห็นว่า Text หมุนออกมานอก Container สีเหลืองที่ขนาดเท่ากับ Text
หากไม่กำหนดจุดหมุน จุดหมุนจะอยู่ตรงกลาง widget หากต้องการเปลี่ยนแปลง สามารถกำหนดค่า alignment ได้ เช่น
Transform.rotate(
angle: angle,
alignment: Alignment.centerLeft,
child: const Text('Hello World')),
หมุน Text โดยจุดหมุนอยู่ที่ด้านซ้ายของ Text
การใช้ Transform ผลของการทำงานจะเป็นการวาด widget ทับไปบนหน้าจอโดยไม่มีการขยายขนาด widget จึงไม่ผลกับ layout หากต้องการให้ตัว layout สัมพันธ์กับตัว widget ตัองใช้ widget ตัวอื่นแทน เช่น RotatedBox เป็นต้น
เนื่องจากบันทึกตัวนี้ยาวเกินกว่าที่คาดไว้ และตัว Multi-child layout widgets มีความซับซ้อนมากกว่า เลยคิดว่ายกไปไว้บันทึกอันใหม่น่าจะดีกว่า