视频链接:哔哩哔哩

量角器功能开发

上一节我们讲解了工具箱 App 的直尺功能的开发,本节我们将讲解 量角器功能的开发流程

和直尺功能开发流程类似,我们接下来去修改量角器的的页面信息

在工作台的项目列表中找到工具箱 App,点击【项目菜单】->【开发文档】进入到需求文档页面。

编辑页面信息

  • 点击 切换到页面菜单,选中 Protractor (量角器测量)

  • 点击 编辑 按钮,对页面信息进行修改
功能详情
  • 本功能基于设备相机画面,在实时预览画面上叠加透明量角器,用于对实物角度进行直观测量,并支持将测量结果以图片形式保存。

  • 页面整体为全屏相机视图,量角器覆盖在相机画面之上,不影响用户观察被测对象。

参考页面

放入下图:

变量列表

配置流程

点击保存按钮保存数据

页面搭建

我们先看一下需求中的页面布局结构:

  • 页面顶部为导航栏,标题为「量角器」
  • 下方是量角器的测量页面

1. 点击右上角 跳转到开发页面,选中“量角器测量页面“页面,开始页面设计。

2. 点击小部件面板图标进入小部件面板

3.在页面预览区域点击顶部导航栏图标,打开添加顶部导航

4. 拖动文本组件到导航栏中间,点击选中刚拖动的文本小部件,右侧出现小部件的配置页面,并配置如下内容

  • 文本内容:量角器

  • 字号:18

  • 字体颜色:#FFFFFFFF

5. 点击左侧自定义管理图标,并选中自定义小部件,点击创建小部件按钮

6. 点击下一步,在小部件上传中填写如下内容后点击下一步

  • 小部件名称:量角器
  • 小部件图片:自定义
  • 小部件类型:protractor
  • 小部件类名:ProtractorCameraWidget
  • 代码块:
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:camera/camera.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter/rendering.dart';
import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart';
import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';

class ProtractorCameraWidget extends StatefulWidget {
  const ProtractorCameraWidget({super.key});

  @override
  State<ProtractorCameraWidget> createState() => _ProtractorCameraWidgetState();
}

