Flutter實戰(zhàn) 對話框詳解

2021-03-08 18:03 更新

本節(jié)將詳細(xì)介紹一下 Flutter 中對話框的使用方式、實現(xiàn)原理、樣式定制及狀態(tài)管理。

#7.6.1 使用對話框

對話框本質(zhì)上也是 UI 布局,通常一個對話框會包含標(biāo)題、內(nèi)容,以及一些操作按鈕,為此,Material 庫中提供了一些現(xiàn)成的對話框組件來用于快速的構(gòu)建出一個完整的對話框。

#AlertDialog

下面我們主要介紹一下 Material 庫中的AlertDialog組件,它的構(gòu)造函數(shù)定義如下:

const AlertDialog({
  Key key,
  this.title, //對話框標(biāo)題組件
  this.titlePadding, // 標(biāo)題填充
  this.titleTextStyle, //標(biāo)題文本樣式
  this.content, // 對話框內(nèi)容組件
  this.contentPadding = const EdgeInsets.fromLTRB(24.0, 20.0, 24.0, 24.0), //內(nèi)容的填充
  this.contentTextStyle,// 內(nèi)容文本樣式
  this.actions, // 對話框操作按鈕組
  this.backgroundColor, // 對話框背景色
  this.elevation,// 對話框的陰影
  this.semanticLabel, //對話框語義化標(biāo)簽(用于讀屏軟件)
  this.shape, // 對話框外形
})

參數(shù)都比較簡單,不在贅述。下面我們看一個例子,假如我們要在刪除文件時彈出一個確認(rèn)對話框,該對話框如圖7-10所示:

圖7-10

該對話框樣式代碼如下:

AlertDialog(
  title: Text("提示"),
  content: Text("您確定要刪除當(dāng)前文件嗎?"),
  actions: <Widget>[
    FlatButton(
      child: Text("取消"),
      onPressed: () => Navigator.of(context).pop(), //關(guān)閉對話框
    ),
    FlatButton(
      child: Text("刪除"),
      onPressed: () {
        // ... 執(zhí)行刪除操作
        Navigator.of(context).pop(true); //關(guān)閉對話框
      },
    ),
  ],
);

實現(xiàn)代碼很簡單,不在贅述。唯一需要注意的是我們是通過Navigator.of(context).pop(…)方法來關(guān)閉對話框的,這和路由返回的方式是一致的,并且都可以返回一個結(jié)果數(shù)據(jù)。現(xiàn)在,對話框我們已經(jīng)構(gòu)建好了,那么如何將它彈出來呢?還有對話框返回的數(shù)據(jù)應(yīng)如何被接收呢?這些問題的答案都在showDialog()方法中。

showDialog()是 Material 組件庫提供的一個用于彈出 Material 風(fēng)格對話框的方法,簽名如下:

Future<T> showDialog<T>({
  @required BuildContext context,
  bool barrierDismissible = true, //點擊對話框barrier(遮罩)時是否關(guān)閉它
  WidgetBuilder builder, // 對話框UI的builder
})

該方法只有兩個參數(shù),含義見注釋。該方法返回一個Future,它正是用于接收對話框的返回值:如果我們是通過點擊對話框遮罩關(guān)閉的,則Future的值為null,否則為我們通過Navigator.of(context).pop(result)返回的result值,下面我們看一下整個示例:

//點擊該按鈕后彈出對話框
RaisedButton(
  child: Text("對話框1"),
  onPressed: () async {
    //彈出對話框并等待其關(guān)閉
    bool delete = await showDeleteConfirmDialog1();
    if (delete == null) {
      print("取消刪除");
    } else {
      print("已確認(rèn)刪除");
      //... 刪除文件
    }
  },
),


// 彈出對話框
Future<bool> showDeleteConfirmDialog1() {
  return showDialog<bool>(
    context: context,
    builder: (context) {
      return AlertDialog(
        title: Text("提示"),
        content: Text("您確定要刪除當(dāng)前文件嗎?"),
        actions: <Widget>[
          FlatButton(
            child: Text("取消"),
            onPressed: () => Navigator.of(context).pop(), // 關(guān)閉對話框
          ),
          FlatButton(
            child: Text("刪除"),
            onPressed: () {
              //關(guān)閉對話框并返回true
              Navigator.of(context).pop(true);
            },
          ),
        ],
      );
    },
  );
}

