视频链接:哔哩哔哩

直尺功能开发

上一节我们讲解了工具箱 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 进入直尺测量页面。

Logo

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

更多推荐