news 2026/4/16 19:50:46

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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,),),);}}

运行效果:

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

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

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/16 10:17:02

臭双非的技术学习之旅——Meta Quest3开发篇

嗨嗨嗨 知识点捏 这个部分其实很杂哼乱&#xff0c;没有一个系统的教学。我们以目前可公开的信息来统合我们的知识点 目前可公开的信息 Quest3 开发使用的是Mate已经研究出的一份较为完善的架构&#xff0c;可以在unity的资源商店里面找到all in one SDK &#xff0c;它提供了很…

作者头像 李华
网站建设 2026/4/16 10:13:37

YOLOv11改进 - C3k2融合 | C3k2融合Mona多认知视觉适配器(CVPR 2025):打破全参数微调的性能枷锁:即插即用的提点神器

前言 本文介绍了新型视觉适配器微调方法Mona,并将其集成到YOLOv11中。传统全参数微调成本高、存储负担重且有过拟合风险,现有PEFT方法性能落后。Mona仅调整5%以内的骨干网络参数,在多个视觉任务中超越全参数微调。其核心亮点包括参数效率高、性能突破和即插即用。适配器模块…

作者头像 李华
网站建设 2026/4/16 10:16:50

simulink中使用fft进行频谱分析卡死可能的解决方法

simulink中使用fft进行频谱分析卡死可能的解决方法 分析前需要确保的设置 在配置参数里面取消勾选单一仿真输出; 在Scope中打开将数据记录到工作区 检查数据维度, FFT工具箱仅仅能分析一维数据, 例如下图中左边为一维数据, 右边的为二维数据, 将右边的数据导入到MATLAB工作区进…

作者头像 李华
网站建设 2026/4/16 14:27:34

Dolby Atmos Lite:轻量级全景声音效模拟工具,多设备音效增强方案

Dolby Atmos Lite是一款专注于音效增强的轻量级工具&#xff0c;旨在通过算法模拟杜比全景声的沉浸式音频体验。该软件以其极小的体积和广泛的设备兼容性&#xff0c;为用户提供了简单的音效优化解决方案&#xff0c;特别适合希望在普通设备上获得更好音频体验的用户。 获取地…

作者头像 李华
网站建设 2026/4/15 16:49:47

python第一阶段第10章

1. 整体介绍1.1 数据来源2. 效果一&#xff1a; 折线图-----2020印美日新冠累计确诊人数2.1 json数据格式2.1.1 什么时json2.1.2 json有什么用2.1.3 json格式数据转化import json # 准备列表 &#xff0c;列表内每一个元素都是字典&#xff0c;将其转换为json data [{"na…

作者头像 李华
网站建设 2026/4/16 11:04:56

LobeChat能否集成雾凇形成条件?气象奇观预测与摄影时机推荐

LobeChat能否集成雾凇形成条件&#xff1f;气象奇观预测与摄影时机推荐 在吉林市的寒冬清晨&#xff0c;松花江畔的树枝上挂满晶莹剔透的冰晶——这就是被誉为“冬天童话”的雾凇奇观。每年吸引无数摄影师驱车数百公里守候一夜&#xff0c;只为捕捉那一瞬的美景。但问题也随之而…

作者头像 李华