示例運行后,我們點擊對話框“取消”按鈕或遮罩,控制臺就會輸出"取消刪除",如果點擊“刪除”按鈕,控制臺就會輸出"已確認(rèn)刪除"。

注意:如果AlertDialog的內(nèi)容過長,內(nèi)容將會溢出,這在很多時候可能不是我們期望的,所以如果對話框內(nèi)容過長時,可以用SingleChildScrollView將內(nèi)容包裹起來。

#SimpleDialog

SimpleDialog也是 Material 組件庫提供的對話框,它會展示一個列表,用于列表選擇的場景。下面是一個選擇 APP 語言的示例,運行結(jié)果如圖7-11。

圖7-11

實現(xiàn)代碼如下:

Future<void> changeLanguage() async {
  int i = await showDialog<int>(
      context: context,
      builder: (BuildContext context) {
        return SimpleDialog(
          title: const Text('請選擇語言'),
          children: <Widget>[
            SimpleDialogOption(
              onPressed: () {
                // 返回1
                Navigator.pop(context, 1);
              },
              child: Padding(
                padding: const EdgeInsets.symmetric(vertical: 6),
                child: const Text('中文簡體'),
              ),
            ),
            SimpleDialogOption(
              onPressed: () {
                // 返回2
                Navigator.pop(context, 2);
              },
              child: Padding(
                padding: const EdgeInsets.symmetric(vertical: 6),
                child: const Text('美國英語'),
              ),
            ),
          ],
        );
      });


  if (i != null) {
    print("選擇了:${i == 1 ? "中文簡體" : "美國英語"}");
  }
}

列表項組件我們使用了SimpleDialogOption組件來包裝了一下,它相當(dāng)于一個 FlatButton,只不過按鈕文案是左對齊的,并且 padding 較小。上面示例運行后,用戶選擇一種語言后,控制臺就會打印出它。

#Dialog

實際上AlertDialogSimpleDialog都使用了Dialog類。由于AlertDialogSimpleDialog中使用了IntrinsicWidth來嘗試通過子組件的實際尺寸來調(diào)整自身尺寸,這就導(dǎo)致他們的子組件不能是延遲加載模型的組件(如ListView、GridView 、 CustomScrollView等),如下面的代碼運行后會報錯。

AlertDialog(
  content: ListView(
    children: ...//省略
  ),
);

如果我們就是需要嵌套一個ListView應(yīng)該怎么做?這時,我們可以直接使用Dialog類,如:

Dialog(
  child: ListView(
    children: ...//省略
  ),
);

下面我們看一個彈出一個有30個列表項的對話框示例,運行效果如圖7-12所示:

圖7-12

實現(xiàn)代碼如下:

Future<void> showListDialog() async {
  int index = await showDialog<int>(
    context: context,
    builder: (BuildContext context) {
      var child = Column(
        children: <Widget>[
          ListTile(title: Text("請選擇")),
          Expanded(
              child: ListView.builder(
            itemCount: 30,
            itemBuilder: (BuildContext context, int index) {
              return ListTile(
                title: Text("$index"),
                onTap: () => Navigator.of(context).pop(index),
              );
            },
          )),
        ],
      );
      //使用AlertDialog會報錯
      //return AlertDialog(content: child);
      return Dialog(child: child);
    },
  );
  if (index != null) {
    print("點擊了:$index");
  }
}

現(xiàn)在,我們己經(jīng)介紹完了AlertDialog、SimpleDialog以及Dialog。上面的示例中,我們在調(diào)用showDialog時,在builder中都是構(gòu)建了這三個對話框組件的一種,可能有些讀者會慣性的以為在builder中只能返回這三者之一,其實這不是必須的!就拿Dialog的示例來舉例,我們完全可以用下面的代碼來替代Dialog

