Flutter: MediaQuery class ข้อมูลหน้าจอที่ใช้แสดงผล

การเขียนแอปแบบกราฟฟิกอินเทอร์เฟส สิ่งที่ขาดไม่ได้เลยสำหรับการทำงานกับงาน 2D คือ หน้าจอที่จะใช้แสดงผลว่ามีโครงสร้างเป็นอย่างไร เช่น ขนาดกว้าง ยาว ขอบภายใน แนวนอน แนวตั้ง สิ่งเหล่านี้จำเป็นต้องทราบเพื่อจะได้ออกแบบหน้าจอส่วนติดต่อกับผู้ใช้งานได้มีประสิทธิภาพและใช้งานได้ง่าย การเข้าถึงข้อมูลเหล่านี้สามารถทำได้ผ่าน MediaQuery class

การเข้าถึง MediaQuery class

ตัว MediaQuery class เป็น InheritedWidget ที่มีข้อมูลของหน้าจอที่เป็นพื้นที่สำหรับแสดงผล การเข้าถึงข้อมูลสามารถใช้คำสั่ง .of() หาก widget ที่เรียกใช้คำสั่งนี้มี MediaQuery ใน widget tree สายเดียวกัน ก็จะคืนค่าเป็น MediaQueryData กลับมา แต่หากไม่เจอจะเกิด Exception error แทน ในกรณีที่ไม่แน่ใจสามารถใช้คำสั่ง .maybeOf() เพื่อคืนค่ากลับมาเป็น null หากไม่เจอ MediaQuery ใน widget tree เดียวกัน

import 'package:flutter/material.dart';
import 'dart:developer';

void main() {
  runApp(const MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    MediaQueryData m = MediaQuery.of(context); // get MediaQuery from BuildContext
    log(m.padding.toString()); // output → [log] EdgeInsets.zero
    log(m.size.toString()); // output → [log] Size(1920.0, 1017.0)
	
    return const MaterialApp(home: Scaffold(body: Center(child: Text('hello'))));
  }
}

ข้อมูลจาก MediaQuery จะมาจาก parent ของ MainApp อีกที

ถ้าลองย่อขนาดหน้าจอ จะพบว่าตัว framework จะทำการ rebuild ตัว widget ใหม่ โดยค่าที่ได้จาก MediaQuery จะเปลี่ยนแปลงตามค่าปัจจุบัน

ลองย่อหน้าต่างของแอปดู จะเห็นว่าข้อมูลจาก log จะเปลี่ยน

ตัว MediaQuery ไม่ว่าจะเรียกจาก widget ตัวไหนก็ตามใน widget tree ก็จะได้ค่าเดียวกัน ตัวอย่าง แบ่งหน้าจอเป็นสองฝั่ง แล้วลองเรียก MediaQuery จาก widget MyText ที่อยู่ข้างใน ผลที่ได้จะเป็นผลเดียวกันตัว MainApp

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    MediaQueryData m = MediaQuery.of(context);
    log(m.padding.toString());
    log(m.size.toString());
	
    return const MaterialApp(
        home: 
          Scaffold(
            body: Row(children: [
                    Expanded(flex: 1, child: MyText('left')), 
                    Expanded(flex: 2, child: MyText('right'))
                  ])));
  }
}

class MyText extends StatelessWidget {
  final String displayText;

  const MyText(this.displayText, {super.key});

  @override
  Widget build(BuildContext context) {
    MediaQueryData m = MediaQuery.of(context);
    log('$displayText: ${m.padding}');
    log('$displayText: ${m.size}');

    return Text(displayText);
  }
}

ผลที่ได้จาก MediaQuery ใน MyText ก็เท่ากับ MainApp

ขนาดหน้าจอที่แสดงผลของแอป

การอ่านค่าหน้าจอของแอปจาก MediaQuery ด้วยคำสั่ง .of()

กว้าง ยาว ในหน่วย pixel (logical pixels)

