|
1 | | -# pip_image |
| 1 | +# 前言 |
2 | 2 |
|
3 | | -A new Flutter application. |
| 3 | +继续上一篇 [Flutter侧滑栏及城市选择UI的实现](https://juejin.im/post/5d316609f265da1b6836f593),今天继续讲Flutter的实现篇,画中画效果的实现。先看一下PIP的实现效果. |
4 | 4 |
|
5 | | -## Getting Started |
| 5 | + |
| 6 | + |
| 7 | + |
| 8 | + |
| 9 | + |
| 10 | + |
| 11 | + |
| 12 | +更多效果请查看PIP DEMO |
| 13 | +代码地址:[FlutterPIP](https://github.com/DingProg/FlutterPIP) |
| 14 | + |
| 15 | +# 为什么会有此文? |
| 16 | +一天在浏览朋友圈时,发现了一个朋友发了一张图(当然不是女朋友,但是个女的),类似上面效果部分. 一看效果挺牛啊,这是怎么实现的呢?心想要不自己实现一下吧?于是开始准备用Android实现一下. |
| 17 | + |
| 18 | +但最近正好学了一下Flutter,并在学习Flutter 自定义View CustomPainter时,发现了和Android上有相同的API,Canvas,Paint,Path等. 查看Canvas的绘图部分drawImage代码如下 |
| 19 | +```dart |
| 20 | + /// Draws the given [Image] into the canvas with its top-left corner at the |
| 21 | + /// given [Offset]. The image is composited into the canvas using the given [Paint]. |
| 22 | + void drawImage(Image image, Offset p, Paint paint) { |
| 23 | + assert(image != null); // image is checked on the engine side |
| 24 | + assert(_offsetIsValid(p)); |
| 25 | + assert(paint != null); |
| 26 | + _drawImage(image, p.dx, p.dy, paint._objects, paint._data); |
| 27 | + } |
| 28 | + void _drawImage(Image image, |
| 29 | + double x, |
| 30 | + double y, |
| 31 | + List<dynamic> paintObjects, |
| 32 | + ByteData paintData) native 'Canvas_drawImage'; |
| 33 | +``` |
| 34 | +可以看出drawImage 调用了内部的_drawImage,而内部的_drawImage使用的是native Flutter Engine的代码 'Canvas_drawImage',交给了Flutter Native去绘制.那Canvas的绘图就可以和移动端的Native一样高效 (Flutter的绘制原理,决定了Flutter的高效性). |
| 35 | + |
| 36 | +# 实现步骤 |
| 37 | +看效果从底层往上层,图片被分为3个部分,第一部分是底层的高斯模糊效果,第二层是原图被裁剪的部分,第三层是一个效果遮罩。 |
| 38 | + |
| 39 | +## Flutter 高斯模糊效果的实现 |
| 40 | +Flutter提供了BackdropFilter,关于BackdropFilter的官方文档是这么说的 |
| 41 | +> A widget that applies a filter to the existing painted content and then paints child. |
| 42 | +> |
| 43 | +> 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. |
| 44 | +
|
| 45 | +简单来说,他就是一个筛选器,筛选所有绘制到子内容的小控件,官方demo例子如下 |
| 46 | + |
| 47 | +```dart |
| 48 | +Stack( |
| 49 | + fit: StackFit.expand, |
| 50 | + children: <Widget>[ |
| 51 | + Text('0' * 10000), |
| 52 | + Center( |
| 53 | + child: ClipRect( // <-- clips to the 200x200 [Container] below |
| 54 | + child: BackdropFilter( |
| 55 | + filter: ui.ImageFilter.blur( |
| 56 | + sigmaX: 5.0, |
| 57 | + sigmaY: 5.0, |
| 58 | + ), |
| 59 | + child: Container( |
| 60 | + alignment: Alignment.center, |
| 61 | + width: 200.0, |
| 62 | + height: 200.0, |
| 63 | + child: Text('Hello World'), |
| 64 | + ), |
| 65 | + ), |
| 66 | + ), |
| 67 | + ), |
| 68 | + ], |
| 69 | +) |
| 70 | +``` |
| 71 | +效果就是对中间200*200大小的地方实现了模糊效果. |
| 72 | +本文对底部图片高斯模糊效果的实现如下 |
| 73 | + |
| 74 | +```dart |
| 75 | +Stack( |
| 76 | + fit: StackFit.expand, |
| 77 | + children: <Widget>[ |
| 78 | + Container( |
| 79 | + alignment: Alignment.topLeft, |
| 80 | + child: CustomPaint( |
| 81 | + painter: DrawPainter(widget._originImage), |
| 82 | + size: Size(_width, _width))), |
| 83 | + Center( |
| 84 | + child: ClipRect( |
| 85 | + child: BackdropFilter( |
| 86 | + filter: flutterUi.ImageFilter.blur( |
| 87 | + sigmaX: 5.0, |
| 88 | + sigmaY: 5.0, |
| 89 | + ), |
| 90 | + child: Container( |
| 91 | + alignment: Alignment.topLeft, |
| 92 | + color: Colors.white.withOpacity(0.1), |
| 93 | + width: _width, |
| 94 | + height: _width, |
| 95 | +// child: Text(' '), |
| 96 | + ), |
| 97 | + ), |
| 98 | + ), |
| 99 | + ), |
| 100 | + ], |
| 101 | + ); |
| 102 | +``` |
| 103 | +其中Container的大小和图片大小一致,并且Container需要有子控件,或者背景色. 其中子控件和背景色可以任意. |
| 104 | +实现效果如图 |
| 105 | + |
| 106 | + |
| 107 | + |
| 108 | +## Flutter 图片裁剪 |
| 109 | + |
| 110 | +### 图片裁剪原理 |
| 111 | +在用Android中的Canvas进行绘图时,可以通过使用PorterDuffXfermode将所绘制的图形的像素与Canvas中对应位置的像素按照一定规则进行混合,形成新的像素值,从而更新Canvas中最终的像素颜色值,这样会创建很多有趣的效果. |
| 112 | + |
| 113 | +Flutter 中也有相同的API,通过设置画笔Paint的blendMode属性,可以达到相同的效果.混合模式具体可以Flutter查看官方文档,有示例. |
| 114 | + |
| 115 | +此处用到的混合模式是BlendMode.dstIn,文档注释如下 |
| 116 | + |
| 117 | +> /// Show the destination image, but only where the two images overlap. The |
| 118 | + /// source image is not rendered, it is treated merely as a mask. The color |
| 119 | + /// channels of the source are ignored, only the opacity has an effect. |
| 120 | + /// To show the source image instead, consider [srcIn]. |
| 121 | + // To reverse the semantic of the mask (only showing the source where the |
| 122 | + /// destination is present, rather than where it is absent), consider [dstOut]. |
| 123 | + /// This corresponds to the "Destination in Source" Porter-Duff operator. |
| 124 | + |
| 125 | +  |
| 126 | +大概说的意思就是,只在源图像和目标图像相交的地方绘制【目标图像】,绘制效果受到源图像对应地方透明度影响. 用Android里面的一个公式表示为 |
| 127 | +``` |
| 128 | +\(\alpha_{out} = \alpha_{src}\) |
| 129 | +
|
| 130 | +\(C_{out} = \alpha_{src} * C_{dst} + (1 - \alpha_{dst}) * C_{src}\) |
| 131 | +``` |
| 132 | + |
| 133 | +### 实际裁剪 |
| 134 | + |
| 135 | +我们要用到一个Frame图片(frame.png),用来和原图进行混合,Frame图片如下 |
| 136 | + |
| 137 | + |
| 138 | + |
| 139 | + |
| 140 | +实现代码 |
| 141 | +```dart |
| 142 | +/// 通过 frameImage 和 原图,绘制出 被裁剪的图形 |
| 143 | + static Future<flutterUi.Image> drawFrameImage( |
| 144 | + String originImageUrl, String frameImageUrl) { |
| 145 | + Completer<flutterUi.Image> completer = new Completer<flutterUi.Image>(); |
| 146 | + //加载图片 |
| 147 | + Future.wait([ |
| 148 | + OriginImage.getInstance().loadImage(originImageUrl), |
| 149 | + ImageLoader.load(frameImageUrl) |
| 150 | + ]).then((result) { |
| 151 | + Paint paint = new Paint(); |
| 152 | + PictureRecorder recorder = PictureRecorder(); |
| 153 | + Canvas canvas = Canvas(recorder); |
| 154 | +
|
| 155 | + int width = result[1].width; |
| 156 | + int height = result[1].height; |
| 157 | +
|
| 158 | + //图片缩放至frame大小,并移动到中央 |
| 159 | + double originWidth = 0.0; |
| 160 | + double originHeight = 0.0; |
| 161 | + if (width > height) { |
| 162 | + double scale = height / width.toDouble(); |
| 163 | + originWidth = result[0].width.toDouble(); |
| 164 | + originHeight = result[0].height.toDouble() * scale; |
| 165 | + } else { |
| 166 | + double scale = width / height.toDouble(); |
| 167 | + originWidth = result[0].width.toDouble() * scale; |
| 168 | + originHeight = result[0].height.toDouble(); |
| 169 | + } |
| 170 | + canvas.drawImageRect( |
| 171 | + result[0], |
| 172 | + Rect.fromLTWH( |
| 173 | + (result[0].width - originWidth) / 2.0, |
| 174 | + (result[0].height - originHeight) / 2.0, |
| 175 | + originWidth, |
| 176 | + originHeight), |
| 177 | + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), |
| 178 | + paint); |
| 179 | +
|
| 180 | + //裁剪图片 |
| 181 | + paint.blendMode = BlendMode.dstIn; |
| 182 | + canvas.drawImage(result[1], Offset(0, 0), paint); |
| 183 | + recorder.endRecording().toImage(width, height).then((image) { |
| 184 | + completer.complete(image); |
| 185 | + }); |
| 186 | + }).catchError((e) { |
| 187 | + print("加载error:" + e); |
| 188 | + }); |
| 189 | + return completer.future; |
| 190 | + } |
| 191 | +``` |
| 192 | + |
| 193 | +分为三个主要步骤 |
| 194 | +- 第一个步骤,加载原图和Frame图片,使用Future.wait 等待两张图片都加载完成 |
| 195 | +- 原图进行缩放,平移处理,缩放至frame合适大小,在将图片平移至图片中央 |
| 196 | +- 设置paint的混合模式,绘制Frame图片,完成裁剪 |
| 197 | + |
| 198 | +裁剪后的效果图如下 |
| 199 | + |
| 200 | + |
| 201 | + |
| 202 | +## Flutter 图片合成及保存 |
| 203 | +### 裁剪完的图片和效果图片(mask.png)的合成 |
| 204 | +先看一下mask图片长啥样 |
| 205 | + |
| 206 | +裁剪完的图片和mask图片的合成,不需要设置混合模式,裁剪图片在底层,合成完的图片在上层.既可实现,但需要注意的是,裁剪的图片需要画到效果区域,所以x,y需要有偏移量,实现代码如下: |
| 207 | +```dart |
| 208 | +
|
| 209 | + /// mask 图形 和被裁剪的图形 合并 |
| 210 | + static Future<flutterUi.Image> drawMaskImage(String originImageUrl, |
| 211 | + String frameImageUrl, String maskImage, Offset offset) { |
| 212 | + Completer<flutterUi.Image> completer = new Completer<flutterUi.Image>(); |
| 213 | + Future.wait([ |
| 214 | + ImageLoader.load(maskImage), |
| 215 | + //获取裁剪图片 |
| 216 | + drawFrameImage(originImageUrl, frameImageUrl) |
| 217 | + ]).then((result) { |
| 218 | + Paint paint = new Paint(); |
| 219 | + PictureRecorder recorder = PictureRecorder(); |
| 220 | + Canvas canvas = Canvas(recorder); |
| 221 | +
|
| 222 | + int width = result[0].width; |
| 223 | + int height = result[0].height; |
| 224 | +
|
| 225 | + //合成 |
| 226 | + canvas.drawImage(result[1], offset, paint); |
| 227 | + canvas.drawImageRect( |
| 228 | + result[0], |
| 229 | + Rect.fromLTWH( |
| 230 | + 0, 0, result[0].width.toDouble(), result[0].height.toDouble()), |
| 231 | + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), |
| 232 | + paint); |
| 233 | +
|
| 234 | + //生成图片 |
| 235 | + recorder.endRecording().toImage(width, height).then((image) { |
| 236 | + completer.complete(image); |
| 237 | + }); |
| 238 | + }).catchError((e) { |
| 239 | + print("加载error:" + e); |
| 240 | + }); |
| 241 | + return completer.future; |
| 242 | + } |
| 243 | +``` |
| 244 | + |
| 245 | +### 效果实现 |
| 246 | +本文开始介绍了,图片分为三层,所以此处使用了Stack组件来包装PIP图片 |
| 247 | +```dart |
| 248 | + new Container( |
| 249 | + width: _width, |
| 250 | + height: _width, |
| 251 | + child: new Stack( |
| 252 | + children: <Widget>[ |
| 253 | + getBackgroundImage(),//底部高斯模糊图片 |
| 254 | + //合成后的效果图片,使用CustomPaint 绘制出来 |
| 255 | + CustomPaint( |
| 256 | + painter: DrawPainter(widget._image), |
| 257 | + size: Size(_width, _width)), |
| 258 | + ], |
| 259 | + ) |
| 260 | +) |
| 261 | +``` |
| 262 | + |
| 263 | +```dart |
| 264 | +class DrawPainter extends CustomPainter { |
| 265 | + DrawPainter(this._image); |
| 266 | +
|
| 267 | + flutterUi.Image _image; |
| 268 | + Paint _paint = new Paint(); |
| 269 | +
|
| 270 | + @override |
| 271 | + void paint(Canvas canvas, Size size) { |
| 272 | + if (_image != null) { |
| 273 | + print("draw this Image"); |
| 274 | + print("width =" + size.width.toString()); |
| 275 | + print("height =" + size.height.toString()); |
| 276 | +
|
| 277 | + canvas.drawImageRect( |
| 278 | + _image, |
| 279 | + Rect.fromLTWH( |
| 280 | + 0, 0, _image.width.toDouble(), _image.height.toDouble()), |
| 281 | + Rect.fromLTWH(0, 0, size.width, size.height), |
| 282 | + _paint); |
| 283 | + } |
| 284 | + } |
| 285 | +
|
| 286 | + @override |
| 287 | + bool shouldRepaint(CustomPainter oldDelegate) { |
| 288 | + return true; |
| 289 | + } |
| 290 | +} |
| 291 | +``` |
| 292 | + |
| 293 | +### 图片保存 |
| 294 | +Flutter 是一个跨平台的高性能UI框架,使用到Native Service的部分,需要各自实现,此处需要把图片保存到本地,使用了一个库,用于获取各自平台的可以保存文件的文件路径. |
| 295 | +``` |
| 296 | +path_provider: ^0.4.1 |
| 297 | +``` |
| 298 | + |
| 299 | +实现步骤,先将上面的PIP用一个RepaintBoundary 组件包裹,然后通过给RepaintBoundary设置key,再去截图保存,实现代码如下 |
| 300 | +```dart |
| 301 | + Widget getPIPImageWidget() { |
| 302 | + return RepaintBoundary( |
| 303 | + key: pipCaptureKey, |
| 304 | + child: new Center(child: new DrawPIPWidget(_originImage, _image)), |
| 305 | + ); |
| 306 | + } |
| 307 | +
|
| 308 | +``` |
| 309 | + |
| 310 | +截屏保存 |
| 311 | +```dart |
| 312 | +Future<void> _captureImage() async { |
| 313 | + RenderRepaintBoundary boundary = |
| 314 | + pipCaptureKey.currentContext.findRenderObject(); |
| 315 | + var image = await boundary.toImage(); |
| 316 | + ByteData byteData = await image.toByteData(format: ImageByteFormat.png); |
| 317 | + Uint8List pngBytes = byteData.buffer.asUint8List(); |
| 318 | + getApplicationDocumentsDirectory().then((dir) { |
| 319 | + String path = dir.path + "/pip.png"; |
| 320 | + new File(path).writeAsBytesSync(pngBytes); |
| 321 | + _showPathDialog(path); |
| 322 | + }); |
| 323 | + } |
| 324 | +``` |
| 325 | + |
| 326 | +显示图片的保存路径 |
| 327 | +```dart |
| 328 | +Future<void> _showPathDialog(String path) async { |
| 329 | + return showDialog<void>( |
| 330 | + context: context, |
| 331 | + barrierDismissible: false, |
| 332 | + builder: (BuildContext context) { |
| 333 | + return AlertDialog( |
| 334 | + title: Text('PIP Path'), |
| 335 | + content: SingleChildScrollView( |
| 336 | + child: ListBody( |
| 337 | + children: <Widget>[ |
| 338 | + Text('Image is save in $path'), |
| 339 | + ], |
| 340 | + ), |
| 341 | + ), |
| 342 | + actions: <Widget>[ |
| 343 | + FlatButton( |
| 344 | + child: Text('退出'), |
| 345 | + onPressed: () { |
| 346 | + Navigator.of(context).pop(); |
| 347 | + }, |
| 348 | + ), |
| 349 | + ], |
| 350 | + ); |
| 351 | + }, |
| 352 | + ); |
| 353 | + } |
| 354 | +``` |
| 355 | + |
| 356 | +# 手势交互实现思路 |
| 357 | + |
| 358 | +目前的实现方式是:把原图移动到中央进行裁剪,默认认为图片的重要显示区域在中央,这样就会存在一个问题,如果图片的重要显示区域没有在中央,或者画中画效果的显示区域不在中央,会存在一定的偏差. |
| 359 | + |
| 360 | +所以需要添加手势交互,当图片重要区域不在中央,或者画中画效果不在中央,可以手动调整显示区域。 |
| 361 | + |
| 362 | + |
| 363 | +实现思路:添加手势操作,获取当前手势的offset,重新拿原图和frame区域进行裁剪,就可以正常显示.(目前暂未去实现) |
| 364 | + |
| 365 | + |
| 366 | +# 文末 |
| 367 | +欢迎star [Github Code](https://github.com/DingProg/FlutterPIP) |
| 368 | + |
| 369 | +文中所有使用的资源图片,仅供学习使用,请在学习后,24小时内删除,如若有侵权,请联系作者删除。 |
6 | 370 |
|
7 | | -This project is a starting point for a Flutter application. |
8 | 371 |
|
9 | | -A few resources to get you started if this is your first Flutter project: |
10 | 372 |
|
11 | | -- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) |
12 | | -- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) |
13 | 373 |
|
14 | | -For help getting started with Flutter, view our |
15 | | -[online documentation](https://flutter.dev/docs), which offers tutorials, |
16 | | -samples, guidance on mobile development, and a full API reference. |
|
0 commit comments