// return Dialog(child: child) 
return UnconstrainedBox(
  constrainedAxis: Axis.vertical,
  child: ConstrainedBox(
    constraints: BoxConstraints(maxWidth: 280),
    child: Material(
      child: child,
      type: MaterialType.card,
    ),
  ),
);

上面代碼運行后可以實現(xiàn)一樣的效果?,F(xiàn)在我們總結(jié)一下:AlertDialog、SimpleDialog以及Dialog是 Material 組件庫提供的三種對話框,旨在幫助開發(fā)者快速構(gòu)建出符合 Material 設(shè)計規(guī)范的對話框,但讀者完全可以自定義對話框樣式,因此,我們?nèi)匀豢梢詫崿F(xiàn)各種樣式的對話框,這樣即帶來了易用性,又有很強的擴展性。

#7.6.2 對話框打開動畫及遮罩

我們可以把對話框分為內(nèi)部樣式和外部樣式兩部分。內(nèi)部樣式指對話框中顯示的具體內(nèi)容,這部分內(nèi)容我們已經(jīng)在上面介紹過了;外部樣式包含對話框遮罩樣式、打開動畫等,本節(jié)主要介紹如何自定義這些外部樣式。

關(guān)于動畫相關(guān)內(nèi)容我們將在本書后面章節(jié)介紹,下面內(nèi)容讀者可以先了解一下(不必深究),讀者可以在學(xué)習(xí)完動畫相關(guān)內(nèi)容后再回頭來看。

我們已經(jīng)介紹過了showDialog方法,它是 Material 組件庫中提供的一個打開 Material 風(fēng)格對話框的方法。那如何打開一個普通風(fēng)格的對話框呢(非 Material 風(fēng)格)? Flutter 提供了一個showGeneralDialog方法,簽名如下:

Future<T> showGeneralDialog<T>({
  @required BuildContext context,
  @required RoutePageBuilder pageBuilder, //構(gòu)建對話框內(nèi)部UI
  bool barrierDismissible, //點擊遮罩是否關(guān)閉對話框
  String barrierLabel, // 語義化標(biāo)簽(用于讀屏軟件)
  Color barrierColor, // 遮罩顏色
  Duration transitionDuration, // 對話框打開/關(guān)閉的動畫時長
  RouteTransitionsBuilder transitionBuilder, // 對話框打開/關(guān)閉的動畫
})

實際上,showDialog方法正是showGeneralDialog的一個封裝,定制了 Material 風(fēng)格對話框的遮罩顏色和動畫。Material 風(fēng)格對話框打開/關(guān)閉動畫是一個Fade(漸隱漸顯)動畫,如果我們想使用一個縮放動畫就可以通過transitionBuilder來自定義。下面我們自己封裝一個showCustomDialog方法,它定制的對話框動畫為縮放動畫,并同時制定遮罩顏色為Colors.black87

Future<T> showCustomDialog<T>({
  @required BuildContext context,
  bool barrierDismissible = true,
  WidgetBuilder builder,
}) {
  final ThemeData theme = Theme.of(context, shadowThemeOnly: true);
  return showGeneralDialog(
    context: context,
    pageBuilder: (BuildContext buildContext, Animation<double> animation,
        Animation<double> secondaryAnimation) {
      final Widget pageChild = Builder(builder: builder);
      return SafeArea(
        child: Builder(builder: (BuildContext context) {
          return theme != null
              ? Theme(data: theme, child: pageChild)
              : pageChild;
        }),
      );
    },
    barrierDismissible: barrierDismissible,
    barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
    barrierColor: Colors.black87, // 自定義遮罩顏色
    transitionDuration: const Duration(milliseconds: 150),
    transitionBuilder: _buildMaterialDialogTransitions,
  );
}


Widget _buildMaterialDialogTransitions(
    BuildContext context,
    Animation<double> animation,
    Animation<double> secondaryAnimation,
    Widget child) {
  // 使用縮放動畫
  return ScaleTransition(
    scale: CurvedAnimation(
      parent: animation,
      curve: Curves.easeOut,
    ),
    child: child,
  );
}

