Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(三)

Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(三)

Flutter: 3.35.6

因为实现了单个的,给出github链接:github.com/yhtqw/Front...

前面我们简单实现了元素的平移和缩放,接下来我们继续实现旋转功能。

元素的旋转会改变角度,角度一变,那么响应事件的热区也会跟着改变,所以我们得提前考虑这些会因为角度改变而改变的地方。

先来简单实现一下旋转,先不考虑上述的热区问题。

要实现旋转,我们就得知道元素的旋转角度,主要得出旋转的角度,那么实现起来就比较简单,所以简单使用数学的知识分析一下吧

从我们这个需求中可以提取到的数据为按下点的坐标,拖动时变换的坐标;所以我们能否根据一个点的坐标,计算出该点与某点形成的夹角,好像刚好有个满足部分,就是arctan2,arctan2的主要作用是根据一个点的坐标,计算出该点与坐标原点所形成的夹角(主要作用);如果我们要知道给出点与任意(x', y')形成的夹角呢?前面的arctan2中将坐标原点换成任意某点不就行了?

使用 arctan2 计算两点连线的角度,核心是计算两点之间的坐标差 (Δx, Δy),然后将其作为 arctan2 的参数。所以实现起来就比较简单了。其实这个实现的原理在很多地方都一样,例如web端的元素拖动旋转也可以使用这个原理。实现的方式应该不止一种吧,只要能计算出这个角度就行了。

值得注意的是,现在我们研究的是单个元素,所以坐标系就是以元素自身形成的(响应的事件也是在这个元素上),等后期要实现多个,坐标系就得以外层容器作为参考了。

dart 复制代码
// 其他省略...

/// 新增旋转状态热区字符串
const String statusRotate = 'rotate';

/// 抽取响应旋转操作区域的大小
final double rotateWidth = 20;
final double rotateHeight = 20;

/// 旋转角度
double rotateNumber = 0;
double initRotateNumber = 0;

void _onPanUpdate(DragUpdateDetails details) {
  print('更新: $details');
  if (status == statusMove) {
    _onMove(details.localPosition.dx, details.localPosition.dy);
  } else if (status == statusScale) {
    _onScale(details.delta.dx, details.delta.dy);
  } else if (status == statusRotate) {
    // 新增旋转热区的响应事件
    _onRotate(details.localPosition.dx, details.localPosition.dy);
  }
}

void _onPanEnd() {
  print('抬起或者因为某些原因并没有触发onPanDown事件');
  setState(() {
    // 当次结束后重新记录,也可以在按下时记录
    initX = x;
    initY = y;
    // 新增旋转角度的记录
    initRotateNumber = rotateNumber;
  });
}

/// 处理旋转
void _onRotate(double dx, double dy) {
  /// 要计算点 (x, y) 与任意点 (x', y') 连线所成的角度,可以使用 arctan2 函数。
  /// 关键在于将两点之间的相对坐标差作为 arctan2 的输入参数。
  /// 这里我们以元素的中心为旋转中心
  /// 利用上述方法计算起始点(按下时)与中心的连线组成的夹角为初始夹角,
  /// 拖动的点与中心点连线组层的夹角为结束时的夹角,
  /// 通过初始夹角与结束夹角计算旋转的角度

  // 确定旋转中心,因为这里的拖动是单个元素,坐标都是相对于元素自身形成的坐标系,所以坐标中心始终都是元素的中心
  double centerX = elementWidth / 2;
  double centerY = elementHeight / 2;

  double diffStartX = startPosition.dx - centerX;
  double diffStartY = startPosition.dy - centerY;
  double diffEndX = dx - centerX;
  double diffEndY = dy - centerY;
  double angleStart = atan2(diffStartY, diffStartX);
  double angleEnd = atan2(diffEndY, diffEndX);

  setState(() {
    rotateNumber = initRotateNumber + angleEnd - angleStart;
  });
}

/// 判断点击在什么区域
String? _onDownZone(double x, double y) {
  if (
    x >= elementWidth - scaleWidth &&
    x <= elementWidth &&
    y >= elementHeight - scaleHeight &&
    y <= elementHeight
  ) {
    return statusScale;
  } else if (
    x >= elementWidth - rotateHeight &&
    x <= elementWidth &&
    y >= 0 &&
    y <= rotateHeight
  ) {
    // 固定右上角为旋转热区
    return statusRotate;
  } else if (
    x >= 0 &&
    x <= elementWidth &&
    y >= 0 &&
    y <= elementHeight
  ) {
    return statusMove;
  }

  return null;
}

// 新增响应旋转操作
Positioned(
  left: x,
  top: y,
  child: Transform.rotate(
    angle: rotateNumber,
    child: GestureDetector(
      onPanDown: _onPanDown,
      onPanUpdate: _onPanUpdate,
      onPanEnd: (details) => _onPanEnd(),
      onPanCancel: _onPanEnd,
      child: Container(
        width: elementWidth,
        height: elementHeight,
        color: Colors.transparent,
        child: Stack(
          alignment: Alignment.center,
          clipBehavior: Clip.none,
          children: [
            Container(
              width: elementWidth,
              height: elementHeight,
              color: Colors.amber,
            ),

            // 响应旋转操作
            Positioned(
              top: 0,
              right: 0,
              child: Container(
                width: scaleWidth,
                height: scaleHeight,
                color: Colors.white,
              ),
            ),

            // 响应缩放操作
          ],
        ),
      ),
    ),
  ),
),

