Flutter 全域性彈窗

YYDev發表於2019-03-30

背景

  • 開發flutter-ui過程中,遇到了全域性彈窗問題
  • 友好的互動介面,能夠產生更好的使用者體驗,比如查詢介面較久或需要耗時處理程式時,給個loading效果。
  • flutter元件中showDialog彈窗元件,能滿足彈窗需求,但使用過程可能不太順手。
  • 原始碼地址

將從以下幾點來分析與實現介面請求前的彈窗效果

  • showDialog介紹
  • 實現簡單彈窗
  • 接入dio package
  • 彈窗關鍵點分析
  • 實現全域性儲存context
  • 實現dio請求時loading
  • 併發請求時loading處理

本文相關連結

準備

  • 新建專案flutter create xxx (有專案就用自己專案,影響的地方不大)
  • pubspec.yaml增加dio依賴包
dependencies:
  flutter:
    sdk: flutter
  dio: ^2.1.0 # dio依賴包 2019/03/30
複製程式碼
  • 建立http資料夾與http/index.dart, http/loading.dart檔案
lib
  |--http   #檔案
	  |--index.dart  # dio
	  |--loading.dart  #loading
  |--main.dart #入口 
複製程式碼

showDialog介紹

showDialog{
  @required BuildContext context,
  bool barrierDismissible = true,
  @Deprecated(
    'Instead of using the "child" argument, return the child from a closure '
    'provided to the "builder" argument. This will ensure that the BuildContext '
    'is appropriate for widgets built in the dialog.'
  ) Widget child,
  WidgetBuilder builder,
}
複製程式碼
  • builder:建立彈窗的元件,這些可以建立需要的互動內容
  • context:上下文,這裡只要打通了,就能實現全域性。這是關鍵

檢視showDialog原始碼,呼叫順序是
showDialog -> showGeneralDialog -> Navigator.of(context, rootNavigator: true).push() context作為引數,作用是提供給了Navigator.of(context, rootNavigator: true).push使用

  • showGeneralDialog的註釋內容,介紹了關閉彈窗的重點
/// The dialog route created by this method is pushed to the root navigator.
/// If the application has multiple [Navigator] objects, it may be necessary to
/// call `Navigator.of(context, rootNavigator: true).pop(result)` to close the
/// dialog rather than just `Navigator.pop(context, result)`.
///
/// See also:
///
///  * [showDialog], which displays a Material-style dialog.
///  * [showCupertinoDialog], which displays an iOS-style dialog.
複製程式碼

實現簡單彈窗

  • demo中floatingActionButton中_incrementCounter事件,事件觸發後顯示彈窗,具體內容可結合程式碼註解

  void _incrementCounter() {
    showDialog(
      context: context,
      builder: (context) {
        // 用Scaffold返回顯示的內容,能跟隨主題
        return Scaffold(
          backgroundColor: Colors.transparent, // 設定透明背影
          body: Center( // 居中顯示
            child: Column( // 定義垂直佈局
              mainAxisAlignment: MainAxisAlignment.center, // 主軸居中佈局,相關介紹可以搜下flutter-ui的內容
              children: <Widget>[
                // CircularProgressIndicator自帶loading效果,需要寬高設定可在外加一層sizedbox,設定寬高即可
                CircularProgressIndicator(),
                SizedBox(
                  height: 10,
                ),
                Text('loading'), // 文字
                // 觸發關閉視窗
                RaisedButton(
                  child: Text('close dialog'),
                  onPressed: () {
                    print('close');
                  },
                ),
              ],
            ), // 自帶loading效果,需要寬高設定可在外加一層sizedbox,設定寬高即可
          ),
        );
      },
    );
  }
複製程式碼
Flutter 全域性彈窗

點選後出來了彈窗了,這一切還沒有結束,只是個開始。 關閉彈窗,點選物理返回鍵就後退了。(尷尬不) 在上面showDialog介紹中最後提供了一段關於showGeneralDialog的註釋程式碼,若需要關閉視窗,可以通過呼叫 Navigator.of(context, rootNavigator: true).pop(result)。 修改下RaisedButton事件內容

