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

50 阅读5分钟

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

Flutter: 3.35.6

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

缩放的功能大概率是在元素的某个区域内响应事件,前期我们以快速实现功能为主,所以暂定右下角为响应缩放操作的热区。因为现在我们还是研究一个元素的操作,所以事件都设置在这个元素上。既然定了右下角为响应缩放的区域,那么元素内的其他区域则响应移动,所以就得区分按下的区域在哪里,通过点击所在区域来判断响应的事件,基于前面的代码更改我们需要新增按下区域的判断逻辑,抽取移动事件应该响应操作的方法,所以做出如下的更改:

import 'package:flutter/material.dart';

/// 抽取状态字符串,用于判断点击的区域
const String statusMove = 'move';
const String statusScale = 'scale';

class TransformContainer extends StatefulWidget {
  const TransformContainer({super.key});

  @override
  State createState() => _TransformContainerState();
}

class _TransformContainerState extends State {
  /// 抽取容器的宽
  final double containerWidth = 300;
  /// 抽取容器的高
  final double containerHeight = 600;
  /// 抽取响应缩放操作区域的大小
  final double scaleWidth = 20;
  final double scaleHeight = 20;

  /// 抽取元素的宽
  double elementWidth = 100;
  /// 抽取元素的高
  double elementHeight = 100;
  double x = 10;
  double y = 10;
  double initX = 10;
  double initY = 10;
  Offset startPosition = Offset(0, 0);
  /// 响应操作的状态字符串
  String? status;

  void _onPanDown(DragDownDetails details) {
    print('按下: $details');

    // 通过点击的区域判断当前元素应该处以什么操作状态
    String? tempStatus = _onDownZone(
      details.localPosition.dx,
      details.localPosition.dy,
    );

    print(tempStatus);

    setState(() {
      startPosition = details.localPosition;
      status = tempStatus;
    });
  }

  void _onPanUpdate(DragUpdateDetails details) {
    print('更新: $details');
    if (status == statusMove) {
      // 如果在移动区域,则响应移动事件
      _onMove(details.localPosition.dx, details.localPosition.dy);
    } else if (status == statusScale) {
      // 如果在缩放区域,则响应缩放事件
      _onScale();
    }
  }

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

  /// 抽取处理移动的方法
  void _onMove(double dx, double dy) {
    setState(() {
      // 计算方法
      x = initX + dx - startPosition.dx;
      y = initY + dy - startPosition.dy;

      // 限制左边界
      if (x < 0) {
        x = 0;
      }
      // 限制右边界
      if (x > containerWidth - elementWidth) {
        x = containerWidth - elementWidth;
      }
      // 限制上边界
      if (y < 0) {
        y = 0;
      }
      // 限制下边界
      if (y > containerHeight - elementHeight) {
        y = containerHeight - elementHeight;
      }
    });
  }

  /// 处理缩放
  void _onScale() {
    // 实现缩放逻辑
  }

