Flutter PIP(畫中畫)效果的實現

北斗星_And發表於2019-07-24

前言

繼續上一篇 Flutter側滑欄及城市選擇UI的實現,今天繼續講Flutter的實現篇,畫中畫效果的實現。先看一下PIP的實現效果.

Flutter PIP(畫中畫)效果的實現

Flutter PIP(畫中畫)效果的實現

更多效果請檢視PIP DEMO 程式碼地址:FlutterPIP

為什麼會有此文?

一天在瀏覽朋友圈時,發現了一個朋友發了一張圖(當然不是女朋友,但是個女的),類似上面效果部分. 一看效果挺牛啊,這是怎麼實現的呢?心想要不自己實現一下吧?於是開始準備用Android實現一下.

但最近正好學了一下Flutter,並在學習Flutter 自定義View CustomPainter時,發現了和Android上有相同的API,Canvas,Paint,Path等. 檢視Canvas的繪圖部分drawImage程式碼如下

 /// Draws the given [Image] into the canvas with its top-left corner at the
  /// given [Offset]. The image is composited into the canvas using the given [Paint].
  void drawImage(Image image, Offset p, Paint paint) {
    assert(image != null); // image is checked on the engine side
    assert(_offsetIsValid(p));
    assert(paint != null);
    _drawImage(image, p.dx, p.dy, paint._objects, paint._data);
  }
  void _drawImage(Image image,
                  double x,
                  double y,
                  List<dynamic> paintObjects,
                  ByteData paintData) native 'Canvas_drawImage';
複製程式碼

可以看出drawImage 呼叫了內部的_drawImage,而內部的_drawImage使用的是native Flutter Engine的程式碼 'Canvas_drawImage',交給了Flutter Native去繪製.那Canvas的繪圖就可以和移動端的Native一樣高效 (Flutter的繪製原理,決定了Flutter的高效性).

實現步驟

看效果從底層往上層,圖片被分為3個部分,第一部分是底層的高斯模糊效果,第二層是原圖被裁剪的部分,第三層是一個效果遮罩。

Flutter 高斯模糊效果的實現

Flutter提供了BackdropFilter,關於BackdropFilter的官方文件是這麼說的

A widget that applies a filter to the existing painted content and then paints child.

The filter will be applied to all the area within its parent or ancestor widget's clip. If there's no clip, the filter will be applied to the full screen.

簡單來說,他就是一個篩選器,篩選所有繪製到子內容的小控制元件,官方demo例子如下

Stack(
  fit: StackFit.expand,
  children: <Widget>[
    Text('0' * 10000),
    Center(
      child: ClipRect(  // <-- clips to the 200x200 [Container] below
        child: BackdropFilter(
          filter: ui.ImageFilter.blur(
            sigmaX: 5.0,
            sigmaY: 5.0,
          ),
          child: Container(
            alignment: Alignment.center,
            width: 200.0,
            height: 200.0,
            child: Text('Hello World'),
          ),
        ),
      ),
    ),
  ],
)
複製程式碼

效果就是對中間200*200大小的地方實現了模糊效果. 本文對底部圖片高斯模糊效果的實現如下

Stack(
      fit: StackFit.expand,
      children: <Widget>[
        Container(
            alignment: Alignment.topLeft,
            child: CustomPaint(
                painter: DrawPainter(widget._originImage),
                size: Size(_width, _width))),
        Center(
          child: ClipRect(
            child: BackdropFilter(
              filter: flutterUi.ImageFilter.blur(
                sigmaX: 5.0,
                sigmaY: 5.0,
              ),
              child: Container(
                alignment: Alignment.topLeft,
                color: Colors.white.withOpacity(0.1),
                width: _width,
                height: _width,
//                child: Text('  '),
              ),
            ),
          ),
        ),
      ],
    );
複製程式碼

其中Container的大小和圖片大小一致,並且Container需要有子控制元件,或者背景色. 其中子控制元件和背景色可以任意. 實現效果如圖

Flutter PIP(畫中畫)效果的實現

Flutter 圖片裁剪

圖片裁剪原理

在用Android中的Canvas進行繪圖時,可以通過使用PorterDuffXfermode將所繪製的圖形的畫素與Canvas中對應位置的畫素按照一定規則進行混合,形成新的畫素值,從而更新Canvas中最終的畫素顏色值,這樣會建立很多有趣的效果.

