Flutter: BuildContext ตัว interface สำหรับติดต่อกับ Element

BuildContext class ถูกออกแบบมาให้ใช้สำหรับเป็นตัวกลางเพื่อติดต่อกับ Element class ที่เก็บข้อมูลของ widget ที่ถูกสร้างอยู่ใน widget tree โดยปกติแล้ว developer มักไม่ได้ไปยุ่งกับการสร้าง Element โดยตรง เนื่องจากเป็นส่วนที่ตัว Flutter framework เป็นตัวจัดการ ซึ่งวิธีในการจัดการเราจะไม่ได้พูดถึงเนื่องจากทางคนออกแบบ framework ไม่ได้ต้องการให้มายุ่งในส่วนนี้ หากลองดูโครงสร้างของ Widget จะเห็นว่ามีคำสั่ง createElement() งานสร้่างแอปทั่วไปก็จะไม่เคย override เอามาใช้งานกันเลย

abstract class StatelessWidget extends Widget {
  /// Initializes [key] for subclasses.
  const StatelessWidget({ super.key });

  /// Creates a [StatelessElement] to manage this widget's location in the tree.
  ///
  /// It is uncommon for subclasses to override this method.
  @override
  StatelessElement createElement() => StatelessElement(this);
}

เมื่อลองมาดู StatelessElement จะพบว่ามัน inherite มาดังนี้
StatelessElement < ComponentElement < Element < DiagnosticableTree < Object
จะเห็นว่ามันมาจาก Element และตัว Element เองก็ implements ตัว BuildContext

abstract class Element extends DiagnosticableTree implements BuildContext {
  /// Creates an element that uses the given widget as its configuration.
  ///
  /// Typically called by an override of [Widget.createElement].
  Element(Widget widget): _widget = widget {
    if (kFlutterMemoryAllocationsEnabled) {
      FlutterMemoryAllocations.instance.dispatchObjectCreated(
        library: _flutterWidgetsLibrary,
        className: '$Element',
        object: this,
      );
    }
  }
  
  /// The configuration for this element.
  ///
  /// Avoid overriding this field on [Element] subtypes to provide a more
  /// specific widget type (i.e. [StatelessElement] and [StatelessWidget]).
  /// Instead, cast at any call sites where the more specific type is required.
  /// This avoids significant cast overhead on the getter which is accessed
  /// throughout the framework internals during the build phase - and for which
  /// the more specific type information is not used.
  @override
  Widget get widget => _widget!;
  Widget? _widget;  
}

หน้าที่หลักของ BuildContext คือ ใช้สำหรับจัดการเรื่องโครงสร้างของ widget ที่อยู่ใน widget tree โดย instances ของตัว BuildContext ทั้งที่ได้จากการคำสั่ง .build(BuildContext context) หรือจาก State.context จะมีการเปลี่ยนแปลงทุกครั้งที่มีการ rebuild ตัว widget tree และก่อนใช้งานทุกครั้งควรตรวจสอบว่ามันถูก .mounted อยู่หรือไม่ เพื่อหลีกเลี่ยงการเกิด Exception error

การใช้งาน BuildContext ในคำสั่ง build(BuildContext context)

ในคำสั่ง build(BuildContext context) ใน StatelessWidget หรือ State จะเป็น BuildContext ของ widget tree ที่จะนำผลที่ได้จาก build ไปใส่ ดังนั้นหากต้องการดึงข้อมูลที่สนใจจาก widget tree สามารถใช้งาน context ที่ส่งมาได้

คำสั่ง BuildContext.widget

ในตัวอย่าง จะลองพิมพ์ค่าที่ได้จากตัว context.widget ออกมาว่ามันคือ widget ตัวไหน ผลที่ได้คือตัว MainApp

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) {
    log(context.widget.toStringDeep()); // output → [log] MainApp

    return const MaterialApp(
      home: Scaffold(
        body: Center(
          child: Text('Hello World!'),
        ),
      ),
    );
  }
}