// 其他省略...

运行效果:

这样就简单实现了旋转。然后我们继续考虑热区的问题,当旋转一定角度的时候,再次点击对应的热区,就无法响应事件了,因为旋转后热区坐标已经发生改变,所以我们得对点击判断中加入角度的影响。

已知某点坐标和旋转角度,求旋转后的坐标值?

要计算旋转后的坐标,可以使用旋转矩阵。给定一个点 (x, y) 绕原点逆时针旋转角度 θ 后的新坐标 (x', y') 计算公式如下:

x' = x * cosθ - y * sinθ; y' = x * sinθ + y * cosθ;

如果我们是绕任意点而不是原点,需要先平移坐标系

  1. 平移: 将 (x, y) 平移到原点,新坐标为 (x - a, y - b);
  2. 旋转: 按照上述公式计算 (x', y');
  3. 平移回原坐标系: 新坐标为(x' + a, y' + b)。

基于上面的公式,我们更改热区点击判断方法:

dart 复制代码
/// 判断点击在什么区域
String? _onDownZone(double x, double y) {
  final offsetScale = rotatePoint(elementWidth, elementHeight);
  // 设置都是最大的顶点坐标,方便下面判断区域的方式结构一致
  // 后续就好抽取方法
  final offsetRotate = rotatePoint(elementWidth, rotateHeight);

  if (
    x >= offsetScale.dx - scaleWidth &&
    x <= offsetScale.dx &&
    y >= offsetScale.dy - scaleHeight &&
    y <= offsetScale.dy
  ) {
    return statusScale;
  } else if (
    x >= offsetRotate.dx - rotateHeight &&
    x <= offsetRotate.dx &&
    y >= offsetRotate.dy - rotateHeight &&
    y <= offsetRotate.dy
  ) {
    return statusRotate;
  } else if (
    x >= 0 &&
    x <= elementWidth &&
    y >= 0 &&
    y <= elementHeight
  ) {
    return statusMove;
  }

  return null;
}

/// 计算旋转后的点坐标
Offset rotatePoint(double x, double y) {
  final deg = rotateNumber * pi / 180;
  // 确定旋转中心,因为这里的拖动是单个元素,坐标都是相对于元素自身形成的坐标系,所以坐标中心始终都是元素的中心
  final centerX = elementWidth / 2;
  final centerY = elementHeight / 2;
  final diffX = x - centerX;
  final diffY = y - centerY;

  final dx = diffX * cos(deg) - diffY * sin(deg) + centerX;
  final dy = diffX * sin(deg) + diffY * cos(deg) + centerY;
  return Offset(dx, dy);
}

可以看到的是旋转和缩放热区即使在旋转后依然能够正常响应,还有最后一点,就是移动的时候也要应用旋转角度计算,因为我们使用的是元素自身为坐标系,坐标系旋转了,自然移动时的计算方式也得跟着变,其实对于后期将事件应用到容器上了过后就不需要考虑这些了,因为外层容器并不会变换,所以后期不使用逆运算,所以我们这里直接使用globalPosition来计算值即可(变换计算坐标感兴趣的可以自行研究一下):

dart 复制代码
void _onPanDown(DragDownDetails details) {
  print('按下: $details');

  String? tempStatus = _onDownZone(details.localPosition.dx, details.localPosition.dy);

  print(tempStatus);

  setState(() {
    if (tempStatus == statusMove) {
      // 如果是移动,则使用globalPosition
      startPosition = details.globalPosition;
    } else {
      startPosition = details.localPosition;
    }
    status = tempStatus;
  });
}

void _onPanUpdate(DragUpdateDetails details) {
  print('更新: $details');
  if (status == statusMove) {
    _onMove(details.globalPosition.dx, details.globalPosition.dy);
  } else if (status == statusScale) {
    _onScale(details.delta.dx, details.delta.dy);
  } else if (status == statusRotate) {
    _onRotate(details.localPosition.dx, details.localPosition.dy);
  }
}

这样就对单个元素实现了变换的效果,前置就算时铺垫完成了,后续就开始实现多个的。

感兴趣的也可以关注我的微信公众号【前端学习小营地】,不定时会分享一些小功能~

今天的分享到此结束,感谢阅读~拜拜~

相关推荐
用户12039112947265 小时前
从零掌握 React JSX:为什么它让前端开发像搭积木一样简单?
前端·react.js·面试
Jonathan Star5 小时前
git commit --amend 是 Git 中用于修改最后一次提交的核心命令
前端·chrome·git
测试人社区—小叶子5 小时前
移动开发新宠:用Flutter 4.0快速构建跨平台应用
运维·网络·人工智能·测试工具·flutter·自动化
在掘金801105 小时前
RequireJS 详解
前端·javascript
小a杰.5 小时前
Flutter 测试驱动开发的基本流程
驱动开发·flutter
小a杰.5 小时前
Flutter 就业市场深度分析
flutter
嗝o゚5 小时前
Flutter适配鸿蒙多屏异构UI开发实战
flutter·开源·wpf·harmonyos
派大鑫wink5 小时前
Python 流程控制实战:打造文字版数独小游戏(新手友好)
服务器·前端·microsoft
飛6795 小时前
玩转 Flutter 自定义 Painter:从零打造丝滑的仪表盘动效与可视化图表
开发语言·javascript·flutter