Flutter: InheritedWidget ส่งต่อข้อมูลไปให้ widget ที่อยู่ลึกลงไป

InheritedWidget เป็น widget อีกประเภทที่ออกแบบมาเพื่อให้ widget tree ที่อยู่ใน child ของมัน ไม่ว่าจะลึกแค่ไหน ให้สามารถแชร์ข้อมูลจากตัว InheritedWidget ที่อยู่ด้านบนได้ผ่าน BuildContext ที่ส่งผ่านไปในคำสั่ง build(BuildContext context) ของ widget ที่อยู่ใน child

ตัว InheritedWidget จะตรวจสอบทุกครั้งที่มีการ rebuild ตัวมันเอง หากข้อมูลที่ผูกติดกับตัวมันมีการเปลี่ยนแปลง มันจะทำการ rebuild ตัว widget ที่อยู่ใน child แต่หากข้อมูลไม่เปลี่ยนแปลงมันก็จะไม่ทำอะไร

การผูก data A B C ไปยัง InheritedWidget โดย widget ที่อยู่ลึกลงไปสามารถเข้าถึงข้อมูลได้

การสร้าง InheritedWidget

InheritedWidget ถูกกำหนดมาเป็น immutable ไม่สามารถเปลี่ยนแปลงได้หลังจากที่สร้าง instance ดังนั้นสมาชิกข้อมูลที่จะใช้สำหรับผูกนั้น จะต้องประกาศเป็น final โครงสร้างของ InheritedWidget class มีดังนี้

abstract class InheritedWidget extends ProxyWidget {
  const InheritedWidget({ super.key, required super.child });

  @override
  InheritedElement createElement() => InheritedElement(this);

  @protected
  bool updateShouldNotify(covariant InheritedWidget oldWidget);
}

สิ่งที่จำเป็นในการสร้าง InheritedWidget มีดังนี้

  1. ข้อมูลที่จะไปผูกไว้กับตัว InheritedWidget
  2. ใส่วิธีการทดสอบการเปลี่ยนแปลงข้อมูลในข้อ 1 ในคำสั่ง updateShouldNotify()

ตัวอย่างการสร้าง InheritedWidget ที่ผูกข้อมูล int ชื่อ a และ b

class ExampleA extends InheritedWidget {
  final int a, b; // "a" and "b" can read from any widgets in child
  
  const ExampleA({ required this.a, required this.b, required super.child, super.key }); 
  
  @override
  bool updateShouldNotify(covariant ExampleA oldWidget) {    
    // Flutter framework call every time when rebuild this InheritedWidget
    // Compares the results of old and new data, returning true if there are changes.
    return oldWidget.a != a || oldWidget.b != b;
  }
}



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

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

class _MainAppState extends State<MainApp> {
  int dataA, dataB, dataC;
  
  @override
  Widget build(BuildContext context) {  
    Widget renderWidgetTree = ...; // widgets to be rendered on the screen

    // attach this.dataA and this.dataB to InheritedWidget
    return ExampleA(a: dataA, b: dataB, child: renderWidgetTree);
  }
}

การอ่านข้อมูลจาก InheritedWidget

widget ที่อยู่ใน child ของ InheritedWidget สามารถอ่านข้อมูลที่แชร์มาจาก InheritedWidget ผ่าน BuildContext ด้วยคำสั่ง dependOnInheritedWidgetOfExactType<T extends InheritedWidget>() ผลที่ได้กลับมา หากตัว widget อยู่ใน child ของ InheritedWidget ตัวคำสั่งจะคืนค่าตัว InheritedWidget ที่อยู่ใกล้ที่สุดมาให้ แต่หากไม่พบจะคืนค่า null

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

Dsmurat, penubag, Jelican9, CC BY-SA 4.0

ตัว widget ที่เรียกใช้ dependOnInheritedWidgetOfExactType เมื่อถูก rebuilt จะถูกเรียกคำสั่ง State.didChangeDependencies ด้วย สามารถเขียน code เพิ่มเติมเพื่อจัดการเหตุการณ์ดังกล่าวได้

