Flutter 自定义水印拍照相机


前言

先说一下我这里的基本需求:

  1. 限制图片拍照比例 4:3
  2. 拍照时,需添加时间、地理位置的水印

然后看一下功能预览:

相机预览生成水印图片
在这里插入图片描述在这里插入图片描述

一、相机预览

1. 引入库

使用camera库

dependencies:
  camera: ^0.7.0+2

2. 自定拍照状态

enum TakeStatus {
  /// 准备中
  preparing,
  /// 拍摄中
  taking,
  /// 待确认
  confirm,
  /// 已完成
  done
}

3. 预览布局

  Widget _buildCameraArea() {
    Widget area;
    if (_takeStatus == TakeStatus.confirm && _curFile != null) {
      // 待确认状态下,显示图片(按照宽度填充,高度超出部分隐藏)
      area = Image.file(File(_curFile.path), fit: BoxFit.fitWidth,);
    } else if (_cameraController != null && _cameraController.value.isInitialized) {
      // 相机预览
      final double screenWidth = MediaQuery.of(context).size.width;
      // 超出部分裁剪
      area = ClipRect(
        child: OverflowBox(
          alignment: Alignment.center,
          child: FittedBox(
            fit: BoxFit.fitWidth,
            child: Container(
              width: screenWidth,
              height: screenWidth * _cameraController.value.aspectRatio,
              child: CameraPreview(_cameraController),
            )
          )
        ),
      );
    } else {
      // 加载时,显示空白
      area = Container(color: Colors.black,);
    }

    return Center(
      // 指定需要截图的区域
      child: RepaintBoundary(
        key: _cameraKey,
        child: Stack(
          children: [
            AspectRatio(
              aspectRatio: widget.aspectRatio ?? 4 / 3,
              child: area,
            ),
            Positioned(
                left: 10,
                right: 120,
                bottom: 10,
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(_time ?? '', style: TextStyle(color: Colors.white, fontSize: 13),),
                    Text(_address ?? '', style: TextStyle(color: Colors.white, fontSize: 13),),
                  ],
                )
            ),
          ],
        ),
      ),
    );
  }

注意:CameraPreview显示在指定区域会被拉伸变形。故,需要CameraPreview正常比例显示,然后在指定区域内超出隐藏。

二、核心功能实现

1. 拍照

  /// 拍照
  void _takePicture() async {
    if (_cameraController == null || _cameraController.value.isTakingPicture) return;
    _timer?.cancel();

    XFile file = await _cameraController.takePicture();
    setState(() {
      _curFile = file;
      _takeStatus = TakeStatus.confirm;
    });
  }

注意:CameraController.takePicture() 拍出的照片,是按照默认相机比例的图片。在回显的时候,按照图片宽度铺满,高度隐藏,即可得到预览相机的图片。

2. 确认时,区域截图

  /// 确认。返回图片数据
  void _confirm() async {
    if (_isCapturing) return;
    _isCapturing = true;
    try {
      // 获取指定区域
      RenderRepaintBoundary boundary = _cameraKey.currentContext.findRenderObject();
      // 转成图片
      ui.Image image = await boundary.toImage(pixelRatio: widget.pixelRatio ?? 2.0);
      ByteData byteData = await image.toByteData(format: ui.ImageByteFormat.png);
      Uint8List imgBytes = byteData.buffer.asUint8List();
      // 保存图片文件在本地
      String basePath = await findSavePath(WatermarkPhoto.SAVE_DIR);
      File file = File('$basePath/${DateTime.now().millisecondsSinceEpoch}.jpg');
      file.writeAsBytesSync(imgBytes);
      // 页面返回图片文件
      Navigator.of(context).pop(file);
    } catch (e) {
      print(e);
    }
    _isCapturing = false;
  }

注意:通过RepaintBoundary包裹需要截图的指定区域,并指定key。通过key获取到该区域的信息,然后实现截图。

3. 打开水印拍照

