自訂 InheritedWidget


在〈第一個 Flutter 專案〉中,建立 MaterialApp 實例時,可以指定 theme 特性,為 MaterialApp 的子元件們套用佈景主題。

...略

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

...略

這邊要討論的不是佈景主題本身。而是佈景主題這類資料,是作為 MaterialApp 的子元件們共用的資料,要讓這種共用資料,在子元件間傳遞,Flutter 的方式是透過 InheritedWidget

為了示範怎麼使用 InheritedWidget,來利用一下〈Scaffold 與 MaterialApp〉中的 AppScaffold,它的 build 方法中的三個 Containercolor 都是寫死的,這邊希望的是,可以藉由 ColorSuite 節點來指定顏色,ColorSuite 的定義如下:

class ColorSuite extends InheritedWidget {
  Color titleColor;
  Color bodyColor;
  Color footerColor;

  ColorSuite({Widget child, Color titleColor, Color bodyColor, Color footerColor}) : super(child: child) {
    this.titleColor = titleColor == null ? Colors.blue : titleColor;
    this.bodyColor = bodyColor == null ? Colors.tealAccent : bodyColor;
    this.footerColor = footerColor == null ? Colors.cyan : footerColor;
  }

  @override
  bool updateShouldNotify(ColorSuite oldWidget) {
    return this.titleColor != oldWidget.titleColor ||
           this.bodyColor != oldWidget.bodyColor ||
           this.footerColor != oldWidget.footerColor;
  }

  static ColorSuite of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<ColorSuite>();
  }
}

你可能會想,幹嘛這麼麻煩,為何不定義為 AppScaffold 的屬性就好?這邊希望的是,被指定給 ColorSuite 的顏色資訊,會在 AppScaffold 的子樹中各階層共享,而不單只是直接子元件共享,因此 ColorSuite 繼承了 InheritedWidget,它也是一種 Widget

Widget > ProxyWidget > InheritedWidget

ColorSuite 可以指定 child 與顏包,繼承 InheritedWidget 後,必須重新定義 updateShouldNotify,決定這個 Widget 被重新建構時,是否通知子樹元件重新建構,在這邊實作的是,當 ColorSuite 重新建構時,若新舊 ColorSuite 上的顏色有不同,就通知子樹元件重新建構,像是使用者操作後更換了顏色的指定,通知子樹元件使用新的顏色重新建構。

在〈自訂 StatelessWidget〉中談過 BuildContext,可以透過它在 Widget 樹中找尋特定的 WidgetColorSuite 定義了個 staticof,這可以方便子元件找到其父裔中的 ColorSuite 實例,稍後就會用到。

接著就可以改造一下〈Scaffold 與 MaterialApp〉中 AppScaffold 等程式碼了,相關的說明寫在註解裡:

import 'package:flutter/material.dart';

void main() => runApp(
  ColorSuite(            // 建立 ColorSuite
    child: AppScaffold(  // 指定 AppScaffold 為子元件
      title: Text(
        'Flutter 筆記',
        textDirection: TextDirection.ltr,
        textAlign: TextAlign.center,
      ),
      body: Text(
          '內容...',
          textDirection: TextDirection.ltr,
          style: TextStyle(color: Colors.black)
      ),
      footer: Text(
        '版權...',
        textDirection: TextDirection.ltr,
        textAlign: TextAlign.center,
      ),
    ),
    // 指定顏色
    titleColor: Colors.green,
    bodyColor: Colors.lightGreenAccent,
    footerColor: Colors.teal,
  ),
);

// 因為實際的 App 會有狀態,AppScaffold 改繼承 StatefulWidget
// Flutter 本身的 Scaffold 也是繼承 StatefulWidget
class AppScaffold extends StatefulWidget {
  final Widget title;
  final Widget body;
  final Widget footer;

  AppScaffold({this.title, this.body, this.footer});

  @override
  State<AppScaffold> createState() => _AppScaffoldState();
}

class _AppScaffoldState extends State<AppScaffold> {
  @override
  Widget build(BuildContext context) {
    // 每次重新建構時,都是找到父裔中的 ColorSuite
    ColorSuite suite = ColorSuite.of(context);

    return Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Container(
            color: suite.titleColor,   // 根據 ColorSuite 設定顏色
            height: 100,
            child: Center(child: widget.title),
          ),
          Expanded(
              child: Container(
                color: suite.bodyColor,  // 根據 ColorSuite 設定顏色
                child: widget.body,
              )
          ),
          Container(
            color: suite.footerColor,    // 根據 ColorSuite 設定顏色
            height: 80,
            child: Center(child: widget.footer),
          )
        ]
    );
  }
}

class ColorSuite extends InheritedWidget {
  Color titleColor;
  Color bodyColor;
  Color footerColor;

  ColorSuite({Widget child, Color titleColor, Color bodyColor, Color footerColor}) : super(child: child) {
    this.titleColor = titleColor == null ? Colors.blue : titleColor;
    this.bodyColor = bodyColor == null ? Colors.tealAccent : bodyColor;
    this.footerColor = footerColor == null ? Colors.cyan : footerColor;
  }

  @override
  bool updateShouldNotify(ColorSuite oldWidget) {
    return this.titleColor != oldWidget.titleColor ||
        this.bodyColor != oldWidget.bodyColor ||
        this.footerColor != oldWidget.footerColor;
  }

  static ColorSuite of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<ColorSuite>();
  }
}

基本上,這就是 Flutter 中 MaterialApp 如何共享佈景主題資訊的原理,可以共享的資訊也可能是可變動的狀態,無論共享的是什麼資訊,重點在於 ColorSuite.of(context),也就是每次重新建構時,都是找到父裔中的 ColorSuite,以便取得顏色資訊。

其實在〈第一個 Flutter 專案〉中,也有個類似的程式片段,也就是在 _MyHomePageStatebuild 中,Theme.of(context).textTheme.display1 就是從父裔中找到共享的佈景主題資料:

class _MyHomePageState extends State<MyHomePage> {
  ...

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.display1, // 找到父裔的共享資料
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,  // 註冊 onPressed 處理器
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), 
    );
  }
}

方才談到,繼承 InheritedWidget 後,必須重新定義 updateShouldNotify,決定這個 Widget 被重新建構時,是否通知子樹元件重新建構,如果子樹元件重建的代價昂貴,你可能不會想重建整個子樹,而是依條件重建子樹中的某些元件,這時可考慮繼承 InheritedModel,之後依 aspect 的指定來決定是否重建子樹中某個部份,詳情可參考 InheritedModel 的 API 文件