```dart Widget build(BuildContext context) { ExampleA? exampleA = context.dependOnInheritedWidgetOfExactType(); int dataA = (exampleA == null ? 0 : exampleA.a);
return Text(dataA.toString());

}


## การประยุกต์ใช้งาน
ตัว InheritedWidget ออกมาแบบมาเพื่อช่วยตรวจสอบว่าข้อมูลที่ผูกไว้มีการเปลี่ยนแปลงหรือไม่ ถ้าไม่มีการเปลี่ยนแปลง (จากการถูกเรียกคำสั่ง `updateShouldNotify()`) มันจะไม่ทำอะไร ตัว widget ที่อยู่ใน child ก็จะไม่มีการเปลี่ยนแปลง แต่หากข้อมูลที่ผูกเอาไว้เปลี่ยน มันจะทำการ rebuild ตัว widget ที่ตัว child อัตโนมัติ

### สร้าง InheritedWidget แยกการทำงานของแต่ละ widget
วิธีนี้เป็นการใช้งานตรงไปตรงมา คือ เขียน class ใหม่สำหรับงานใดงานหนึ่ง เช่น มี 2 widget ที่จะใช้แสดงบนหน้าจอและเปลี่ยนแปลงค่าเมื่อข้อมูลถูกเปลี่่ยน

<div class="image"><img src="images/20241113/20241113_0200_demo1.png" class="u-max-full-width" /><div><p>ตัวอย่าง widget 2 ตัวที่แชร์ข้อมูลจาก widget หลัก และปุ่มสำหรับเปลี่ยนข้อมูล</p>
</div></div>

- ข้อมูลที่ต้องเปลี่ยนคือ Text1 Text2 Text3 
- ปุ่ม setState1 เปลี่ยนค่าของ Text1 Text2 
- ปุ่ม setState2 เปลี่ยนค่า Text3
- `_Widget1` `_Widget2` เป็น InheritedWidget
- `_W1` `_W2` เป็น StatefulWidget ที่จะผูกไว้กับ child ของ `_Widget1` `_Widget2`
- ใน InheritedWidget จะใส่ debugPrint เพื่อแสดงค่าผลลัพธ์การตรวจสอบการเปลี่ยนแปลงของข้อมูลใน `updateShouldNotify()`
- ใน StatefulWidget จะใส่ debugPrint เพื่อแจ้งว่าตัว widget ถูกเรียกคำสั่ง `build()`

code ในส่วนของ `_Widget1` และ `_Widget2`

```dart
class _Widget1 extends InheritedWidget {
  final String text1, text2;

  const _Widget1(
      {required this.text1, required this.text2, required super.child, super.key});

  @override
  bool updateShouldNotify(covariant _Widget1 oldWidget) {
    bool r = oldWidget.text1 != text1 || oldWidget.text2 != text2;
    debugPrint('_Widget1 $r');
    return r;
  }
}

class _Widget2 extends InheritedWidget {
  final String text3;

  const _Widget2({required this.text3, required super.child, super.key});

  @override
  bool updateShouldNotify(covariant _Widget2 oldWidget) {
    bool r = oldWidget.text3 != text3;
    debugPrint('_Widget2 $r');
    return r;
  }
}

code ในส่วนของ _W1 และ _W2 ที่จะดึงข้อมูลจาก _Widget1 และ _Widget2 มาแสดง

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

  @override
  State<StatefulWidget> createState() => _StateW1();
}

class _StateW1 extends State {
  @override
  Widget build(BuildContext context) {
    debugPrint('_StateW1.build()');

    String text1, text2;
    _Widget1? widget1 = context.dependOnInheritedWidgetOfExactType<_Widget1>();
    assert(widget1 != null);
    text1 = widget1!.text1;
    text2 = widget1!.text2;

    return Container(
        decoration: BoxDecoration(border: Border.all(color: Colors.red)),
        child: Table(defaultColumnWidth: const IntrinsicColumnWidth(), children: [
          TableRow(children: [const Text('Lable1:'), Text(text1)]),
          TableRow(children: [const Text('Label2:'), Text(text2)])
        ]));
  }
}


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

  @override
  State<StatefulWidget> createState() => _StateW2();
}

