工具箱APP开发(08):直尺开发
本文详细介绍了工具箱App中直尺测量功能的开发流程。首先在开发文档中配置了直尺页面的基本信息,包括支持二维测量、实时显示XY轴距离的功能说明。接着创建了dataMap变量存储测量数据,并设计了保存数据、查看历史记录等流程。页面搭建部分重点讲解了导航栏、测量区域和自定义直尺部件的实现,包括可拖动的参考线、刻度显示等功能。最后配置了数据保存逻辑和路由设置,完成Android应用的打包流程。该功能通过F
视频链接:哔哩哔哩
直尺功能开发
上一节我们讲解了工具箱 App 的分贝仪功能的开发,本节我们将讲解 直尺功能的开发流程。
和分贝仪功能开发流程类似,我们接下来去修改直尺的的页面信息
在工作台的项目列表中找到工具箱 App,点击【项目菜单】->【开发文档】进入到需求文档页面。

编辑页面信息
- 点击 切换到页面菜单,选中 RulerMeasurement (直尺测量页面)。


- 点击 编辑 按钮,对页面信息进行修改
功能详情
功能详情填写:
-
支持通过拖动 X 轴和 Y 轴进行直尺测量,实现二维距离计算。
-
拖动过程中实时计算并显示当前测量的水平距离(X)与垂直距离(Y)。
-
根据 X、Y 轴位移计算总测量长度,用于表示实际测量结果
参考页面
放入下图:

变量列表
进入变量列表,点击 添加变量:
| 变量名称 | 变量类型 | 是否为数组 | 默认值 | 变量描述 |
|---|---|---|---|---|
| data | Map | 否 | 尺子的x轴与y轴的值 |
配置流程
在流程设计中,填写以下流程:
点击保存按钮
跳出300*400的弹窗,并向弹窗中传入data参数
点击历史记录按钮
携带type参数跳转到历史记录页面
点击保存按钮保存数据
页面搭建
我们先看一下需求中的页面布局结构:
- 页面顶部为导航栏,标题为「直尺」,右侧有保存图标按钮以及历史记录图标按钮
- 下方是直尺的测量页面
1. 点击右上角 跳转到开发页面,选中“直尺测量页面“页面,开始页面设计。


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

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

4. 拖动文本组件到导航栏中间,点击选中刚拖动的文本小部件,右侧出现小部件的配置页面,并配置如下内容
-
文本内容:直尺
-
字号:
18 -
字体颜色:
#FFFFFFFF

5. 拖动按钮到导航栏右侧,选中按钮然后在右侧的属性编辑器中配置属性,属性信息如下:
-
宽:
40 -
高:
40 -
背景颜色:
#00FCFCFC -
阴影大小:
0

6.拖动图标到按钮中,然后在右侧的属性编辑器中配置属性,属性信息如下:
-
图标选择:
保存图标 -
图标大小:
24 -
图标颜色:
#FFFFFF

7.拖动按钮到导航栏右侧,选中按钮然后在右侧的属性编辑器中配置属性,属性信息如下:
-
宽:
40 -
高:
40 -
背景颜色:
#00FCFCFC -
阴影大小:
0

8.拖动图标到按钮中,然后在右侧的属性编辑器中配置属性,属性信息如下:
-
图标选择:
历史图标 -
图标大小:
24 -
图标颜色:
#FFFFFF

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