ขนาดหน้าจอที่แสดงผลของแอปในหน่วย logical pixels ซึ่งจะไม่เท่ากับ physical pixels เสมอไป ตัว framework จะคำนวณตามค่า devicePixelRatio เช่น จอ Smart phone มีความละเอียดสูง ค่านี้ก็จะสูงตาม

MediaQueryData m = MediaQuery.of(context);
log("Screen width: ${m.size.width}px.");
log("Screen height: ${m.size.height}px.");

หรือจะใช้คำสั่ง .sizeOf() เพื่อดึงมาเฉพาะข้อมูล Size ก็ได้

Size s = MediaQuery.sizeOf(context);
log("Screen width: ${s.width}px.");
log("Screen height: ${s.height}px.");

จำนวน pixel ที่แท้จริงของหน้าจอ (physical pixels)

หากต้องการทราบถึงจำนวนจุด pixels จริง ๆ บนหน้าจอ สามารถใช้คำสั่งตามตัวอย่าง หากทดสอบบนหน้าจอ PC ที่กำหนดค่า dpi ตามปกติ จะได้ค่าเท่ากับ devicePixelRatio = 1.0 แต่ถ้าหากอ่านค่าจากพวกอุปกรณ์พกพาจะได้ค่าอื่น เช่น Nexus 6 จะอ่านค่า devicePixelRatio = 3.5 หรือ Apple iPhone 14 จะอ่านค่า devicePixelRatio = 3 เป็นต้น จากนั้นเอามาคูณกับค่า pixel ที่อ่านได้จาก .size

MediaQueryData m = MediaQuery.of(context);
log("devicePixelRatio: ${m.devicePixelRatio}"); // output → [log] devicePixelRatio: 1.0
log("Screen physical width: ${m.size.width * m.devicePixelRatio}px.");
log("Screen physical height: ${m.size.height * m.devicePixelRatio}px.");

// read only devicePixelRatio
double d = MediaQuery.devicePixelRatioOf(context);
log("devicePixelRatio: $d"); // output → [log] devicePixelRatio: 1.0

ตั้งค่า Scaling ใน Setting ของ Windows จะมีผลต่อค่า devicePixelRatio

พื้นที่หน้าจอที่ไม่สามารถใช้งานได้

เนื่องจากในอุปกรณ์ smart phone หรือ tablet มักจะมี System UI ที่เข้ามาบดบังพื้นที่การแสดงผลของแอป เช่น

  • visual keyboard
  • notch บน iPhone X ขึ้นไป หรือบน Android รุ่นใหม่ ๆ
  • Home Bar บน iPhone X ขึ้นไป

จึงทำให้มีส่วนที่ตัว framework ไม่สามารถวาด UI หรือตรวจสอบการแตะหรือวาดนิ้วได้ โดยค่าเหล่านี้จะเก็บไว้ใน EdgeInsets จะอ่านได้จากคำสั่ง

ตำแหน่งพื้นที่ที่ไม่สามารถทำงานได้

หากมีความจำเป็นต้องการเขียนแอปบน smart phone หรือ tablet สามารถใช้ SafeArea class เพื่อป้องกันการแสดง widget ไปยังพื้นที่ที่ผู้ใช้ไม่สามารถแตะหรือวาดนิ้วได้

เปรียบเทียบระหว่างการไม่ใช้และใช้งาน SafeArea

การวางแนวหน้าจอ แนวตั้ง แนวนอน

การตรวจสอบแนวหน้าจอหรือ Screen orientation สามารถอ่านได้จากคำสั่ง .orientation หรือจากคำสั่ง MediaQuery.orientationOf()

จากการทดสอบหากจอเป็นสี่เหลี่ยมจัตุรัส ผลที่ได้จะเป็น Orientation.portrait

ทดสอบปรับขนาดแอปเพื่อดูค่า orientation