หากลองใส่คำสั่งนี้ในตัว Text ที่แสดงผล โดยสร้าง MyText ขึ้นมาแล้วแทนคำสั่ง Text เดิมด้วย child: MyText('Hello World!') จะได้ผลดังนี้

class MyText extends StatefulWidget {
  final String text;

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

  @override
  State<MyText> createState() => _MyTextState();
}

class _MyTextState extends State<MyText> {
  @override
  Widget build(BuildContext context) {
    log(context.widget.toStringDeep()); // output → [log] MyText
    log(context.mounted.toString()); // output → [log] true

    MyText myText = context.widget as MyText;
    log(myText.text); // output → [log] Hello World!

    return Text(widget.text);
  }
}

คำสั่ง BuildContext.size แสดงขนาดของตัว widget

ค่า BuildContext.size คือขนาดของ widget ที่ถูกแสดงบนจอ ลองเปลี่ยนตัว MyText เมื่อมีการแตะหรือคลิก จะแสดงขนาดใน log ดู

class _MyTextState extends State<MyText> {
  @override
  Widget build(BuildContext context) {
    void logSize() {
      log("MyText size = ${context.size}");
    }  
	
    return GestureDetector(
      onTap: logSize,
      child: Text(widget.text),
    );
  }
}

แสดง log เมื่อคลิกที่ตัวข้อความ

Dsmurat, penubag, Jelican9, CC BY-SA 4.0

BuildContext.size จะสามารถเข้าถึงได้หลังจากที่ตัว widget ถูกสร้างและนำเข้าไปใส่ไว้ใน widget tree แล้วเท่านั้น ไม่สามารถเรียกคำสั่งนี้ได้ใน build(BuildContext context) หากเรียกใช้งานในขณะที่ยัง build ไม่เสร็จ จะเกิด Exception error ขึ้น

คำสั่ง BuildContext.mounted ตรวจสอบว่า widget ถูกใส่ไว้ใน widget tree หรือไม่

ในการสั่ง setStatus ตัว widget หากคำสั่งนี้มาจากตัว widget เองที่จะเกิดขึ้นเมื่อ widget อยู่บนหน้าจอแน่ ๆ ก็คงไม่มีปัญหาอะไร แต่หากเรียกคำสั่งนี้จากจุดที่อยู่นอก widget เช่น callback หรือเหตุการณ์แบบ asynchronous

การตรวจสอบว่าตัว widget ยังอยู่ใน widget tree หรือไม่ ให้ตรวจสอบจากคำสั่ง .mounted ต้องมีค่าเป็น true มิฉะนั้นจะเกิด Exception error ขึ้นได้

ค้นหา InheritedWidget

widget ที่เป็น child ของ InheritedWidget สามารถใช้คำสั่ง .dependOnInheritedWidgetOfExactType<T>() เพื่ออ้างถึง InheritedWidget ดังกล่าวได้ สามารถอ่านวิธีใช้ได้ที่บทความ

ค้นหา widget ที่อยู่ด้านบน ที่มี type เป็น T

ในกรณีที่ต้องการตรวจสอบว่าตัว widget นั้นอยู่ใน child ของ widget ที่มี type เป็น T หรือไม่ สามารถใช้คำสั่ง .findAncestorWidgetOfExactType<T>() ตัวคำสั่งจะทำการค้นหาทุก Element ที่อยู่ภายใน widget tree จนกว่าจะเจอตัว type เป็น T ตามที่ระบุ และหากค้นจนครบแล้วไม่เจอจะคืนค่ากลับมาเป็น null

ตัวอย่าง เมื่อแตะหรือคลิกที่ตัวข้อความ Hello World! จะทำการเรียก findScaffold() และแสดงข้อความ log ออกมา

class _MyTextState extends State<MyText> {
  @override
  Widget build(BuildContext context) {
    void findScaffold() {
      Scaffold? scaffold = context.findAncestorWidgetOfExactType<Scaffold>();
      if (scaffold != null) {
        log('Scaffold is ancestor of this widget');
        log(scaffold.body!.toStringDeep()); // output → [log] Center(alignment: Alignment.center)
      }
    }

    return GestureDetector(
      onTap: findScaffold,
      child: Text(widget.text),
    );
  }
}