10.点击下一步,在小部件上传中填写如下内容后点击下一步
- 小部件名称:直尺
- 小部件图片:自定义
- 小部件类型:Ruler
- 小部件类名:Ruler
- 项目入参:
- 标题:初始值
- 变量名:data
- 变量类型:Map
- 是否为数组:否
- 描述:由父组件传入的初始值
- 代码块:
import 'dart:math';
import 'package:display_metrics/display_metrics.dart';
import 'package:flutter/material.dart';
import 'dart:math' as math;
class RulerTest extends StatefulWidget {
const RulerTest({super.key});
@override
State<RulerTest> createState() => _RulerTestState();
}
class _RulerTestState extends State<RulerTest> {
Map data = {};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("测试"),
actions: [
IconButton(
icon: Icon(Icons.abc),
onPressed: () {
print("当前的值:${data}");
},
)
],
),
body: Column(
children: [
Ruler(data: data),
],
),
);
}
}
class Ruler extends StatelessWidget {
const Ruler({
super.key,
this.data = const {"x": 0.0, "y": 0.0},
});
final Map data;
@override
Widget build(BuildContext context) {
return Expanded(
child: DisplayMetricsWidget(
child: RulerPage(data: data),
),
);
}
}
class RulerPage extends StatefulWidget {
const RulerPage({
super.key,
this.data = const {"x": 0.0, "y": 0.0},
});
final Map data;
@override
State<RulerPage> createState() => _RulerPageState();
}
class _RulerPageState extends State<RulerPage> {
// 大画布尺寸(可以改)
double canvasWidth = 2000;
double canvasHeight = 1500;
// 直尺宽度和高度占用
final double rulerThickness = 40;
// 两条可拖动参考线的初始位置(相对于画布左上角)
double guideX = 150;
double guideY = 120;
// 将像素转换为你想要的单位(这里直接返回像素,可按需修改)
double pxToUnit(double px) {
// 返回厘米单位
return px / _pxPerCm!;
}
@override
void initState() {
super.initState();
// 获取屏幕的宽高
WidgetsBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(milliseconds: 500), () {
_initMetrics();
});
});
}
double? _pxPerCm;
Future<void> _initMetrics() async {
final metrics = DisplayMetrics.of(context);
final ppi = metrics.ppi; // 每英寸像素数
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
final pxPerCm = ppi / 2.54; // 每厘米多少物理像素
final logicalPx = pxPerCm / devicePixelRatio; // 转成逻辑像素单位
setState(() {
_pxPerCm = logicalPx; // 更新状态
double x = 0;
double y = 0;
try {
if (widget.data["x"] != null) {
x = double.parse("${widget.data['x']}") * _pxPerCm!;
}
} catch (e) {}
try {
if (widget.data["y"] != null) {
y = double.parse("${widget.data['y']}") * _pxPerCm!;
}
} catch (e) {}
guideX = x;
guideY = y;
setState(() {});
});
}
@override
Widget build(BuildContext context) {
// 整体使用Stack布局:左上角留出直尺区域
return Scaffold(
body: _pxPerCm == null
? const Center(child: CircularProgressIndicator())
: LayoutBuilder(builder: (context, constraints) {
return Stack(children: [
// 顶部直尺(横向)
Positioned(
left: rulerThickness,
top: 0,
right: 0,
height: rulerThickness,
child: IgnorePointer(
// 让下方滚动捕获手势
ignoring: true,
child: RulerPainterWidget(
length: canvasWidth,
thickness: rulerThickness,
isHorizontal: true,
cmPx: _pxPerCm!),
),
),
// 左侧直尺(竖向)
Positioned(
left: 0,
top: rulerThickness,
width: rulerThickness,
bottom: 0,
child: IgnorePointer(
ignoring: true,
child: RulerPainterWidget(
length: canvasHeight,
thickness: rulerThickness,
isHorizontal: false,
cmPx: _pxPerCm!),
),
),
// 左上角空白(原点标记)
Positioned(
left: 0,
top: 0,
width: rulerThickness,
height: rulerThickness,
child: Container(
color: Colors.grey.shade200,
child: const Center(
child: Text('0,0', style: TextStyle(fontSize: 12))),
),
),
// 主内容:水平与竖直两个方向的滚动区域(实现大画布可滚动)
Positioned(
left: rulerThickness,
top: rulerThickness,
right: 0,
bottom: 0,
child: SizedBox(
width: canvasWidth,
height: canvasHeight,
child: Stack(
children: [
// 画布背景(可替换为实际内容)
Container(color: Colors.white),
// 竖直参考线(可拖)
Positioned(
left: guideX.clamp(0.0, canvasWidth - 1),
top: 0,
bottom: 0,
child: DraggableGuideVertical(
initialX: guideX,
onDrag: (dx) {
// 这里 dx 是相对父级的局部增量
setState(() {
// 需要将偏移考虑进来?我们在外部直接用绝对坐标
guideX = (dx).clamp(0.0, canvasWidth - 1);
widget.data['x'] =
pxToUnit(guideX).toStringAsFixed(1);
widget.data["y"] = widget.data["y"] ?? 0;
});
},
getValue: () {
// 计算实际显示值:在画布坐标系中,左上角为0
// 还可以把 scroll offset 考虑在内,如果你要显示相对于屏幕的值的话
return pxToUnit(guideX);
},
),
),
// 横向参考线(可拖)
Positioned(
top: guideY.clamp(0.0, canvasHeight - 1),
left: 0,
right: 0,
child: DraggableGuideHorizontal(
initialY: guideY,
onDrag: (dy) {
setState(() {
guideY = (dy).clamp(0.0, canvasHeight - 1);
widget.data["x"] = widget.data["x"] ?? 0;
widget.data['y'] =
pxToUnit(guideY).toStringAsFixed(1);
});
},
getValue: () => pxToUnit(guideY),
),
),
],
),
),
),
]);
}),
);
}
}
/// 画布上的细格(可选)
class GridPainter extends CustomPainter {
final double spacing;
GridPainter({this.spacing = 50});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.grey.shade300
..strokeWidth = 0.5;
for (double x = 0; x < size.width; x += spacing) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
for (double y = 0; y < size.height; y += spacing) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
}
@override
bool shouldRepaint(covariant GridPainter oldDelegate) => false;
}
/// 绘制直尺(水平或垂直)
class RulerPainterWidget extends StatefulWidget {
final double length; // 画布对应的长度(像素)
final double thickness; // 直尺占用的宽或高
final bool isHorizontal;
final double cmPx; // 1cm 对应的像素
const RulerPainterWidget({
super.key,
required this.length,
required this.thickness,
required this.isHorizontal,
required this.cmPx,
});
@override
State<RulerPainterWidget> createState() => _RulerPainterWidgetState();
}
class _RulerPainterWidgetState extends State<RulerPainterWidget> {
@override
Widget build(BuildContext context) {
return CustomPaint(
size: widget.isHorizontal
? Size(widget.length, widget.thickness)
: Size(widget.thickness, widget.length),
painter: _RulerPainter(
length: widget.length,
thickness: widget.thickness,
isHorizontal: widget.isHorizontal,
cmPx: widget.cmPx,
),
);
}
}
class _RulerPainter extends CustomPainter {
final double length;
final double thickness;
final bool isHorizontal;
final double cmPx; // 1cm 对应的像素
_RulerPainter({
required this.length,
required this.thickness,
required this.isHorizontal,
required this.cmPx,
});
@override
void paint(Canvas canvas, Size size) {
final bg = Paint()..color = Colors.grey.shade200;
canvas.drawRect(Offset.zero & size, bg);
final tickPaint = Paint()..color = Colors.black;
const textStyle = TextStyle(fontSize: 10, color: Colors.black);
final mmPx = cmPx / 10; // 每1mm对应的像素
final totalMm = (length / mmPx).floor(); // 总共多少毫米
for (int i = 0; i <= totalMm; i++) {
final pos = i * mmPx;
double tickLength = thickness * 0.25; // 默认短线(1mm)
// 每5mm是中线
if (i % 5 == 0) {
tickLength = thickness * 0.45;
}
// 每10mm(1cm)是长线并标数字
if (i % 10 == 0) {
tickLength = thickness * 0.6;
final cmValue = (i / 10).round();
final tp = TextPainter(
text: TextSpan(text: '$cmValue', style: textStyle),
textDirection: TextDirection.ltr,
)..layout();
if (isHorizontal) {
tp.paint(
canvas, Offset(pos + 2, thickness - tickLength - tp.height - 2));
} else {
canvas.save();
canvas.translate(2 + tp.height, pos - tp.width / 2);
canvas.rotate(-math.pi / 2);
tp.paint(canvas, Offset.zero);
canvas.restore();
}
}
// 绘制线
if (isHorizontal) {
canvas.drawLine(
Offset(pos, thickness),
Offset(pos, thickness - tickLength),
tickPaint,
);
} else {
canvas.drawLine(
Offset(thickness, pos),
Offset(thickness - tickLength, pos),
tickPaint,
);
}
}
}
@override
bool shouldRepaint(covariant _RulerPainter oldDelegate) {
return oldDelegate.length != length ||
oldDelegate.thickness != thickness ||
oldDelegate.cmPx != cmPx;
}
}
/// 可拖动的竖线组件
class DraggableGuideVertical extends StatefulWidget {
final double initialX;
final void Function(double newX) onDrag;
final double Function() getValue;
const DraggableGuideVertical({
super.key,
required this.initialX,
required this.onDrag,
required this.getValue,
});
@override
State<DraggableGuideVertical> createState() => _DraggableGuideVerticalState();
}
class _DraggableGuideVerticalState extends State<DraggableGuideVertical> {
late double x;
@override
void initState() {
super.initState();
x = widget.initialX;
}
@override
void didUpdateWidget(covariant DraggableGuideVertical oldWidget) {
super.didUpdateWidget(oldWidget);
x = widget.initialX;
}
@override
Widget build(BuildContext context) {
// thin clickable area with visible vertical line
return GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragUpdate: (details) {
setState(() {
x += details.delta.dx;
widget.onDrag(x);
});
},
child: MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
child: Container(
width: 30, // 点击热区(包含线)
child: Stack(
// 允许超出
clipBehavior: Clip.none,
children: [
Positioned(
left: 0, // 线在中间
top: 0,
bottom: 0,
child: Container(width: 2, color: Colors.red.withOpacity(0.9)),
),
Positioned(
bottom: 0,
left: -40,
child: Container(
width: 80,
height: 40,
decoration: const BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(40),
topRight: Radius.circular(40),
),
),
alignment: Alignment.center,
child: Text(
"${widget.getValue().toStringAsFixed(1)} cm",
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
),
],
),
),
),
);
}
}
/// 可拖动的横线组件
class DraggableGuideHorizontal extends StatefulWidget {
final double initialY;
final void Function(double newY) onDrag;
final double Function() getValue;
const DraggableGuideHorizontal({
super.key,
required this.initialY,
required this.onDrag,
required this.getValue,
});
@override
State<DraggableGuideHorizontal> createState() =>
_DraggableGuideHorizontalState();
}
class _DraggableGuideHorizontalState extends State<DraggableGuideHorizontal> {
late double y;
@override
void initState() {
super.initState();
y = widget.initialY;
}
@override
void didUpdateWidget(covariant DraggableGuideHorizontal oldWidget) {
super.didUpdateWidget(oldWidget);
y = widget.initialY;
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragUpdate: (details) {
setState(() {
y += details.delta.dy;
widget.onDrag(y);
});
},
child: MouseRegion(
cursor: SystemMouseCursors.resizeRow,
child: SizedBox(
height: 30,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
top: 0,
child:
Container(height: 2, color: Colors.blue.withOpacity(0.9)),
),
Positioned(
right: -20,
top: -20,
child: Transform.rotate(
angle: -pi / 2,
child: Container(
width: 80,
height: 40,
decoration: const BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(40),
topRight: Radius.circular(40),
),
),
alignment: Alignment.center,
child: Text(
"${widget.getValue().toStringAsFixed(1)} cm",
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
),
),
],
),
),
),
);
}
}
11.点击返回小部件列表,我们可以看到刚才添加的小部件已经显示在列表中了
12.点击小部件面板图标,在我的小部件中找到直尺小部件