class _StateW2 extends State {
  @override
  Widget build(BuildContext context) {
    debugPrint('_StateW2.build()');

    String text3;
    _Widget2? widget2 = context.dependOnInheritedWidgetOfExactType<_Widget2>();
    assert(widget2 != null);
    text3 = widget2!.text3;

    return Container(
      decoration: BoxDecoration(border: Border.all(color: Colors.blue)),
      child: Table(
	    defaultColumnWidth: const IntrinsicColumnWidth(), 
		children: [TableRow(children: [const Text('Lable3:'), Text(text3)])]
	  ));
  }
}

code ในส่วนของ MainApp ที่จะรวมทุกอย่างเข้าด้วยกัน

  • String text1 = 'Text1', text2 = 'Text2', text3 = 'Text3'; ตัวแปรที่จะใช้สำหรับแสดงข้อมูลใน _W1 และ _W2 โดยมี _Widget1 และ _Widget2 ที่เป็น InheritedWidget สำหรับผูกค่าดังกล่าวเอาไว้
  • initState() จะทำการประกาศปุ่มสำหรับกด setState1 และ setState2 ในตัวแปร late Widget button1, button2;
  • build() จะเอาทุกส่วนมาประกอบกันเพื่อสร้างผลลัพธ์ที่ต้องการแสดงบนหน้าจอ
class MainApp extends StatefulWidget {
  const MainApp({super.key});

  @override
  State<StatefulWidget> createState() => _StateMainApp();
}

class _StateMainApp extends State {
  String text1 = 'Text1', text2 = 'Text2', text3 = 'Text3';
  late Widget button1, button2;

  @override
  void initState() {
    super.initState();

    button1 = OutlinedButton(
        key: const ValueKey('button1'),
        onPressed: () => setState(() {
              debugPrint(DateTime.timestamp().toString());
              text1 = DateTime.now().millisecond.toString();
              text2 = DateTime.now().second.toString();
            }),
        child: const Text('setState1'));

    button2 = OutlinedButton(
        key: const ValueKey('button2'),
        onPressed: () => setState(() {
              debugPrint(DateTime.timestamp().toString());
              text3 = DateTime.now().microsecond.toString();
            }),
        child: const Text('setState2'));
  }

  @override
  Widget build(BuildContext context) {
    Widget widget1, widget2, outputBody;

    widget1 = _Widget1(text1: text1, text2: text2, child: const _W1());
    widget2 = _Widget2(text3: text3, child: const _W2());

    outputBody = Column(
      mainAxisSize: MainAxisSize.max,
      children: [
        Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [widget1, widget2]),
        Row(children: [button1, button2]),
      ],
    );

    return MaterialApp(home: Scaffold(body: outputBody));
  }
}

เมื่อทำการ debug จะพบว่าโปรแกรมแสดงออกมาหน้าจอดังนี้

เมื่อเริ่ม debug โปรแกรม

เมื่อลองกดปุ่ม setState1 จะทำคำสั่งเพื่อให้ text1 และ text2 มีค่าเปลี่ยนแปลง โดยใช้เลขของเวลาขณะที่กดปุ่ม

setState(() {
  debugPrint(DateTime.timestamp().toString());
  text1 = DateTime.now().millisecond.toString();
  text2 = DateTime.now().second.toString();
})

เมื่อกดปุ่ม setState1

ผลการทำงานจะเห็นว่าตัว _StateW1 นั้นถูกเรียก build() เพียงตัวเดียว เนื่องจากค่าที่ได้จากการเปรียบเทียบใน updateShouldNotify() ของ _Widget1 เป็น true

เมื่อลองกดปุ่ม setState2 จะทำคำสั่งเพื่อให้ text3 มีค่าเปลี่ยนแปลง โดยใช้เลขของเวลาขณะที่กดปุ่ม