Flutter 中也有相同的API,通過設定畫筆Paint的blendMode屬性,可以達到相同的效果.混合模式具體可以Flutter檢視官方文件,有示例.

此處用到的混合模式是BlendMode.dstIn,文件註釋如下

/// Show the destination image, but only where the two images overlap. The /// source image is not rendered, it is treated merely as a mask. The color /// channels of the source are ignored, only the opacity has an effect. /// To show the source image instead, consider [srcIn]. // To reverse the semantic of the mask (only showing the source where the /// destination is present, rather than where it is absent), consider [dstOut]. /// This corresponds to the "Destination in Source" Porter-Duff operator.

Flutter PIP(畫中畫)效果的實現

大概說的意思就是,只在源影象和目標影象相交的地方繪製【目標影象】,繪製效果受到源影象對應地方透明度影響. 用Android裡面的一個公式表示為

\(\alpha_{out} = \alpha_{src}\)

\(C_{out} = \alpha_{src} * C_{dst} + (1 - \alpha_{dst}) * C_{src}\)
複製程式碼

實際裁剪

我們要用到一個Frame圖片(frame.png),用來和原圖進行混合,Frame圖片如下

frame.png

實現程式碼

/// 通過 frameImage 和 原圖,繪製出 被裁剪的圖形
  static Future<flutterUi.Image> drawFrameImage(
      String originImageUrl, String frameImageUrl) {
    Completer<flutterUi.Image> completer = new Completer<flutterUi.Image>();
    //載入圖片
    Future.wait([
      OriginImage.getInstance().loadImage(originImageUrl),
      ImageLoader.load(frameImageUrl)
    ]).then((result) {
      Paint paint = new Paint();
      PictureRecorder recorder = PictureRecorder();
      Canvas canvas = Canvas(recorder);

      int width = result[1].width;
      int height = result[1].height;

      //圖片縮放至frame大小,並移動到中央
      double originWidth = 0.0;
      double originHeight = 0.0;
      if (width > height) {
        double scale = height / width.toDouble();
        originWidth = result[0].width.toDouble();
        originHeight = result[0].height.toDouble() * scale;
      } else {
        double scale = width / height.toDouble();
        originWidth = result[0].width.toDouble() * scale;
        originHeight = result[0].height.toDouble();
      }
      canvas.drawImageRect(
          result[0],
          Rect.fromLTWH(
              (result[0].width - originWidth) / 2.0,
              (result[0].height - originHeight) / 2.0,
              originWidth,
              originHeight),
          Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
          paint);

      //裁剪圖片
      paint.blendMode = BlendMode.dstIn;
      canvas.drawImage(result[1], Offset(0, 0), paint);
      recorder.endRecording().toImage(width, height).then((image) {
        completer.complete(image);
      });
    }).catchError((e) {
      print("載入error:" + e);
    });
    return completer.future;
  }
複製程式碼

分為三個主要步驟

  • 第一個步驟,載入原圖和Frame圖片,使用Future.wait 等待兩張圖片都載入完成
  • 原圖進行縮放,平移處理,縮放至frame合適大小,在將圖片平移至圖片中央
  • 設定paint的混合模式,繪製Frame圖片,完成裁剪

裁剪後的效果圖如下

Flutter PIP(畫中畫)效果的實現

Flutter 圖片合成及儲存

裁剪完的圖片和效果圖片(mask.png)的合成

先看一下mask圖片長啥樣

Flutter PIP(畫中畫)效果的實現
裁剪完的圖片和mask圖片的合成,不需要設定混合模式,裁剪圖片在底層,合成完的圖片在上層.既可實現,但需要注意的是,裁剪的圖片需要畫到效果區域,所以x,y需要有偏移量,實現程式碼如下:


  /// mask 圖形 和被裁剪的圖形 合併
  static Future<flutterUi.Image> drawMaskImage(String originImageUrl,
      String frameImageUrl, String maskImage, Offset offset) {
    Completer<flutterUi.Image> completer = new Completer<flutterUi.Image>();
    Future.wait([
      ImageLoader.load(maskImage),
      //獲取裁剪圖片
      drawFrameImage(originImageUrl, frameImageUrl)
    ]).then((result) {
      Paint paint = new Paint();
      PictureRecorder recorder = PictureRecorder();
      Canvas canvas = Canvas(recorder);

      int width = result[0].width;
      int height = result[0].height;

      //合成
      canvas.drawImage(result[1], offset, paint);
      canvas.drawImageRect(
          result[0],
          Rect.fromLTWH(
              0, 0, result[0].width.toDouble(), result[0].height.toDouble()),
          Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
          paint);

      //生成圖片
      recorder.endRecording().toImage(width, height).then((image) {
        completer.complete(image);
      });
    }).catchError((e) {
      print("載入error:" + e);
    });
    return completer.future;
  }