13.拖动直尺小部件到自动扩展布局中,然后在右侧的属性编辑器中配置属性,属性信息如下:
-
初始值:
data

14.点击页面管理,点击添加页面图标

15.点击新组件,在创建空白页面中点击创建页面

16.组件信息添加以下内容后点击保存
- 组件名称:saveRuler
- 组件别名:保存尺子数据弹窗
- 组件描述:通过此弹窗保存尺子数据
17.选中自定义组件,配置如下内容
- 宽:300
- 高:400
18.拖动容器到自定义组件中,选中容器并做以下配置:
-
宽:
无限 -
高:
无限 -
圆角:
10 -
内边距:
10
-
背景颜色:
#FFFFFFFF


19.拖动上下布局到容器中,然后在右侧的属性编辑器中配置属性,属性信息如下:
-
主轴对齐:
两端对齐 -
次轴对齐:
居中对齐

20.拖动左右布局到上下布局中,然后在右侧的属性编辑器中配置属性,属性信息如下:
-
主轴对齐:
两端对齐 -
次轴对齐:
居中对齐

21.拖动容器到左右中,然后在右侧的属性编辑器中配置属性,属性信息如下:
-
宽:50
-
高:80
-
背景颜色:#00FFFFFF

22.拖动文本小部件到左右布局中,然后在右侧的属性编辑器中配置属性,属性信息如下:
- 文本内容:保存
- 文本大小:28
- 字体粗细:700

