背景

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 中用于描述指针事件的基础类。它用于处理屏幕上的触摸、鼠标、触控笔等输入设备的交互。

相关子类

  1. PointerDownEvent
    • 当指针接触屏幕时触发。
    • 常用于开始检测手势。
  2. PointerMoveEvent
    • 当指针在屏幕上移动时触发。
    • 常用于处理拖动或滑动手势。
  3. PointerUpEvent
    • 当指针离开屏幕时触发。
    • 常用于结束手势检测。
  4. 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),
),
),
),
)
],
);
}
}

分别点击文字区域和透明区域查看结果
image.png|500

1
2
3
4
5
6
7
8
9
10
11
12
// HitTestBehavior.deferToChild
flutter: 顶部文字组件点击
flutter: 底部组件被点击了

// HitTestBehavior.opaque
flutter: 顶部文字组件点击
flutter: 顶部文字组件点击

// HitTestBehavior.translucent
flutter: 顶部文字组件点击
flutter: 顶部文字组件点击
flutter: 底部组件被点击了

忽略指针,阻止widget上触摸事件的响应,使用IgnorePointerAbsorbPointer

特性 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

事件处理流程概述

  1. 用户触摸事件:当用户在屏幕上触摸时,Flutter 会捕获该触摸事件并获取触摸点的屏幕坐标。
  2. 坐标转换:Flutter 会将触摸点的屏幕坐标转换为相对于每个部件(Widget)的局部坐标。
  3. HitTest 检测:Flutter 会从根部件开始,遍历 Widget 树的每个 RenderObject,并判断触摸点是否命中当前部件。
  4. 命中响应:如果一个部件(例如 FlatButton)被命中,它会响应事件,执行其回调函数(如 onTaponPressed)。
  5. 事件传播:如果部件没有响应事件,事件会继续向上传递到父部件,直到找到一个响应事件的部件或到达树的顶端。
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
image.png|500

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上触摸事件的响应,使用IgnorePointerAbsorbPointer

特性 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 //单击手势检测器

行程预约

image.png|200

实现之前先找轮子:

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 // model

CustomPainter

Flutter 中用于自定义绘制的一个类。你可以通过它在画布上绘制任意形状、图形或效果

TimeLineWidget

主要逻辑

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.generate 创建每个时间段的时间项。每个时间段由 TimeLineItem 表示,它使用 CustomPaint 绘制时间段的线条和时间标签。
List cells = List.generate(count, (index) {
return TimeLineItem(
index: index + dayRegion.getStartIndex(),
cellHeight: dayRegion.cellHeight,
selectStartIndex: selectRegion.getStartIndex(),
);
});

// 根据 disableRegions 对时间范围进行调整。如果禁用的时间段超出可选范围(dayRegion),进行裁剪,确保禁用时间段在允许范围内。
List disableCells = [];
disableRegions.forEach(
(time) {

、、、

disableCells.add(TimeRegionWidget(
timeRegion: time,
isAppointmentResize: false,
));
},
);

return SingleChildScrollView(
controller: controller,
child: GestureDetector(
onTapUp: (details) {
// 通过 GestureDetector 捕获点击事件,计算点击位置对应的时间索引(index)。点击的索引被回调到外部逻辑。
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.addListener 绑定滚动事件,确保滚动时同步更新选中区域
_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();
});
}

// 在拖动选中区域时,调用 _startAutoScroll 判断用户是否接近边缘,并触发滚动
void _startAutoScroll(DragUpdateDetails details) {
_autoScrollTimer?.cancel();
// 定时器,16ms间隔自动滚动(60FPS,1秒60帧,1秒=1000毫秒)
_autoScrollTimer = Timer.periodic(Duration(milliseconds: 16), (timer) {
// 获取当前上下文中的 RenderBox 对象,用于计算视图的尺寸和位置
final RenderBox renderBox = context.findRenderObject() as RenderBox;
final Offset localOffset =
renderBox.globalToLocal(details.globalPosition);

final double dy = localOffset.dy; // 相对于时间轴可视化区域的位置偏移

if (dy < _scrollThreshold) {
// 摸点接近上边界(dy < _scrollThreshold),触发向上滚动。
if (_controller.offset > 0) {
// 偏移量向上滚动的距离是固定的 _scrollSpeed,
// .clamp 确保滚动位置不会超出有效范围
_controller.jumpTo((_controller.offset - _scrollSpeed)
.clamp(0.0, _controller.position.maxScrollExtent));
} else {
_stopAutoScroll();
}
} else if (dy > renderBox.size.height - _scrollThreshold) {
// 如果触摸点接近下边界(dy > renderBox.size.height - _scrollThreshold),触发向下滚动。
if (_controller.offset < _controller.position.maxScrollExtent) {
// 偏移量向下滚动的距离是固定的 _scrollSpeed,
// .clamp 确保滚动位置不会超出有效范围
_controller.jumpTo((_controller.offset + _scrollSpeed)
.clamp(0.0, _controller.position.maxScrollExtent));
} else {
_stopAutoScroll();
}
}
});
}

void _stopAutoScroll() {
_autoScrollTimer?.cancel();
}

// 在滚动时调用 _syncSelectBoxPosition 实现选中区域的同步更新
void _syncSelectBoxPosition() {

// 计算滚动的增量 offsetDelta
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;
// 选中区域的起始位置 Y 坐标
_selectBoxPosition = _selectBoxPosition.clamp(minOffsetY, maxOffsetY);

_updateSelectWidgetStatus();
}

void _appointmentResizeToUp(double offsetDelta) {

// 选中区域的Y坐标 和 高度变化
_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();
});
}

// 更新选中区域的属性,Y坐标,高度 是否和禁用区域重叠
void _updateSelectWidgetStatus() {
selectRegion.setPosition(_selectBoxPosition);
selectRegion.height = _selectBoxHeight;

_checkSelcteIsOverlap();

onBookingSelectback();
}
// 每次选中区域更新后,调用 _checkSelcteIsOverlap 检测是否与禁用区域冲突
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,
),
],
);
});
}
}

TimeRegionWidget

构造函数参数

  • timeRegion: 表示时间段的对象,包含该时间段的具体信息,如时间、颜色、文本等。
  • isAppointmentResize: 布尔值,指示是否允许调整预约的大小(即是否展示上、下调整按钮)。
  • position: 可选参数,表示时间段的位置,若未传入,则使用 timeRegion 中的值。
  • height: 可选参数,表示时间段的高度,若未传入,则使用 timeRegion 中的值。
  • 拖拽回调:有多个回调函数,分别用于处理时间段上方和下方的拖拽事件:
    • onDragTopUpdateonDragTopEndonDragTopCancel:处理拖拽上方边缘的事件。
    • onDragBottomUpdateonDragBottomEndonDragBottomCancel:处理拖拽下方边缘的事件。
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, // 开始的位置Y坐标,若未传入,则使用 timeRegion 中的值
this.height, // 区域高度,若未传入,则使用 timeRegion 中的值
// 拖拽回调
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),
)),
// 通过 onVerticalDragUpdate、onVerticalDragEnd、onVerticalDragCancel 处理上方拖拽的回调。
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),
),
)),
);
}
}