Future<File> takeWatermarkPhoto(BuildContext context, {
  double aspectRatio,
  double pixelRatio,
}) async  {
  return await Navigator.of(context).push(PageRouteBuilder(
    opaque:false,
    pageBuilder: (BuildContext context, Animation<double> animation,Animation<double> secondaryAnimation) {
      return WatermarkPhoto(aspectRatio: aspectRatio, pixelRatio: pixelRatio);
    },
    transitionsBuilder: (
      BuildContext context,
      Animation<double> animation,
      Animation<double> secondaryAnimation,
      Widget child,
    ) => FadeTransition(
      opacity: animation,
      child: child,
    ),
  ));
}

设置默认的打开页面交互方式,只需关注水印拍照后返回的结果。

4. 定位获取

本例子中,定位功能是使用高德地图Android定位SDK实现的。你也可以直接使用别人封装好的Flutter定位插件。

三、调用示例

final File pickedFile = await takeWatermarkPhoto(context);
if (pickedFile != null) {
  // 可通过Image.file()来显示图片
}

四、完整代码

import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:camera/camera.dart';
import 'package:date_format/date_format.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:stmy_mobile/plugin/amap/amap_location.dart';
import 'package:stmy_mobile/plugin/amap/amap_location_option.dart';
import 'package:stmy_mobile/utils/permission_util.dart';

class WatermarkPhoto extends StatefulWidget {
  static const String SAVE_DIR = 'tempImage';

  final double aspectRatio;
  final double pixelRatio;

  WatermarkPhoto({this.aspectRatio, this.pixelRatio});

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

class _WatermarkPhotoState extends State<WatermarkPhoto> with WidgetsBindingObserver {
  final GlobalKey _cameraKey = GlobalKey();
  CameraController _cameraController;
  String _time;
  String _address;
  TakeStatus _takeStatus = TakeStatus.preparing;
  XFile _curFile;
  Timer _timer;
  bool _isCapturing = false;
  
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);