23.拖动按钮小部件左右内容中,然后在右侧的属性编辑器中配置属性,属性信息如下:
-
宽:
50 -
高:50
-
背景颜色:#00FCFCFC
-
阴影大小:0

24.拖动图标到按钮小部件中,然后在右侧的属性编辑器中配置属性,属性信息如下:
-
图标选择:关闭图标
-
图标大小:30
-
图标颜色:#FF9E9E9E

25.拖动上下布局到上下布局中,然后在右侧的属性编辑器中配置属性,属性信息如下:
26.拖动容器到上下布局中,然后在右侧的属性编辑器中配置属性,属性信息如下:
- 宽:无限
- 高:60
- 下边框
- 样式:第二个图标
- 宽度:1
- 颜色:#FFCCCCCC
- 背景颜色:#00FFFFFF
- 对齐方式居中


27.拖动左右布局到容器中
28.拖动文本到左右布局中,然后在右侧的属性编辑器中配置属性,属性信息如下:
- 文本内容:X:
- 字体大小:16
- 字体粗细:700

29.拖动文本到左右布局中,然后在右侧的属性编辑器中配置属性,属性信息如下:
- 文本内容:${data['x']}
- 字体大小:16

30.在部件树中选中容器,右击点击复制,选中上下布局,右键选择粘贴
31.选中刚粘贴的X:文本小部件,并改为Y:
32.选中刚粘贴的${data['x']}文本小部件,并改为${data['y']}
33.拖动左右布局到上下布局中
34.拖动文本到左右布局中,然后在右侧的属性编辑器中配置属性,属性信息如下:
- 文本内容:备注:
- 字体大小:16
- 字体粗细:700