現(xiàn)在,我們使用showCustomDialog打開文件刪除確認(rèn)對話框,代碼如下:

... //省略無關(guān)代碼
showCustomDialog<bool>(
  context: context,
  builder: (context) {
    return AlertDialog(
      title: Text("提示"),
      content: Text("您確定要刪除當(dāng)前文件嗎?"),
      actions: <Widget>[
        FlatButton(
          child: Text("取消"),
          onPressed: () => Navigator.of(context).pop(),
        ),
        FlatButton(
          child: Text("刪除"),
          onPressed: () {
            // 執(zhí)行刪除操作
            Navigator.of(context).pop(true);
          },
        ),
      ],
    );
  },
);

運行效果如圖7-13所示:

圖7-13

可以發(fā)現(xiàn),遮罩顏色比通過showDialog方法打開的對話框更深。另外對話框打開/關(guān)閉的動畫已變?yōu)榭s放動畫了,讀者可以親自運行示例查看效果。

#7.6.3 對話框?qū)崿F(xiàn)原理

我們已經(jīng)知道對話框最終都是由showGeneralDialog方法打開的,我們來看看它的具體實現(xiàn):

Future<T> showGeneralDialog<T>({
  @required BuildContext context,
  @required RoutePageBuilder pageBuilder,
  bool barrierDismissible,
  String barrierLabel,
  Color barrierColor,
  Duration transitionDuration,
  RouteTransitionsBuilder transitionBuilder,
}) {
  return Navigator.of(context, rootNavigator: true).push<T>(_DialogRoute<T>(
    pageBuilder: pageBuilder,
    barrierDismissible: barrierDismissible,
    barrierLabel: barrierLabel,
    barrierColor: barrierColor,
    transitionDuration: transitionDuration,
    transitionBuilder: transitionBuilder,
  ));
}

實現(xiàn)很簡單,直接調(diào)用Navigatorpush方法打開了一個新的對話框路由_DialogRoute,然后返回了push的返回值。可見對話框?qū)嶋H上正是通過路由的形式實現(xiàn)的,這也是為什么我們可以使用Navigatorpop 方法來退出對話框的原因。關(guān)于對話框的樣式定制在_DialogRoute中,沒有什么新的東西,讀者可以自行查看。

#7.6.4 對話框狀態(tài)管理

我們在用戶選擇刪除一個文件時,會詢問是否刪除此文件;在用戶選擇一個文件夾是,應(yīng)該再讓用戶確認(rèn)是否刪除子文件夾。為了在用戶選擇了文件夾時避免二次彈窗確認(rèn)是否刪除子目錄,我們在確認(rèn)對話框底部添加一個“同時刪除子目錄?”的復(fù)選框,如圖7-14所示:

圖7-14

現(xiàn)在就有一個問題:如何管理復(fù)選框的選中狀態(tài)?習(xí)慣上,我們會在路由頁的 State 中來管理選中狀態(tài),我們可能會寫出如下這樣的代碼:

class _DialogRouteState extends State<DialogRoute> {
  bool withTree = false; // 復(fù)選框選中狀態(tài)


  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        RaisedButton(
          child: Text("對話框2"),
          onPressed: () async {
            bool delete = await showDeleteConfirmDialog2();
            if (delete == null) {
              print("取消刪除");
            } else {
              print("同時刪除子目錄: $delete");
            }
          },
        ),
      ],
    );
  }


  Future<bool> showDeleteConfirmDialog2() {
    withTree = false; // 默認(rèn)復(fù)選框不選中
    return showDialog<bool>(
      context: context,
      builder: (context) {
        return AlertDialog(
          title: Text("提示"),
          content: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: <Widget>[
              Text("您確定要刪除當(dāng)前文件嗎?"),
              Row(
                children: <Widget>[
                  Text("同時刪除子目錄?"),
                  Checkbox(
                    value: withTree,
                    onChanged: (bool value) {
                      //復(fù)選框選中狀態(tài)發(fā)生變化時重新構(gòu)建UI
                      setState(() {
                        //更新復(fù)選框狀態(tài)
                        withTree = !withTree;
                      });
                    },
                  ),
                ],
              ),
            ],
          ),
          actions: <Widget>[
            FlatButton(
              child: Text("取消"),
              onPressed: () => Navigator.of(context).pop(),
            ),
            FlatButton(
              child: Text("刪除"),
              onPressed: () {
                //執(zhí)行刪除操作
                Navigator.of(context).pop(withTree);
              },
            ),
          ],
        );
      },
    );
  }
}

