首頁 > 軟體

Flutter使用Overlay與ColorFiltered新手引導實現範例

2022-10-10 14:01:37

思路

開發過程中常見這樣的需求,頁面中有幾個按鈕,使用者首次進入時需要對這幾個按鈕高亮展示並加上文字提示。常見的一種方案是找UI切圖,那如何完全使用程式碼來實現呢?

就以Flutter原始Demo頁面為例,如果我們需要對中間展示區域以及右下角按鈕進行一個引導提示。

我們需要做到的效果是除了紅色框內的Widget,其餘部分要蓋上一層半透明黑色浮層,相當於是全螢幕浮層,紅色區域鏤空。

首先是黑色浮層,這個比較容易,Flutter中的Overlay可以輕易實現,它可以浮在任意的Widget之上,包括Dialog

那麼如何鏤空呢?

一種思路是首先拿到對應的Widget與其寬高xy偏移量,然後在Overlay中先鋪一層浮層後,把該WidgetOverlay的對應位置中再繪製一遍。也就是說該Widget存在兩份,一份是原本的Widget,另一份是在Overlay之上又繪製一層,並且不會被浮層所覆蓋,即為高亮。這是一種思路,但如果你需要進行引導提示的Widget自身有透明度,那麼這個方案就略有問題,因為你的浮層即為半透明,那麼使用者就可以穿過頂層的Widget看到下面的內容,略有瑕疵。

那麼另一種思路就是我們不去在Overlay之上蓋上另一個克隆Widget,而是將Overlay半透明黑色塗層對應位置進行鏤空即可,就不存在任何問題了。

Flutter BlendMode

既然需要鏤空,我們需要了解一下Flutter中的圖層混合模式概念

在畫布上繪製形狀或影象時,可以使用不同的演演算法來混合畫素,每個演演算法都存在兩個輸入,即源(正在繪製的影象 src)和目標(要合成源影象的影象 dst)

我們把半透明黑色塗層 和 需要進行高亮的Widget 理解為src和dst。

接下來我們通過下面的圖例可知,如果我們需要實現鏤空效果,需要的混合模式為SrcOutDstOut,因為他們的混合模式為一個源展示,且該源與另一個源有非透明畫素交匯部分完全剔除。

ColorFiltered

Flutter中為我們提供了ColorFiltered,這是一個官方為我們封裝的一個以Color作為源的混合模式Widget。其接收兩個引數,colorFilterchild,前者我們可以理解為上述的src,後者則為dst

下面以一段簡單的程式碼說明