setState(() {
  debugPrint(DateTime.timestamp().toString());
  text3 = DateTime.now().microsecond.toString();
})

เมื่อกดปุ่ม setState2

ผลการทำงานจะเห็นว่าตัว _StateW2 นั้นถูกเรียก build() เพียงตัวเดียว เนื่องจากค่าที่ได้จากการเปรียบเทียบใน updateShouldNotify() ของ _Widget2 เป็น true

จากผลการทำงานจะเห็นได้ว่า InheritedWidget ช่วยดูแลในส่วนของการ rebuild ตัว widget ไม่ต้องเขียนคำสั่ง setState() กับตัว _W1 หรือ _W2 โดยตรง แค่ rebuild ตัว InheritedWidget ถ้าข้อมูลที่ถูกผูกไว้เปลี่ยนแปลง ตัว InheritedWidget ก็จะไปทำการสั่งให้ rebuild ตัว widget ที่อยู่ใน child ให้เอง ส่วนตัวไหนที่ไม่เปลี่ยนก็จะไม่ต้อง rebuild ให้เสียเวลา

สร้าง InheritedWidget ตัวเดียว ควบคุมการทำงาน widget หลายตัว

ในบางครั้งหากงานมันไม่ซับซ้อนมาก และมี StatefulWidget หลายตัวที่ต้องการใช้ InheritedWidget ก็เขียนแบบ InheritedWidget ตัวเดียว ใช้งานกับ StatefulWidget ได้หลายตัวก็สามารถทำได้ ในการออกแบบก็แล้วแต่ความขี้เกียจของคนออกแบบว่าจะเอาแบบเขียนน้อยหรือเขียนมาก แต่แน่ ๆ คือลดการประกาศ class ได้

code ใหม่จะใส่เลข module เข้าไปเพื่อแยกว่าจะใช้กับตัวไหน _W1 หรือ _W2 ตัว logic ใน updateShouldNotify() ตามเลข module ส่วนตัว text1 text2 text3 จะกำหนดให้มี default value เพื่อจะได้ผูกเฉพาะตัวแปรที่ใช้งานเท่านั้น

class _WidgetX extends InheritedWidget {
  final int module; // 1 for _W1 , 2 for _W2
  final String text1, text2, text3;

  const _WidgetX(
      {required this.module,
      this.text1 = 'no data!!',
      this.text2 = 'no data!!',
      this.text3 = 'no data!!',
      required super.child,
      super.key});

  @override
  bool updateShouldNotify(covariant _WidgetX oldWidget) {
    // compare by module number
    if (module == 1) {
      return oldWidget.text1 != text1 || oldWidget.text2 != text2;
    } else {
      return oldWidget.text3 != text3;
    }
  }
}

ในส่วนของ _W1 และ _W2 ก็อ้าง InheritedWidget ชื่อ _WidgetX แทนของเดิม

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

  @override
  State<StatefulWidget> createState() => _StateW1();
}

class _StateW1 extends State {
  @override
  Widget build(BuildContext context) {
    debugPrint('_StateW1.build()');

    String text1, text2;
    _WidgetX? widget1 = context.dependOnInheritedWidgetOfExactType<_WidgetX>();
    assert(widget1 != null);
    text1 = widget1!.text1;
    text2 = widget1.text2;

    return ...; //...same as old code...
  }
}

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

  @override
  State<StatefulWidget> createState() => _StateW2();
}

class _StateW2 extends State {
  @override
  Widget build(BuildContext context) {
    debugPrint('_StateW2.build()');

    String text3;
    _WidgetX? widget1 = context.dependOnInheritedWidgetOfExactType<_WidgetX>();
    assert(widget1 != null);
    text3 = widget1!.text3;

    return ...; //...same as old code...
  }
}