然后,當(dāng)我們運行上面的代碼時我們會發(fā)現(xiàn)復(fù)選框根本選不中!為什么會這樣呢?其實原因很簡單,我們知道setState方法只會針對當(dāng)前 context 的子樹重新 build,但是我們的對話框并不是在_DialogRouteStatebuild 方法中構(gòu)建的,而是通過showDialog單獨構(gòu)建的,所以在_DialogRouteState的context中調(diào)用setState是無法影響通過showDialog構(gòu)建的 UI 的。另外,我們可以從另外一個角度來理解這個現(xiàn)象,前面說過對話框也是通過路由的方式來實現(xiàn)的,那么上面的代碼實際上就等同于企圖在父路由中調(diào)用setState來讓子路由更新,這顯然是不行的!簡爾言之,根本原因就是 context 不對。那如何讓復(fù)選框可點擊呢?通常有如下三種方法:

#單獨抽離出StatefulWidget

既然是 context 不對,那么直接的思路就是將復(fù)選框的選中邏輯單獨封裝成一個StatefulWidget,然后在其內(nèi)部管理復(fù)選狀態(tài)。我們先來看看這種方法,下面是實現(xiàn)代碼:

// 單獨封裝一個內(nèi)部管理選中狀態(tài)的復(fù)選框組件
class DialogCheckbox extends StatefulWidget {
  DialogCheckbox({
    Key key,
    this.value,
    @required this.onChanged,
  });


  final ValueChanged<bool> onChanged;
  final bool value;


  @override
  _DialogCheckboxState createState() => _DialogCheckboxState();
}


class _DialogCheckboxState extends State<DialogCheckbox> {
  bool value;


  @override
  void initState() {
    value = widget.value;
    super.initState();
  }


  @override
  Widget build(BuildContext context) {
    return Checkbox(
      value: value,
      onChanged: (v) {
        //將選中狀態(tài)通過事件的形式拋出
        widget.onChanged(v);
        setState(() {
          //更新自身選中狀態(tài)
          value = v;
        });
      },
    );
  }
}

下面是彈出對話框的代碼:

Future<bool> showDeleteConfirmDialog3() {
  bool _withTree = false; //記錄復(fù)選框是否選中
  return showDialog<bool>(
    context: context,
    builder: (context) {
      return AlertDialog(
        title: Text("提示"),
        content: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Text("您確定要刪除當(dāng)前文件嗎?"),
            Row(
              children: <Widget>[
                Text("同時刪除子目錄?"),
                DialogCheckbox(
                  value: _withTree, //默認(rèn)不選中
                  onChanged: (bool value) {
                    //更新選中狀態(tài)
                    _withTree = !_withTree;
                  },
                ),
              ],
            ),
          ],
        ),
        actions: <Widget>[
          FlatButton(
            child: Text("取消"),
            onPressed: () => Navigator.of(context).pop(),
          ),
          FlatButton(
            child: Text("刪除"),
            onPressed: () {
              // 將選中狀態(tài)返回
              Navigator.of(context).pop(_withTree);
            },
          ),
        ],
      );
    },
  );
}

最后,就是使用:

RaisedButton(
  child: Text("話框3(復(fù)選框可點擊)"),
  onPressed: () async {
    //彈出刪除確認(rèn)對話框,等待用戶確認(rèn)
    bool deleteTree = await showDeleteConfirmDialog3();
    if (deleteTree == null) {
      print("取消刪除");
    } else {
      print("同時刪除子目錄: $deleteTree");
    }
  },
),

