Flutter : การทำแอปให้รองรับ Layout ในทุกขนาดหน้าจอ
สวัสดีครับ ช่วงนี้กำลังทำแอปด้วย Flutter ลองเล่นอะไรสนุกๆไปเรื่อยๆ แล้วก็ได้มีโอกาสได้ทำแอปให้รองรับกับหน้าจอแท็บเลตครับ เลยมาเขียนบล็อกสรุปเรื่องนี้สั้นๆ
บล็อกก่อนหน้านี้เกี่ยวกับเรื่อง BuildContext
Flutter : สรุปเรื่อง BuildContext , Widget , State , Key ใน Flutter
เริ่มต้น
ปกติเวลาเราทำแอป ก็ต้องทำหน้าจอให้รองรับสำหรับอุปกรณ์ที่มีจอขนาดใหญ่ด้วย เช่นแท็บเลต ซึ่ง UI/UX มันก็จะแตกต่างจากในหน้าจอมือถือ เช่นมีเมนูด้านซ้าย รายละเอียดด้านขวา ส่วนในมือถือก็กดแล้วไปหน้าใหม่ เราจะมาลองทำกัน
New Flutter Project ก่อน โดยผมจะทำแอปแสดงเมนูชื่อสัตว์
import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( title: 'Animal', theme: ThemeData( primarySwatch: Colors.pink, ), home: MyAnimalListPage(title: 'Animal'), ); } } class MyAnimalListPage extends StatefulWidget { MyAnimalListPage({Key key, this.title}) : super(key: key); final String title; @override _MyAnimalListPageState createState() => _MyAnimalListPageState(); } class _MyAnimalListPageState extends State<MyAnimalListPage> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text( 'Animal list',style: TextStyle(fontSize: 32), ), ], ), ) ); } }
สร้างหน้า Animal List
ก่อนอื่นทำหน้าสำหรับ list ก่อน โดยใช้ ListView
class MyAnimalListPage extends StatefulWidget { MyAnimalListPage({Key key, this.title}) : super(key: key); final String title; @override _MyAnimalListPageState createState() => _MyAnimalListPageState(); } class _MyAnimalListPageState extends State<MyAnimalListPage> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: ListView(children: <Widget>[ buildAnimalListItem(context, "Dog"), buildAnimalListItem(context, "Cat"), buildAnimalListItem(context, "Tiger"), buildAnimalListItem(context, "Lion"), ])); } Widget buildAnimalListItem(BuildContext context, String name) { return Container( padding: EdgeInsets.all(16), child: Text(name, style: TextStyle(fontSize: 22))); } }
ได้หน้าจอ list แล้ว
สร้างหน้า Animal Detail
สร้างหน้าใหม่ ชื่อ AnimalDetailPage
ออกแบบหน้าจอง่ายๆ แค่เอาค่าชื่อสัตว์จากหน้า Animal list มาแสดง
class MyAnimalDetailPage extends StatefulWidget { final String animalName; MyAnimalDetailPage({@required this.animalName}); @override _MyAnimalDetailPageState createState() => _MyAnimalDetailPageState(); } class _MyAnimalDetailPageState extends State<MyAnimalDetailPage> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Container( child: Center( child: Text( widget.animalName, style: TextStyle(fontSize: 32), ))), ); } }
เปลี่ยนหน้าไปยัง Animal Detail
ที่ AnimalListPage เพิ่ม GestureDetector onTap ให้ route ไปที่ AnimalDetailPage
class _MyAnimalListPageState extends State<MyAnimalListPage> { @override Widget build(BuildContext context) { return Scaffold( ... } Widget buildAnimalListItem(BuildContext context, String name) { return GestureDetector( onTap: () { Navigator.of(context).push(MaterialPageRoute(builder: (context) { return MyAnimalDetailPage(animalName: name); })); }, child: ... } }
ตอนนี้จะสามารถเปลี่ยนหน้า ไป AnimalPageDetail ได้ พร้อมกับเอาค่ามาแสดง
ลองรันใน tablet
สร้างหน้าจอสำหรับแท็บเลต
ต่อมา เราต้องแยก layout เป็น 2 ส่วน ส่วนนึงสำหรับมือถือ อีกส่วนสำหรับแท็บเลต
โดยการแยกว่าเป้นหน้าจอแท็บเลต สามารถใช้ความกว้างขอจอแยกได้ คือ หน้าจอต้องกว้างมากกว่า 600
bool tabletMode = MediaQuery.of(context).size.width > 600;
ถ้าเป็น แท็บเลตก็แสดงผลหน้าจอสำหรับแท็บเลต โดยจะแบ่ง
พื้นที่เป็นด้านซ้าย 1 ส่วน ด้านขวา 3 ส่วน วิธีแบ่งก็ใช้ Widget ที่ชื่อว่า Flexible
class _MyAnimalListPageState extends State<MyAnimalListPage> { @override Widget build(BuildContext context) { bool tabletMode = MediaQuery.of(context).size.width > 600;; if (tabletMode) { return buildTabletLayout(context); } else { return buildPhoneLayout(context); } } Scaffold buildPhoneLayout(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: ListView(children: <Widget>[ buildAnimalListItem(context, "Dog"), buildAnimalListItem(context, "Cat"), buildAnimalListItem(context, "Tiger"), buildAnimalListItem(context, "Lion"), ])); } Scaffold buildTabletLayout(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Row(children: <Widget>[ Flexible( flex: 1, child: Material( elevation: 4, child: ListView(children: <Widget>[ buildAnimalListItem(context, "Dog"), buildAnimalListItem(context, "Cat"), buildAnimalListItem(context, "Tiger"), buildAnimalListItem(context, "Lion"), ]))), Flexible(flex: 3, child: MyAnimalDetailPage(animalName: "...")) ])); }
ลองรัน จะพบว่า AnimalPageDetail มี AppBar ซ้อนอยู่ ดังนั้นเราต้องไปแยก layout ที่ AnimalDetailPage ด้วย
ปรับแต่ง AnimalListPage
ก่อนอื่นมาปรับเมนู Animal list ให้ dynamic มากขึ้น
สร้าง class Animal
class Animal { int id; String name; Color background; Color textColor; Animal({this.id, this.name, this.background, this.textColor}); }
ประกาศตัวแปร list เก็บค่าว่ามี Animal อะไรบ้าง และ currentAnimalSelected เก็บค่าว่าตอนนี้เมนูไหนที่ถูกเลือกอยู่
List<Animal> listAnimal; int currentAnimalSelected;
ที่ initState กำหนดค่าเริ่มต้นให้กับ listAnimal
class _MyAnimalListPageState extends State<MyAnimalListPage> { List<Animal> listAnimal; int currentAnimalSelected; @override void initState() { currentAnimalSelected = 0; initAnimalList(); super.initState(); } void initAnimalList() { listAnimal = List(); listAnimal.add(Animal( id: 0, name: "Dog", background: Colors.blue, textColor: Colors.white)); listAnimal.add(Animal( id: 1, name: "Cat", background: Colors.orange, textColor: Colors.white)); listAnimal.add(Animal( id: 2, name: "Tiger", background: Colors.pink, textColor: Colors.white)); listAnimal.add(Animal( id: 3, name: "Lion", background: Colors.purple, textColor: Colors.white)); } ... }
และที่ AnimalListPage ก็เปลี่ยน ListView มาใช้ ListView.builder แทน ทำให้ dynamic มากขึ้น
class _MyAnimalListPageState extends State<MyAnimalListPage> { ... @override Widget build(BuildContext context) { bool tabletMode = MediaQuery.of(context).size.width > 600; if (tabletMode) { return buildTabletLayout(context); } else { return buildPhoneLayout(context); } } Widget buildPhoneLayout(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: ListView.builder( itemCount: listAnimal.length, itemBuilder: (context, index) { return buildAnimalListItem(context, listAnimal[index], false); })); } Widget buildTabletLayout(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Row(children: <Widget>[ Flexible( flex: 1, child: Material( elevation: 4, child: ListView.builder( itemCount: listAnimal.length, itemBuilder: (context, index) { return buildAnimalListItem( context, listAnimal[index], true); }))), Flexible( flex: 3, child: MyAnimalDetailPage(animal: listAnimal[currentAnimalSelected])) ])); }
จากนั้นตอนกดที่เมนู จะแยกเป็น 2 กรณีคือ ถ้าเป็น phone จะเปลี่ยนหน้าโดยใช้ Navigator แต่ถ้าเป็น tablet แค่ กำหนด currentAnimalSelected ใหม่ แล้ว setState()
Widget buildAnimalListItem( BuildContext context, Animal animal, bool tabletMode) { return GestureDetector( onTap: () { if (tabletMode) { setState(() { currentAnimalSelected = animal.id; }); } else { Navigator.of(context).push(MaterialPageRoute(builder: (context) { return MyAnimalDetailPage(animal: animal); })); } }, child: Container( color: animal.background, padding: EdgeInsets.all(16), child: Text(animal.name, style: TextStyle(color: animal.textColor, fontSize: 22)))); }
ที่หน้า AnimalDetilPage ก็แยกหน้าจอเป็น phone กับ tablet
และปรับให้ตัวหนังสือใน tablet ใหญ่กว่านิดหน่อย
class MyAnimalDetailPage extends StatefulWidget { final Animal animal; MyAnimalDetailPage({@required this.animal}); @override _MyAnimalDetailPageState createState() => _MyAnimalDetailPageState(); } class _MyAnimalDetailPageState extends State<MyAnimalDetailPage> { @override Widget build(BuildContext context) { bool tabletMode = MediaQuery.of(context).size.width > 600; if (tabletMode) { return buildTabletLayout(); } else { return buildPhoneLayout(); } } Widget buildPhoneLayout() { return Scaffold( appBar: AppBar(), body: Container( color: widget.animal.background, child: Center( child: Text( widget.animal.name, style: TextStyle(color: widget.animal.textColor, fontSize: 32), ))), ); } Widget buildTabletLayout() { return Scaffold( body: Container( color: widget.animal.background, child: Center( child: Text( widget.animal.name, style: TextStyle(color: widget.animal.textColor, fontSize: 52), ))), ); } }
ลองรัน ก็จะได้หน้าจอสำหรับ tablet แล้ว
แท็บเลตแนวตั้งก็ยังแสดงผลในโหมดแท็บเลตอยู่ หาก width > 600
ลองรันในมือถือ ไม่ได้แสดงผลในโหมดแท็บเลต เพราะ width < 600
แต่หากเราทำหน้าจอเป็นแนวนอน ก็จะแสดงในโหมดแท็บเลต เพราะ width > 600
กรณีนี้ก็อยู่ที่เราว่าต้องการแบบนี้หรือไม่ ถ้าไม่ก็ต้องไปปรับเงื่อนไข เช่นใช้ smallWidth แทน
สรุปผล
LayoutBuilder
จริงๆแล้ว Flutter มี Widget มาจัดการเรื่องนี้ ชื่อว่า LayoutBuilder โดยเราต้องกำหนดค่าผ่าน builder
สิ่งที่เราต้องทำคือ เอา constraints มาเช็คเงื่อนไขนั่นเอง ซึ่งผลลัพธ์ก็เหมือนเราใช้ MediaQuery
@override Widget build(BuildContext context) { return LayoutBuilder(builder: (context, constraints) { if (constraints.minWidth > 600) { return buildTabletLayout(context); } else { return buildPhoneLayout(context); } }); }
สรุป
การทำหน้าจอให้รองรับสำหรับแท็บเลตนั่นง่ายมาก แค่แยก layout ออกมาอีกอันและเช็คเงื่อนไขจากความกว้างของจอ กรณีเป็น มือถือตอนกดที่เมนูก็ให้เรียก Navigator เพื่อไปหน้าใหม่ แต่ถ้าเป็นแท็บเล็ตก็ช้วิธีการแสดง Widget อีกตัวแล้วก็ setState และสุดท้ายเราสามารถใช้ LayoutBuilder Widget แทนการเรียก MediaQuery ตรงๆได้ แล้วนำค่า constraint มาเช็คเงื่อนไข
โค้ดอยู่ที่ Github Gist
https://gist.github.com/benznest/8960e2db2b96b3e34486c25eaa8ee32d