news 2026/5/7 7:21:52

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/30 15:41:13

2026中专生学编程,考什么证书最被认可?

凌晨的实训室里&#xff0c;键盘敲击声不绝于耳&#xff0c;一群年轻学生专注地盯着屏幕上跳动的代码&#xff0c;他们手中各类技能证书的复印件&#xff0c;被整齐地放在简历最显眼的位置。前不久&#xff0c;一场技术类岗位招聘会上&#xff0c;某大型通信企业的面试官看到中…

作者头像 李华
网站建设 2026/4/18 14:37:08

LobeChat天气预报实时查询实现方式

LobeChat天气预报实时查询实现方式 在智能对话系统日益普及的今天&#xff0c;用户早已不再满足于“你好”“再见”式的简单互动。他们期待的是一个能听懂需求、主动办事的数字助手——比如随口一句“今天北京热吗&#xff1f;”&#xff0c;就能立刻得到准确的气温与穿衣建议。…

作者头像 李华
网站建设 2026/5/6 22:23:44

LobeChat批量生成内容实践:营销文案自动化产出

LobeChat批量生成内容实践&#xff1a;营销文案自动化产出 在电商大促季&#xff0c;市场团队需要为数百款新品撰写风格统一的推广文案——如果还靠人工逐条敲字&#xff0c;不仅效率低下&#xff0c;还容易出现语气不一致、关键词遗漏等问题。有没有可能让AI像流水线工人一样&…

作者头像 李华
网站建设 2026/5/5 4:03:17

免费公益夸克网盘在线解析不限速下载 -在线免费使用

在夸克网盘下载文件速度太慢该怎么办&#xff1f;今天教你一招完全免费好用的方法。这个方法还是听我朋友说的。我先展示一下我的下载速度。地址获取&#xff1a;放在这里了&#xff0c;可以直接获取 这个速度&#xff0c;真是佩服。我下载才几十KB。这个速度这是几十倍。下面我…

作者头像 李华
网站建设 2026/5/5 4:56:05

3步轻松解锁原神帧率:告别60帧限制的完整指南

还在为《原神》60帧限制而烦恼吗&#xff1f;这款专为原神玩家打造的帧率解锁工具&#xff0c;能让你彻底摆脱帧率束缚&#xff0c;享受丝滑流畅的游戏体验&#xff01;无论你是高刷显示器用户还是追求极致画面的玩家&#xff0c;这份指南都将帮助你轻松完成设置。 【免费下载链…

作者头像 李华
网站建设 2026/5/1 5:04:45

用户投诉处理指南:LobeChat建议妥善回应

用户投诉处理指南&#xff1a;LobeChat建议妥善回应 在客户服务领域&#xff0c;每一次用户投诉都是一次信任的考验。尤其是在AI驱动的时代&#xff0c;用户不再满足于“机器人式”的模板回复——他们期待的是理解、共情与高效解决。如何让AI客服既能快速响应&#xff0c;又能像…

作者头像 李华