運行后效果如圖7-15所示:

圖7-15

可見復(fù)選框能選中了,點擊“取消”或“刪除”后,控制臺就會打印出最終的確認(rèn)狀態(tài)。

#使用StatefulBuilder方法

上面的方法雖然能解決對話框狀態(tài)更新的問題,但是有一個明顯的缺點——對話框上所有可能會改變狀態(tài)的組件都得單獨封裝在一個在內(nèi)部管理狀態(tài)的StatefulWidget中,這樣不僅麻煩,而且復(fù)用性不大。因此,我們來想想能不能找到一種更簡單的方法?上面的方法本質(zhì)上就是將對話框的狀態(tài)置于一個StatefulWidget的上下文中,由StatefulWidget在內(nèi)部管理,那么我們有沒有辦法在不需要單獨抽離組件的情況下創(chuàng)建一個StatefulWidget的上下文呢?想到這里,我們可以從Builder組件的實現(xiàn)獲得靈感。在前面介紹過Builder組件可以獲得組件所在位置的真正的 Context,那它是怎么實現(xiàn)的呢,我們看看它的源碼:

class Builder extends StatelessWidget {
  const Builder({
    Key key,
    @required this.builder,
  }) : assert(builder != null),
       super(key: key);
  final WidgetBuilder builder;


  @override
  Widget build(BuildContext context) => builder(context);
}

可以看到,Builder實際上只是繼承了StatelessWidget,然后在build方法中獲取當(dāng)前 context 后將構(gòu)建方法代理到了builder回調(diào),可見,Builder實際上是獲取了StatelessWidget 的上下文(context)。那么我們能否用相同的方法獲取StatefulWidget 的上下文,并代理其build方法呢?下面我們照貓畫虎,來封裝一個StatefulBuilder方法:

class StatefulBuilder extends StatefulWidget {
  const StatefulBuilder({
    Key key,
    @required this.builder,
  }) : assert(builder != null),
       super(key: key);


  final StatefulWidgetBuilder builder;


  @override
  _StatefulBuilderState createState() => _StatefulBuilderState();
}


class _StatefulBuilderState extends State<StatefulBuilder> {
  @override
  Widget build(BuildContext context) => widget.builder(context, setState);
}

代碼很簡單,StatefulBuilder獲取了StatefulWidget的上下文,并代理了其構(gòu)建過程。下面我們就可以通過StatefulBuilder來重構(gòu)上面的代碼了(變動只在DialogCheckbox部分):

... //省略無關(guān)代碼
Row(
  children: <Widget>[
    Text("同時刪除子目錄?"),
    //使用StatefulBuilder來構(gòu)建StatefulWidget上下文
    StatefulBuilder(
      builder: (context, _setState) {
        return Checkbox(
          value: _withTree, //默認(rèn)不選中
          onChanged: (bool value) {
            //_setState方法實際就是該StatefulWidget的setState方法,
            //調(diào)用后builder方法會重新被調(diào)用
            _setState(() {
              //更新選中狀態(tài)
              _withTree = !_withTree;
            });
          },
        );
      },
    ),
  ],
),

實際上,這種方法本質(zhì)上就是子組件通知父組件(StatefulWidget)重新 build 子組件本身來實現(xiàn)UI更新的,讀者可以對比代碼理解。實際上StatefulBuilder正是 Flutter SDK 中提供的一個類,它和Builder的原理是一樣的,在此,提醒讀者一定要將StatefulBuilderBuilder理解透徹,因為它們在 Flutter 中是非常實用的。

#精妙的解法

是否還有更簡單的解決方案呢?要確認(rèn)這個問題,我們就得先搞清楚UI是怎么更新的,我們知道在調(diào)用setState方法后StatefulWidget就會重新 build,那setState方法做了什么呢?我們能不能從中找到方法?順著這個思路,我們就得看一下setState的核心源碼:

void setState(VoidCallback fn) {
  ... //省略無關(guān)代碼
  _element.markNeedsBuild();
}