RaisedButton(
  child: Text('close dialog'),
  onPressed: () {
    Navigator.of(context, rootNavigator: true).pop();
  },
),
複製程式碼

這樣彈窗可以通過按鈕控制關閉了

接入dio

在觸發介面請求時,先呼叫showDialog觸發彈窗,介面請求完成關閉視窗

  • http/index.dart 實現get介面請求,同時增加interceptors,接入onRequest、onResponse、onError函式,虛擬碼如下
import 'package:dio/dio.dart' show Dio, DioError, InterceptorsWrapper, Response;

Dio dio;

class Http {
  static Dio instance() {
    if (dio != null) {
      return dio;// 例項化dio
    }
    dio = new Dio();
    // 增加攔截器
    dio.interceptors.add(
      InterceptorsWrapper(
        // 介面請求前資料處理
        onRequest: (options) {
          return options;
        },
        // 介面成功返回時處理
        onResponse: (Response resp) {
          return resp;
        },
        // 介面報錯時處理
        onError: (DioError error) {
          return error;
        },
      ),
    );
    return dio;
  }

  /**
   * get介面請求
   * path: 介面地址
   */
  static get(path) {
    return instance().get(path);
  }
}

複製程式碼
  • http/loading.dart 實現彈窗,dio在onRequest時呼叫 Loading.before,onResponse/onError呼叫Loading。complete完畢視窗,虛擬碼如下
import 'package:flutter/material.dart';

class Loading {
  static void before(text) {
    // 請求前顯示彈窗
    // showDialog();
  }

  static void complete() {
    // 完成後關閉loading視窗
	// Navigator.of(context, rootNavigator: true).pop();
  }
}


// 彈窗內容
class Index extends StatelessWidget {
  final String text;

  Index({Key key, @required this.text}):super(key: key);

  @override
  Widget build(BuildContext context) {
    return xxx;
  }
}

複製程式碼

彈窗關鍵點分析

context

解決了showDialog中的context,即能實現彈窗任意呼叫,不侷限於dio請求。context不是任意的,只在Scaffold中能夠使Navigator.of(context)中找得到Navigator物件。(剛接觸時很多時候會覺得同樣都是context,為啥呼叫of(context)會報錯。)

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    print('main${Navigator.of(context)}'); // !!!這裡發報錯!!!
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
  ... // 省略其它內容
}

錯誤內容如下:
I/flutter ( 9137): Navigator operation requested with a context that does not include a Navigator.
I/flutter ( 9137): The context used to push or pop routes from the Navigator must be that of a widget that is a
I/flutter ( 9137): descendant of a Navigator widget.

即在MaterialApp中未能找到。

複製程式碼

讓我們在_MyHomePageState中檢視下build返回Scaffold時,context物件內容是否有Navigator物件

class _MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    print('home${Navigator.of(context)}');  // 正常列印NavigatorState#600dc(tickers: tracking 1 ticker)
    
  }
  ... // 省略其它內容
}
複製程式碼

所以全域性彈窗的context,需要scaffold中的context。專案初始時在build第一次返回scaffold元件前,把context全域性儲存起來,提供能showDialog使用。(第一次返回沒有侷限,只要在呼叫showDiolog呼叫前全域性儲存context即可,自行而定。),至此可以解決了dio中呼叫showDialog時,context經常運用錯誤導致報錯問題。

擴充套件分析flutter-ui中與provide結合使用後遇到的context。 flutter-ui先通過Store.connect封裝provide資料層,這裡的context返回的provide例項的上下文,接著return MaterialApp中,這裡的上下文也是MaterialApp本身的,這些都沒法使用Navigator物件,最終在build Scaffold時,通過Provide資料管理提前setWidgetCtx,全域性儲存Scaffold提供的context。

實現全域性儲存context

1 在http/loading.dart檔案的Loading類暫存一個context靜態變數。

class Loading {
  static dynamic ctx;
  static void before(text) {
    // 請求前顯示彈窗
	// showDialog(context: ctx, builder: (context) {
	//   return Index(text:text);
	// );
  }

  static void complete() {
    // 完成後關閉loading視窗
	// Navigator.of(ctx, rootNavigator: true).pop();
  }
}
複製程式碼

