工具箱APP开发(09):量角器开发
本文介绍了工具箱App中量角器功能的开发流程。通过修改页面信息,创建自定义小部件实现相机画面叠加透明量角器的功能。关键开发步骤包括:1) 页面搭建,添加导航栏和量角器组件;2) 实现相机控制、角度测量和图片保存功能;3) 使用Flutter绘制半圆形量角器刻度;4) 支持手势操作调整测量角度。最终实现通过设备相机实时测量物体角度,并可将测量结果保存为图片的功能。开发完成后需配置路由并打包测试,确保
视频链接:哔哩哔哩
量角器功能开发
上一节我们讲解了工具箱 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<ProtractorCameraWidget>
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 进入量角器测量页面。

更多推荐


所有评论(0)