可以發(fā)現(xiàn),setState中調(diào)用了ElementmarkNeedsBuild()方法,我們前面說過,F(xiàn)lutter 是一個響應(yīng)式框架,要更新 UI 只需改變狀態(tài)后通知框架頁面需要重構(gòu)即可,而ElementmarkNeedsBuild()方法正是來實現(xiàn)這個功能的!markNeedsBuild()方法會將當(dāng)前的Element對象標(biāo)記為“dirty”(臟的),在每一個 Frame,F(xiàn)lutter 都會重新構(gòu)建被標(biāo)記為“dirty”Element對象。既然如此,我們有沒有辦法獲取到對話框內(nèi)部UI的Element對象,然后將其標(biāo)示為為“dirty”呢?答案是肯定的!我們可以通過 Context 來得到Element對象,至于ElementContext的關(guān)系我們將會在后面“Flutter 核心原理”一章中再深入介紹,現(xiàn)在只需要簡單的認(rèn)為:在組件樹中,context實際上就是Element對象的引用。知道這個后,那么解決的方案就呼之欲出了,我們可以通過如下方式來讓復(fù)選框可以更新:

Future<bool> showDeleteConfirmDialog4() {
  bool _withTree = false;
  return showDialog<bool>(
    context: context,
    builder: (context) {
      return AlertDialog(
        title: Text("提示"),
        content: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Text("您確定要刪除當(dāng)前文件嗎?"),
            Row(
              children: <Widget>[
                Text("同時刪除子目錄?"),
                Checkbox( // 依然使用Checkbox組件
                  value: _withTree,
                  onChanged: (bool value) {
                    // 此時context為對話框UI的根Element,我們 
                    // 直接將對話框UI對應(yīng)的Element標(biāo)記為dirty
                    (context as Element).markNeedsBuild();
                    _withTree = !_withTree;
                  },
                ),
              ],
            ),
          ],
        ),
        actions: <Widget>[
          FlatButton(
            child: Text("取消"),
            onPressed: () => Navigator.of(context).pop(),
          ),
          FlatButton(
            child: Text("刪除"),
            onPressed: () {
              // 執(zhí)行刪除操作
              Navigator.of(context).pop(_withTree);
            },
          ),
        ],
      );
    },
  );
}

上面的代碼運行后復(fù)選框也可以正常選中??梢钥吹剑覀冎挥昧艘恍写a便解決了這個問題!當(dāng)然上面的代碼并不是最優(yōu),因為我們只需要更新復(fù)選框的狀態(tài),而此時的context我們用的是對話框的根context,所以會導(dǎo)致整個對話框 UI 組件全部 rebuild,因此最好的做法是將context的“范圍”縮小,也就是說只將Checkbox的 Element 標(biāo)記為dirty,優(yōu)化后的代碼為:

... //省略無關(guān)代碼
Row(
  children: <Widget>[
    Text("同時刪除子目錄?"),
    // 通過Builder來獲得構(gòu)建Checkbox的`context`,
    // 這是一種常用的縮小`context`范圍的方式
    Builder(
      builder: (BuildContext context) {
        return Checkbox(
          value: _withTree,
          onChanged: (bool value) {
            (context as Element).markNeedsBuild();
            _withTree = !_withTree;
          },
        );
      },
    ),
  ],
),

#7.6.5 其它類型的對話框

#底部菜單列表

showModalBottomSheet方法可以彈出一個 Material 風(fēng)格的底部菜單列表模態(tài)對話框,示例如下:

// 彈出底部菜單列表模態(tài)對話框
Future<int> _showModalBottomSheet() {
  return showModalBottomSheet<int>(
    context: context,
    builder: (BuildContext context) {
      return ListView.builder(
        itemCount: 30,
        itemBuilder: (BuildContext context, int index) {
          return ListTile(
            title: Text("$index"),
            onTap: () => Navigator.of(context).pop(index),
          );
        },
      );
    },
  );
}

點擊按鈕,彈出該對話框:

RaisedButton(
  child: Text("顯示底部菜單列表"),
  onPressed: () async {
    int type = await _showModalBottomSheet();
    print(type);
  },
),