複製程式碼

效果實現

本文開始介紹了,圖片分為三層,所以此處使用了Stack元件來包裝PIP圖片

 new Container(
    width: _width,
    height: _width,
    child: new Stack(
         children: <Widget>[
        getBackgroundImage(),//底部高斯模糊圖片
        //合成後的效果圖片,使用CustomPaint 繪製出來
        CustomPaint(
            painter: DrawPainter(widget._image),
            size: Size(_width, _width)),
         ],
    )
)
複製程式碼
class DrawPainter extends CustomPainter {
  DrawPainter(this._image);

  flutterUi.Image _image;
  Paint _paint = new Paint();

  @override
  void paint(Canvas canvas, Size size) {
    if (_image != null) {
      print("draw this Image");
      print("width =" + size.width.toString());
      print("height =" + size.height.toString());

      canvas.drawImageRect(
          _image,
          Rect.fromLTWH(
              0, 0, _image.width.toDouble(), _image.height.toDouble()),
          Rect.fromLTWH(0, 0, size.width, size.height),
          _paint);
    }
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}
複製程式碼

圖片儲存

Flutter 是一個跨平臺的高效能UI框架,使用到Native Service的部分,需要各自實現,此處需要把圖片儲存到本地,使用了一個庫,用於獲取各自平臺的可以儲存檔案的檔案路徑.

path_provider: ^0.4.1
複製程式碼

實現步驟,先將上面的PIP用一個RepaintBoundary 元件包裹,然後通過給RepaintBoundary設定key,再去截圖儲存,實現程式碼如下

 Widget getPIPImageWidget() {
    return RepaintBoundary(
      key: pipCaptureKey,
      child: new Center(child: new DrawPIPWidget(_originImage, _image)),
    );
  }

複製程式碼

截圖儲存

Future<void> _captureImage() async {
    RenderRepaintBoundary boundary =
        pipCaptureKey.currentContext.findRenderObject();
    var image = await boundary.toImage();
    ByteData byteData = await image.toByteData(format: ImageByteFormat.png);
    Uint8List pngBytes = byteData.buffer.asUint8List();
    getApplicationDocumentsDirectory().then((dir) {
      String path = dir.path + "/pip.png";
      new File(path).writeAsBytesSync(pngBytes);
      _showPathDialog(path);
    });
  }
複製程式碼

顯示圖片的儲存路徑

Future<void> _showPathDialog(String path) async {
    return showDialog<void>(
      context: context,
      barrierDismissible: false,
      builder: (BuildContext context) {
        return AlertDialog(
          title: Text('PIP Path'),
          content: SingleChildScrollView(
            child: ListBody(
              children: <Widget>[
                Text('Image is save in $path'),
              ],
            ),
          ),
          actions: <Widget>[
            FlatButton(
              child: Text('退出'),
              onPressed: () {
                Navigator.of(context).pop();
              },
            ),
          ],
        );
      },
    );
  }
複製程式碼

手勢互動實現思路

目前的實現方式是:把原圖移動到中央進行裁剪,預設認為圖片的重要顯示區域在中央,這樣就會存在一個問題,如果圖片的重要顯示區域沒有在中央,或者畫中畫效果的顯示區域不在中央,會存在一定的偏差.

所以需要新增手勢互動,當圖片重要區域不在中央,或者畫中畫效果不在中央,可以手動調整顯示區域。

實現思路:新增手勢操作,獲取當前手勢的offset,重新拿原圖和frame區域進行裁剪,就可以正常顯示.(目前暫未去實現)

文末

歡迎star Github Code

文中所有使用的資源圖片,僅供學習使用,請在學習後,24小時內刪除,如若有侵權,請聯絡作者刪除。

相關文章