class TestColorFilteredPage extends StatelessWidget {
  const TestColorFilteredPage({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return ColorFiltered(
      colorFilter: const ColorFilter.mode(Colors.yellow, BlendMode.srcOut),
      child: Stack(
        children: [
          Positioned.fill(
              child: Container(
            color: Colors.transparent,
          )),
          Positioned(
              top: 100,
              left: 100,
              child: Container(
                color: Colors.black,
                height: 100,
                width: 100,
              ))
        ],
      ),
    );
  }
}

效果:

可以看到作為srccolorFiler除了與作為dstStack非透明畫素交匯的地方被鏤空了,其他地方均正常顯示。

此處需要說明一下,作為dstchild,要實現蒙版的效果,必須要與src有所交匯,所以Stack中使用了透明的Positioned.fill填充,之所以要用透明色,是因為我們使用的混合模式srcOut的演演算法會剔除非透明畫素互動部分

實現

上述部分思路已經足夠支援我們寫出想要的效果了,接下來我們來進行實現

獲取鏤空位置

首先我需要拿到對應Widgetkey,就可以拿到對應的寬高與xy偏移量

RenderObject? promptRenderObject =
    promptWidgetKey.currentContext?.findRenderObject();
double widgetHeight = promptRenderObject?.paintBounds.height ?? 0;
double widgetWidth = promptRenderObject?.paintBounds.width ?? 0;
double widgetTop = 0;
double widgetLeft = 0;
if (promptRenderObject is RenderBox) {
  Offset offset = promptRenderObject.localToGlobal(Offset.zero);
  widgetTop = offset.dy;
  widgetLeft = offset.dx;
}

ColorFiltered child

lastOverlay = OverlayEntry(builder: (ctx) {
  return GestureDetector(
    onTap: () {
      // 點選後移除當前展示的overlay
      _removeCurrentOverlay();
      // 準備展示下一個overlay
      _prepareToPromptSingleWidget();
    },
    child: Stack(
      children: [
        Positioned.fill(
            child: ColorFiltered(
          colorFilter: ColorFilter.mode(
              Colors.black.withOpacity(0.7), BlendMode.srcOut),
          child: Stack(
            children: [
              // 透明色填充背景,作為蒙版
              Positioned.fill(
                  child: Container(
                color: Colors.transparent,
              )),
              // 鏤空區域
              Positioned(
                  left: l,
                  top: t,
                  child: Container(
                    width: w,
                    height: h,
                    decoration: decoration ??
                        const BoxDecoration(color: Colors.black),
                  )),
            ],
          ),
        )),
        // 文字提示,需要放在ColorFiltered的外層
        Positioned(
            left: l - 40,
            top: t - 40,
            child: Material(
              color: Colors.transparent,
              child: Text(
                tips,
                style: const TextStyle(fontSize: 14, color: Colors.white),
              ),
            ))
      ],
    ),
  );
});
Overlay.of(context)?.insert(lastOverlay!);

其中的文字偏移量,可以自己通過程式碼來設定,展示在中心,或者判斷位置跟隨Widget展示均可,此處不再贅述。

最後我們把Overlay新增到螢幕上展示即可。

完整程式碼

這裡我將邏輯封裝在靜態工具類中,鑑於單個頁面可能會有不止一個引導Widget,所以對於這個靜態工具類,我們需要傳入需要進行高亮引導的Widget和提示語的集合。

class PromptItem {
  GlobalKey promptWidgetKey;
  String promptTips;
  PromptItem(this.promptWidgetKey, this.promptTips);
}
class PromptBuilder {
  static List<PromptItem> toPromptWidgetKeys = [];
  static OverlayEntry? lastOverlay;
  static promptToWidgets(List<PromptItem> widgetKeys) {
    toPromptWidgetKeys = widgetKeys;
    _prepareToPromptSingleWidget();
  }
  static _prepareToPromptSingleWidget() async {
    if (toPromptWidgetKeys.isEmpty) {
      return;
    }
    PromptItem promptItem = toPromptWidgetKeys.removeAt(0);
    RenderObject? promptRenderObject =
        promptItem.promptWidgetKey.currentContext?.findRenderObject();
    double widgetHeight = promptRenderObject?.paintBounds.height ?? 0;
    double widgetWidth = promptRenderObject?.paintBounds.width ?? 0;
    double widgetTop = 0;
    double widgetLeft = 0;
    if (promptRenderObject is RenderBox) {
      Offset offset = promptRenderObject.localToGlobal(Offset.zero);
      widgetTop = offset.dy;
      widgetLeft = offset.dx;
    }
    if (widgetHeight != 0 &&
        widgetWidth != 0 &&
        widgetTop != 0 &&
        widgetLeft != 0) {
      _buildNextPromptOverlay(
          promptItem.promptWidgetKey.currentContext!,
          widgetWidth,
          widgetHeight,
          widgetLeft,
          widgetTop,
          null,
          promptItem.promptTips);
    }
  }
  static _buildNextPromptOverlay(BuildContext context, double w, double h,
      double l, double t, Decoration? decoration, String tips) {
    _removeCurrentOverlay();
    lastOverlay = OverlayEntry(builder: (ctx) {
      return GestureDetector(
        onTap: () {
          // 點選後移除當前展示的overlay
          _removeCurrentOverlay();
          // 準備展示下一個overlay
          _prepareToPromptSingleWidget();
        },
        child: Stack(
          children: [
            Positioned.fill(
                child: ColorFiltered(
              colorFilter: ColorFilter.mode(
                  Colors.black.withOpacity(0.7), BlendMode.srcOut),
              child: Stack(
                children: [
                  // 透明色填充背景,作為蒙版
                  Positioned.fill(
                      child: Container(
                    color: Colors.transparent,
                  )),
                  // 鏤空區域
                  Positioned(
                      left: l,
                      top: t,
                      child: Container(
                        width: w,
                        height: h,
                        decoration: decoration ??
                            const BoxDecoration(color: Colors.black),
                      )),
                ],
              ),
            )),
            // 文字提示,需要放在ColorFiltered的外層
            Positioned(
                left: l - 40,
                top: t - 40,
                child: Material(
                  color: Colors.transparent,
                  child: Text(
                    tips,
                    style: const TextStyle(fontSize: 14, color: Colors.white),
                  ),
                ))
          ],
        ),
      );
    });
    Overlay.of(context)?.insert(lastOverlay!);
  }
  static _removeCurrentOverlay() {
    if (lastOverlay != null) {
      lastOverlay!.remove();
      lastOverlay = null;
    }
  }
}
class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {
  int _counter = 0;
  GlobalKey centerWidgetKey = GlobalKey();
  GlobalKey bottomWidgetKey = GlobalKey();
  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }
  @override
  void initState() {
    super.initState();
    // 頁面展示時進行prompt繪製,在此新增observer監聽等待渲染完成後掛載prompt
    WidgetsBinding.instance.addObserver(this);
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
      List<PromptItem> prompts = [];
      prompts.add(PromptItem(centerWidgetKey, "這是中心Widget"));
      prompts.add(PromptItem(bottomWidgetKey, "這是底部Button"));
      PromptBuilder.promptToWidgets(prompts);
    });
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          mainAxisAlignment: MainAxisAlignment.center,
          // 需要高亮展示的widget,需要宣告其GlobalKey
          key: centerWidgetKey,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        // 需要高亮展示的widget,需要宣告其GlobalKey
        key: bottomWidgetKey,
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

最終效果

小結

本文僅總結程式碼實現思路,對於具體細節並未處理,可以在PromptItemPromptBuilder進行更多的屬性宣告以更加靈活的展示prompt,比如圓角等引數。有任何問題歡迎大家隨時討論。

最後附上github地址:github.com/slowguy/flu…

以上就是Flutter使用Overlay與ColorFiltered新手引導實現範例的詳細內容,更多關於Flutter使用Overlay ColorFiltered的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com