35:拖动容器到左右布局中,然后在右侧的属性编辑器中配置属性,属性信息如下:
- 宽:200
- 高:50
- 下边框
- 样式:第二个图标
- 宽度:1
- 颜色:#FFCCCCCC
- 背景色:#00FFFFFF

36.点击右上角的页面设置,添加一个变量
- 变量名:note
- 变量类型:String
- 变量描述:备注内容
37.点击小部件面板,拖动文本输入框到容器中,然后在右侧的属性编辑器中配置属性,属性信息如下:
- 数据绑定:note
- 宽:200
- 高:50
- 输入装饰属性
- 边框:无

38.拖动左右布局到上下布局中,然后在右侧的属性编辑器中配置属性,属性信息如下:
- 主轴对齐:居中
- 次轴对齐:居中

39.拖动按钮到左右布局中,然后在右侧的属性编辑器中配置属性,属性信息如下:
- 宽:160
- 高:40
- 背景颜色:Primary
- 阴影大小:4
- 圆角:10

40.拖动文本到按钮中,然后在右侧的属性编辑器中配置属性,属性信息如下:
- 文本内容:保存
- 字体粗细:500
- 字体颜色:#FFFFFFFF

至此页面搭建完成
配置逻辑(动作流程图)
配置关闭逻辑
1.点击关闭按钮,在右侧配置中选择动作,打开动作 → 点击 编辑流程图

2.在单击事件中添加一个动作
- 动作类型:导航,选中关闭弹窗、抽屉等


配置保存按钮逻辑
1.点击关闭按钮,在右侧配置中选择动作,打开动作 → 点击 编辑流程图

2.在单击事件中添加一个动作并选中
- 动作类型:自定义代码块
- 新增自定义代码块
- 代码块名称:保存数据
- 自定义代码块
void function(){
String id = DateTime.now().toString().substring(0, 19);
String x = data['x'];
String y = data['y'];
SQLiteUtil().execute(
'''INSERT INTO data (id, x, y, note, type) values ( '$id', '$x', '$y', '$note', 'ruler')''');
}


3.再添加一个动作并配置以下信息
- 动作类型:导航,选中关闭弹窗、抽屉等


配置直尺页面逻辑
点击页面管理,选择直尺测量页面
配置保存按钮逻辑
1.点击关闭按钮,在右侧配置中选择动作,打开动作 → 点击 编辑流程图

2.在单击事件中添加一个动作
- 动作类型:弹窗
- 弹窗类型:中间弹窗
- 弹窗动作:打开
- 弹窗大小
- 弹窗宽度:300
- 弹窗高度:400
- 弹窗内容:选择自定义组件—>saveRuler
- 弹窗参数:入参:变量->data


配置历史记录按钮逻辑
1.点击历史记录按钮,在右侧配置中选择动作,打开动作 → 点击 编辑流程图

2.在单击事件中添加一个动作
- 动作类型:弹窗
- 导航到:History
- 跳转方式:打开新页面
- 动画类型:系统默认
- 携带参数
- 参数的key值:type
- 参数的数据:常量->ruler



至此,页面及逻辑全部设置完成。
配置路由并开始打包
1.将直尺测量页面的路由输入框内容清空,使其作为根路由页面。

2.需要修改DecibelMeterMeasurement (分贝仪测量页面)的路由名称,避免出现两个根路由导致报错。在页面管理中选择DecibelMeterMeasurement (分贝仪测量页面),将其路由修改为 decibelMeterMeasurement。

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

在弹窗中做如下配置
-
平台:Android
-
版本号:1.0.0
-
选择Flutter版本:最新版
-
点击下载

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

实机效果
打开 App 进入直尺测量页面。


更多推荐



所有评论(0)