  /// 判断点击在什么区域
  String? _onDownZone(double x, double y) {
    if (
      x >= elementWidth - scaleWidth &&
      x <= elementWidth &&
      y >= elementHeight - scaleHeight &&
      y <= elementHeight
    ) {
      // 右下角响应缩放操作
      return statusScale;
    } else if (
      x >= 0 &&
      x <= elementWidth &&
      y >= 0 &&
      y <= elementHeight
    ) {
      // 在元素内部则响应移动操作
      return statusMove;
    }

    return null;
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      width: containerWidth,
      height: containerHeight,
      color: Colors.blueAccent,
      child: Stack(
        children: [
          Positioned(
            left: x,
            top: y,
            child: GestureDetector(
              onPanDown: _onPanDown,
              onPanUpdate: _onPanUpdate,
              onPanEnd: (details) => _onPanEnd(),
              onPanCancel: _onPanEnd,
              child: SizedBox(
                width: elementWidth,
                height: elementHeight,
                child: Stack(
                  alignment: Alignment.center,
                  clipBehavior: Clip.none,
                  children: [
                    Container(
                      width: elementWidth,
                      height: elementHeight,
                      color: Colors.amber,
                    ),

                    // 响应缩放操作
                    Positioned(
                      bottom: 0,
                      right: 0,
                      child: Container(
                        width: scaleWidth,
                        height: scaleHeight,
                        color: Colors.white,
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

运行效果:

image01.gif

可以看到,我们抽取了响应的区域,当点击对应的区域则可以实现响应的操作。接下来我们实现缩放的逻辑。

通过按下移动的过程中,我们可以得到下面这些数据:

  • globalPosition: 提供指针在全局坐标系中的当前位置坐标
  • localPosition: 表示指针相对于当前监听组件坐标系的位置坐标
  • delta: 表示自上次更新以来的位置偏移量

缩放主要是对这个元素的宽高进行,使用delta中的数据改变宽高即可:

// 其他省略...

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);
  }
}

/// 处理缩放
void _onScale(double dx, double dy) {
  // 宽高加上每次移动的偏移量实现缩放
  setState(() {
    elementHeight += dy;
    elementWidth += dx;
  });
}

// 其他省略...

运行效果:

image02.gif

这样就简单的实现了缩放,如果没有其他的要求,不在意拉伸挤压的话,这就完成了,实际上肯定有需要保持等比例缩放(例如对一张图片的缩放,如果拉伸挤压后就很难看了)。

实现等比例拉伸,我们需要以某个标准,例如以宽还是以高,如果以宽为标准,则宽度变化时,高度通过初始宽度和现在宽度的比例计算得出,反之亦然(或者以变化过程中变化较快的一边为标准):

/// 新增是否使用等比例
bool useProportional = true;

/// 处理缩放
void _onScale(double dx, double dy) {
  if (useProportional) {
    // 以宽为基准等比计算高度
    double tempWidth = elementWidth + dx;

    setState(() {
      elementHeight = elementHeight * (tempWidth / elementWidth);
      elementWidth = tempWidth;
    });
  } else {
    setState(() {
      elementHeight += dy;
      elementWidth += dx;
    });
  }
}

运行效果:

image03.gif

可以看到,现在就简单实现了等比缩放了,目前这样依然有一个问题,如果我们缩小到负数,元素的宽高就变成了负值,这明显会报错,所以我们得对极限情况做出修正:

/// 新增最小值
final double minWidth = 40;
final double minHeight = 40;

/// 处理缩放
void _onScale(double dx, double dy) {
  double tempWidth = elementWidth + dx;

  // 单独演示使用宽度为基准,后面可以更改为以短边为基准,处理的逻辑差不多
  // 限制边界值,使用clamp和下面注释的逻辑是一样的
  tempWidth = tempWidth.clamp(minWidth, containerWidth);
  // if (tempWidth < minWidth) {
  //   tempWidth = minWidth;
  // } else if (tempWidth > containerWidth) {
  //   tempWidth = containerWidth;
  // }

  if (useProportional) {
    setState(() {
      elementHeight = elementHeight * (tempWidth / elementWidth);
      elementWidth = tempWidth;
    });
  } else {
    setState(() {
      elementHeight += dy;
      elementWidth = tempWidth;
    });
  }
}

运行效果:

image04.gif

这样我们又对边界情况做出了限制。接下来我们实现以元素中心为缩放中心的效果,如果我们想以元素的中心为缩放的中心(现在的中心是元素的左上角),这样在缩放过程中,对应的坐标值也得更着变化,缩放从原来的一边转换为双边。

之前的缩放以左上角为缩放中心,现在需要以元素中心为准,则在变化过程中加上双倍的变化值(中心向两边扩张或者缩小),再将坐标值变化对应的移动值即可(左上角坐标因为中心的变换需要跟着变化):

/// 处理缩放
void _onScale(double dx, double dy) {
  // 加上双倍的变换值
  double tempWidth = elementWidth + dx * 2;

  // 限制边界值
  tempWidth = tempWidth.clamp(minWidth, containerWidth);

  if (useProportional) {
    double tempHeight = elementHeight * (tempWidth / elementWidth);
    setState(() {
      x -= (tempWidth - elementWidth) / 2;
      y -= (tempHeight - elementHeight) / 2;
      elementHeight = tempHeight;
      elementWidth = tempWidth;
    });
  } else {
    // 非等比缩放直接应用变化
    setState(() {
      elementHeight += dy * 2;
      elementWidth = tempWidth;
      x -= dx;
      y -= dy;
    });
  }
}

运行效果:

image05.gif

这样就简单实现了元素的缩放,因为只是快速演示,所以考虑的情况没有那么复杂,部分极限的情况也没有做限制。

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

实现缩放的功能就到此结束,感谢阅读,拜拜~