    AMapLocation.init(AMapLocationOption());
    _time = formatDate(DateTime.now(), [yyyy, '-', mm, '-' , dd, ' ', HH, ':', nn, ':', ss]);
    _address = '未知位置';
    _initCamera();
  }

  void _initCamera() async {
    try {
      _timer?.cancel();
      _timer = Timer.periodic(Duration(seconds: 1), (timer) {
        if (mounted) {
          setState(() {
            _time = formatDate(DateTime.now(), [yyyy, '-', mm, '-' , dd, ' ', HH, ':', nn, ':', ss]);
          });
        }
      });
      setState(() {
        _takeStatus = TakeStatus.preparing;
      });
      List cameras = await availableCameras();
      _cameraController = CameraController(cameras.first, ResolutionPreset.high,
        enableAudio: false,
        imageFormatGroup: ImageFormatGroup.jpeg,
      );
      _cameraController.addListener(() {
        if (mounted) setState(() {});
      });
      await _cameraController.initialize();
      if (mounted) {
        setState(() {
          _takeStatus = TakeStatus.taking;
        });
      }
      if (await checkLocationPermission()) {
        LocationInfo info = await AMapLocation.getLocation(true);
        if (info.isSuccess()) {
          String address = info.formattedAddress;
          if ((address == null || address.isEmpty) && (info.province != null)) {
            address = info.province + info.city + info.district;
          }
          setState(() {
            _address = address;
          });
        }
      }
    } on CameraException catch (e) {
      print(e);
    }
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (_cameraController == null || !_cameraController.value.isInitialized) {
      return;
    }
    if (state == AppLifecycleState.inactive) {
      _cameraController?.dispose();
    } else if (state == AppLifecycleState.resumed) {
      if (_cameraController != null) {
        _initCamera();
      }
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _cameraController?.dispose();
    AMapLocation.destroy();
    _timer?.cancel();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: Stack(
        children: [
          _buildCameraArea(),
          _buildTopBar(),
          _buildAction(),
        ],
      ),
    );
  }
  
  Widget _buildCameraArea() {
    Widget area;
    if (_takeStatus == TakeStatus.confirm && _curFile != null) {
      area = Image.file(File(_curFile.path), fit: BoxFit.fitWidth,);
    } else if (_cameraController != null && _cameraController.value.isInitialized) {
      final double screenWidth = MediaQuery.of(context).size.width;
      area = ClipRect(
        child: OverflowBox(
          alignment: Alignment.center,
          child: FittedBox(
            fit: BoxFit.fitWidth,
            child: Container(
              width: screenWidth,
              height: screenWidth * _cameraController.value.aspectRatio,
              child: CameraPreview(_cameraController),
            )
          )
        ),
      );
    } else {
      area = Container(color: Colors.black,);
    }

    return Center(
      child: RepaintBoundary(
        key: _cameraKey,
        child: Stack(
          children: [
            AspectRatio(
              aspectRatio: widget.aspectRatio ?? 4 / 3,
              child: area,
            ),
            Positioned(
                left: 10,
                right: 120,
                bottom: 10,
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(_time ?? '', style: TextStyle(color: Colors.white, fontSize: 13),),
                    Text(_address ?? '', style: TextStyle(color: Colors.white, fontSize: 13),),
                  ],
                )
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildTopBar() {
    String flashIcon = 'assets/icon-flash-auto.png';
    if (_cameraController != null && _cameraController.value.isInitialized) {
      switch (_cameraController.value.flashMode) {
        case FlashMode.auto:
          flashIcon = 'assets/icon-flash-auto.png';
          break;
        case FlashMode.off:
          flashIcon = 'assets/icon-flash-off.png';
          break;
        case FlashMode.always:
        case FlashMode.torch:
          flashIcon = 'assets/icon-flash-on.png';
          break;
      }
    }

    if (_takeStatus == TakeStatus.confirm) {
      return Container();
    }

    return Positioned(
      top: MediaQuery.of(context).padding.top + 10,
      left: 10,
      right: 10,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          IconButton(
            color: Colors.white,
            icon: Icon(Icons.arrow_back, size: 32,),
            onPressed: () => Navigator.of(context).pop()
          ),
          IconButton(
            color: Colors.white,
            icon: Image.asset(flashIcon, width: 32, height: 32,),
            onPressed: _toggleFlash
          )
        ],
      )
    );
  }

  Widget _buildAction() {
    Widget child;
    if (_takeStatus == TakeStatus.confirm) {
      child = Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          OutlineButton(
            shape: CircleBorder(),
            color: Colors.black.withOpacity(0.5),
            padding: EdgeInsets.all(10),
            borderSide: BorderSide(color: Colors.grey),
            child: Image.asset('assets/icon-close.png', width: 24, height: 24,),
            onPressed: _cancel
          ),
          OutlineButton(
            shape: CircleBorder(),
            color: Colors.black.withOpacity(0.5),
            padding: EdgeInsets.all(10),
            borderSide: BorderSide(color: Colors.grey),
            child: Image.asset('assets/icon-confirm.png', width: 24, height: 24,),
            onPressed: _confirm
          )
        ],
      );
    } else {
      child = OutlineButton(
        shape: CircleBorder(),
        color: Colors.black.withOpacity(0.5),
        padding: EdgeInsets.all(8),
        borderSide: BorderSide(color: Colors.grey),
        child: Icon(Icons.camera, color: Colors.white, size: 48,),
        onPressed: _takePicture
      );
    }

    return Positioned(
      bottom: 50,
      left: 50,
      right: 50,
      child: child
    );
  }

  /// 切换闪光灯
  void _toggleFlash() {
    if (_cameraController == null) return;

    switch (_cameraController.value.flashMode) {
      case FlashMode.auto:
        _cameraController.setFlashMode(FlashMode.always);
        break;
      case FlashMode.off:
        _cameraController.setFlashMode(FlashMode.auto);
        break;
      case FlashMode.always:
      case FlashMode.torch:
        _cameraController.setFlashMode(FlashMode.off);
        break;
    }
  }

  /// 拍照
  void _takePicture() async {
    if (_cameraController == null || _cameraController.value.isTakingPicture) return;
    _timer?.cancel();

    XFile file = await _cameraController.takePicture();
    setState(() {
      _curFile = file;
      _takeStatus = TakeStatus.confirm;
    });
  }

  /// 取消。重新拍照
  void _cancel() {
    setState(() {
      _takeStatus = TakeStatus.preparing;
    });
    _cameraController?.dispose();
    _initCamera();
  }

  /// 确认。返回图片数据
  void _confirm() async {
    if (_isCapturing) return;
    _isCapturing = true;
    try {
      RenderRepaintBoundary boundary = _cameraKey.currentContext.findRenderObject();
      ui.Image image = await boundary.toImage(pixelRatio: widget.pixelRatio ?? 2.0);
      ByteData byteData = await image.toByteData(format: ui.ImageByteFormat.png);
      Uint8List imgBytes = byteData.buffer.asUint8List();
      String basePath = await findSavePath(WatermarkPhoto.SAVE_DIR);
      File file = File('$basePath/${DateTime.now().millisecondsSinceEpoch}.jpg');
      file.writeAsBytesSync(imgBytes);
      Navigator.of(context).pop(file);
    } catch (e) {
      print(e);
    }
    _isCapturing = false;
  }
}