code ในตอนที่สร้าง ที่เพิ่ม module เข้าไป

  @override
  Widget build(BuildContext context) {
    Widget widget1, widget2, outputBody;

    widget1 = _WidgetX(module: 1, text1: text1, text2: text2, child: const _W1());
    widget2 = _WidgetX(module: 2, text3: text3, child: const _W2());

ได้ผลการทำงานเหมือนกัน

สร้างคำสั่ง .of() และ .maybeOf()

จากตัวอย่างที่ผ่านมา หาก widget ที่อยู่ใน child ต้องการเข้าถึง InheritedWidget ที่ถูกผูกไว้ ต้องใช้คำสั่ง .dependOnInheritedWidgetOfExactType<T>() ดูไม่เป็นมิตรต่อการอ่าน code เท่าไหร่ ถ้าดูคำสั่งใน class ของ Flutter framework ที่ inheritance ตัว InheritedWidget มันจะมีคำสั่งแบบ static ชื่อ .of() ที่ทำงานเหมือนกัน แต่สั้นกว่า เช่น RadioTheme.of()

Dsmurat, penubag, Jelican9, CC BY-SA 4.0

ในคำสั่ง .of() จาก RadioTheme.of() จะมีพฤติกรรมว่าหากไม่พบตัว Theme ที่อยู่ใน BuildContext มันจะคืนค่ากลับมาเป็น radioTheme แทน จะเห็นว่าเมื่อมันไม่มีข้อมูลให้ดึงก็จะส่งค่ากลับมาเป็นค่าเริ่มต้นสักอย่างที่ออกแบบไว้

ในกรณีที่ตัว widget อาจไม่ได้ผูกติดกับตัว InheritedWidget และต้องการคืนค่าเป็น null ให้เพิ่มคำสั่ง .maybeOf()

  • .of() คืนค่าเป็น InheritedWidget ตัวที่อยู่ใกล้ที่สุดที่พบ
  • .maybeOf() คืนค่าเป็น InheritedWidget ที่เจอ หรือคืนค่า null หากไม่พบ

code ของ InheritedWidget ที่เพิ่ม .of() และ .maybeOf()

class _WidgetX extends InheritedWidget {
  // -- old code --
  
  // add 2 static methods
  static _WidgetX of(BuildContext context) {
    _WidgetX? result = maybeOf(context);
    assert(result != null); // in debug mode if (result == null) → throw error
    return result!;
  }
  
  static _WidgetX? maybeOf(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<_WidgetX>();
  }
}

ใน widget ที่เรียกข้อมูลที่ผูกไว้กับ InheritedWidget เขียนใหม่ได้ดังนี้

    String text1, text2;
    // _WidgetX? widget1 = context.dependOnInheritedWidgetOfExactType<_WidgetX>();
    _WidgetX widget1 = _WidgetX.of(context);
    text1 = widget1.text1;
    text2 = widget1.text2;

แต่สำหรับ InheritedWidget ที่สร้างขึ้นมาจากตัวอย่างข้างบน คำสั่ง .of() หากไม่พบ InheritedWidget ที่ระบุ การทำงานก็จะล้มเหลวจากคำสั่ง assert(result != null); เพื่อป้องกันการใช้งานไปผูกกับ InheritedWidget ผิดตัวเท่านั้น และมีผลเฉพาะตอน debug หากลืม test แล้วไปใช้งานก็อาจจะเกิด Exception ตามมาได้ ดังนั้นหากต้องการจัดการกับในกรณีที่ตัว widget ไม่ได้ไปผูกไว้กับ InheritedWidget ที่ถูกต้องและได้ค่า null กลับมา ควรเรียกคำสั่ง .maybeOf() จะตรงกับจุดประสงค์มากกว่า และทำให้กลับมาอ่าน code ภายหลังเข้าใจมากกว่าด้วย

ในกรณีที่ต้องการให้ตัว widget อ่านข้อมูลอย่างเดียว ไม่ต้องการให้ rebuild เมื่อข้อมูลเปลี่ยน มีคนอื่นแนะนำว่าให้เพิ่ม rebuild เข้าไปเป็น optional parameter อีกตัว

class _WidgetX extends InheritedWidget {
  // -- old code --
  
  // add 2 static methods
  static _WidgetX of(BuildContext context, {bool rebuild = true}) {
    _WidgetX? result = maybeOf(context, rebuild: rebuild);
    assert(result != null); // in debug mode if (result == null) → throw error
    return result!;
  }
  
  static _WidgetX? maybeOf(BuildContext context, {bool rebuild = true}) {
    if(rebuild) {
      return context.dependOnInheritedWidgetOfExactType<_WidgetX>();
	}
	else {
	  return context.getInheritedWidgetOfExactType<_WidgetX>();
	}
  }
}

โปรแกรมตัวอย่าง

import 'package:flutter/material.dart';

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

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

  @override
  State<StatefulWidget> createState() => _StateMainApp();
}

