web analytics

Flutter : การทำแอปให้รองรับ Layout ในทุกขนาดหน้าจอ

cover

สวัสดีครับ ช่วงนี้กำลังทำแอปด้วย Flutter ลองเล่นอะไรสนุกๆไปเรื่อยๆ แล้วก็ได้มีโอกาสได้ทำแอปให้รองรับกับหน้าจอแท็บเลตครับ เลยมาเขียนบล็อกสรุปเรื่องนี้สั้นๆ

 

บล็อกก่อนหน้านี้เกี่ยวกับเรื่อง BuildContext 

Flutter : สรุปเรื่อง BuildContext , Widget , State , Key ใน Flutter

 

เริ่มต้น

ปกติเวลาเราทำแอป ก็ต้องทำหน้าจอให้รองรับสำหรับอุปกรณ์ที่มีจอขนาดใหญ่ด้วย เช่นแท็บเลต ซึ่ง UI/UX มันก็จะแตกต่างจากในหน้าจอมือถือ เช่นมีเมนูด้านซ้าย รายละเอียดด้านขวา ส่วนในมือถือก็กดแล้วไปหน้าใหม่ เราจะมาลองทำกัน

b1

 

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),
            ),
          ],
        ),
      )
    );
  }
}

screenshot_1549509147

สร้างหน้า 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 แล้ว

2

 

สร้างหน้า Animal Detail

สร้างหน้าใหม่ ชื่อ AnimalDetailPage
ออกแบบหน้าจอง่ายๆ แค่เอาค่าชื่อสัตว์จากหน้า Animal list มาแสดง

3

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 ได้ พร้อมกับเอาค่ามาแสดง

a1

 

ลองรันใน tablet

a2

 

สร้างหน้าจอสำหรับแท็บเลต

ต่อมา เราต้องแยก 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 ด้วย

4

 

ปรับแต่ง 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 แล้ว

5a

 

แท็บเลตแนวตั้งก็ยังแสดงผลในโหมดแท็บเลตอยู่ หาก width > 600

6

 

ลองรันในมือถือ ไม่ได้แสดงผลในโหมดแท็บเลต เพราะ width < 600

7 8

 

แต่หากเราทำหน้าจอเป็นแนวนอน ก็จะแสดงในโหมดแท็บเลต เพราะ width > 600
กรณีนี้ก็อยู่ที่เราว่าต้องการแบบนี้หรือไม่ ถ้าไม่ก็ต้องไปปรับเงื่อนไข เช่นใช้ smallWidth แทน

9

 

สรุปผล

a3

 

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