当前位置: 首页 > news >正文

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

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

Flutter: 3.35.6

前面我们实现了单个元素的,现在实现多个元素的。因为有前面功能的落地实现,我们也可以对于部分属性的提前抽取,部分数据模型的提前封装。

还是按照简单到复杂的实现思路,我们先对容器部分进行简单分析。前面也提到最后的手势操作提升到容器,因为对比给每个子元素设置手势,这样的内存开销会减小很多;目前容器的基础属性有宽和高,后期如果需要新的属性直接再添加即可:

import'package:flutter/material.dart';classMultipleTransformContainerextendsStatefulWidget{constMultipleTransformContainer({super.key,this.containerWidth,this.containerHeight,});/// 容器的宽,不传默认为父容器的最大宽度finaldouble?containerWidth;/// 容器的高,不传默认为父容器的最大高度finaldouble?containerHeight;@overrideState<MultipleTransformContainer>createState()=>_MultipleTransformContainerState();}class_MultipleTransformContainerStateextendsState<MultipleTransformContainer>{/// 按下事件void_onPanDown(DragDownDetails details){}/// 按下移动事件void_onPanUpdate(DragUpdateDetails details){}/// 结束事件void_onPanEnd(){}@overrideWidgetbuild(BuildContext context){returnGestureDetector(onPanDown:_onPanDown,onPanUpdate:_onPanUpdate,onPanEnd:(details)=>_onPanEnd(),onPanCancel:_onPanEnd,child:Container(width:widget.containerWidth??double.infinity,height:widget.containerHeight??double.infinity,color:Colors.transparent,),);}}

接下来对子元素进行简单分析。子元素主要分为三个部分,一个是自身的属性(随着变换操作而变化),一个是中间临时的变量值(响应单次事件过程中需要初始化和中间临时改变的值),一个是操作的区域(响应变换的事件)。

结合前面的单个案例,我们可以提取子元素的部分属性:

  • 元素宽度:一般来说元素的宽属性为必传,如果有默认值可能会导致后期元素拉伸,所以限制为必传
  • 元素高度:和宽一样
  • 元素的x坐标:坐标就可以设置初始的默认值了,因为不会对元素自身形成拉伸压缩效果
  • 元素的y坐标:和x一样
  • 旋转角度:和x一样
  • id:用于确定当前操作的元素
import'../configs/constants_config.dart';classElementModel{constElementModel({requiredthis.id,requiredthis.elementWidth,requiredthis.elementHeight,this.x=ConstantsConfig.initX,this.y=ConstantsConfig.initY,this.rotationAngle=ConstantsConfig.initRotationAngle,});/// 当前元素的唯一idfinalint id;/// 元素的宽finaldouble elementWidth;/// 元素的高finaldouble elementHeight;/// 元素的x坐标finaldouble x;/// 元素的y坐标finaldouble y;/// 元素的旋转角度finaldouble rotationAngle;ElementModelcopyWith({double?elementWidth,double?elementHeight,double?x,double?y,double?rotationAngle,}){returnElementModel(id:id,elementWidth:elementWidth??this.elementWidth,elementHeight:elementHeight??this.elementHeight,x:x??this.x,y:y??this.y,rotationAngle:rotationAngle??this.rotationAngle,);}}
/// 用于设置一些初始化值classConstantsConfig{/// 元素的初始化x坐标staticconstdouble initX=10;/// 元素的初始化y坐标staticconstdouble initY=10;/// 元素的初始化旋转角度staticconstdouble initRotationAngle=0;}

结合前面的案例,我们抽取临时中间变量如下:

  • x坐标:单次操作开始时的x坐标,同上次操作结束时的x坐标
  • y坐标:逻辑和x一样
  • 旋转角度:逻辑和x一样
  • 操作状态值
/// 元素当前操作状态enumElementStatus{move,rotate,scale,}/// 元素的临时中间变量classTemporaryModel{constTemporaryModel({requiredthis.x,requiredthis.y,requiredthis.rotationAngle,this.status,});/// 单次操作完成时的初始x坐标finaldouble x;/// 单次操作完成时的初始y坐标finaldouble y;/// 单次操作完成时的初始旋转角度finaldouble rotationAngle;/// 对应的元素的操作状态finalElementStatus?status;TemporaryModelcopyWith({double?x,double?y,double?rotationAngle,ElementStatus?status,}){returnTemporaryModel(x:x??this.x,y:y??this.y,rotationAngle:rotationAngle??this.rotationAngle,status:status??this.status,);}}

接下来就是控制操作区域,其实在使用 javascript 实现该功能的时候也分析过,所以这里直接基于这个来做一个简单的说明(难免会站在上帝视角)。

因为常规来说控制的区域位于元素容器的四个顶点处,如果我们也想要自定义去他区域,就要给出相应的计算区域的方式;这里给出一种确定响应区域的计算方式,基于元素本身创建一个坐标系,坐标原点为元素的左上角,使用元素的总体宽高和响应区域中心点来计算出一个比例,通过这个比例就能让我们使用区域内包括区域外的任意区域来做响应的区域,例如,元素整体宽高为20*20,我需要响应区域的中心点在右上角(20, 0),所以这个比例就是 (x: 20/20,y: 0/20)。计算方式有了,下面就该确定响应区域的样式,常规来说一般就是一张图片,我们前期就以图片为主,后面就当作扩展功能允许自定义。最后一点就是该响应区域的触发方式是什么,例如有些操作是响应点击操作(删除,镜像等等),有些操作是响应按下移动操作(移动,缩放,旋转等等),所以我们还需要一个触发方式。基于此我们开始抽取响应区域:

import'element_model.dart';enumTriggerMethod{move,down,;}classResponseAreaModel{constResponseAreaModel({requiredthis.areaWidth,requiredthis.areaHeight,requiredthis.xRatio,requiredthis.yRatio,requiredthis.status,requiredthis.icon,requiredthis.trigger,});/// 响应区域的宽finaldouble areaWidth;/// 响应区域的高finaldouble areaHeight;/// 响应区域的比例横向finaldouble xRatio;/// 响应区域的比例竖向finaldouble yRatio;/// 响应区域应该响应什么操作finalElementStatus status;/// 响应区域的iconfinalString icon;/// 当前响应操作的触发方式finalTriggerMethod trigger;}

前期的准备工作差不多就完成了,下面我们简单来实现一个元素的移动。

现在是多个元素的,当前正在操作的肯定只有一个元素,所以按下的时候得选中元素,后续的操作就是作用于选中的元素,因为还只是移动操作,所以也先不考虑旋转。因为我们将容器的宽高设置成了可不传,但是我们操作过程中可能对于边界值需要用到容器的宽高做计算,所以备份一份,如果没有传递则通过GlobalKey去获取容器的宽高:

import'package:flutter/material.dart';import'models/element_model.dart';import'transform_item.dart';classMultipleTransformContainerextendsStatefulWidget{constMultipleTransformContainer({super.key,this.containerWidth,this.containerHeight,});/// 容器的宽,不传默认为父容器的最大宽度finaldouble?containerWidth;/// 容器的高,不传默认为父容器的最大高度finaldouble?containerHeight;@overrideState<MultipleTransformContainer>createState()=>_MultipleTransformContainerState();}class_MultipleTransformContainerStateextendsState<MultipleTransformContainer>{/// 用于获取容器的宽高finalGlobalKey _multipleTransformContainerGlobalKey=GlobalKey();finalList<ElementModel>_elementList=[ElementModel(id:DateTime.now().microsecondsSinceEpoch,elementWidth:100,elementHeight:100,),];/// 记录一份容器的宽高,用于没传递的时候有个真实的容器宽高double _containerWidth=0;double _containerHeight=0;/// 当前选中的元素ElementModel?_currentElement;/// 临时的中间变量,用于计算TemporaryModel?_temporary;/// 开始点击的位置Offset _startPosition=Offset(0,0);@overridevoidinitState(){super.initState();WidgetsBinding.instance.addPostFrameCallback((_){_getContainerSize();});}@overridevoiddispose(){_multipleTransformContainerGlobalKey.currentState?.dispose();super.dispose();}/// 获取容器的宽高属性,用于没传递容器宽高的时候有个真实的容器宽高void_getContainerSize(){double tempWidth=0;double tempHeight=0;if(widget.containerHeight!=null&&widget.containerWidth!=null){tempHeight=widget.containerHeight!;tempWidth=widget.containerWidth!;}else{tempWidth=_multipleTransformContainerGlobalKey.currentContext?.size?.width??0;tempHeight=_multipleTransformContainerGlobalKey.currentContext?.size?.height??0;}setState((){_containerHeight=tempHeight;_containerWidth=tempWidth;});}/// 按下事件void_onPanDown(DragDownDetails details){finaldx=details.localPosition.dx;finaldy=details.localPosition.dy;ElementModel?currentElement;TemporaryModel temp=TemporaryModel(x:0,y:0,rotationAngle:0);// 遍历判断当前点击的位置是否落在了某个元素的响应区域for(varitemin_elementList){finalstatus=_onDownZone(x:dx,y:dy,item:item);if(status!=null){currentElement=item;temp=temp.copyWith(status:status);break;}}if(currentElement!=null){// 如果点击的区域存在元素,并且点击区域存在的元素和当前选中的元素不是一个// 则选中该元素,并设置其部分初始化属性if(_currentElement?.id!=currentElement.id){_currentElement=currentElement;}_temporary=temp.copyWith(x:currentElement.x,y:currentElement.y,);_startPosition=Offset(dx,dy);setState((){});}else{// 如果点击的区域不存在元素,并且当前选中的元素不为null,则置空选中if(_currentElement!=null){_currentElement=null;_temporary=null;setState((){});}}}/// 按下移动事件void_onPanUpdate(DragUpdateDetails details){if(_currentElement==null||_temporary==null)return;if(_temporary?.status==ElementStatus.move){_onMove(x:details.localPosition.dx,y:details.localPosition.dy);}}/// 结束事件void_onPanEnd(){}/// 处理元素移动void_onMove({required double x,required double y}){if(_currentElement==null||_temporary==null)return;double tempX=_temporary!.x+x-_startPosition.dx;double tempY=_temporary!.y+y-_startPosition.dy;// 限制左边界if(tempX<0){tempX=0;}// 限制右边界if(tempX>_containerWidth-_currentElement!.elementWidth){tempX=_containerWidth-_currentElement!.elementWidth;}// 限制上边界if(tempY<0){tempY=0;}// 限制下边界if(tempY>_containerHeight-_currentElement!.elementHeight){tempY=_containerHeight-_currentElement!.elementHeight;}_currentElement=_currentElement!.copyWith(x:tempX,y:tempY,);_onChange();}/// 当前元素属性变化的时候更新列表中对应元素的属性void_onChange(){if(_currentElement==null||_temporary==null)return;for(vari=0;i<_elementList.length;i++){finalitem=_elementList[i];if(item.id==_currentElement?.id){_elementList[i]=item.copyWith(x:_currentElement?.x,y:_currentElement?.y,);setState((){});break;}}}/// 判断点击的区域////// 以传入的[item]元素为参考,/// 判断当前点击的坐标[x]和[y]落在[item]元素的哪个响应区域ElementStatus?_onDownZone({required double x,required double y,required ElementModel item,}){if(x>=item.x&&x<=item.elementWidth+item.x&&y>=item.y&&y<=item.elementHeight+item.y){// 判断移动区域,目前没有考虑元素的旋转returnElementStatus.move;}returnnull;}@overrideWidgetbuild(BuildContext context){returnGestureDetector(onPanDown:_onPanDown,onPanUpdate:_onPanUpdate,onPanEnd:(details)=>_onPanEnd(),onPanCancel:_onPanEnd,child:Container(key:_multipleTransformContainerGlobalKey,width:widget.containerWidth??double.infinity,height:widget.containerHeight??double.infinity,color:Colors.transparent,child:_containerWidth==0||_containerHeight==0?null:Stack(children:[..._elementList.map((item)=>TransformItem(elementItem:item,selected:item.id==_currentElement?.id,)),],),),);}}
import'package:flutter/material.dart';import'models/element_model.dart';/// 抽取渲染的元素classTransformItemextendsStatelessWidget{constTransformItem({super.key,requiredthis.elementItem,requiredthis.selected});finalElementModel elementItem;finalbool selected;@overrideWidgetbuild(BuildContext context){returnPositioned(left:elementItem.x,top:elementItem.y,child:Container(width:elementItem.elementWidth,height:elementItem.elementHeight,decoration:BoxDecoration(color:selected?Colors.amberAccent:Colors.blueAccent,),),);}}

运行效果:

这样就简单实现了元素的移动效果,代码还要很大的优化空间,不着急,我们一步一步来。

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

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

http://www.jsqmd.com/news/101624/

相关文章:

  • AI 工具实战测评:从技术性能到场景落地的全方位解析
  • Simulink保存为低版本模型文件
  • mfc最简单自定义消息投递实例
  • 【Hadoop+Spark+python毕设】网络安全入侵数据可视化分析系统、计算机毕业设计、包括数据爬取、数据分析、数据可视化、实战教学
  • LobeChat能否支持播客生成?音频内容创作新模式
  • 数据可视化工具,助你打造好看图表
  • Python 列表(List)详解
  • 一文了解图神经网络研究背景基本概念
  • D.二分查找-二分答案-求最小——1283. 使结果不超过阈值的最小除数
  • 巴菲特的投资时间管理
  • 文本消息发送:构造请求体、API 调用流程及 Go 语言的 Struct 映射实现
  • Motrix浏览器扩展:如何让你的下载速度提升300%?
  • 毕设 stm32与深度学习口罩佩戴检测系统(源码+硬件+论文)
  • 13、Linux文件系统挂载与检查全攻略
  • R 基础语法
  • A.每日一题——3562. 折扣价交易股票的最大利润
  • Obsidian Style Settings 终极指南:如何快速自定义你的笔记界面
  • TradingView图表库深度解析:实时数据流与K线生成实战指南
  • YOLOv11改进 - C3k2融合 | C3k2融合HMHA分层多头注意力机制(CVPR 2025):优化模型在复杂场景下的目标感知能力
  • 百度网盘解析:2025年最实用的下载限速终极解决方案
  • 大数据领域 Eureka 服务的性能瓶颈分析与突破
  • win11灵活控制Python版本,使用pyenv-win
  • 14、Linux 系统中光盘刻录与文件系统创建指南
  • 同样是PPT模板网站,为啥使用PPT模板 大家都选择LFPPT
  • 用户投诉处理指南:LobeChat建议妥善回应
  • Token 缓存策略对比:探讨本地内存、Redis 和数据库缓存的优缺点及适用场景
  • JavaScript for 循环详解
  • 应用页:专为电视与车机优化的轻量级应用管理解决方案
  • 15、Linux系统存储管理与RAID配置指南
  • 20、Mozilla 开发中的脚本、数据结构与数据库支持