ข้อมูลอื่น ๆ ที่สามารถอ่านได้จาก MediaQuery

  • การขยายขนาดตัวอักษร .textScalerOf()TextScaler
  • แสดงตัวอักษรแบบเน้น .boldTextOf()true ถ้า platform ระบุว่าต้องการให้แสดงผลให้มีความหนา/เข้มเป็นพิเศษ
  • การแสดงผลแบบสีมีความแตกต่างกันสูง .highContrastOf()true ถ้า platform ระบุว่าต้องการให้แสดงผลสีแบบ high contrast (มีเฉพาะ iOS)
  • แสดงสีแบบสลับ .invertColorsOf()true ถ้า platform ระบุว่าต้องการให้แสดงผลสีแบบ invert (มีเฉพาะ iOS)
  • Dark/Light mode .platformBrightnessOf()Brightness
  • แสดงเวลารูปแบบ 24H .alwaysUse24HourFormatOf()true ถ้า platform ระบุว่าต้องการให้แสดงผลแบบ 24H
  • ไม่ต้องแสดงเอฟเฟกเคลื่อนไหว .disableAnimationsOf()true ถ้า platform ระบุว่าต้องการลด/ไม่แสดงเอฟเฟกเคลื่อนไหว
  • รายการจอแบบพิเศษต่าง ๆ .displayFeaturesOf()DisplayFeature เช่น อุปกรณ์ที่มีสองจอ จอพับได้
  • แสดงข้อความ on/off .onOffSwitchLabelsOf()true ถ้า platform ระบุว่าต้องการให้แสดงข้อความ on/off ข้างในสวิตช์ด้วย (มีเฉพาะ iOS)
  • รูปแบบการควบคุมการนำทาง .navigationModeOf()NavigationMode หากเขียนแอปบน platform ที่มีการใช้อุปกรณ์ในการเปลี่ยนเส้นทางของแอป เช่น การใช้รีโมทบนแอปที่ทำงานบนทีวี

ข้อควรระวังเมื่อใช้งาน MediaQuery

เนื่องจากการอ่านข้อมูลจาก MediaQuery จะเหมือนกับการอ่านข้อมูลจาก InheritedWidget ที่ใช้คำสั่ง dependOnInheritedWidgetOfExactType<T>() widget ตัวไหนที่อ่านด้วยวิธีดังกล่าวจะถูกผูกกับ MediaQuery หาก MediaQuery เกิดการเปลี่ยนแปลง จะทำให้ widget ตัวดังกล่าวถูก rebuilt

  • ดังนั้นหากไม่ได้ใช้ข้อมูลจาก MediaQuery ก็อย่าไปเรียกคำสั่ง .of() เพื่ออ่านข้อมูล
  • หากใช้ข้อมูลแค่บางส่วนก็ให้เรียกเฉพาะส่วนที่ต้องการเท่านั้น เช่น ต้องการข้อมูลว่าต้องแสดงเวลา 24H หรือไม่ ก็ให้เรียกคำสั่ง .alwaysUse24HourFormatOf() เพราะถ้าข้อมูลส่วนอื่นของ MediaQuery เปลี่ยนแปลงแต่ส่วนนี้ไม่ได้เปลี่ยน ก็จะไม่กระทบกับ widget และไม่ต้องถูก rebuilt ใหม่
  • หากไม่ต้องการให้ widget ที่เรียกข้อมูล MediaQuery ถูก rebuilt เมื่อ MediaQuery ดังกล่าวเปลี่ยนแปลง ให้เรียกด้วยคำสั่ง .getInheritedWidgetOfExactType()
MediaQuery? mediaQuery = context.getInheritedWidgetOfExactType<MediaQuery>();
assert(mediaQuery != null);

MediaQueryData m = mediaQuery!.data;

log("light:${m.platformBrightness} ");
log("Viewport width: ${m.size.width}px.");
log("Viewport height: ${m.size.height}px.");
log("Orientation: ${m.orientation}");