diff --git a/requirements/issue-924-fab-on-long-press/TaskContract.md b/requirements/issue-924-fab-on-long-press/TaskContract.md new file mode 100644 index 000000000..c4a3788a8 --- /dev/null +++ b/requirements/issue-924-fab-on-long-press/TaskContract.md @@ -0,0 +1,43 @@ +# TaskContract — issue #924 [TDFab] 暴露 onLongPress 方法 + +## 基本信息 + +- issue: https://github.com/Tencent/tdesign-flutter/issues/924 +- 组件:`TFab` +- 分支:`fix/issue-924-fab-on-long-press` +- 目录:`requirements/issue-924-fab-on-long-press` +- 类型:新特性(新增可选回调参数 `onLongPress`) + +## 问题描述 + +业务需要在悬浮按钮上实现长按逻辑(例如长按展开菜单、快捷操作等)。`TFab` 内部使用 `InkWell` 承载点击,但未将长按能力透出,调用方无法接入 `onLongPress`。 + +## 根因分析 + +`TFab` 的 `build` 中仅向 `InkWell` 传入了 `onTap: onClick`,缺少 `onLongPress` 参数及对应构造字段,导致 Material 长按手势链路无法由外部配置。 + +## 修复方案 + +1. 为 `TFab` 增加可选参数 `onLongPress`(`VoidCallback?`),文档注释为「长按回调」。 +2. 在 `InkWell` 上设置 `onLongPress: onLongPress`,与 `onTap` 并存,行为与 Flutter 原生一致。 +3. 为满足仓库 `check-issue-fix` 对组件文件的色值检查,将原先硬编码的 `Colors.white` 与手写阴影改为 `TTheme.of(context).textColorAnti` 与主题投影 `shadowsMiddle` / `shadowsBase` 回退链。 +4. 补充 `tdesign-component/test/t_fab_test.dart` 覆盖长按与单击共存;示例页增加「交互」模块演示长按弹出 `SnackBar`;同步 `example/assets/api/fab_api.md`。 + +## 贡献指南对照 + +- 开发规范:新增 API 使用 `///` 注释;样式与色值从 `TTheme` 获取;保持 `TFab` 命名与现有导出一致。 +- 代码 Review 自检:构造方法在字段之前;未引入与需求无关的重构;测试可重复执行。 +- 文档自检:示例与 API 表已更新;未手工改动 `tdesign-site/src/**/README.md`。 + +## 交付物清单 + +| 序号 | 交付物 | 说明 | +|------|--------|------| +| 1 | `requirements/issue-924-fab-on-long-press/test-cases.md` | 验收用例 | +| 2 | `requirements/issue-924-fab-on-long-press/code-review-report.md` | 代码审查结论 | +| 3 | `requirements/issue-924-fab-on-long-press/acceptance-report.md` | 验收报告 | +| 4 | `requirements/issue-924-fab-on-long-press/pr-body.md` | PR 摘要 | +| 5 | `tdesign-component/lib/src/components/fab/t_fab.dart` | 组件实现 | +| 6 | `tdesign-component/test/t_fab_test.dart` | 单元测试 | +| 7 | `tdesign-component/example/lib/page/t_fab_page.dart` | 示例演示 | +| 8 | `tdesign-component/example/assets/api/fab_api.md` | API 文档 | diff --git a/requirements/issue-924-fab-on-long-press/acceptance-report.md b/requirements/issue-924-fab-on-long-press/acceptance-report.md new file mode 100644 index 000000000..f6af07d99 --- /dev/null +++ b/requirements/issue-924-fab-on-long-press/acceptance-report.md @@ -0,0 +1,43 @@ +# Acceptance Report — issue #924 [TDFab] 暴露 onLongPress 方法 + +## 验收结论 + +状态:通过(自动化测试与文档自检已完成;UI 光感变更建议设计走查) + +## 需求对照 + +| 验收项 | 结果 | 说明 | +|--------|------|------| +| issue 要求透出长按能力 | 通过 | 新增 `onLongPress` 并绑定 `InkWell.onLongPress` | +| 与单击互不干扰 | 通过 | 见 `t_fab_test.dart` TC-02 | +| 示例可人工验证 | 通过 | `TFabPage`「交互」模块长按出 `SnackBar` | +| API 文档 | 通过 | `fab_api.md` 已增加参数行 | + +## 执行检查 + +### 通过项 + +```bash +cd tdesign-component && flutter test test/t_fab_test.dart +cd tdesign-component && flutter analyze lib/src/components/fab/t_fab.dart +node scripts/issue-workflow/check-issue-fix.mjs \ + --requirements-dir requirements/issue-924-fab-on-long-press \ + --component-file tdesign-component/lib/src/components/fab/t_fab.dart \ + --class-name TFab \ + --all-build tdesign-component/demo_tool/all_build.sh \ + --require-all-build-class +``` + +### 未通过项或阻塞项 + +- 无。 + +## 人工验收指引 + +1. 打开示例应用,进入 Fab 示例页,找到「交互」中的「Fab onLongPress 长按回调」。 +2. 长按按钮,确认出现「已长按」提示。 +3. 在业务工程中引用新版本 `TFab`,传入 `onLongPress` 验证自定义逻辑。 + +## 环境说明 + +- Flutter 测试在 macOS 本机执行;`tdesign_flutter_tools` 本地为 path stub,未运行全量 `all_build.sh` 生成 API,已手工维护 `fab_api.md` 与代码一致。 diff --git a/requirements/issue-924-fab-on-long-press/code-review-report.md b/requirements/issue-924-fab-on-long-press/code-review-report.md new file mode 100644 index 000000000..c0dcb13ae --- /dev/null +++ b/requirements/issue-924-fab-on-long-press/code-review-report.md @@ -0,0 +1,44 @@ +# Code Review Report — issue #924 + +## 审查结论 + +状态:通过(自检) + +## 修改范围 + +- `tdesign-component/lib/src/components/fab/t_fab.dart`:新增 `onLongPress`,`InkWell` 透传;图标反色与阴影改走主题。 +- `tdesign-component/test/t_fab_test.dart`:新增用例。 +- `tdesign-component/example/lib/page/t_fab_page.dart`:新增交互示例模块;`ExamplePage.test` 增加同构项便于站点「单元测试」区块生成。 +- `tdesign-component/example/assets/api/fab_api.md`:API 表增加 `onLongPress` 行。 + +## 规范检查 + +### 1. 构造方法与字段顺序 + +- `TFab` 保持「构造方法在前、字段在后」;新增参数置于 `onClick` 之后,与 `InkWell` 参数语义相邻。 + +### 2. 注释风格 + +- 新增公开字段使用 `/// 长按回调`。 + +### 3. all_build 配置 + +- `TFab` 已在 `tdesign-component/demo_tool/all_build.sh` 中配置,本次未新增组件类名。 + +### 4. TTheme 使用 + +- 主色 / 危险态上图标与文字反色使用 `textColorAnti`;投影使用 `shadowsMiddle` 与 `shadowsBase` 回退。 + +### 5. TResourceDelegate 使用 + +- 本次无新增用户可见组件内固定文案;示例页 `SnackBar` 文案为演示用途,沿用示例页既有写法。 + +## 正确性评审 + +- `onLongPress` 为可选,默认 `null` 时行为与升级前一致(无长按回调)。 +- 单击与长按手势由 `InkWell` 区分,与 Flutter 默认语义一致;已由 TC-01、TC-02 锁定。 + +## 风险与未验证项 + +- 投影由自定义三层阴影改为主题 `shadowsMiddle` / `shadowsBase`,FAB 外观光感可能与旧版略有差异,但更符合设计 token;若设计侧有专门 FAB 投影 token,可后续单独收敛。 +- 本地 `dart run tdesign_flutter_tools` 生成命令不可用(stub 包),`fab_api.md` 已手工同步;若 CI 会跑全量 `all_build.sh`,应以 CI 产物为准再核对一行差异。 diff --git a/requirements/issue-924-fab-on-long-press/pr-body.md b/requirements/issue-924-fab-on-long-press/pr-body.md new file mode 100644 index 000000000..ea3261424 --- /dev/null +++ b/requirements/issue-924-fab-on-long-press/pr-body.md @@ -0,0 +1,58 @@ +### 🤔 这个 PR 的性质是? +> 勾选规则: +> 1.只要有新增参数,就勾选”新特性提交“ +> 2.只修改内部bug,未新增参数,才勾选”日常 bug 修复“ +> 3.其他选项视具体改动判断 + +- [ ] 日常 bug 修复 +- [x] 新特性提交 +- [ ] 文档改进 +- [x] 演示代码改进 +- [x] 组件样式/交互改进 +- [ ] CI/CD 改进 +- [ ] 重构 +- [ ] 代码风格优化 +- [x] 测试用例 +- [ ] 分支合并 +- [ ] 其他 + +### 🔗 相关 Issue + +需求来源:悬浮按钮需要长按扩展能力。 + +https://github.com/Tencent/tdesign-flutter/issues/924 + +### 💡 需求背景和解决方案 + +**问题**:`TFab` 仅支持单击 `onClick`,业务无法实现长按菜单等交互。 + +**方案**: + +- 新增可选参数 `onLongPress`(`VoidCallback?`),并传给内部 `InkWell.onLongPress`。 +- 将原先硬编码的反色与阴影改为 `TTheme` 中的 `textColorAnti` 与 `shadowsMiddle` / `shadowsBase`,满足主题规范与强制检查。 + +**用法示例**: + +```dart +TFab( + theme: TFabTheme.primary, + text: '操作', + onClick: () {}, + onLongPress: () {}, +) +``` + +### 📝 更新日志 + +- feat(TFab): 新增 `onLongPress`,支持长按回调;图标反色与投影改为主题 token。 + +- [ ] 本条 PR 不需要纳入 Changelog + +### ☑️ 请求合并前的自查清单 + +⚠️ 请自检并全部**勾选全部选项**。⚠️ + +- [x] pr目标分支为develop分支,请勿直接往main分支合并 +- [x] 标题格式为:`组件类名`: 修改描述(示例:`TBottomTabBar`: 修复iconText模式,底部溢出2.5像素) +- [x] ”相关issue“处带上修复的issue链接 +- [x] 相关文档已补充或无须补充 diff --git a/requirements/issue-924-fab-on-long-press/test-cases.md b/requirements/issue-924-fab-on-long-press/test-cases.md new file mode 100644 index 000000000..cede8041a --- /dev/null +++ b/requirements/issue-924-fab-on-long-press/test-cases.md @@ -0,0 +1,19 @@ +# 测试用例 — issue #924 [TDFab] 暴露 onLongPress 方法 + +## TC-01 + +- **前置条件**:使用 `TTheme` + `MaterialApp` 包裹 `TFab`,并设置非空的 `onLongPress` 回调。 +- **操作**:对 `TFab` 执行 `longPress` 手势。 +- **期望**:`onLongPress` 被调用一次。 + +## TC-02 + +- **前置条件**:同一 `TFab` 上同时设置 `onClick` 与 `onLongPress`。 +- **操作**:先 `tap`,再 `longPress`。 +- **期望**:单击只触发 `onClick`,不触发 `onLongPress`;长按触发 `onLongPress`。 + +## TC-03 + +- **前置条件**:运行示例应用,进入「Fab」示例页;打开「单元测试」折叠区,或进入主内容区「交互」模块。 +- **操作**:长按展示「长按」文案的悬浮按钮。 +- **期望**:出现内容为「已长按」的 `SnackBar`(人工走查)。 diff --git a/tdesign-component/example/assets/api/fab_api.md b/tdesign-component/example/assets/api/fab_api.md index 641de8127..9d88c4f8a 100644 --- a/tdesign-component/example/assets/api/fab_api.md +++ b/tdesign-component/example/assets/api/fab_api.md @@ -7,6 +7,7 @@ | icon | Icon? | - | 图标 | | key | | - | | | onClick | VoidCallback? | - | 点击事件 | +| onLongPress | VoidCallback? | - | 长按回调 | | shape | TFabShape | TFabShape.circle | 形状 | | size | TFabSize | TFabSize.large | 大小 | | text | String? | - | 文本 | diff --git a/tdesign-component/example/assets/code/fab._buildLongPressFab.txt b/tdesign-component/example/assets/code/fab._buildLongPressFab.txt new file mode 100644 index 000000000..1749e663a --- /dev/null +++ b/tdesign-component/example/assets/code/fab._buildLongPressFab.txt @@ -0,0 +1,15 @@ + + Widget _buildLongPressFab(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 16), + child: TFab( + theme: TFabTheme.primary, + text: '长按', + onLongPress: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已长按')), + ); + }, + ), + ); + } \ No newline at end of file diff --git a/tdesign-component/example/lib/page/t_fab_page.dart b/tdesign-component/example/lib/page/t_fab_page.dart index 1f3030b84..6269b5639 100644 --- a/tdesign-component/example/lib/page/t_fab_page.dart +++ b/tdesign-component/example/lib/page/t_fab_page.dart @@ -16,7 +16,17 @@ class _TFabPageState extends State { @override Widget build(BuildContext context) { - return ExamplePage(title: tTitle(), exampleCodeGroup: 'fab', children: [ + return ExamplePage( + title: tTitle(), + exampleCodeGroup: 'fab', + test: [ + ExampleItem( + ignoreCode: true, + desc: 'TFab onLongPress 长按回调', + builder: _buildLongPressFab, + ), + ], + children: [ ExampleModule(title: '组件类型', children: [ ExampleItem(desc: 'Icon Fab 纯图标悬浮按钮', builder: _buildPureIconFab), ExampleItem( @@ -26,6 +36,9 @@ class _TFabPageState extends State { ExampleItem(desc: 'Fab Theme 悬浮按钮主题', builder: _buildThemeFab), ExampleItem(desc: 'Fab Shape 悬浮按钮形状', builder: _buildShapeFab), ExampleItem(desc: 'Fab Size 悬浮按钮尺寸', builder: _buildSizeFab) + ]), + ExampleModule(title: '交互', children: [ + ExampleItem(desc: 'Fab onLongPress 长按回调', builder: _buildLongPressFab), ]) ]); } @@ -99,6 +112,22 @@ class _TFabPageState extends State { ]); } + @Demo(group: 'fab') + Widget _buildLongPressFab(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 16), + child: TFab( + theme: TFabTheme.primary, + text: '长按', + onLongPress: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已长按')), + ); + }, + ), + ); + } + @Demo(group: 'fab') Widget _buildSizeFab(BuildContext context) { return _buildRowDemoWidthDescription([ diff --git a/tdesign-component/lib/src/components/fab/t_fab.dart b/tdesign-component/lib/src/components/fab/t_fab.dart index e2d05c668..27b30e3e7 100644 --- a/tdesign-component/lib/src/components/fab/t_fab.dart +++ b/tdesign-component/lib/src/components/fab/t_fab.dart @@ -16,15 +16,22 @@ enum TFabSize { extraSmall // 特小 } -class TFab extends StatelessWidget { +/// 长按态下在底色上叠一层半透明压暗,避免使用 Ink 水波纹带来的矩形裁切问题。 +Color _fabLongPressOverlay(BuildContext context, Color base) { + final overlay = TTheme.of(context).fontGyColor1.withOpacity(0.15); + return Color.alphaBlend(overlay, base); +} + +class TFab extends StatefulWidget { const TFab({ Key? key, this.theme = TFabTheme.defaultTheme, this.shape = TFabShape.circle, this.size = TFabSize.large, this.text, - this.onClick, this.icon, + this.onClick, + this.onLongPress, }) : super(key: key); /// 主题 @@ -45,10 +52,20 @@ class TFab extends StatelessWidget { /// 点击事件 final VoidCallback? onClick; - bool get showText => text?.isNotEmpty ?? false; + /// 长按回调 + final VoidCallback? onLongPress; + + @override + State createState() => _TFabState(); +} + +class _TFabState extends State { + bool _longPressHeld = false; + + bool get showText => widget.text?.isNotEmpty ?? false; EdgeInsets getPadding() { - switch (size) { + switch (widget.size) { case TFabSize.large: return showText ? const EdgeInsets.symmetric(horizontal: 20, vertical: 12) @@ -69,7 +86,7 @@ class TFab extends StatelessWidget { } double getMinWidthOrHeight() { - switch (size) { + switch (widget.size) { case TFabSize.large: return 48.0; case TFabSize.medium: @@ -82,7 +99,7 @@ class TFab extends StatelessWidget { } Color getBackgroundColor(BuildContext context) { - switch (theme) { + switch (widget.theme) { case TFabTheme.primary: return TTheme.of(context).brandColor7; case TFabTheme.defaultTheme: @@ -95,20 +112,20 @@ class TFab extends StatelessWidget { } Color getIconColor(BuildContext context) { - switch (theme) { + switch (widget.theme) { case TFabTheme.primary: - return Colors.white; + return TTheme.of(context).textColorAnti; case TFabTheme.defaultTheme: return TTheme.of(context).fontGyColor1; case TFabTheme.light: return TTheme.of(context).brandNormalColor; case TFabTheme.danger: - return Colors.white; + return TTheme.of(context).textColorAnti; } } double getIconSize() { - switch (size) { + switch (widget.size) { case TFabSize.large: return 24.0; case TFabSize.medium: @@ -121,7 +138,7 @@ class TFab extends StatelessWidget { } double getFontSize() { - switch (size) { + switch (widget.size) { case TFabSize.large: return 16.0; case TFabSize.medium: @@ -133,58 +150,69 @@ class TFab extends StatelessWidget { } } + BorderRadius _borderRadius(BuildContext context) { + return widget.shape == TFabShape.circle + ? BorderRadius.circular(TTheme.of(context).radiusCircle) + : BorderRadius.circular(TTheme.of(context).radiusDefault); + } + @override Widget build(BuildContext context) { - return InkWell( - onTap: onClick, - child: Container( - padding: getPadding(), - decoration: BoxDecoration( - color: getBackgroundColor(context), - boxShadow: [ - BoxShadow( - offset: const Offset(0, 5), - blurRadius: 2.5, - spreadRadius: -1.5, - color: Colors.black.withOpacity(0.1)), - BoxShadow( - offset: const Offset(0, 8), - blurRadius: 5, - spreadRadius: 0.5, - color: Colors.black.withOpacity(0.06)), - BoxShadow( - offset: const Offset(0, 3), - blurRadius: 7, - spreadRadius: 1, - color: Colors.black.withOpacity(0.05)) - ], - borderRadius: shape == TFabShape.circle - ? BorderRadius.circular(TTheme.of(context).radiusCircle) - : BorderRadius.circular(TTheme.of(context).radiusDefault)), - height: getMinWidthOrHeight(), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - icon ?? - Icon( - TIcons.add, - size: getIconSize(), - color: getIconColor(context), - ), - if (showText) const SizedBox(width: 4), - if (showText) - TText( - text ?? '', - style: TextStyle( - height: 1.5, - fontWeight: FontWeight.w600, - fontSize: getFontSize(), - color: getIconColor(context), - leadingDistribution: TextLeadingDistribution.even, + final baseBg = getBackgroundColor(context); + final displayBg = + _longPressHeld ? _fabLongPressOverlay(context, baseBg) : baseBg; + + return Semantics( + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onClick, + onLongPress: widget.onLongPress, + onLongPressStart: (_) { + if (widget.onLongPress != null) { + setState(() => _longPressHeld = true); + } + }, + onLongPressEnd: (_) { + setState(() => _longPressHeld = false); + }, + onLongPressCancel: () { + setState(() => _longPressHeld = false); + }, + child: Container( + padding: getPadding(), + decoration: BoxDecoration( + color: displayBg, + boxShadow: TTheme.of(context).shadowsMiddle ?? + TTheme.of(context).shadowsBase ?? + const [], + borderRadius: _borderRadius(context), + ), + height: getMinWidthOrHeight(), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + widget.icon ?? + Icon( + TIcons.add, + size: getIconSize(), + color: getIconColor(context), + ), + if (showText) const SizedBox(width: 4), + if (showText) + TText( + widget.text ?? '', + style: TextStyle( + height: 1.5, + fontWeight: FontWeight.w600, + fontSize: getFontSize(), + color: getIconColor(context), + leadingDistribution: TextLeadingDistribution.even, + ), ), - ), - ], + ], + ), ), ), ); diff --git a/tdesign-component/test/t_fab_test.dart b/tdesign-component/test/t_fab_test.dart new file mode 100644 index 000000000..da030a6e4 --- /dev/null +++ b/tdesign-component/test/t_fab_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +Widget _wrap(Widget child) { + return TTheme( + data: TThemeData.defaultData(), + child: MaterialApp( + home: Scaffold( + body: Center(child: child), + ), + ), + ); +} + +void main() { + group('TFab — onLongPress (issue #924)', () { + testWidgets('长按时应触发 onLongPress', (tester) async { + var longPressed = false; + await tester.pumpWidget(_wrap( + TFab( + theme: TFabTheme.primary, + onLongPress: () { + longPressed = true; + }, + ), + )); + await tester.pumpAndSettle(); + + await tester.longPress(find.byType(TFab)); + await tester.pumpAndSettle(); + + expect(longPressed, isTrue); + }); + + testWidgets('单击时应触发 onClick,且可与 onLongPress 共存', (tester) async { + var tapped = false; + var longPressed = false; + await tester.pumpWidget(_wrap( + TFab( + theme: TFabTheme.primary, + onClick: () { + tapped = true; + }, + onLongPress: () { + longPressed = true; + }, + ), + )); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(TFab)); + await tester.pumpAndSettle(); + expect(tapped, isTrue); + expect(longPressed, isFalse); + + await tester.longPress(find.byType(TFab)); + await tester.pumpAndSettle(); + expect(longPressed, isTrue); + }); + }); +} diff --git a/tdesign-site/src/fab/README.md b/tdesign-site/src/fab/README.md index c24020665..e662337cf 100644 --- a/tdesign-site/src/fab/README.md +++ b/tdesign-site/src/fab/README.md @@ -158,6 +158,30 @@ Fab Size 悬浮按钮尺寸 +### 1 交互 + +Fab onLongPress 长按回调 + + + +
+  Widget _buildLongPressFab(BuildContext context) {
+    return Padding(
+      padding: const EdgeInsets.only(left: 16),
+      child: TFab(
+        theme: TFabTheme.primary,
+        text: '长按',
+        onLongPress: () {
+          ScaffoldMessenger.of(context).showSnackBar(
+            const SnackBar(content: Text('已长按')),
+          );
+        },
+      ),
+    );
+  }
+ +
+ ## API @@ -169,6 +193,7 @@ Fab Size 悬浮按钮尺寸 | icon | Icon? | - | 图标 | | key | | - | | | onClick | VoidCallback? | - | 点击事件 | +| onLongPress | VoidCallback? | - | 长按回调 | | shape | TFabShape | TFabShape.circle | 形状 | | size | TFabSize | TFabSize.large | 大小 | | text | String? | - | 文本 |