enum TakeStatus {
  /// 准备中
  preparing,
  /// 拍摄中
  taking,
  /// 待确认
  confirm,
  /// 已完成
  done
}

Future<File> takeWatermarkPhoto(BuildContext context, {
  double aspectRatio,
  double pixelRatio,
}) async  {
  return await Navigator.of(context).push(PageRouteBuilder(
    opaque:false,
    pageBuilder: (BuildContext context, Animation<double> animation,Animation<double> secondaryAnimation) {
      return WatermarkPhoto(aspectRatio: aspectRatio, pixelRatio: pixelRatio);
    },
    transitionsBuilder: (
      BuildContext context,
      Animation<double> animation,
      Animation<double> secondaryAnimation,
      Widget child,
    ) => FadeTransition(
      opacity: animation,
      child: child,
    ),
  ));
}

/// 获取文件存储路径
Future<String> findSavePath([ String basePath ]) async {
  final directory = Platform.isAndroid
      ? await getExternalStorageDirectory()
      : await getApplicationDocumentsDirectory();
  if (basePath == null) {
    return directory.path;
  }
  String saveDir = path.join(directory.path, basePath);
  Directory root = Directory(saveDir);
  if (!root.existsSync()) {
    await root.create();
  }
  return saveDir;
}
  • 4
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 9
    评论
Flutter中,可以通过自定义ScrollPhysics来实现自定义的滚动效果。ScrollPhysics是一个抽象类,它定义了一系列方法来控制滚动行为和效果。 要自定义ScrollPhysics,首先需要创建一个继承自ScrollPhysics的子类,并重写其中的方法。具体来说,可以重写applyTo方法、applyPhysicsToUserOffset方法、applyBoundaryConditions方法和createBallisticSimulation方法来实现不同的滚动效果。 applyTo方法用于创建一个新的ScrollPhysics对象,并将当前对象作为父级。这个方法在构建父级对象时被调用。applyPhysicsToUserOffset方法用于根据物理效果对用户的偏移进行处理。applyBoundaryConditions方法用于将边界条件应用到滚动位置。createBallisticSimulation方法用于创建一个模拟对象,用于滚动的惯性动画效果。 在自定义ScrollPhysics时,可以根据需要重写这些方法,并实现自己的滚动效果逻辑。例如,可以重写applyPhysicsToUserOffset方法来控制滑动到边缘时的波纹效果,或者重写applyBoundaryConditions方法来实现特定的边界处理。 通过自定义ScrollPhysics,可以根据项目需求实现各种滚动效果,增强用户体验。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* *3* [Flutter重写ScrollPhysics实现ExtendedTabBarView滑动到边缘的监听](https://blog.csdn.net/ab958147137/article/details/109998211)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值