class _StateMainApp extends State {
  String text1 = 'Text1', text2 = 'Text2', text3 = 'Text3';
  late Widget button1, button2;

  @override
  void initState() {
    super.initState();

    button1 = OutlinedButton(
        key: const ValueKey('button1'),
        onPressed: () => setState(() {
              debugPrint(DateTime.timestamp().toString());
              text1 = DateTime.now().millisecond.toString();
              text2 = DateTime.now().second.toString();
            }),
        child: const Text('setState1'));

    button2 = OutlinedButton(
        key: const ValueKey('button2'),
        onPressed: () => setState(() {
              debugPrint(DateTime.timestamp().toString());
              text3 = DateTime.now().microsecond.toString();
            }),
        child: const Text('setState2'));
  }

  @override
  Widget build(BuildContext context) {
    Widget widget1, widget2, outputBody;

    widget1 = _WidgetX(module: 1, text1: text1, text2: text2, child: const _W1());
    widget2 = _WidgetX(module: 2, text3: text3, child: const _W2());

    outputBody = Column(
      mainAxisSize: MainAxisSize.max,
      children: [
        Row(crossAxisAlignment: CrossAxisAlignment.start, children: [widget1, widget2]),
        Row(children: [button1, button2]),
      ]
    );

    return MaterialApp(home: Scaffold(body: outputBody));
  }
}

class _WidgetX extends InheritedWidget {
  final int module;
  final String text1, text2, text3;

  const _WidgetX(
      {required this.module,
      this.text1 = 'no data!!',
      this.text2 = 'no data!!',
      this.text3 = 'no data!!',
      required super.child,
      super.key});

  @override
  bool updateShouldNotify(covariant _WidgetX oldWidget) {
    if (module == 1) {
      return oldWidget.text1 != text1 || oldWidget.text2 != text2;
    } else {
      return oldWidget.text3 != text3;
    }
  }

  static _WidgetX? maybeOf(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<_WidgetX>();
  }

  static _WidgetX of(BuildContext context) {
    _WidgetX? result = maybeOf(context);
    assert(result != null);
    return result!;
  }
}

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

  @override
  State<StatefulWidget> createState() => _StateW1();
}

class _StateW1 extends State {
  @override
  Widget build(BuildContext context) {
    debugPrint('_StateW1.build()');

    String text1, text2;
    _WidgetX widget1 = _WidgetX.of(context);
    text1 = widget1.text1;
    text2 = widget1.text2;

    return Container(
        decoration: BoxDecoration(border: Border.all(color: Colors.red)),
        child: Table(defaultColumnWidth: IntrinsicColumnWidth(), children: [
          TableRow(children: [Text('Lable1:'), Text(text1)]),
          TableRow(children: [Text('Label2:'), Text(text2)])
        ]));
  }
}

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

  @override
  State<StatefulWidget> createState() => _StateW2();
}

class _StateW2 extends State {
  @override
  Widget build(BuildContext context) {
    debugPrint('_StateW2.build()');

    String text3;
    _WidgetX widget1 = _WidgetX.of(context);
    text3 = widget1.text3;

    return Container(
        decoration: BoxDecoration(border: Border.all(color: Colors.blue)),
        child: Table(defaultColumnWidth: IntrinsicColumnWidth(), children: [
          TableRow(children: [Text('Lable3:'), Text(text3)]),
        ]));
  }
}