背景 Flutter 手势相关的学习
控件
常见手势事件
说明
GestureDetector
onTap, onDoubleTap, onLongPress, onPanUpdate, onVerticalDragUpdate, onHorizontalDragUpdate, onScaleUpdate
支持多种手势,包括点击、双击、长按、拖动、缩放等。
InkWell
onTap
支持点击手势并提供水波纹点击效果,通常用于按钮等交互控件。
Dismissible
onDismissed
支持滑动手势,通常用于列表项的删除操作,滑动时会触发删除事件。
Draggable
onDragStarted, onDragUpdate, onDragEnd
支持拖动手势,允许用户拖动控件并提供拖动过程中的事件处理。
RawGestureDetector
自定义手势识别事件 (TapGestureRecognizer, LongPressGestureRecognizer 等)
提供底层手势处理,允许开发者完全自定义手势识别器。
Listener
onPointerDown, onPointerMove, onPointerUp
监听原始的指针事件,适用于捕捉触摸、按下、抬起等底层事件。
ScaleGestureDetector
onScaleUpdate, onScaleEnd
处理缩放手势,通常与 GestureDetector 一起使用来识别双指缩放操作。
RawKeyboardListener
onKey
用于监听键盘事件,适用于处理焦点变化和键盘输入。
Listener 简单介绍 它是主要的功能是用来监听屏幕触摸事件,取决于它的子组件区域范围,比如按下、移动、抬起、取消等操作时可以添加监听。
我们知道Flutter组件只有按钮才会有事件,如果在某个widget,自定义组件上添加事件那我就需要借助Listener
主要属性
事件
描述
onPointerDown
当指针按下时触发
onPointerMove
当指针移动时触发
onPointerUp
当指针抬起时触发
onPointerCancel
手当指针操作被取消时触发(1.系统中断:例如来电、通知。 2.手势冲突。 3.多点触控事件,某个手指被系统取消)。清理状态和重置手势逻辑
onPointerHover
当指针悬停时触发(适用于桌面应用
onPointerSignal
当指针信号(如滚动)时触发
behavior
指定当前层级的命中测试关系
简单使用: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class ListenerSimpleWidget extends StatelessWidget { const ListenerSimpleWidget({super .key}); @override Widget build(BuildContext context) { return Listener( child: ConstrainedBox( constraints: BoxConstraints.tight(Size(200 , 100 )), child: Container( color: Colors.grey, ), ), onPointerDown: (event) => print ("按下事件" ), onPointerUp: (event) => print ("抬起事件" ), onPointerMove: (event) => print ("移动事件" ), onPointerCancel: (event) => print ("取消事件" ), ); } }
完整的事件流程:down > move > up (cancel)。
PointerEvent 简单介绍: 是 Flutter 中用于描述指针事件的基础类。它用于处理屏幕上的触摸、鼠标、触控笔等输入设备的交互。
相关子类
PointerDownEvent
PointerMoveEvent
当指针在屏幕上移动时触发。
常用于处理拖动或滑动手势。
PointerUpEvent
PointerCancelEvent
当系统取消指针事件时触发。
用于处理中断和重置手势状态。
关键属性
字段
描述
position
相对于全局坐标(左上角 (0,0)),指针事件发生时的位置
localPosition
它是指针相对于当对于本身布局坐标的偏移
delta
指针移动的距离(仅适用于 PointerMoveEvent)
pressure
触摸压力(通常在 0.0 到 1.0 之间)。适用:1、压力感应绘画,压力越大,画笔越粗。2、交互的震动反馈。3、游戏反馈,速度和强度
orientation
指针的方向
timeStamp
事件发生的时间戳
pointer
指针的标识符,用于区分不同的触点
buttons
当前按下的按钮(适用于鼠标事件)
kind
指针的类型(如触摸、鼠标、触控笔)
behavior 简单介绍 有不同层级的组件重叠,决定当前组件如何响应命中测试,它的值类型为HitTestBehavior,这是一个枚举类,有三个枚举值
HitTestBehavior.deferToChild:默认值,设置当前Widget的可视化区域优先响应命中。但是同属于当前Widget的透明区域,不能 响应命中测试。只能往当前组件的底级组件进行命中测试,决定父级Widget是否要响应点击。
HitTestBehavior.opaque:当前Widget当作不透明进行命中测试,为了扩大当前Widget的点击区域,在当前Widget的可视化和透明区域,都可以响应命中测试。
HitTestBehavior.translucent:设置当前当前Widget的可视化区域优先响应命中。但是同属于当前Widget的透明区域,可以 响应命中测试,同时往底部组件继续进行命中测试,最后当前组件和底部组件都可以接收事件。
简单代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 class ListenerWidget extends StatelessWidget { const ListenerWidget({super .key}); @override Widget build(BuildContext context) { return Stack( children: [ Listener( child: ConstrainedBox( constraints: BoxConstraints.tight(Size(200 , 100 )), child: Container( color: Colors.grey, ), ), onPointerDown: (event) => print ("底部组件被点击了" ), ), Listener( onPointerDown: (event) => print ("顶部文字组件点击" ), behavior: HitTestBehavior.opaque, child: ConstrainedBox( constraints: BoxConstraints.tight(Size(200 , 100 )), child: const Center( child: Text( "点击文字" , style: TextStyle(color: Colors.white, fontSize: 30 ), ), ), ), ) ], ); } }
分别点击文字区域和透明区域查看结果
1 2 3 4 5 6 7 8 9 10 11 12 flutter: 顶部文字组件点击 flutter: 底部组件被点击了 flutter: 顶部文字组件点击 flutter: 顶部文字组件点击 flutter: 顶部文字组件点击 flutter: 顶部文字组件点击 flutter: 底部组件被点击了
忽略指针 ,阻止widget上触摸事件的响应,使用IgnorePointer和AbsorbPointer
特性
AbsorbPointer
IgnorePointer
吸收触摸事件
是的,阻止子部件响应触摸事件。
是的,阻止子部件响应触摸事件。
事件是否传递给其他部件
否,事件被吸收,不会传递到下层部件。
是的,事件可以传递给下层部件。
适用场景
禁用交互并阻止事件向下传播。
禁用交互并允许事件传递给下层部件。
本身是否可以接收指针
可以
不可以
对 UI 树的影响
仅影响当前子部件,触摸事件完全被阻止。
只阻止子部件的交互,但允许下层部件接收事件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class AbsorbPointerWidget extends StatelessWidget { const AbsorbPointerWidget({super .key}); @override Widget build(BuildContext context) { return Listener( child: AbsorbPointer( child: Listener( child: Container( color: Colors.green, width: 200 , height: 100 , ), onPointerDown: (event) { print ("点击子部件Widget" ); }, ), ), onPointerDown: (event) { print ("点击当前组件Widget" ); }, ); } }
AbsorbPointer 最适用于你需要阻止部件交互并且不希望事件传递给下层部件的情况。
IgnorePointer 适用于你希望阻止某个部件的交互,但又允许触摸事件传递给下层部件的场景。
原理相关 1 2 3 4 url: https://book.flutterchina.club/chapter8/hittest.html#_8-3-1-flutter-%E4%BA%8B%E4%BB%B6%E5%A4%84%E7%90%86%E6%B5%81%E7%A8%8B title: "8.3 Flutter事件机制 | 《Flutter实战·第二版》" host: book.flutterchina.club favicon: https://book.flutterchina.club/assets/img/logo.png
事件处理流程概述
用户触摸事件 :当用户在屏幕上触摸时,Flutter 会捕获该触摸事件并获取触摸点的屏幕坐标。
坐标转换 :Flutter 会将触摸点的屏幕坐标转换为相对于每个部件(Widget)的局部坐标。
HitTest 检测:Flutter 会从根部件开始,遍历 Widget 树的每个 RenderObject,并判断触摸点是否命中当前部件。
命中响应 :如果一个部件(例如 FlatButton)被命中,它会响应事件,执行其回调函数(如 onTap 或 onPressed)。
事件传播 :如果部件没有响应事件,事件会继续向上传递到父部件,直到找到一个响应事件的部件或到达树的顶端。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 +--------------------------------------------------+ | 用户触摸事件 | +--------------------------------------------------+ | v +--------------------------------------------------+ | 获取触摸点的屏幕坐标 | +--------------------------------------------------+ | v +--------------------------------------------------+ | 转换为相对于各部件的局部坐标 | +--------------------------------------------------+ | v +--------------------------------------------------+ | 从根部件开始,遍历 Widget 树 | | (递归遍历所有 RenderObject 部件) | +--------------------------------------------------+ | v +--------------------------------------------------+ | 检查每个部件的 `hitTest` 方法 | | (判断触摸点是否在该部件的区域内) | +--------------------------------------------------+ | v +--------------------------------------------------+ | 如果部件命中触摸点,记录命中信息并返回 `true ` | | (命中部件被加入 `HitTestResult` 中) | +--------------------------------------------------+ | v +--------------------------------------------------+ | 如果部件没有响应,事件传递给父级部件 | | (继续调用父部件的 `hitTest` 方法) | +--------------------------------------------------+ | v +--------------------------------------------------+ | 如果命中部件有响应事件,执行事件回调 | | (如 `onTap` 或 `onPressed`) | +--------------------------------------------------+ | v +--------------------------------------------------+ | 事件处理完毕,传播结束 | +--------------------------------------------------+
hit test 遍历渲染树对象(render object),从根节点开始从底部往顶部,对每一个对象进行命中测试(hit test),如果可以测试通过将结果添加到 HitTestResult 中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 void _handlePointerEventImmediately(PointerEvent event) { HitTestResult? hitTestResult; if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent || event is PointerPanZoomStartEvent) { hitTestResult = HitTestResult(); hitTestInView(hitTestResult, event.position, event.viewId); if (event is PointerDownEvent || event is PointerPanZoomStartEvent) { _hitTests[event.pointer] = hitTestResult; } } else if (event is PointerUpEvent || event is PointerCancelEvent || event is PointerPanZoomEndEvent) { hitTestResult = _hitTests.remove(event.pointer); } else if (event.down || event is PointerPanZoomUpdateEvent) { hitTestResult = _hitTests[event.pointer]; } if (hitTestResult != null || event is PointerAddedEvent || event is PointerRemovedEvent) { dispatchEvent(event, hitTestResult); } }
dispatch event 测试(hit test)完毕后,遍历 HitTestResult,执行每一个对象的 handleEvent 方法
结束事件 清空 HitTestResult 列表
例如:
命中测试:RenderView -> 绿色render object -> 文字 render object 分发事件:文字 -> 绿色内容 所以顶部组件的事件会比底部组件的事件先响应接收事件
1 2 3 4 5 url: https://juejin.cn/post/7410229183895060532?from=search-suggest#heading-7 title: "详细了解Flutter事件分发的工作原理Flutter 的手势事件分发系统是一个非常灵活且复杂的系统,它将用户的触摸、点 - 掘金" description: "Flutter 的手势事件分发系统是一个非常灵活且复杂的系统,它将用户的触摸、点击、滑动等输入事件传递给合适的 Widget,并在需要时触发手势回调。要深入理解这个过程,必须从 PointerEven" host: juejin.cn favicon: https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web/static/favicons/favicon-32x32.png
GestureDetector GestureDetector是对Listener的封装,提供非常多的手势,包括单击 、双击 、拖动 、混合手势 等。
单击手势
字段
属性
描述
onTapDown
GestureTapDownCallback
手指按下时的回调函数
onTapUp
GestureTapUpCallback
手指松开时的回调函数
onTap
GestureTapCallback
手指点击时的回调函数
onTapCancel
GestureTapCancelCallback
手指取消点击时的回调函数
双击手势
字段
属性
描述
onDoubleTapDown
GestureTapDownCallback
手指按下时的回调函数
onDoubleTap
GestureTapCallback
手指双击时的回调函数
onDoubleTapCancel
GestureTapCancelCallback
手指取消时的回调函数
长按手势
字段
属性
描述
onLongPressDown
GestureLongPressDownCallback
手指按下去时的回调函数
onLongPressCancel
GestureLongPressCancelCallback
手指长按取消时的回调函数
onLongPress
GestureLongPressCallback
手指长按时的回调函数
onLongPressStart
GestureLongPressStartCallback
手指长按并开始拖动时的回调函数
onLongPressMoveUpdate
GestureLongPressMoveUpdateCallback
手指长按并移动时的回调函数
onLongPressUp
GestureLongPressUpCallback
手指长按并松开时的回调函数
onLongPressEnd
GestureLongPressEndCallback
手指长按结束拖动时的回调函数
垂直滑动手势
字段
属性
描述
onVerticalDragDown
GestureDragDownCallback
手指按下时的回调函数
onVerticalDragStart
GestureDragStartCallback
手指开始垂直滑动时的回调函数
onVerticalDragUpdate
GestureDragUpdateCallback
手指垂直滑动时的回调函数
onVerticalDragEnd
GestureDragEndCallback
手指垂直滑动结束时的回调函数
onVerticalDragCancel
GestureDragCancelCallback
手指垂直滑动取消时的回调函数
水平滑动手势
字段
属性
描述
onHorizontalDragDown
GestureDragDownCallback
手指按下时的回调函数
onHorizontalDragStart
GestureDragStartCallback
手指开始水平滑动时的回调函数
onHorizontalDragUpdate
GestureDragUpdateCallback
手指水平滑动时的回调函数
onHorizontalDragEnd
GestureDragEndCallback
手指水平滑动结束时的回调函数
onHorizontalDragCancel
GestureDragCancelCallback
手指水平滑动取消时的回调函数
拖动手势
字段
属性
描述
onPanDown
GestureDragDownCallback
手指按下时的回调函数
onPanStart
GestureDragStartCallback
手指开始拖动时的回调函数
onPanUpdate
GestureDragUpdateCallback
手指移动时的回调函数
onPanEnd
GestureDragEndCallback
手指抬起时的回调函数
onPanCancel
GestureDragCancelCallback
手指取消拖动时的回调函数
缩放手势
字段
属性
描述
onScaleStart
GestureScaleStartCallback
开始缩放时的回调函数
onScaleUpdate
GestureScaleUpdateCallback
缩放移动时的回调函数
onScaleEnd
GestureScaleEndCallback
缩放结束时的回调函数
其他手势
字段
属性
描述
onForcePressStart
GestureForcePressStartCallback
手指强制按下时的回调函数
onForcePressPeak
GestureForcePressPeakCallback
手指按压力度最大时的回调函数
onForcePressUpdate
GestureForcePressUpdateCallback
手指按下后移动时的回调函数
onForcePressEnd
GestureForcePressEndCallback
手指离开时的回调函数
手势冲突 忽略指针 ,阻止widget上触摸事件的响应,使用IgnorePointer和AbsorbPointer
特性
AbsorbPointer
IgnorePointer
吸收触摸事件
是的,阻止子部件响应触摸事件。
是的,阻止子部件响应触摸事件。
事件是否传递给其他部件
否,事件被吸收,不会传递到下层部件。
是的,事件可以传递给下层部件。
适用场景
禁用交互并阻止事件向下传播。
禁用交互并允许事件传递给下层部件。
本身是否可以接收指针
可以
不可以
对 UI 树的影响
仅影响当前子部件,触摸事件完全被阻止。
只阻止子部件的交互,但允许下层部件接收事件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class AbsorbPointerWidget extends StatelessWidget { const AbsorbPointerWidget({super .key}); @override Widget build(BuildContext context) { return Listener( child: AbsorbPointer( child: Listener( child: Container( color: Colors.green, width: 200 , height: 100 , ), onPointerDown: (event) { print ("点击子Widget" ); }, ), ), onPointerDown: (event) { print ("点击父Widget" ); }, ); } }
AbsorbPointer 最适用于你需要阻止部件交互并且不希望事件传递给下层部件的情况。
IgnorePointer 适用于你希望阻止某个部件的交互,但又允许触摸事件传递给下层部件的场景。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 GestureArenaMember |-- _CombiningGestureArenaMember |-- GestureRecognizer |-- MultiTapGestureRecognizer |-- MultiDragGestureRecognizer |-- HorizontalMultiDragGestureRecognizer |-- ImmediateMultiDragGestureRecognizer |-- VerticalMultiDragGestureRecognizer |-- DelayedMultiDragGestureRecognizer |-- DoubleTapGestureRecognizer |-- OneSequenceGestureRecognizer |-- EagerGestureRecognizer |-- ForcePressGestureRecognizer |-- ScaleGestureRecognizer |-- DragGestureRecognizer |-- VerticalDragGestureRecognizer |-- HorizontalDragGestureRecognizer |-- PanGestureRecognizer |-- PrimaryPointerGestureRecognizer |-- LongPressGestureRecognizer |-- BaseTapGestureRecognizer |-- TapGestureRecognizer
行程预约
实现之前先找轮子:1 2 3 4 5 6 url: https://flutter.ducafecat.com/pubs/syncfusion_flutter_datepicker-package-info title: "日期和时间选择器, syncfusion_flutter_datepicker | flutter package" description: "日期和时间选择器, syncfusion_flutter_datepicker, Flutter日期范围选择器小部件,允许用户轻松选择日期或日期范围。它有四个内置视图,可以快速导航到所需的日期 Flutter Ducafecat 根据业务对海量优秀插件包进行分类方便查询。 Flutter Ducafecat 弥补了 pub.dev 站点的业务分类。" host: flutter.ducafecat.com favicon: https://flutter.ducafecat.com/favicon.ico image: https://flutter.ducafecat.com/logo.svg
1 2 3 4 5 url: https://flutter.syncfusion.com/?_gl=1*s5c9bw*_gcl_au*MTkzNzQzMTU3OC4xNzMxOTI2MDc2*_ga*OTQzNDA0MDQ5LjE3MTYzNTg4MzM.*_ga_41J4HFMX1J*MTczMTkyNjA3Ni4xNy4xLjE3MzE5MjYxNTYuMC4wLjA.#/event-calendar/getting-started title: "Demos & Examples of Syncfusion Flutter Widgets" description: "Explore the web demos and examples of the Syncfusion Flutter UI widgets like charts, calendar, gauge and more." host: flutter.syncfusion.com image: https://cdn.syncfusion.com/content/images/Flutter/Web_SB/flutter_logo_1.png
结构 1 2 3 4 5 6 7 book |-- booking_schedule_date.dart |-- booking_schedule_enum.dart |-- circle_painter.dart |-- time_line_widget.dart |-- time_region_widget.dart |-- time_region.dart
CustomPainter Flutter 中用于自定义绘制的一个类。你可以通过它在画布上绘制任意形状、图形或效果
主要逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 class TimeLineWidget extends StatelessWidget { TimeLineWidget({ Key? key, required this .controller, required this .selectWidget, required this .disableRegions, required this .dayRegion, this .onTapCallback, required this .selectRegion, }) : super (key: key); final ScrollController controller; final Widget selectWidget; final List <TimeRegion> disableRegions; final TimeRegion dayRegion; final TimeRegion selectRegion; final BookingTapCallback? onTapCallback; @override Widget build(BuildContext context) { int count = dayRegion.getInterval() + 1 ; List cells = List .generate(count, (index) { return TimeLineItem( index: index + dayRegion.getStartIndex(), cellHeight: dayRegion.cellHeight, selectStartIndex: selectRegion.getStartIndex(), ); }); List disableCells = []; disableRegions.forEach( (time) { 、、、 disableCells.add(TimeRegionWidget( timeRegion: time, isAppointmentResize: false , )); }, ); return SingleChildScrollView( controller: controller, child: GestureDetector( onTapUp: (details) { int index = ((details.localPosition.dy - 20 ) / dayRegion.cellHeight).ceil(); if (index > 0 && index <= 48 ) { onTapCallback!(index); } }, child: Stack( children: [ Column(children: [...cells]), ...disableCells, selectWidget, ], ), )); } }
BookingScheduleDate 主要属性分类和用途
属性名称
类型
描述
disableRegions
List<TimeRegion>
禁用的时间段列表,用于定义哪些时间段用户无法选择或操作。
selectRegion
TimeRegion
当前选中的时间段,包含起始时间、结束时间及相关状态。
dayRegion
TimeRegion
全天的时间范围,定义时间轴的整体范围。
onBookingSelectback
BookingSelectback
时间段选择后的回调,用于向父组件传递选中的时间段或状态变化。
布局相关属性
属性名称
类型
描述
_selectBoxPosition
double
选中区域的起始位置(Y 坐标)。基于时间轴的顶部偏移。
_selectBoxHeight
double
选中区域的高度,对应选中时间段的时长。
_marginTop
double
选中区域距离时间轴顶部的默认偏移量。
_cellHeight
double
每个时间单位(如一小时或半小时)在 UI 中对应的高度。
滚动控制相关属性
属性名称
类型
描述
_controller
ScrollController
时间轴的滚动控制器,用于同步滚动和拖动行为。
_autoScrollTimer
Timer?
用于处理拖动到边缘时的自动滚动逻辑。
_scrollThreshold
double
边界滚动的触发阈值。用户手势距离边缘的距离超过该值时开始滚动。
_scrollSpeed
double
自动滚动的速度,决定滚动的单位距离。
_previousScrollOffset
double
记录上一次滚动的位置,用于计算滚动的增量。
拖动状态相关属性
属性名称
类型
描述
_isDraging
bool
标记当前是否处于拖动状态。
_isAppointmentResizeTop
bool
标记是否正在调整顶部区域的大小。
_isAppointmentResizeBottom
bool
标记是否正在调整底部区域的大小。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 typedef BookingSelectback = void Function ();class BookingScheduleDate extends StatefulWidget { final List <TimeRegion> disableRegions; final TimeRegion selectRegion; final TimeRegion dayRegion; final BookingSelectback onBookingSelectback; const BookingScheduleDate( {super .key, required this .disableRegions, required this .selectRegion, required this .dayRegion, required this .onBookingSelectback}); @override BookingScheduleDateState createState() => BookingScheduleDateState( disableRegions, selectRegion, dayRegion, onBookingSelectback); } class BookingScheduleDateState extends State <BookingScheduleDate > { late ScrollController _controller; final double _marginTop = 20 ; double _selectBoxPosition = 20 ; final double _cellHeight = 40 ; double _selectBoxHeight = 2 * 40 ; Timer? _autoScrollTimer; final double _scrollThreshold = 80 ; final double _scrollSpeed = 5.0 ; double _previousScrollOffset = 0.0 ; bool _isDraging = false ; bool _isAppointmentResizeTop = false ; bool _isAppointmentResizeBottom = false ; final List <TimeRegion> disableRegions; late TimeRegion selectRegion; final TimeRegion dayRegion; final BookingSelectback onBookingSelectback; BookingScheduleDateState(this .disableRegions, this .selectRegion, this .dayRegion, this .onBookingSelectback); @override void initState() { super .initState(); _controller = ScrollController(); _controller.addListener(_syncSelectBoxPosition); onSelectWidgetTime(selectRegion.startTime); } @override void dispose() { _autoScrollTimer?.cancel(); _controller.removeListener(_syncSelectBoxPosition); _controller.dispose(); super .dispose(); } void onSelectWidgetTime(DateTime startTime) { setState(() { double height = selectRegion.getInterval() * _cellHeight; selectRegion.startTime = startTime; selectRegion.height = height; _selectBoxHeight = height; _selectBoxPosition = selectRegion.getPosition(); _checkSelcteIsOverlap(); }); } void _startAutoScroll(DragUpdateDetails details) { _autoScrollTimer?.cancel(); _autoScrollTimer = Timer.periodic(Duration (milliseconds: 16 ), (timer) { final RenderBox renderBox = context.findRenderObject() as RenderBox; final Offset localOffset = renderBox.globalToLocal(details.globalPosition); final double dy = localOffset.dy; if (dy < _scrollThreshold) { if (_controller.offset > 0 ) { _controller.jumpTo((_controller.offset - _scrollSpeed) .clamp(0.0 , _controller.position.maxScrollExtent)); } else { _stopAutoScroll(); } } else if (dy > renderBox.size.height - _scrollThreshold) { if (_controller.offset < _controller.position.maxScrollExtent) { _controller.jumpTo((_controller.offset + _scrollSpeed) .clamp(0.0 , _controller.position.maxScrollExtent)); } else { _stopAutoScroll(); } } }); } void _stopAutoScroll() { _autoScrollTimer?.cancel(); } void _syncSelectBoxPosition() { double offsetDelta = _controller.offset - _previousScrollOffset; if (_isDraging) { setState(() { _dragSelectWidget(offsetDelta); }); } else if (_isAppointmentResizeBottom) { setState(() { _appointmentResizeToDown(offsetDelta); }); } else if (_isAppointmentResizeTop) { setState(() { _appointmentResizeToUp(offsetDelta); }); } _previousScrollOffset = _controller.offset; } void _dragSelectWidget(double offsetDelta) { _selectBoxPosition += offsetDelta; double minOffsetY = _marginTop; double maxOffsetY = (dayRegion.getInterval() + 1 ) * _cellHeight - _selectBoxHeight - _marginTop; _selectBoxPosition = _selectBoxPosition.clamp(minOffsetY, maxOffsetY); _updateSelectWidgetStatus(); } void _appointmentResizeToUp(double offsetDelta) { _selectBoxPosition += offsetDelta; _selectBoxHeight -= offsetDelta; int allStartIndex = TimeRegion.getIndexWithDateTime(selectRegion.allowStartTime, selectRegion.date); int startIndex = selectRegion.getStartIndex(); int endIndex = selectRegion.getEndIndex(); if (_selectBoxPosition <= _marginTop) { _selectBoxHeight = selectRegion.height; _selectBoxPosition = _marginTop; } else if (_selectBoxHeight <= _cellHeight) { _selectBoxHeight = _cellHeight; _selectBoxPosition = (startIndex - allStartIndex) * _cellHeight + _marginTop; } double maxOffsetY = (endIndex - allStartIndex) * _cellHeight - _selectBoxHeight + _marginTop; double maxHeight = (endIndex - allStartIndex) * _cellHeight; _selectBoxHeight = _selectBoxHeight.clamp(_cellHeight, maxHeight); _selectBoxPosition = _selectBoxPosition.clamp(_marginTop, maxOffsetY); _updateSelectWidgetStatus(); } void _appointmentResizeToDown(double offsetDelta) { _selectBoxHeight += offsetDelta; double minHeight = _cellHeight; double maxHeight = (dayRegion.getEndIndex() - selectRegion.getStartIndex()) * _cellHeight; _selectBoxHeight = _selectBoxHeight.clamp(minHeight, maxHeight); _updateSelectWidgetStatus(); } void _updateSelectBoxPositionAndSizeEnd() { setState(() { double point = (_selectBoxPosition - _marginTop) / _cellHeight; int startIndex = point.round(); int endIndex = startIndex + (_selectBoxHeight / _cellHeight).round(); _selectBoxHeight = (endIndex - startIndex) * _cellHeight; _selectBoxPosition = (startIndex * _cellHeight) + _marginTop; _updateSelectWidgetStatus(); }); } void _updateSelectWidgetStatus() { selectRegion.setPosition(_selectBoxPosition); selectRegion.height = _selectBoxHeight; _checkSelcteIsOverlap(); onBookingSelectback(); } void _checkSelcteIsOverlap() { selectRegion.region = TimeRegionEnum.select; disableRegions.forEach((element) { if (selectRegion.crosses(element) || selectRegion.overlaps(element)) { selectRegion.region = TimeRegionEnum.overlap; } }); } Widget _buildSelectTimeRegion() { return GestureDetector( onVerticalDragEnd: (details) { _isDraging = false ; _stopAutoScroll(); _updateSelectBoxPositionAndSizeEnd(); }, onVerticalDragCancel: () { _isDraging = false ; _updateSelectBoxPositionAndSizeEnd(); }, onVerticalDragUpdate: (details) { _isDraging = true ; setState(() { _dragSelectWidget(details.delta.dy); _startAutoScroll(details); }); }, child: TimeRegionWidget( position: _selectBoxPosition, height: _selectBoxHeight, timeRegion: selectRegion, isAppointmentResize: true , onDragTopUpdate: (details) { _isAppointmentResizeTop = true ; setState(() { _appointmentResizeToUp(details.delta.dy); _startAutoScroll(details); }); }, onDragTopEnd: (details) { _isAppointmentResizeTop = false ; _stopAutoScroll(); _updateSelectBoxPositionAndSizeEnd(); }, onDragTopCancel: () { _isAppointmentResizeTop = false ; _updateSelectBoxPositionAndSizeEnd(); }, onDragBottomUpdate: (details) { _isAppointmentResizeBottom = true ; setState(() { _appointmentResizeToDown(details.delta.dy); _startAutoScroll(details); }); }, onDragBottomEnd: (details) { _isAppointmentResizeBottom = false ; _stopAutoScroll(); _updateSelectBoxPositionAndSizeEnd(); }, onDragBottomCancel: () { _isAppointmentResizeBottom = false ; _updateSelectBoxPositionAndSizeEnd(); }, ), ); } @override Widget build(BuildContext context) { return LayoutBuilder(builder: (context, constraints) { return Stack( children: [ TimeLineWidget( controller: _controller, selectWidget: _buildSelectTimeRegion(), disableRegions: disableRegions, dayRegion: dayRegion, onTapCallback: (index) { if (selectRegion.getInterval() + index <= dayRegion.getInterval() + 1 ) { _selectBoxPosition = (index - 1 ) * dayRegion.cellHeight + _marginTop; _updateSelectBoxPositionAndSizeEnd(); } }, selectRegion: selectRegion, ), ], ); }); } }
构造函数参数
timeRegion: 表示时间段的对象,包含该时间段的具体信息,如时间、颜色、文本等。
isAppointmentResize: 布尔值,指示是否允许调整预约的大小(即是否展示上、下调整按钮)。
position: 可选参数,表示时间段的位置,若未传入,则使用 timeRegion 中的值。
height: 可选参数,表示时间段的高度,若未传入,则使用 timeRegion 中的值。
拖拽回调:有多个回调函数,分别用于处理时间段上方和下方的拖拽事件:
onDragTopUpdate、onDragTopEnd、onDragTopCancel:处理拖拽上方边缘的事件。
onDragBottomUpdate、onDragBottomEnd、onDragBottomCancel:处理拖拽下方边缘的事件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 class TimeRegionWidget extends StatelessWidget { final TimeRegion timeRegion; final bool isAppointmentResize; final double? position; final double? height; final GestureDragUpdateCallback? onDragTopUpdate; final GestureDragEndCallback? onDragTopEnd; final GestureDragCancelCallback? onDragTopCancel; final GestureDragUpdateCallback? onDragBottomUpdate; final GestureDragEndCallback? onDragBottomEnd; final GestureDragCancelCallback? onDragBottomCancel; TimeRegionWidget({ Key? key, required this .timeRegion, required this .isAppointmentResize, this .position, this .height, this .onDragTopUpdate, this .onDragTopEnd, this .onDragTopCancel, this .onDragBottomUpdate, this .onDragBottomEnd, this .onDragBottomCancel, }) : super (key: key); @override Widget build(BuildContext context) { return Container( margin: EdgeInsets.only(top: position ?? timeRegion.getPosition(), left: 60 , right: 20 ), height: height ?? timeRegion.height, decoration: BoxDecoration( color: timeRegion.color.withOpacity(0.2 ), borderRadius: const BorderRadius.all(Radius.circular(4 )), shape: BoxShape.rectangle, border: Border.all( color: timeRegion.color.withOpacity(0.2 ), width: 1.0 , ), ), child: isAppointmentResize? Stack( clipBehavior: Clip.none, children: [ Positioned( top: -10 , left: -65 , width: 60 , height: 30 , child: Text( timeRegion.text, textAlign: TextAlign.center, style: TextStyle(color: timeRegion.color, fontSize: 14 ), )), Positioned( left: 0 , top: -40 , child: GestureDetector( onVerticalDragUpdate: onDragTopUpdate, onVerticalDragEnd: onDragTopEnd, onVerticalDragCancel: onDragTopCancel, child: Container( width: 80 , height: 80 , child: CustomPaint( painter: CirclePainter(timeRegion.color.withOpacity(0.2 )), ), ), )), Positioned( right: 0 , bottom: -40 , child: GestureDetector( onVerticalDragUpdate: onDragBottomUpdate, onVerticalDragEnd: onDragBottomEnd, onVerticalDragCancel: onDragBottomCancel, child: Container( width: 80 , height: 80 , child: CustomPaint( painter: CirclePainter(timeRegion.color.withOpacity(0.2 )), ), ), )) ], ): Container( width: double .infinity, height: double .infinity, padding: EdgeInsets.all(0 ), child: Center( child: Text( timeRegion.getHintText(context), style: TextStyle(color: CommonColors.wordGray, fontSize: 16 , fontWeight: FontWeight.bold), ), )), ); } }