widget tree ที่ใช้ทดสอบ

การค้นหา State ใน widget tree ที่ใกล้ที่สุด

ตัว StatefulWidget จะมี State ที่เก็บสถานะของ widget หากต้องค้นหาตัว State สามารถใช้คำสั่ง .findAncestorStateOfType<T>() โดยค่าที่ได้จะเป็น State ตัวที่อยู่ใกล้ที่สุด หากไม่พบจะคืนค่า null โดยมีข้อควรระวังในการใช้งานดังนี้

  1. ไม่ควรใช้ในคำสั่ง build() เพราะอาจมีผลทำให้ตัว widget ไม่ถูก rebuilt หากค่า State ที่ได้ถูกทำให้เปลี่ยนแปลง
  2. ไม่ใช่พร่ำเพรื่อ ควรใช้เมื่อมีความจำเป็น เช่น สั่ง Scroll view เพื่อเลื่อนตำแหน่งหน้าจอ หรือเปลี่ยนตำแหน่ง focus ของ widget เพื่อตอบสนองผู้ใช้งาน
  3. วิธีนี้จะทำให้ดูแลรักษา code ยาก ควรใช้วิธีเปลี่ยน State จาก callback

ตัวอย่าง จะเป็นการให้ตัว MainApp แสดงข้อความที่เป็นวันที่และเวลาปัจจุบันบนหน้าจอ เมื่อแตะหรือคลิกจะทำการค้นหา _MainAppState จากนั้นจะเรียกคำสั่ง void callFromOutside() เพื่อให้ _MainAppState ทำการ rebuild หน้าจอใหม่

คลิกที่ข้อความเพื่อแสดงวันที่และเวลาปัจจุบัน

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

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

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

  @override
  State<MainApp> createState() => _MainAppState();
}

class _MainAppState extends State<MainApp> {
  void callFromOutside() {
    // ❌ this is not good practice to rebuild widget from outside
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    var now = DateTime.now().toString();

    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: MyText(now),
        ),
      ),
    );
  }
}

class MyText extends StatelessWidget {
  final String text;

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

  @override
  Widget build(BuildContext context) {
    void rebuiltMainAppState() {
      _MainAppState? mainAppState = context.findAncestorStateOfType<_MainAppState>();
      assert(mainAppState != null);
      log('try to call method in _MainAppState');
      mainAppState!.callFromOutside();
    }

    return GestureDetector(
      onTap: rebuiltMainAppState,
      child: Text(text),
    );
  }
}
Dsmurat, penubag, Jelican9, CC BY-SA 4.0

คำสั่ง .findAncestorStateOfType<T>() เป็นคำสั่งใช้การประมวลผลมาก เพราะแอปในชีวิตจริงมีโครงสร้างที่ซับซ้อน และมี widget จำนวนมาก ดังนั้นควรใช้งานเมื่อจำเป็นจริง ๆ
ในกรณีที่ต้องการเข้าถึง widget ใด ๆ จากภายนอก แนะนำว่าให้ใช้ GlobalKey() ผูกกับ widget ที่ต้องการ เพื่อสามารถเข้าถึงได้ทันทีไม่ต้องค้นหา

การค้นหา State ใน widget tree ที่ี่ไกลที่สุด

คำสั่ง .findRootAncestorStateOfType<T>() หลักการทำงานจะทำงานเหมือน .findAncestorStateOfType<T>() คือหาตัว State ตาม type ที่ระบุ แต่จะหาตัวที่อยู่ไกลที่สุดแทน

widget ที่เอา BuildContext ไปใช้งาน

ในการใช้งาน widget หลายตัว มักมีการเอา BuildContext ไปใช้เพื่อดึง widget นั้นออกมา ตัวอย่างเช่น