class _ProtractorCameraWidgetState extends State&lt;ProtractorCameraWidget&gt;
    with WidgetsBindingObserver {
  CameraController? _controller;
  Future<void>? _initializeControllerFuture;
  final GlobalKey _repaintKey = GlobalKey();

  double _angle = -pi / 2; // 指针初始在左端

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);

    _initCamera();
  }

  Future<void> _initCamera() async {
    if (_controller != null) return;

    final cameras = await availableCameras();
    final firstCamera = cameras.first;

    final controller = CameraController(firstCamera, ResolutionPreset.high);

    _initializeControllerFuture = controller.initialize();
    await _initializeControllerFuture;
    // 关闭闪光灯
    await controller.setFlashMode(FlashMode.off);

    if (!mounted) return;

    setState(() {
      _controller = controller;
    });
  }

  Future<void> _disposeCamera() async {
    if (_controller != null) {
      await _controller!.dispose();
      _controller = null;
      _initializeControllerFuture = null;
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);

    _disposeCamera();
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.inactive ||
        state == AppLifecycleState.paused) {
      // App 进入后台
      _disposeCamera();
    } else if (state == AppLifecycleState.resumed) {
      // App 回到前台
      _initCamera();
    }
  }

  Future<void> _takePhoto() async {
    await Permission.photos.request();
    await Permission.storage.request();

    if (_controller == null || !_controller!.value.isInitialized) {
      EasyLoading.showToast("相机未准备好");
      return;
    }

    try {
      EasyLoading.show(status: "正在保存图片...");

      // 1️⃣ 拍摄原始照片
      final XFile rawPhoto = await _controller!.takePicture();
      final Uint8List cameraBytes = await rawPhoto.readAsBytes();

      // 2️⃣ 渲染量角器叠加层
      RenderRepaintBoundary boundary = _repaintKey.currentContext!
          .findRenderObject() as RenderRepaintBoundary;
      ui.Image overlayImage = await boundary.toImage(pixelRatio: 3.0);
      ByteData? overlayByteData =
          await overlayImage.toByteData(format: ui.ImageByteFormat.png);
      if (overlayByteData == null) {
        EasyLoading.showToast("叠加层渲染失败");
        return;
      }
      final Uint8List overlayBytes = overlayByteData.buffer.asUint8List();

      // 3️⃣ 合成相机照片 + 叠加层
      ui.Codec cameraCodec = await ui.instantiateImageCodec(cameraBytes);
      ui.FrameInfo cameraFrame = await cameraCodec.getNextFrame();
      final ui.Image cameraImage = cameraFrame.image;

      ui.Codec overlayCodec = await ui.instantiateImageCodec(overlayBytes);
      ui.FrameInfo overlayFrame = await overlayCodec.getNextFrame();
      final ui.Image overlay = overlayFrame.image;

      // 4️⃣ 创建画布合成
      final recorder = ui.PictureRecorder();
      final canvas = Canvas(recorder);

      // 将相机图填充整个 canvas
      final paint = Paint();
      canvas.drawImageRect(
          cameraImage,
          Rect.fromLTWH(0, 0, cameraImage.width.toDouble(),
              cameraImage.height.toDouble()),
          Rect.fromLTWH(
              0, 0, overlay.width.toDouble(), overlay.height.toDouble()),
          paint);

      // 绘制叠加层
      canvas.drawImage(overlay, Offset.zero, paint);

      // 生成最终图片
      final finalImage =
          await recorder.endRecording().toImage(overlay.width, overlay.height);
      final ByteData? finalByteData =
          await finalImage.toByteData(format: ui.ImageByteFormat.png);

      if (finalByteData == null) {
        EasyLoading.showToast("生成图片失败");
        return;
      }

      final Uint8List finalBytes = finalByteData.buffer.asUint8List();

      // 5️⃣ 保存到相册
      final result = await ImageGallerySaverPlus.saveImage(finalBytes);
      EasyLoading.showSuccess("保存成功");
      debugPrint("保存结果: $result");
    } catch (e) {
      EasyLoading.showError("保存失败");
      debugPrint("截图失败: $e");
    }
  }

  void _updateAngle(Offset localPosition, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final dx = localPosition.dx - center.dx;
    final dy = localPosition.dy - center.dy;

    double rawAngle = atan2(dy, dx);
    if (rawAngle > 0) {
      return;
    }

    setState(() {
      _angle = rawAngle;
    });
  }

  String? _lastImagePath;

  void _openLastPhoto() {
    if (_lastImagePath == null) return;

    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (_) => Scaffold(
          appBar: AppBar(title: const Text('照片预览')),
          body: Center(
            child: Image.file(
              File(_lastImagePath!),
              fit: BoxFit.contain,
            ),
          ),
        ),
      ),
    );
  }

  final ImagePicker _picker = ImagePicker();

  Future<void> _openGallery() async {
    try {
      final XFile? image = await _picker.pickImage(source: ImageSource.gallery);

      if (image == null) return;

      // 你可以在这里做你想做的事
      // 比如:预览 / 编辑 / 再次保存
      debugPrint('选中的图片路径: ${image.path}');
      _lastImagePath = image.path;
      _openLastPhoto();
    } catch (e) {
      debugPrint('打开相册失败: $e');
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_controller == null || _initializeControllerFuture == null) {
      return const Center(child: CircularProgressIndicator());
    }

    return Expanded(
      child: FutureBuilder<void>(
        future: _initializeControllerFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.done &&
              _controller != null) {
            final size = MediaQuery.of(context).size;
            return GestureDetector(
              onPanUpdate: (details) =>
                  _updateAngle(details.localPosition, size),
              child: Stack(
                alignment: Alignment.center,
                children: [
                  // 1️⃣ 相机预览
                  Positioned.fill(child: CameraPreview(_controller!)),

                  // 2️⃣ RepaintBoundary 只包量角器和文本
                  RepaintBoundary(
                    key: _repaintKey,
                    child: Stack(
                      alignment: Alignment.center,
                      children: [
                        CustomPaint(
                          size: size,
                          painter: ProtractorPainter(_angle),
                        ),
                        Positioned(
                          top: 140,
                          child: Text(
                            '${((_angle + pi) * 180 / pi).clamp(0, 180).toStringAsFixed(0)}°',
                            style: const TextStyle(
                                color: Colors.white, fontSize: 48),
                          ),
                        ),
                      ],
                    ),
                  ),

                  // 3️⃣ 拍照按钮,不在 RepaintBoundary 里

                  Positioned(
                    bottom: 130,
                    right: 40,
                    child: InkWell(
                      onTap: _openGallery,
                      child: Container(
                        width: 56,
                        height: 56,
                        decoration: BoxDecoration(
                          color: Colors.black.withOpacity(0.5),
                          shape: BoxShape.circle,
                        ),
                        child: const Icon(
                          Icons.photo,
                          color: Colors.white,
                          size: 28,
                        ),
                      ),
                    ),
                  ),
                  Positioned(
                    bottom: 120,
                    left: 0,
                    right: 0,
                    child: Center(
                      child: InkWell(
                        onTap: _takePhoto,
                        child: Container(
                          width: 80,
                          height: 80,
                          decoration: BoxDecoration(
                            shape: BoxShape.circle,
                            border: Border.all(
                                color: const Color.fromARGB(255, 233, 232, 232),
                                width: 6),
                          ),
                          child: Center(
                            child: Container(
                              width: 60,
                              height: 60,
                              decoration: BoxDecoration(
                                color: Colors.white,
                                shape: BoxShape.circle,
                              ),
                            ),
                          ),
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            );
          } else {
            return const Center(child: CircularProgressIndicator());
          }
        },
      ),
    );
  }
}

class ProtractorPainter extends CustomPainter {
  final double angle;
  ProtractorPainter(this.angle);

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = min(size.width, size.height) * 0.45; // 大半圆量角器

    // 半透明圆弧
    final arcPaint = Paint()
      ..color = Colors.white.withOpacity(0.2)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 4;
    canvas.drawArc(Rect.fromCircle(center: center, radius: radius), -pi, pi,
        false, arcPaint);

    // 底线
    final baseLinePaint = Paint()
      ..color = Colors.white
      ..strokeWidth = 1;
    final left = Offset(center.dx - radius, center.dy);
    final right = Offset(center.dx + radius, center.dy);
    canvas.drawLine(left, right, baseLinePaint);

    final tickPaint = Paint()..color = Colors.white;

    // 绘制刻度:每1°小刻度,5°中刻度,10°大刻度
    for (int deg = 0; deg <= 180; deg++) {
      double rad = (deg - 180) * pi / 180;
      double tickLength;
      if (deg % 10 == 0) {
        tickLength = 20; // 大刻度
      } else if (deg % 5 == 0) {
        tickLength = 12; // 中刻度
      } else {
        tickLength = 6; // 小刻度
      }

      final start =
          Offset(center.dx + radius * cos(rad), center.dy + radius * sin(rad));
      final end = Offset(center.dx + (radius - tickLength) * cos(rad),
          center.dy + (radius - tickLength) * sin(rad));
      canvas.drawLine(start, end, tickPaint);

      if (deg % 10 == 0) {
        final textPainter = TextPainter(
          text: TextSpan(
              text: '$deg',
              style: const TextStyle(color: Colors.white, fontSize: 10)),
          textDirection: TextDirection.ltr,
        );
        textPainter.layout();
        final textOffset = Offset(
            center.dx + (radius - 35) * cos(rad) - textPainter.width / 2,
            center.dy + (radius - 35) * sin(rad) - textPainter.height / 2);
        textPainter.paint(canvas, textOffset);
      }
    }

    // 指针
    final pointerPaint = Paint()
      ..color = const Color.fromARGB(255, 157, 198, 255)
      ..strokeWidth = 1;
    final pointerEnd = Offset(
        center.dx + radius * cos(angle), center.dy + radius * sin(angle));
    canvas.drawLine(center, pointerEnd, pointerPaint);

    // 中心点
    canvas.drawCircle(center, 10, Paint()..color = Colors.white);
  }

  @override
  bool shouldRepaint(covariant ProtractorPainter oldDelegate) {
    return oldDelegate.angle != angle;
  }
}

7.点击返回小部件列表,我们可以看到刚才添加的小部件已经显示在列表中了

8.点击小部件面板图标,在我的小部件中找到量角器小部件

9.拖动直尺小部件到上下布局中

至此页面搭建完成

配置逻辑(动作流程图)

因为拍照的逻辑我们在自定义代码块中就已经实现了,所以我们就不需要额外去配置逻辑

配置路由并开始打包

1.将量角器测量页面的路由输入框内容清空,使其作为根路由页面。

2.上一节中我们将直尺页面的路由配置设置为了根路由,为了避免出现两个根路由导致报错,我们需要修改RulerMeasurement (直尺测量页面)的路由名称。在页面管理中选择RulerMeasurement (直尺测量页面),将其路由修改为 rulerMeasurement

3.接下来进行 App 打包:点击右上角 下载 → 下载应用

在弹窗中做如下配置

  • 平台:Android

  • 版本号:1.0.0

  • 选择Flutter版本:最新版

  • 点击下载

系统将自动跳转至日志管理页面。你可以扫码关注公众号,打包成功后会收到通知。
待打包完成后点击下载按钮,安装应用并进行测试。

实机效果

打开 App 进入量角器测量页面。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