欢迎光临
我们一直在努力

工具箱APP开发(08):直尺开发

视频链接:哔哩哔哩

直尺功能开发

上一节我们讲解了工具箱 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)
未经允许不得转载:171主机测评 » 工具箱APP开发(08):直尺开发
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址