運行后效果如圖7-16所示:

圖7-16

showModalBottomSheet的實現(xiàn)原理和showGeneralDialog實現(xiàn)原理相同,都是通過路由的方式來實現(xiàn)的,讀者可以查看源碼對比。但值得一提的是還有一個showBottomSheet方法,該方法會從設(shè)備底部向上彈出一個全屏的菜單列表,示例如下:

// 返回的是一個controller
PersistentBottomSheetController<int> _showBottomSheet() {
  return showBottomSheet<int>(
    context: context,
    builder: (BuildContext context) {
      return ListView.builder(
        itemCount: 30,
        itemBuilder: (BuildContext context, int index) {
          return ListTile(
            title: Text("$index"),
            onTap: (){
              // do something
              print("$index");
              Navigator.of(context).pop();
            },
          );
        },
      );
    },
  );
}

運行效果如圖7-17所示:

圖7-17

PersistentBottomSheetController中包含了一些控制對話框的方法比如close方法可以關(guān)閉該對話框,功能比較簡單,讀者可以自行查看源碼。唯一需要注意的是,showBottomSheet和我們上面介紹的彈出對話框的方法原理不同:showBottomSheet是調(diào)用widget樹頂部的Scaffold組件的ScaffoldStateshowBottomSheet同名方法實現(xiàn),也就是說要調(diào)用showBottomSheet方法就必須得保證父級組件中有Scaffold。

#Loading框

其實Loading框可以直接通過showDialog+AlertDialog來自定義:

showLoadingDialog() {
  showDialog(
    context: context,
    barrierDismissible: false, //點擊遮罩不關(guān)閉對話框
    builder: (context) {
      return AlertDialog(
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            CircularProgressIndicator(),
            Padding(
              padding: const EdgeInsets.only(top: 26.0),
              child: Text("正在加載,請稍后..."),
            )
          ],
        ),
      );
    },
  );
}

顯示效果如圖7-18所示:

圖7-18

如果我們嫌 Loading 框太寬,想自定義對話框?qū)挾?,這時只使用SizedBoxConstrainedBox是不行的,原因是showDialog中已經(jīng)給對話框設(shè)置了寬度限制,根據(jù)我們在第五章“尺寸限制類容器”一節(jié)中所述,我們可以使用UnconstrainedBox先抵消showDialog對寬度的限制,然后再使用SizedBox指定寬度,代碼如下:

... //省略無關(guān)代碼
UnconstrainedBox(
  constrainedAxis: Axis.vertical,
  child: SizedBox(
    width: 280,
    child: AlertDialog(
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          CircularProgressIndicator(value: .8,),
          Padding(
            padding: const EdgeInsets.only(top: 26.0),
            child: Text("正在加載,請稍后..."),
          )
        ],
      ),
    ),
  ),
);

代碼運行后,效果如圖7-19所示:

圖7-19

#日歷選擇

我們先看一下 Material 風(fēng)格的日歷選擇器,如圖7-20所示:

圖7-20

實現(xiàn)代碼:

Future<DateTime> _showDatePicker1() {
  var date = DateTime.now();
  return showDatePicker(
    context: context,
    initialDate: date,
    firstDate: date,
    lastDate: date.add( //未來30天可選
      Duration(days: 30),
    ),
  );
}

iOS 風(fēng)格的日歷選擇器需要使用showCupertinoModalPopup方法和CupertinoDatePicker組件來實現(xiàn):

Future<DateTime> _showDatePicker2() {
  var date = DateTime.now();
  return showCupertinoModalPopup(
    context: context,
    builder: (ctx) {
      return SizedBox(
        height: 200,
        child: CupertinoDatePicker(
          mode: CupertinoDatePickerMode.dateAndTime,
          minimumDate: date,
          maximumDate: date.add(
            Duration(days: 30),
          ),
          maximumYear: date.year + 1,
          onDateTimeChanged: (DateTime value) {
            print(value);
          },
        ),
      );
    },
  );
}

運行效果如圖7-21所示:

圖7-21

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號