2 在main.dart中_MyHomePageState build函式返回前注入Loading.ctx = context; 為了便於區別,我們使用ctx來儲存

import 'package:flutter_loading/http/loading.dart' show Loading;
... // 省略部分程式碼

class _MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    print('home  $context');
    print('home  ${Navigator.of(context)}');
    Loading.ctx = context; // 注入context
    return ...;
  }
複製程式碼

實現dio請求時loading

上述內容解決了context關鍵點。接下來實現介面互動。點選按鈕,呼叫dio.get介面拉取資料,在onRequest前呼叫Loading.before(); onResponse呼叫Loading.complete()進行關閉。

import 'package:flutter/material.dart';

class Loading {
  static dynamic ctx;

  static void before(text) {
    // 請求前顯示彈窗
    showDialog(
      context: ctx,
      builder: (context) {
        return Index(text: text);
      },
    );
  }

  static void complete() {
    // 完成後關閉loading視窗
    Navigator.of(ctx, rootNavigator: true).pop();
  }
}
複製程式碼

修改下dio的內容,介面請求返回較快時,為了看到loading效果,故在onResponse增加了Future.delayed,延遲3s返回資料。

import 'package:dio/dio.dart' show Dio, DioError, InterceptorsWrapper, Response;
import 'loading.dart' show Loading;
Dio dio;

class Http {
  static Dio instance() {
    if (dio != null) {
      return dio;// 例項化dio
    }
    dio = new Dio();
    // 增加攔截器
    dio.interceptors.add(
      InterceptorsWrapper(
        // 介面請求前資料處理
        onRequest: (options) {
          Loading.before('正在加速中...');
          return options;
        },
        // 介面成功返回時處理
        onResponse: (Response resp) {
          // 這裡為了讓資料介面返回慢一點,增加了3秒的延時
          Future.delayed(Duration(seconds: 3), () {
            Loading.complete();
            return resp;
          });
        },
        // 介面報錯時處理
        onError: (DioError error) {
          return error;
        },
      ),
    );
    return dio;
  }

  /**
   * get介面請求
   * path: 介面地址
   */
  static get(path) {
    return instance().get(path);
  }
}

複製程式碼

修改下_incrementCounter函式的內容為通過http.get觸發介面呼叫

import 'package:flutter/material.dart';
import 'package:flutter_loading/http/loading.dart' show Loading;
import 'http/index.dart' show Http;
	... // 省略程式碼
  void _incrementCounter() {
	// Loading.before('loading...');
    Http.get('https://raw.githubusercontent.com/efoxTeam/flutter-ui/master/version.json');
  }
	... // 省略程式碼
複製程式碼

ok. 你將會看到如下效果。

Alt 預覽

併發請求時loading處理

併發請求,loading只需要保證有一個在當前執行。介面返回結束,只需要保證最後一個完成時,關閉loading。

  • 使用Set有排重作用,比較使用用來管理併發請求地址。通過Set.length控制彈窗與關閉視窗。
  • 增加LoadingStatus判斷是否已經有彈窗存在
  • 修改onRequest/onResponse/onError入參
import 'package:flutter/material.dart';

Set dict = Set();
bool loadingStatus = false;
class Loading {
  static dynamic ctx;

  static void before(uri, text) {
    dict.add(uri); // 放入set變數中
    // 已有彈窗,則不再顯示彈窗, dict.length >= 2 保證了有一個執行彈窗即可,
    if (loadingStatus == true || dict.length >= 2) {
      return ;
    }
    loadingStatus = true; // 修改狀態
    // 請求前顯示彈窗
    showDialog(
      context: ctx,
      builder: (context) {
        return Index(text: text);
      },
    );
  }

  static void complete(uri) {
    dict.remove(uri);
    // 所有介面介面返回並有彈窗
    if (dict.length == 0 && loadingStatus == true) {
      loadingStatus = false; // 修改狀態
      // 完成後關閉loading視窗
      Navigator.of(ctx, rootNavigator: true).pop();
    }
  }
}

複製程式碼
http/index.dart

onReuest: Loading.before(options.uri, '正在加速中...');
onReponse: Loading.complete(resp.request.uri);
onError: Loading.complete(error.request.uri );
複製程式碼

歡迎大家交流~

相關文章