diff --git a/specs/components/027-Input-Slider/acceptance.md b/specs/components/027-Input-Slider/acceptance.md index 6f139d9556..5340c05464 100644 --- a/specs/components/027-Input-Slider/acceptance.md +++ b/specs/components/027-Input-Slider/acceptance.md @@ -1,23 +1,59 @@ # Slider 验证范围与结论 -## 本次范围 +## 已提交生产候选与环境 -2026-10-20 原记录整理;未重跑组件、Golden、设备或远端CI。以下结论仅限原候选与范围。 +以下历史候选与 Golden 收敛记录不自动覆盖本次兼容修复;当前候选结果见文末。 -## 已有验证结论 +已提交生产候选为 `a65182d898f534e5181841c17e1298962336fc30`;其修复基于 PR #1212 原 head `f2472da21bfa6f9cc3044594b1e9daa93a797f61` 上的端点、交互和约束修复。2026-10-27 完成的本地验收绑定源码、测试、资源清单指纹 `fa8eac29a79fd5521d5eb2d394e36c0edd13372bf0edef6161f06557f28961ee`;完整清单、环境和命令见 [本地验证记录](./evidence/local-validation-20261027.json)。 -| 原记录范围 | 原文可追溯标识与环境 | 有界结论 | -| --- | --- | --- | -| 既有记录 | `d2ff7a0aa5317c0f5412848610f2a1ae05b6dfa7`、`fe14543572bc7233226b2f08db6fc1424cd81ed2`;Flutter 3.32.0 | 组件与布局:有明确通过记录(限原范围);Demo 与消费:有明确通过记录(限原范围);静态检查:有明确通过记录(限原范围);Golden:有明确通过记录(限原范围);设备:仅按下文的实际操作与未覆盖范围解释 | +Flutter 3.32.0 使用 Linux x86_64,3.47.8 使用 macOS arm64;自动 Golden 仅在前者执行。Specs 编辑不改变该组件清单指纹,也不刷新此前测试结果。 + +## 功能与接口 + +- 两版各 86 个组件、17 个 Demo、179 个工具测试通过;组件和 Example 的 `flutter analyze --fatal-infos` 均零诊断。Slider 生产源码覆盖率均为 675/692(97.54%)。 +- 回归覆盖正常尺寸、0/100 端点、连续/离散、单/双游标、非零取值范围、禁用、RTL、主题厚度、非法主题、固有尺寸、持续拖动、拖动中缩放、零尺寸生命周期及父级滚动。包含 1920 组长度/厚度/间距/横轴约束组合和 32 组持续缩放配置。 +- 原候选的间距限制与格式化输入已被本轮兼容修复替换;当前候选的验证结果另行记录,不沿用上述候选的通过结论。 +- 构造参数、默认值、字段、方法签名、枚举、typedef 和包导出与原 PR、上述 develop 参考均保持一致;内部几何库未导出,没有新增公开 API 或 SDK 版本分支。57 份生成 API 文档、370 个示例片段及片段静态分析通过。 + +## 视觉与对齐 + +- 小程序对齐参考为 `Tencent/tdesign-miniprogram@a2aeed69163b6da423c99b66ff780c10043a4553`,在微信开发者工具 WebView 实测。普通 thumb 中心位于可见端点;胶囊沿行程两端各留 3px,默认横截面两侧各留 2px;垂直示例可见长度为 200px,数值和刻度复用同一行程。 +- 默认主题真实操作覆盖 15 个可交互示例,单游标到 0/100、双游标到 0..100,并保存明暗运行截图。实际 thumb 主体最小控件边界余量为 0.5px;垂直数值和刻度两端相对实际绘制中心误差为 0px。阴影、按压光晕不作为 thumb 主体边界。 +- 与 develop `ca555ca40a8a317bb0e3eb521a844d54b4832eee` 在同 Linux、Flutter、依赖锁和字体环境对照。垂直可见长度 168→200px、胶囊端点及刻度位置属于本次有意修复;不以与 develop 像素完全相同判定无回归。 +- Linux 34 个 Demo Golden 和 1 个组件 Golden 使用默认精确比较器通过,没有放宽容差。端点和几何修复阶段有意更新基线;最终恢复 8px 行为后未更新基线,四张默认水平/垂直 0/100 明暗完整页相对已确认样式均为 0 像素变化。 +- 基线位于 [Demo Golden](../../../tdesign-component/example/test/slider/goldens) 和 [组件 Golden](../../../tdesign-component/test/components/slider/goldens),由现有集中式视觉入口登记执行;不重复提交本地拼图、actual/diff 或运行日志。 + +## 取舍与验证边界 -各标识可能对应不同阶段,不能合成一次完整验证;候选或日志缺失时仍需复核。 +曾考虑全局取消连续双游标最小像素间距;因会扩大连续回调的输出范围、影响依赖正跨度的业务,最终撤回。早期 2px 行程留白、普通离散内缩和 168px 垂直长度也不再作为当前契约;最终设计以 [spec.md](./spec.md) 为准。 + +上述结论为本地验收,不代表远端 CI 或设备验证。Skyline、微信真机及平台构建未执行;长 formatter 文本的全局展示策略不属于本次扩展范围。最终推送 SHA 的远端检查独立确认,待办见 [tasks.md](./tasks.md)。早于本次指纹的历史候选不作为当前通过证明。 + +## 按独立缺陷收敛Golden(当前本地结论) + +本轮基于已提交生产候选,仅调整测试组织和Spec。生产组件、公开Demo、生成产物、字体和组件测试均未改。前一轮本地候选把Demo基线32→16张,本轮进一步按独立可见风险收敛为6张,组件Golden仍为1张;新的指纹、覆盖对应关系及执行结果见 [独立缺陷验证记录](./evidence/golden-defects-20261027.json)。此前34次及16次比较保留各自验收身份,不作为当前6次比较的证明。 + +| 职责 | 当前基线 | 保护的风险 | +| --- | --- | --- | +| 默认完整页 | page_light/dark | 分组、顺序、默认中间值、禁用状态、明暗配色 | +| 端点矩阵 | endpoints_light/dark | 普通/胶囊、水平/垂直、连续/离散的0/100与完整范围;thumb、数值、刻度及分段对齐 | +| 视觉边界矩阵 | boundaries_light/dark | 粗轨道近端点、RTL、窄尺寸、横轴缩小及大厚度约束 | +| 功能回归 | 不另建Golden | 回调、8px规则、持续拖动/缩放、零尺寸、滚动、键盘、吸附及广泛参数组合 | -## 验证范围与未覆盖项 +- Demo基线与比较次数均16→6;含组件总基线17→7。两张默认页逐字节不变,四张新矩阵原尺寸内容与此前对应区域独立对照,52个代表区域像素变化均为0。 +- 两版各17个Demo和179个工具测试通过,组件与Example严格分析零诊断;86个组件回归用例及其生产代码保持原样,复用已提交生产候选的有界结果,未在本轮重复执行覆盖率门禁。 +- Linux四张新矩阵先检查实际图和旧区域,再建立一次基线;随后同命令不带更新参数复验,6个Demo和1个组件Golden精确通过,未放宽容差。 +- 在隔离临时副本故意重现端点内距偏差1px、取消厚度上限,端点矩阵与边界矩阵分别出现预期像素失败。正常交付源码和公开Demo未因此修改,反例结果独立于正常通过记录。 +- 参数笛卡尔积的额外图片、重复中间拖动图和吸附/最小厚度等等效视觉状态已移除;对应计算和交互仍由现有功能回归覆盖。本轮未提交、未推送,远端CI和设备不属于当前结论。 -原验收未进一步列出未覆盖项;本次未补做新候选验证,不能从历史通过记录外推全部状态、平台或设备。 +## 回调和正常尺寸兼容修复(当前候选) -## 必要证据与追溯 +- 单值与双值非刻度输入保留原始受控值用于自定义 formatter 和默认数值标签;控件位置仍吸附刻度。正常尺寸的连续双游标,即使受控区间窄于8px,只要两端有足够空间仍采用8px;不足时由同一几何模型限制,防止产生越界值。 +- Flutter 3.32.0 Linux 与 3.47.8 macOS 分别执行88项 Slider 组件功能回归、17项 Demo 功能回归及组件严格静态分析,均通过。真实拖动覆盖普通和胶囊窄区间,验证间距及回调取值;既有用例覆盖约束、缩放、两端、RTL 与生命周期。 +- Linux Flutter 3.32.0 按 `TDESIGN_DEMO_TEST_MODULES=false` 执行6项 Demo Golden 和1项组件 Golden,使用默认精确比较器,均通过;本轮没有更新基线。未针对当前候选重跑全项目工具测试、覆盖率及生成产物检查,历史结果不作为当前候选通过证明。 +- 本轮 Slider 构造参数、字段、方法、枚举、formatter typedef 与包导出经 AST 对照 develop 均未变化;仅恢复 formatter 输入和可容纳情况下原有最小间距。靠近取值边界时的间距约束是为避免非法回调而保留的修复,不能宣称所有边界行为与旧版逐项相同。相关本地测试和文件哈希见 [本轮验证记录](./evidence/compatibility-20261027.json)。 -未绑定唯一候选的结果不作为当前通过证明;旧命令与流水由Git历史追溯。 +## 跨组件 Golden 同步 -当前待办见 [tasks.md](./tasks.md);实际执行入口见[贡献规范](../../../CONTRIBUTING.md#组件测试与-ci-回归门禁)。清理不关闭未完成项。 +- PR head `804fac7da54be20e7d1a921d8606c408e643bfdf` 的 CI 暴露出 Swiper、Loading 示例和 Theme 组合场景中引用的 Slider 仍使用旧基线。与 develop 隔离复现并只替换 Slider 源码的对照确认差异由 Slider 修复引起;共享字体保持 PR 版本时换回旧 Slider 即通过。 +- 逐张检查并更新 Swiper 6 张、Loading 4 张及 Theme 1 张基线。图片尺寸不变,像素差异均在 Slider 区域;新图与该 CI 的实际渲染逐像素一致。Linux Flutter 3.32.0 使用默认精确比较器复验,不放宽容差;最终远端 CI 仍需按推送后的 head 单独核对。 diff --git a/specs/components/027-Input-Slider/evidence/compatibility-20261027.json b/specs/components/027-Input-Slider/evidence/compatibility-20261027.json new file mode 100644 index 0000000000..c09fbf19cf --- /dev/null +++ b/specs/components/027-Input-Slider/evidence/compatibility-20261027.json @@ -0,0 +1,45 @@ +{ + "base_commit": "a65182d898f534e5181841c17e1298962336fc30", + "scope": "local working tree, no push", + "files_sha256": { + "tdesign-component/lib/src/components/slider/t_slider.dart": "adade463d81164d81850f9b93ec008ba8dc5a88b4a571fa8e68e38547ec3f3e2", + "tdesign-component/lib/src/components/slider/t_slider_geometry.dart": "ebecf3084920b97d89853752a38ec873dccba2ea457a66f3c4a65429a5e73676", + "tdesign-component/test/components/slider/t_slider_geometry_test.dart": "e60f9ec187404a93d014345cd469089328039187c53b9a37de064dd96c032f6e", + "tdesign-component/test/components/slider/t_slider_constraints_test.dart": "e520c23f13a446f51416e9eeec19f79133178730cd39ac269085621b10fd09a7", + "tdesign-component/test/components/slider/t_slider_test.dart": "0cd2168d6470b8c752e57ae45af8265255a6773721aa6e94aa993d2a10191953", + "tdesign-component/test/components/slider/t_slider_golden_test.dart": "0d3cd81e28110aa477fe0aaaa0ab7b37887acc3d30ba6dd4c9d92c0fb9a21347", + "tdesign-component/example/test/slider/slider_demo_test.dart": "267851f8048e872b77db5afdec3b6a3580d6309bbda167debfc1f4d6717842d0", + "tdesign-component/example/test/slider/slider_demo_golden_test.dart": "c08a002c6dd90854906adde41e754565b5727ead976e968fd36cd7d057bb47ff", + "tdesign-component/example/test/slider/slider_endpoint_golden_test.dart": "04c1ba6b5e826e96748b29be52785b15b16206a379f7a480233406d8509e1ee3", + "tdesign-component/example/test/slider/slider_boundary_golden_test.dart": "c83d6a0d1e6450185ebaf9ec15261f8a38771675a31e61ecf98561f19a2ab933", + "tdesign-component/example/test/slider/slider_boundary_sources.dart": "aaf37eae9ba4e5c55d2b7e321ea37388c45235545215c102e9218f63c9e6729d", + "tdesign-component/example/test/slider/slider_golden_matrix.dart": "64740f4b129fa918b9117d40439390572c5a367267e8d6a1d364034f58434dab", + "tdesign-component/example/test/slider/goldens/slider_boundaries_dark.png": "7f7f7bd4d3f7fa0052e47d8dd304ff1192a28387bbcb01bd71407db1ba6bfe1f", + "tdesign-component/example/test/slider/goldens/slider_boundaries_light.png": "69b047a4948f404d1f7dd50ae9ea759ad5ffbe53c42486519dfaa67c50c0d7a2", + "tdesign-component/example/test/slider/goldens/slider_endpoints_dark.png": "b63bbc3bc397fbe0dc2ea7f19094b54be2a4e6f48754170e6639f01fe9115d8c", + "tdesign-component/example/test/slider/goldens/slider_endpoints_light.png": "453fd9cc6b27abba00401be05b191f3b210eb1c269010aef8b84c8b83b744405", + "tdesign-component/example/test/slider/goldens/slider_page_dark.png": "c8b9b468af693072e65424f88fb904a55148e4aa52d893020ce2a26534433b73", + "tdesign-component/example/test/slider/goldens/slider_page_light.png": "0c0dc340aa5c695469154679202084bff29d8576ccb4baac15024de1e0a8d133", + "tdesign-component/test/components/slider/goldens/t_slider_material.png": "95c967976172cf8872f34aa623837c5161e244b65e460ab0bddc77b9bf215729" + }, + "validation": { + "flutter_3.32.0_linux": { + "component_tests": 88, + "demo_tests": 17, + "component_analysis": "pass", + "demo_golden": 6, + "component_golden": 1 + }, + "flutter_3.47.8_macos": { + "component_tests": 88, + "demo_tests": 17, + "component_analysis": "pass" + }, + "golden_comparator": "exact, no updates", + "public_api": "compared selected Slider declarations and barrel to origin/develop; unchanged" + }, + "limits": [ + "whole-project tooling, coverage and generated outputs not repeated for this candidate", + "remote CI and devices not validated" + ] +} diff --git a/specs/components/027-Input-Slider/evidence/golden-defects-20261027.json b/specs/components/027-Input-Slider/evidence/golden-defects-20261027.json new file mode 100644 index 0000000000..66051de8ab --- /dev/null +++ b/specs/components/027-Input-Slider/evidence/golden-defects-20261027.json @@ -0,0 +1,138 @@ +{ + "base_commit": "a65182d898f534e5181841c17e1298962336fc30", + "local_only": true, + "candidate_fingerprint": "ed217c6ace1c1de01600a6b54c7268fd50e9eea92eb24fe301afa692e70e8a3f", + "candidate_files": { + "tdesign-component/example/test/slider/slider_demo_golden_test.dart": "c08a002c6dd90854906adde41e754565b5727ead976e968fd36cd7d057bb47ff", + "tdesign-component/example/test/slider/slider_endpoint_golden_test.dart": "04c1ba6b5e826e96748b29be52785b15b16206a379f7a480233406d8509e1ee3", + "tdesign-component/example/test/slider/slider_boundary_golden_test.dart": "c83d6a0d1e6450185ebaf9ec15261f8a38771675a31e61ecf98561f19a2ab933", + "tdesign-component/example/test/slider/slider_boundary_sources.dart": "aaf37eae9ba4e5c55d2b7e321ea37388c45235545215c102e9218f63c9e6729d", + "tdesign-component/example/test/slider/slider_golden_matrix.dart": "64740f4b129fa918b9117d40439390572c5a367267e8d6a1d364034f58434dab", + "tdesign-component/tool/component_test_manifest.dart": "840a0ff4be23c76b95bdb3578f120d6243a7327ac2d8b3b4a32dc626c745e643", + "tdesign-component/example/test/slider/goldens/slider_boundaries_dark.png": "7f7f7bd4d3f7fa0052e47d8dd304ff1192a28387bbcb01bd71407db1ba6bfe1f", + "tdesign-component/example/test/slider/goldens/slider_boundaries_light.png": "69b047a4948f404d1f7dd50ae9ea759ad5ffbe53c42486519dfaa67c50c0d7a2", + "tdesign-component/example/test/slider/goldens/slider_endpoints_dark.png": "b63bbc3bc397fbe0dc2ea7f19094b54be2a4e6f48754170e6639f01fe9115d8c", + "tdesign-component/example/test/slider/goldens/slider_endpoints_light.png": "453fd9cc6b27abba00401be05b191f3b210eb1c269010aef8b84c8b83b744405", + "tdesign-component/example/test/slider/goldens/slider_page_dark.png": "c8b9b468af693072e65424f88fb904a55148e4aa52d893020ce2a26534433b73", + "tdesign-component/example/test/slider/goldens/slider_page_light.png": "0c0dc340aa5c695469154679202084bff29d8576ccb4baac15024de1e0a8d133" + }, + "unchanged_scopes": [ + "tdesign-component/lib", + "tdesign-component/example/lib", + "tdesign-component/example/assets", + "tdesign-component/test/components", + "tdesign-component/example/test/fonts" + ], + "before": { + "demo_images": 16, + "demo_comparisons": 16, + "component_images": 1, + "component_comparisons": 1, + "demo_png_bytes": 1934223 + }, + "after": { + "demo_images": 6, + "demo_comparisons": 6, + "component_images": 1, + "component_comparisons": 1, + "demo_png_bytes": 501110 + }, + "coverage_roles": [ + { + "role": "default page", + "baselines": [ + "slider_page_light.png", + "slider_page_dark.png" + ], + "risk": "public grouping, initial values, colors, typography and disabled styles", + "cases": "18 public instances including 3 disabled controls", + "support": "slider_demo_test.dart: scenario order and parameters" + }, + { + "role": "endpoints", + "baselines": [ + "slider_endpoints_light.png", + "slider_endpoints_dark.png" + ], + "risk": "thumb/value/tick endpoint clipping, capsule inset and discrete gap alignment", + "cases": "18 regions per theme: ordinary/capsule, horizontal/vertical, single0/100 and full ranges, continuous/discrete", + "support": "slider_demo_test.dart endpoint alignment; component endpoint pixel and nonzero-range drag tests" + }, + { + "role": "visual boundaries", + "baselines": [ + "slider_boundaries_light.png", + "slider_boundaries_dark.png" + ], + "risk": "thick track near end, RTL, narrow discrete rendering, longitudinal/cross-axis thumb scaling, large thickness constraints", + "cases": "8 regions per theme", + "support": "component geometry/constraints suites and Demo constraint fixture" + }, + { + "role": "functional-only boundaries", + "baselines": [], + "risk": "callback values/lifecycle, 8px range rule, sustained resize, zero size, parent scroll, keyboard, off-grid snapping, capsule minimum thickness, broad parameter combinations", + "cases": "existing86 component and17Demo tests retained without editing", + "support": "t_slider_constraints_test.dart, t_slider_geometry_test.dart, t_slider_test.dart, slider_demo_test.dart" + } + ], + "boundary_scene_ids": [ + "near-edge", + "narrow-discrete", + "rtl-scale", + "rtl-capsule-scale", + "narrow-continuous", + "thin-cross-axis", + "large-normal", + "large-capsule" + ], + "selection_policy": "one visible failure mode per representative; broad combinations remain functional tests instead of complete visual products", + "visual_validation": { + "renderer": "Linux Flutter3.32.0", + "demo": 6, + "component": 1, + "updates": 1, + "new_baselines": 4, + "unchanged_default_baselines": 2, + "comparator": "default exact, tolerance unchanged", + "retained_regions_compared_to_prior_images": 52, + "changed_content_pixels": 0, + "matrix_render_scale": 1 + }, + "functional_validation": { + "each_sdk": [ + "3.32.0 Linux x86_64", + "3.47.8 macOS arm64" + ], + "demo_each": 17, + "tooling_each": 179, + "strict_analysis": "component and example: zero diagnostics" + }, + "component_regression": "86 test cases, coverage97.54% and production code unchanged from accepted base; not rerun this test-only batch", + "defect_reintroduction_checks": [ + { + "mutation": "endpoint-inset", + "expected_failure": true, + "test": "slider endpoints matrix light", + "changed_pixels": 8584, + "diff_percent": 0.65, + "scope": "isolated temporary copy; delivery source unchanged" + }, + { + "mutation": "cross-overflow", + "expected_failure": true, + "test": "slider boundaries matrix light", + "changed_pixels": 9234, + "diff_percent": 1.67, + "scope": "isolated temporary copy; delivery source unchanged" + } + ], + "committed": false, + "pushed": false, + "unverified": [ + "remote CI for local delta", + "physical device", + "Skyline", + "platform builds" + ] +} diff --git a/specs/components/027-Input-Slider/evidence/local-validation-20261027.json b/specs/components/027-Input-Slider/evidence/local-validation-20261027.json new file mode 100644 index 0000000000..2a1c59ab84 --- /dev/null +++ b/specs/components/027-Input-Slider/evidence/local-validation-20261027.json @@ -0,0 +1,151 @@ +{ + "date": "2026-10-27", + "base_pr_head": "f2472da21bfa6f9cc3044594b1e9daa93a797f61", + "source_inventory_algorithm": "SHA-256 of sorted-key JSON path/hash map; covers changed component sources, tests, generated assets, fonts and Golden images; specs excluded to avoid self-reference", + "source_inventory_fingerprint": "fa8eac29a79fd5521d5eb2d394e36c0edd13372bf0edef6161f06557f28961ee", + "source_inventory": { + "tdesign-component/example/assets/api/slider_api.md": "6a85e61ac2d9c74fd1a0bf4dc8b57cfd6343f75a652522d23c3a17ff9dc6de6c", + "tdesign-component/example/assets/code/slider.SliderVerticalExample.txt": "3af924c91c0be781617b6fa1af045573357f4b69a9be5488508d6aaa02a1c89a", + "tdesign-component/example/lib/page/slider/slider_vertical_example.dart": "0a529c286110c4f0c36b1e4caad877801eb2823a28e7edca33fd46cc1a096a7f", + "tdesign-component/example/test/fonts/README.md": "ba03de675533fcc11be8bfbadec57ece83d2543dff6669a9c8667a4cda7e80b9", + "tdesign-component/example/test/fonts/TDesignGoldenCJK-Regular.otf": "c22bfd8a9f169d5807298a2df3fd5560ddaaa32079199c2664bf000291fc0197", + "tdesign-component/example/test/fonts/component_demo_glyphs.txt": "43b8c9e4ac7d1826948224a193dc9bcdc1059ea0dd394d0813685bd6e15321d2", + "tdesign-component/example/test/slider/goldens/slider_changed_dark.png": "96f7233e21f6bcb5232bfc5faa867bfdf7c5bbdff2d729c411846c7b7ecc6682", + "tdesign-component/example/test/slider/goldens/slider_changed_light.png": "211ecbbd8e4da9c9b35e3a464f55b527da7f98d697873af9226e811399af25eb", + "tdesign-component/example/test/slider/goldens/slider_constraints_0_dark.png": "a679870e9f4c493e3cd4c43949a221184e33bb7ce27badf2899b9ad8c91c83ec", + "tdesign-component/example/test/slider/goldens/slider_constraints_0_light.png": "3479d8c3e53d4619444b83c7ec2a1cf1477de05c83fe64d6cb1b653544f04c6f", + "tdesign-component/example/test/slider/goldens/slider_constraints_100_dark.png": "dbed672ac26884bdace3e1197a25af695d0bb04263218ad7e782307c1e2b4a47", + "tdesign-component/example/test/slider/goldens/slider_constraints_100_light.png": "d6ccf9a02b05a058eab9c345d2a66404b140ed7fbce4bfac4d13b11182763a90", + "tdesign-component/example/test/slider/goldens/slider_dragged_dark.png": "96f7233e21f6bcb5232bfc5faa867bfdf7c5bbdff2d729c411846c7b7ecc6682", + "tdesign-component/example/test/slider/goldens/slider_dragged_light.png": "211ecbbd8e4da9c9b35e3a464f55b527da7f98d697873af9226e811399af25eb", + "tdesign-component/example/test/slider/goldens/slider_endpoint_0_dark.png": "361027258654b3252ca38bb2d6237a393f15cb28be6bf3e43d06b027398e78fe", + "tdesign-component/example/test/slider/goldens/slider_endpoint_0_light.png": "25ce717e1215bc02119554f3fc248c71c3387798a561b62109a9a17e7dcb2170", + "tdesign-component/example/test/slider/goldens/slider_endpoint_100_dark.png": "e475ecfe340340147be844b50b8d0bc42f9ae1137b0c9cffcf8cc9c4042d1870", + "tdesign-component/example/test/slider/goldens/slider_endpoint_100_light.png": "f75ea46762c411e37c82ad26ad4340381c354e5dcb467c37c7b81ea6a311ddc2", + "tdesign-component/example/test/slider/goldens/slider_geometry_24_ltr_0_dark.png": "ef5cbd06146357e1685158f431f56a56f0bf4c50db84b1a163c0bfec2aebcf5f", + "tdesign-component/example/test/slider/goldens/slider_geometry_24_ltr_0_light.png": "eaad689941d60bb0dd8be09b3b09ff1a0f041d9748ed5d05b617fc6aac78a293", + "tdesign-component/example/test/slider/goldens/slider_geometry_24_ltr_100_dark.png": "3605c8a2f0a08e24de552f31b3ee76e47d6e4dc3024ac97996321d4e38d8da33", + "tdesign-component/example/test/slider/goldens/slider_geometry_24_ltr_100_light.png": "30b77fc74a4f69a1bd3b951c348decd9fa27de69819b5c68905df06dffa3bbb4", + "tdesign-component/example/test/slider/goldens/slider_geometry_24_rtl_0_dark.png": "f89819bd92e52afbe4a510c4b6850c0f8513ac07a3a4f67d2cfe0a8355144a86", + "tdesign-component/example/test/slider/goldens/slider_geometry_24_rtl_0_light.png": "e614b5fcfbe33975c041d0435a17fc1195f6c31478a0313cdff633754e4b6f90", + "tdesign-component/example/test/slider/goldens/slider_geometry_24_rtl_100_dark.png": "c480dacbe72e5509bc6d9ff4375d4dbcb50a95f36252c15cceb92c234be23121", + "tdesign-component/example/test/slider/goldens/slider_geometry_24_rtl_100_light.png": "b1cf18d8c61bfcb67326ab0f2f4a7d2c9833caa1e31fd26caa7b299611ce4983", + "tdesign-component/example/test/slider/goldens/slider_geometry_8_ltr_0_dark.png": "522d95dd7b0a7b7f66d06670c5424d21cfab50ddffe05685797a97147abb947b", + "tdesign-component/example/test/slider/goldens/slider_geometry_8_ltr_0_light.png": "49104fde8433077f99c534574e59e1b6d274903322bc4cec37aa65b507eb8b63", + "tdesign-component/example/test/slider/goldens/slider_geometry_8_ltr_100_dark.png": "8a90bd48ab559433d8e54a7d60d746fd8324a110cca37c6800cc354921d1fdbb", + "tdesign-component/example/test/slider/goldens/slider_geometry_8_ltr_100_light.png": "42fa728d26b59c215264e7ee7ac8f7b0309fd8c61710343f41660fa467a00c58", + "tdesign-component/example/test/slider/goldens/slider_geometry_8_rtl_0_dark.png": "06bca94d998dd1b6caa1587b06cae7f6c6f5df78dfe4721a7fab604286bc880b", + "tdesign-component/example/test/slider/goldens/slider_geometry_8_rtl_0_light.png": "5e979abc6ac4582f680bf06fb1376032eea2a9b5f60e20d7ee5dfa6b4dab6e1e", + "tdesign-component/example/test/slider/goldens/slider_geometry_8_rtl_100_dark.png": "0d56b432d52ec0a61e61261a883b18a49c92c8321976ed085c0d7e17a2224093", + "tdesign-component/example/test/slider/goldens/slider_geometry_8_rtl_100_light.png": "f3a3d76f58023b011990716dc818575edd418e2e364a790c93550466854d93dc", + "tdesign-component/example/test/slider/goldens/slider_geometry_matrix_dark.png": "4e684b224d6c32eb5f58e48b049af21a702eaccd017df73bcb6e9337fa371ec1", + "tdesign-component/example/test/slider/goldens/slider_geometry_matrix_light.png": "c826fdfdcf3586cbb4701407218a794d7cbad36f048b81796613169ebb33f9e0", + "tdesign-component/example/test/slider/goldens/slider_page_dark.png": "c8b9b468af693072e65424f88fb904a55148e4aa52d893020ce2a26534433b73", + "tdesign-component/example/test/slider/goldens/slider_page_light.png": "0c0dc340aa5c695469154679202084bff29d8576ccb4baac15024de1e0a8d133", + "tdesign-component/example/test/slider/slider_constraints_fixture.dart": "6a39ce409f53cbfbb3b4b1b10640bd61bfbef7816640a83da3e940625b91689d", + "tdesign-component/example/test/slider/slider_demo_test.dart": "267851f8048e872b77db5afdec3b6a3580d6309bbda167debfc1f4d6717842d0", + "tdesign-component/example/test/slider/slider_dragged_golden_test.dart": "44899203f7a2209d58f0503caa721bcf8b284d68f85dc508de34d4e83a050bcb", + "tdesign-component/example/test/slider/slider_endpoint_test_utils.dart": "691243967d481fcfb32c42a08c786fac3af9241da2eb046d82d18b2ee35ba886", + "tdesign-component/example/test/slider/slider_geometry_golden_test.dart": "29acab32495abdb5d840e89a526f449d1283e604b6813dc0471902fdcb0ad171", + "tdesign-component/lib/src/components/slider/t_slider.dart": "14ead0b2ee7b5cfed8ca3410aa8b1b981d9dedb0be60df610240bf37cf6fc0b4", + "tdesign-component/lib/src/components/slider/t_slider_geometry.dart": "45152312f745694183b137f1b43ab15385460507e7ae662769e181fea0d75601", + "tdesign-component/lib/src/components/slider/t_slider_theme.dart": "9aa619a8c00acd85b6924d3e05776c20a27a4f7c20164291e6b756b89fde48f1", + "tdesign-component/test/components/slider/goldens/t_slider_material.png": "95c967976172cf8872f34aa623837c5161e244b65e460ab0bddc77b9bf215729", + "tdesign-component/test/components/slider/t_slider_constraints_test.dart": "55ada5541315bbf9b303a9564b8b9eb467234b6ae8a34f0cd9eef2a9173d289a", + "tdesign-component/test/components/slider/t_slider_geometry_test.dart": "e2cbc1d0813d2c32c48e79a0124bf6d2fe4f69abaeffbe569be9a5a9ac019afa", + "tdesign-component/test/components/slider/t_slider_test.dart": "0cd2168d6470b8c752e57ae45af8265255a6773721aa6e94aa993d2a10191953", + "tdesign-component/tool/component_test_manifest.dart": "575b4b7707fc95241d44059c9c7883e0e620a7cf9f27283a1b4ee25805008b8f" + }, + "environments": [ + { + "flutter": "3.32.0", + "platform": "Linux x86_64" + }, + { + "flutter": "3.47.8", + "platform": "macOS arm64" + } + ], + "local_validation": { + "component": 86, + "demo": 17, + "tooling": 179, + "analysis": "zero diagnostics component + example", + "coverage": "675/692 = 97.54%" + }, + "visual_validation": { + "demo_module_flag": false, + "demo": 34, + "component": 1, + "updates_this_batch": 0, + "comparator": "exact, unchanged" + }, + "test_entrypoints": { + "component": [ + "test/components/slider/t_slider_test.dart", + "test/components/slider/t_slider_geometry_test.dart", + "test/components/slider/t_slider_constraints_test.dart" + ], + "demo": [ + "example/test/slider/slider_demo_test.dart" + ], + "demo_golden": [ + "example/test/slider/slider_demo_golden_test.dart", + "example/test/slider/slider_dragged_golden_test.dart", + "example/test/slider/slider_geometry_golden_test.dart" + ], + "component_golden": [ + "test/components/slider/t_slider_golden_test.dart" + ], + "tooling": "test/tool" + }, + "commands": { + "component": "flutter test --no-pub test/components/slider/t_slider_test.dart test/components/slider/t_slider_geometry_test.dart test/components/slider/t_slider_constraints_test.dart --coverage --exclude-tags golden --reporter expanded", + "coverage": "dart run tool/check_component_coverage.dart slider", + "demo_from_example": "flutter test --no-pub --dart-define=TDESIGN_DEMO_TEST_MODULES=false test/slider/slider_demo_test.dart --reporter expanded", + "analysis_component_and_example": "flutter analyze --no-pub --fatal-infos", + "tools": "flutter test --no-pub test/tool --reporter expanded", + "golden_from_example_linux_332": "flutter test --no-pub --dart-define=TDESIGN_DEMO_TEST_MODULES=false test/slider/slider_demo_golden_test.dart test/slider/slider_dragged_golden_test.dart test/slider/slider_geometry_golden_test.dart --reporter expanded", + "component_golden_linux_332": "flutter test --no-pub test/components/slider/t_slider_golden_test.dart --reporter expanded", + "generated_snippets": "dart run tool/generate_example_code.dart --check", + "generated_snippet_analysis": "dart run tool/check_demo_snippets.dart", + "generated_api": "node tool/generate_api.mjs --check" + }, + "generated_artifacts": { + "api_documents_checked": 57, + "snippets_analyzed": 370 + }, + "reference": { + "miniprogram_sha": "a2aeed69163b6da423c99b66ff780c10043a4553", + "miniprogram_runtime": "WeChat DevTools WebView", + "develop_sha": "ca555ca40a8a317bb0e3eb521a844d54b4832eee" + }, + "behavior": { + "normal_continuous_minimum_center_separation_px": 8, + "constrained_separation": "min(8 * geometry scale, controlled normalized span * thumb travel)", + "coincident_discrete_and_controlled_inputs": "pre-existing support retained", + "new_public_api": false, + "sdk_version_branches": false, + "range_callback_numeric_clamping": false + }, + "default_runtime_comparison": { + "states": "default theme, horizontal and vertical, 0/100, light/dark", + "interactive_controls_per_state": 15, + "changed_pixels_vs_approved_pre_rollback_style": [ + 0, + 0, + 0, + 0 + ], + "max_vertical_label_endpoint_error_px": 0, + "minimum_thumb_body_clearance_px": 0.5, + "develop_differences": "intended endpoint/label corrections and vertical visible track 168px -> 200px; not pixel equivalence to develop" + }, + "remote_ci": "not included in local acceptance; inspect checks on pushed SHA", + "unverified": [ + "remote CI", + "physical device", + "Skyline", + "platform builds" + ] +} diff --git a/specs/components/027-Input-Slider/plan.md b/specs/components/027-Input-Slider/plan.md index 1e779a32f9..2a9102c835 100644 --- a/specs/components/027-Input-Slider/plan.md +++ b/specs/components/027-Input-Slider/plan.md @@ -4,7 +4,7 @@ normal/capsule 的轨道与刻度属于组件形态,由组件实现,不由 Demo 局部主题补画。 -保留 Flutter 原生手势和受控值,不因视觉对齐扩大组件点击或布局区域。 +复用内部几何模型解析可见轨道、thumb 行程、交互尺寸及连续范围间距;保留原生手势识别和受控值。单值拖动按当前几何映射位置;区分可见长度与完整命中布局,不能靠缩短轨道容纳 thumb。 ## 影响范围 @@ -20,4 +20,4 @@ normal/capsule 的轨道与刻度属于组件形态,由组件实现,不由 D 3. 围绕该职责的默认、显式配置、状态变化、异常与生命周期做真实验证;按适用范围执行双 SDK、Golden、设备或交付检查。 4. 结果绑定候选、环境和必要证据,更新 acceptance.md;真实剩余工作只在 tasks.md 维护。 -本次文档整理不执行上述实现或候选验收,不据旧命令流水推定当前检查通过。 +具体执行范围与绑定指纹记录在 acceptance.md;推送后的远端检查独立验证,不从本地结果外推。 diff --git a/specs/components/027-Input-Slider/spec.md b/specs/components/027-Input-Slider/spec.md index 4adf73bf72..610a3f1c94 100644 --- a/specs/components/027-Input-Slider/spec.md +++ b/specs/components/027-Input-Slider/spec.md @@ -6,7 +6,6 @@ 公开示例与默认轨道、刻度和游标几何不一致。 - ## 范围与非目标 ### 目标 @@ -23,22 +22,29 @@ - 垂直方向由 `RotatedBox` 组合,胶囊外观由 `SliderTheme` 组合;二者不新增与 Flutter 框架重复的 API。 - 视觉字段由 TSliderThemeData 覆盖 TDesign Token;外部 SliderTheme 和 ColorScheme 不反向配置;token 默认提供 Gy1 描边 thumb、品牌色轨道、Gy4 未激活轨道、区分前后轨道的禁用色,并以禁用文字 token 绘制禁用态常驻数值。 -- Demo 的 8 个公开区块与内部 19 个 Slider 实例在数量、顺序、类型和默认值上与固定小程序基线一致。 +- Demo 的公开区块与内部 18 个 Slider 实例在数量、顺序、类型和默认值上与固定小程序基线一致。 - 所有可交互实例均由页面持有状态;禁用实例 callback 为 null。 - 垂直组合仅旋转轨道和 thumb,数值与刻度文字保持正常阅读方向;最小值位于顶部,数值向下递增,与设计稿一致。 - `TRangeSlider` 在构建时拒绝落在 `min/max` 外的范围值。 - 不新增公开 API;修正既有 `showThumbValue` 语义和默认视觉属于缺陷修复。 +### 端点几何与刻度对齐 + +- 以小程序实测 commit `a2aeed69163b6da423c99b66ff780c10043a4553` 为基准:胶囊 thumb 沿行程方向距离外沿 3px(中心内收 13px),默认横截面两侧留白 2px。普通轨道 thumb 中心位于可见轨道端点;连续/离散、单/双游标共享行程,刻度不改变端点。 +- 水平刻度文字中心与各值的实际 thumb 中心对齐;RTL 使用同一方向映射。 +- 垂直示例的数值与刻度中心与旋转后的 thumb 中心对齐,连续普通轨道不额外内缩半个轨道高度。 +- 验证覆盖水平/垂直、连续/离散、单/双游标,以及真实拖动到 0/100 后的明暗截图。 + ## 关键决策与取舍 | 决策 | 理由与取舍 | | --- | --- | | 采用的方案 | normal/capsule 的轨道与刻度属于组件形态,由组件实现,不由 Demo 局部主题补画。 | -| 保留的边界与代价 | 保留 Flutter 原生手势和受控值,不因视觉对齐扩大组件点击或布局区域。 | +| 保留的边界与代价 | 保留 Flutter 原生手势识别、范围选择和受控值;单值持续拖动按当前几何位置映射,避免缩放后累积位移失真;区分可见轨道长度与完整 thumb 命中布局,不能靠缩短轨道容纳 thumb。 | ## 兼容性与迁移 -本次只整理设计记录,不据此新增 API、改变默认值或判断全部兼容。后续接口删除、类型收紧或默认行为变化按最终源码和迁移指南核对。 +本次不新增或删除公开 API,不改变构造参数、类型和默认值。正常尺寸且两端可容纳时保留连续范围 8px 最小中心距;离散同刻度和直接传入相等端点继续支持。接近取值边界或布局受限时约束间距,避免非法回调。端点、刻度位置与垂直可见长度的有意视觉修复不等于保持旧错误像素。 ## 验收标准 @@ -47,3 +53,31 @@ 验收标准描述应满足的行为,不表示已执行或通过;执行范围与证据见 [acceptance.md](./acceptance.md),剩余事项只在 [tasks.md](./tasks.md) 维护。 跨组件配置职责见 [Theme 契约](../../engineering/048-Theme-configuration-contracts/spec.md),视觉方法见 [Golden 规范](../../engineering/044-Testing-golden-regression/spec.md)。 + +### 可配置几何的统一解析 + +- 组件、刻度文字与垂直组合复用未导出的内部几何解析器,不新增包公开 API。可见轨道与 thumb 行程分别表达,传给 Material 的坐标轨道不携带绘制厚度,避免 SDK 隐式内缩成为第二计算源。 +- 胶囊轨道厚度至少为默认 20px thumb 加两侧各 2px 留白;较大的 spacer3 在可用横轴空间内生效。宽度不足时先收缩外部内缩,仍不足则按比例缩小轨道与 thumb,并保留正行程;零宽度安全退化,不触发绘制断言。 +- 普通 trackHeight 只控制可见厚度,连续/离散不改变行程;尺寸参数非法值安全归零。水平方向的布局留白保障 thumb 本体在约束内;垂直组合使用完整设定长度,普通 thumb 跨过轨道端点属于预期效果。 +- 离散输入的控件位置按 min/max/divisions 吸附至最近刻度;格式化回调及默认数值标签继续使用业务传入的原始受控值,保留既有显示和回调契约。 +- 垂直组合明确采用局部 LTR 坐标,保持最小值在顶部;文字保留宿主方向。禁止示例另行硬编码轨道厚度。 + +### 小程序端点实测对齐 + +- 垂直组合通过未导出的内部轴向布局配置解析几何,不继承水平端部留白;200px 设定长度对应 200px 可见轨道,相对可见轨道普通 thumb 中心为 0/200,胶囊为 13/187。轨道外预留 thumb 半径加半描边的完整命中空间(默认各 10.5px),布局总长与可见长度分开表达,不能因命中留白缩短轨道。 +- 不保留旧公式开关、不按 SDK 版本分支,不用负 padding、平移或 Demo 局部主题修补。轨道绘制、交互行程、thumb、数值与刻度复用同一解析结果。 + +### 交互尺寸和双轴约束 + +- 连续双游标在两端可容纳时保留既有正常尺寸8px最小中心间距,即使当前受控区间小于8px;间距随几何缩小,并由两个游标到各自取值边界的空间限制,避免越界。离散同刻度、业务直接传入的相等端点仍按既有规则支持。thumb 首选尺寸和绘制尺寸读取实际布局的同一 metrics,不在回调层截断非法值。 +- 轴向空间决定缩放和最小有效行程,厚度只在横轴可用空间内解析,不能挤占端部布局留白而改变可见长度。声明长度与控件总宽度由同一几何模型精确换算;覆盖短长度与大厚度组合。 +- 持续拖动、拖动中宽度变化、普通/胶囊、单/双、连续/离散和 RTL 均须双版本回归;零宽度安全退化,恢复宽度后继续正常操作。 +- 正尺寸变化保留已开始的手势,指针坐标映射到当前行程;退化为零时只结束滑块已拥有的交互一次,不打断父滚动。恢复后可开始新手势。 + +### Golden 按独立缺陷覆盖 + +- Demo Golden 由三组明暗基线组成,共6张:默认完整页、端点矩阵、视觉边界矩阵;组件Golden单独保留1张。默认页负责分组、顺序、初始状态及禁用配色,参数组合不重复采样页面壳。 +- 端点矩阵从真实公开Demo操作后的图像采样18个代表区域,覆盖普通/胶囊、水平/垂直、连续/离散、单值0/100、完整区间,以及数值、刻度、分段和thumb的对齐。完整区间在一个场景中同时展示两端。 +- 边界矩阵保留8个代表区域:普通粗轨道近端点标签、普通/胶囊RTL、48px离散胶囊、8px连续胶囊、8px横轴约束、192/240主题厚度约束。测试专用来源与公开示例均保持原尺寸采样,不复制Slider几何实现,不改变公开Demo。 +- 持续拖动、拖动中缩放、零尺寸、回调顺序与取值、父滚动、键盘、离散吸附、小胶囊token及宽泛参数组合由现有组件/功能回归验证。中间拖动、吸附后等效值等无独立视觉结构的状态不再各自新增图片。 +- 每个保留Golden必须对应可见风险;源场景数量及覆盖映射记录在acceptance.md关联的验证记录中。矩阵可读性和缺陷检测能力优先于参数穷举数量。 diff --git a/specs/components/027-Input-Slider/tasks.md b/specs/components/027-Input-Slider/tasks.md index 97d48638b2..934230a885 100644 --- a/specs/components/027-Input-Slider/tasks.md +++ b/specs/components/027-Input-Slider/tasks.md @@ -1,11 +1,9 @@ -# Slider 未完成事项 +# Slider 当前待办与验证边界 -## 原记录明确未关闭 +- [ ] 推送后核对 PR 最终 head 对应的远端 CI;发生 autofix 提交时重新核对其差异和新 head,不能用本地或旧 SHA 的通过替代。 -原任务记录未留下明确未完成项;这不代表当前候选已经重新验证。 +当前本地候选已执行双版本 Slider 功能回归、组件静态分析与 Linux Golden;先前候选的覆盖率、生成产物和接口对比不能直接外推到本轮,范围见 [acceptance.md](./acceptance.md)。本次没有新增连续端点重合能力;正常尺寸可容纳时保留 8px 最小中心距。 -## 当前候选复核 +Skyline、微信真机和平台构建属于未验证范围,不因本地通过或推送而视为完成;仅在后续明确要求对应平台验收时执行。历史任务整理未留下可独立追溯的其他未完成项,不由此推定旧设备或远端验证已通过。 -- [ ] 后续实际交付前,将验收标准绑定最终候选 SHA、SDK 与相关环境,核对受影响结果;本次仅做文档整理,未运行组件、Golden 或设备验收。 - -已完成的开发步骤和旧 PR 创建过程已清理。原记录的远端 CI/Review 或设备连接状态不作为当前事实;相关事项需以实际证据核对,不能随文档整理关闭。 +按独立缺陷收敛的6张Demo+1张组件Golden覆盖对应关系与本轮验收见acceptance.md;远端检查须与最终head分别核对。 diff --git a/tdesign-component/example/assets/api/slider_api.md b/tdesign-component/example/assets/api/slider_api.md index a7942b83dc..7e5d5aa220 100644 --- a/tdesign-component/example/assets/api/slider_api.md +++ b/tdesign-component/example/assets/api/slider_api.md @@ -8,7 +8,7 @@ | 名称 | 类型 | 默认值 | 说明 | 必传 | | --- | --- | --- | --- | --- | -| divisions | int? | - | 离散刻度数;null 表示连续。 非 null 时必须大于 0。 | 否 | +| divisions | int? | - | 离散刻度数;null 表示连续。 非 null 时必须大于 0;输入值吸附到最近刻度,显示数值使用吸附后的值。 | 否 | | key | Key? | - | 组件标识,用于区分或保留组件状态。 | 否 | | max | double | 1 | 最大值。 必须大于 min。 | 否 | | min | double | 0 | 最小值。 max 必须大于 min。 | 否 | @@ -33,7 +33,7 @@ | 名称 | 类型 | 默认值 | 说明 | 必传 | | --- | --- | --- | --- | --- | -| divisions | int? | - | 离散刻度数;null 表示连续。 非 null 时必须大于 0。 | 否 | +| divisions | int? | - | 离散刻度数;null 表示连续。 非 null 时必须大于 0;输入值吸附到最近刻度,显示数值使用吸附后的值。 | 否 | | key | Key? | - | 组件标识,用于区分或保留组件状态。 | 否 | | max | double | 1 | 最大值。 必须大于 min。 | 否 | | min | double | 0 | 最小值。 max 必须大于 min。 | 否 | @@ -44,19 +44,19 @@ | showScaleValue | bool | false | 是否显示刻度值;开启时必须提供 `divisions`。 | 否 | | showThumbValue | bool | false | 是否持续显示拇指上方数值。 | 否 | | thumbFormatter | TSliderThumbFormatter? | - | 拇指上方数值格式化回调。 | 否 | -| value | RangeValues | - | 受控范围值;两端值应处于 min 与 max 指定的范围内,父组件需回传新值。 | 是 | +| value | RangeValues | - | 受控范围值;两端值应处于 min 与 max 指定的范围内,父组件需回传新值。 连续拖动在正常尺寸下保留 8 逻辑像素最小中心间距;空间不足时随几何缩小,且不超过当前区间行程。 离散同刻度、直接传入的相等端点仍受支持。 | 是 | | variant | TSliderVariant | TSliderVariant.normal | 滑块视觉结构,默认使用标准细轨道。 | 否 | ### TSliderVariant -Slider visual structure. +滑块视觉结构。 #### 枚举值 | 名称 | 类型 | 默认值 | 说明 | 必传 | | --- | --- | --- | --- | --- | -| normal | TSliderVariant | - | Standard thin track. | - | -| capsule | TSliderVariant | - | Capsule track with a 3px inset active segment and 20px thumbs. | - | +| normal | TSliderVariant | - | 标准细轨道。 | - | +| capsule | TSliderVariant | - | 胶囊轨道,默认激活区四周内缩 3px,20px thumb 沿轨道方向两端各留 3px,默认横截面两侧各留 2px。 空间充足时轨道高度至少为 24px;厚度受横轴约束,行程不足时按比例缩小。 | - | ### TSliderThumbFormatter @@ -101,6 +101,6 @@ TSlider 与 TRangeSlider 共用的组件级 ThemeExtension。 | overlayColor | Color? | - | 交互反馈颜色;为空时使用品牌色的透明层。 | 否 | | thumbBorderColor | Color? | - | 滑块描边颜色;为空时使用全局灰阶色。 | 否 | | thumbColor | Color? | - | 滑块填充颜色;为空时使用全局反色文字色。 | 否 | -| trackHeight | double? | - | 普通轨道粗细;胶囊形态仍使用其内置规格。 null 时为 4 逻辑像素。 | 否 | +| trackHeight | double? | - | 普通轨道粗细;胶囊形态仍使用其内置规格。 null 时为 4 逻辑像素;非有限或负值按 0 处理;实际厚度不超过可用横轴空间。 | 否 | | valueIndicatorColor | Color? | - | 数值提示背景颜色;为空时使用全局品牌色。 | 否 | | valueIndicatorTextColor | Color? | - | 数值提示文字颜色;为空时使用全局主要文字色。 | 否 | diff --git a/tdesign-component/example/assets/code/manifest.json b/tdesign-component/example/assets/code/manifest.json index 7d7ae9c120..4a1ead646f 100644 --- a/tdesign-component/example/assets/code/manifest.json +++ b/tdesign-component/example/assets/code/manifest.json @@ -1592,7 +1592,7 @@ "title": "垂直型滑块", "items": [ { - "description": "", + "description": "单游标垂直滑块", "assetKey": "slider.SliderVerticalExample" } ] diff --git a/tdesign-component/example/assets/code/slider.SliderVerticalExample.txt b/tdesign-component/example/assets/code/slider.SliderVerticalExample.txt index 36e632b6c7..546832acac 100644 --- a/tdesign-component/example/assets/code/slider.SliderVerticalExample.txt +++ b/tdesign-component/example/assets/code/slider.SliderVerticalExample.txt @@ -1,3 +1,4 @@ +import 'package:tdesign_flutter/src/components/slider/t_slider_geometry.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; class SliderVerticalExample extends StatefulWidget { @@ -13,158 +14,150 @@ class SliderVerticalExample extends StatefulWidget { } class _SliderVerticalExampleState extends State { - Widget _buildVertical(BuildContext context) { - final trackInset = context.tTheme.spacer2; - final trackLength = 200 - 2 * trackInset; + /// 旋转后轨道的长度。 + static const double _trackLength = 200; + + /// 旋转后滑块自身的厚度,容纳 24px 半径的按压态光晕。 + static const double _thumbZoneWidth = SliderGeometry.hitRadius * 2; + + /// 数值与刻度文字所在的列宽。 + static const double _labelWidth = 42; + Widget _buildVertical(BuildContext context) { Widget verticalSlider({ required Widget slider, + bool capsule = false, String? thumbLabel, double? normalizedValue, bool showMarks = false, Key? labelKey, Key? marksKey, - double height = 202, + double extraSpace = 2, }) { - return SizedBox( - height: height, - child: Center( - child: SizedBox( - width: 100, - height: 200, - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned( - left: 8, - top: 0, - child: SizedBox( - width: 48, - height: 200, - child: RotatedBox(quarterTurns: 1, child: slider), - ), - ), - if (showMarks) - Positioned( - left: 58, - width: 42, - top: 0, - bottom: 0, - child: Stack( - key: marksKey, - clipBehavior: Clip.none, - children: List.generate(6, (index) { - return Positioned( - top: trackInset + index * trackLength / 5, - child: FractionalTranslation( - translation: const Offset(0, -0.5), - child: TText('${index * 20}'), - ), - ); - }), - ), - ), - if (thumbLabel != null && normalizedValue != null) + return SliderGeometryScope( + axis: Axis.vertical, + child: Builder( + builder: (context) { + final metrics = SliderGeometry.resolve( + context, + capsule: capsule, + ).forTrackLength(_trackLength, crossExtent: _thumbZoneWidth); + + final marks = [ + if (showMarks) + for (var index = 0; index < 6; index++) Positioned( - left: 58, - top: trackInset + normalizedValue * trackLength, + top: metrics.thumbOffset(index / 5), child: FractionalTranslation( translation: const Offset(0, -0.5), - child: TText(thumbLabel, key: labelKey), + child: TText('${index * 20}'), ), ), - ], - ), - ), + if (thumbLabel != null && normalizedValue != null) + Positioned( + top: metrics.thumbOffset(normalizedValue), + child: FractionalTranslation( + translation: const Offset(0, -0.5), + child: TText(thumbLabel, key: labelKey), + ), + ), + ]; + + return SizedBox( + height: metrics.width + extraSpace, + child: Center( + child: SizedBox( + height: metrics.width, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: _thumbZoneWidth, + height: metrics.width, + child: Directionality( + textDirection: TextDirection.ltr, + child: RotatedBox(quarterTurns: 1, child: slider), + ), + ), + if (marks.isNotEmpty) + SizedBox( + key: marksKey, + width: _labelWidth, + height: metrics.width, + child: Stack( + clipBehavior: Clip.none, + children: marks, + ), + ), + ], + ), + ), + ), + ); + }, ), ); } + // 标题由页面 ExampleItem 的 desc 统一提供,这里只负责滑块本身。 return switch (widget.type) { - SliderVerticalExampleType.single => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _subsection(context, '单游标垂直滑块'), - verticalSlider( - slider: TSlider( - key: const ValueKey('slider-vertical'), - value: _vertical, - min: 0, - max: 100, - onChanged: (value) => setState(() => _vertical = value), - ), - thumbLabel: _integer(_vertical), - normalizedValue: _vertical / 100, - labelKey: const ValueKey('slider-vertical-label'), - ), - ], + SliderVerticalExampleType.single => verticalSlider( + slider: TSlider( + key: const ValueKey('slider-vertical'), + value: _vertical, + min: 0, + max: 100, + onChanged: (value) => setState(() => _vertical = value), + ), + thumbLabel: _integer(_vertical), + normalizedValue: _vertical / 100, + labelKey: const ValueKey('slider-vertical-label'), ), - SliderVerticalExampleType.scaleRange => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _subsection(context, '带刻度的双游标垂直滑块'), - verticalSlider( - slider: TRangeSlider( - key: const ValueKey('slider-vertical-scale-range'), - value: _verticalScaleRange, - min: 0, - max: 100, - divisions: 5, - onChanged: (value) => setState(() => _verticalScaleRange = value), - ), - showMarks: true, - marksKey: const ValueKey('slider-vertical-scale-labels'), - ), - ], + SliderVerticalExampleType.scaleRange => verticalSlider( + slider: TRangeSlider( + key: const ValueKey('slider-vertical-scale-range'), + value: _verticalScaleRange, + min: 0, + max: 100, + divisions: 5, + onChanged: (value) => setState(() => _verticalScaleRange = value), + ), + showMarks: true, + marksKey: const ValueKey('slider-vertical-scale-labels'), ), - SliderVerticalExampleType.capsule => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _subsection(context, '胶囊型垂直滑块'), - verticalSlider( - slider: TSlider( - key: const ValueKey('slider-vertical-capsule'), - value: _verticalCapsule, - min: 0, - max: 100, - variant: TSliderVariant.capsule, - onChanged: (value) => setState(() => _verticalCapsule = value), - ), - thumbLabel: _integer(_verticalCapsule), - normalizedValue: _verticalCapsule / 100, - labelKey: const ValueKey('slider-vertical-capsule-label'), - ), - ], + SliderVerticalExampleType.capsule => verticalSlider( + slider: TSlider( + key: const ValueKey('slider-vertical-capsule'), + value: _verticalCapsule, + min: 0, + max: 100, + variant: TSliderVariant.capsule, + onChanged: (value) => setState(() => _verticalCapsule = value), + ), + capsule: true, + thumbLabel: _integer(_verticalCapsule), + normalizedValue: _verticalCapsule / 100, + labelKey: const ValueKey('slider-vertical-capsule-label'), ), - SliderVerticalExampleType.capsuleScaleRange => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _subsection(context, '带刻度的胶囊型垂直滑块'), - verticalSlider( - slider: TRangeSlider( - key: const ValueKey('slider-vertical-capsule-scale-range'), - value: _verticalCapsuleScaleRange, - min: 0, - max: 100, - divisions: 5, - variant: TSliderVariant.capsule, - onChanged: (value) => - setState(() => _verticalCapsuleScaleRange = value), - ), - showMarks: true, - marksKey: const ValueKey('slider-vertical-capsule-scale-labels'), - height: 218, - ), - ], + SliderVerticalExampleType.capsuleScaleRange => verticalSlider( + slider: TRangeSlider( + key: const ValueKey('slider-vertical-capsule-scale-range'), + value: _verticalCapsuleScaleRange, + min: 0, + max: 100, + divisions: 5, + variant: TSliderVariant.capsule, + onChanged: (value) => + setState(() => _verticalCapsuleScaleRange = value), + ), + capsule: true, + showMarks: true, + marksKey: const ValueKey('slider-vertical-capsule-scale-labels'), + extraSpace: 18, ), }; } - Widget _subsection(BuildContext context, String text) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: TText(text, font: context.tTheme.fontBodyMedium), - ); - double _vertical = 35; static String _integer(double value) => value.round().toString(); diff --git a/tdesign-component/example/lib/page/slider/slider_page.dart b/tdesign-component/example/lib/page/slider/slider_page.dart index 00ea792a0b..6131a895a2 100644 --- a/tdesign-component/example/lib/page/slider/slider_page.dart +++ b/tdesign-component/example/lib/page/slider/slider_page.dart @@ -132,12 +132,13 @@ class _TSliderPageState extends State { title: '垂直型滑块', children: [ ExampleItem( - desc: '', + desc: '单游标垂直滑块', compactStyle: const CompactExampleStyle.surface(contentSpacing: 2), methodName: 'SliderVerticalExample', builder: (_) => const SliderVerticalExample(), ), ExampleItem( + desc: '带刻度的双游标垂直滑块', ignoreCode: true, compactStyle: const CompactExampleStyle.surface(), builder: (_) => const SliderVerticalExample( @@ -145,6 +146,7 @@ class _TSliderPageState extends State { ), ), ExampleItem( + desc: '胶囊型垂直滑块', ignoreCode: true, compactStyle: const CompactExampleStyle.surface(), builder: (_) => const SliderVerticalExample( @@ -152,6 +154,7 @@ class _TSliderPageState extends State { ), ), ExampleItem( + desc: '带刻度的胶囊型垂直滑块', ignoreCode: true, compactStyle: const CompactExampleStyle.surface(), builder: (_) => const SliderVerticalExample( diff --git a/tdesign-component/example/lib/page/slider/slider_vertical_example.dart b/tdesign-component/example/lib/page/slider/slider_vertical_example.dart index 6552398a0f..52e6baf927 100644 --- a/tdesign-component/example/lib/page/slider/slider_vertical_example.dart +++ b/tdesign-component/example/lib/page/slider/slider_vertical_example.dart @@ -1,3 +1,4 @@ +import 'package:tdesign_flutter/src/components/slider/t_slider_geometry.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import '../../annotation/example_code.dart'; @@ -15,158 +16,150 @@ class SliderVerticalExample extends StatefulWidget { } class _SliderVerticalExampleState extends State { - Widget _buildVertical(BuildContext context) { - final trackInset = context.tTheme.spacer2; - final trackLength = 200 - 2 * trackInset; + /// 旋转后轨道的长度。 + static const double _trackLength = 200; + + /// 旋转后滑块自身的厚度,容纳 24px 半径的按压态光晕。 + static const double _thumbZoneWidth = SliderGeometry.hitRadius * 2; + + /// 数值与刻度文字所在的列宽。 + static const double _labelWidth = 42; + Widget _buildVertical(BuildContext context) { Widget verticalSlider({ required Widget slider, + bool capsule = false, String? thumbLabel, double? normalizedValue, bool showMarks = false, Key? labelKey, Key? marksKey, - double height = 202, + double extraSpace = 2, }) { - return SizedBox( - height: height, - child: Center( - child: SizedBox( - width: 100, - height: 200, - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned( - left: 8, - top: 0, - child: SizedBox( - width: 48, - height: 200, - child: RotatedBox(quarterTurns: 1, child: slider), - ), - ), - if (showMarks) - Positioned( - left: 58, - width: 42, - top: 0, - bottom: 0, - child: Stack( - key: marksKey, - clipBehavior: Clip.none, - children: List.generate(6, (index) { - return Positioned( - top: trackInset + index * trackLength / 5, - child: FractionalTranslation( - translation: const Offset(0, -0.5), - child: TText('${index * 20}'), - ), - ); - }), - ), - ), - if (thumbLabel != null && normalizedValue != null) + return SliderGeometryScope( + axis: Axis.vertical, + child: Builder( + builder: (context) { + final metrics = SliderGeometry.resolve( + context, + capsule: capsule, + ).forTrackLength(_trackLength, crossExtent: _thumbZoneWidth); + + final marks = [ + if (showMarks) + for (var index = 0; index < 6; index++) Positioned( - left: 58, - top: trackInset + normalizedValue * trackLength, + top: metrics.thumbOffset(index / 5), child: FractionalTranslation( translation: const Offset(0, -0.5), - child: TText(thumbLabel, key: labelKey), + child: TText('${index * 20}'), ), ), - ], - ), - ), + if (thumbLabel != null && normalizedValue != null) + Positioned( + top: metrics.thumbOffset(normalizedValue), + child: FractionalTranslation( + translation: const Offset(0, -0.5), + child: TText(thumbLabel, key: labelKey), + ), + ), + ]; + + return SizedBox( + height: metrics.width + extraSpace, + child: Center( + child: SizedBox( + height: metrics.width, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: _thumbZoneWidth, + height: metrics.width, + child: Directionality( + textDirection: TextDirection.ltr, + child: RotatedBox(quarterTurns: 1, child: slider), + ), + ), + if (marks.isNotEmpty) + SizedBox( + key: marksKey, + width: _labelWidth, + height: metrics.width, + child: Stack( + clipBehavior: Clip.none, + children: marks, + ), + ), + ], + ), + ), + ), + ); + }, ), ); } + // 标题由页面 ExampleItem 的 desc 统一提供,这里只负责滑块本身。 return switch (widget.type) { - SliderVerticalExampleType.single => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _subsection(context, '单游标垂直滑块'), - verticalSlider( - slider: TSlider( - key: const ValueKey('slider-vertical'), - value: _vertical, - min: 0, - max: 100, - onChanged: (value) => setState(() => _vertical = value), - ), - thumbLabel: _integer(_vertical), - normalizedValue: _vertical / 100, - labelKey: const ValueKey('slider-vertical-label'), - ), - ], + SliderVerticalExampleType.single => verticalSlider( + slider: TSlider( + key: const ValueKey('slider-vertical'), + value: _vertical, + min: 0, + max: 100, + onChanged: (value) => setState(() => _vertical = value), + ), + thumbLabel: _integer(_vertical), + normalizedValue: _vertical / 100, + labelKey: const ValueKey('slider-vertical-label'), ), - SliderVerticalExampleType.scaleRange => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _subsection(context, '带刻度的双游标垂直滑块'), - verticalSlider( - slider: TRangeSlider( - key: const ValueKey('slider-vertical-scale-range'), - value: _verticalScaleRange, - min: 0, - max: 100, - divisions: 5, - onChanged: (value) => setState(() => _verticalScaleRange = value), - ), - showMarks: true, - marksKey: const ValueKey('slider-vertical-scale-labels'), - ), - ], + SliderVerticalExampleType.scaleRange => verticalSlider( + slider: TRangeSlider( + key: const ValueKey('slider-vertical-scale-range'), + value: _verticalScaleRange, + min: 0, + max: 100, + divisions: 5, + onChanged: (value) => setState(() => _verticalScaleRange = value), + ), + showMarks: true, + marksKey: const ValueKey('slider-vertical-scale-labels'), ), - SliderVerticalExampleType.capsule => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _subsection(context, '胶囊型垂直滑块'), - verticalSlider( - slider: TSlider( - key: const ValueKey('slider-vertical-capsule'), - value: _verticalCapsule, - min: 0, - max: 100, - variant: TSliderVariant.capsule, - onChanged: (value) => setState(() => _verticalCapsule = value), - ), - thumbLabel: _integer(_verticalCapsule), - normalizedValue: _verticalCapsule / 100, - labelKey: const ValueKey('slider-vertical-capsule-label'), - ), - ], + SliderVerticalExampleType.capsule => verticalSlider( + slider: TSlider( + key: const ValueKey('slider-vertical-capsule'), + value: _verticalCapsule, + min: 0, + max: 100, + variant: TSliderVariant.capsule, + onChanged: (value) => setState(() => _verticalCapsule = value), + ), + capsule: true, + thumbLabel: _integer(_verticalCapsule), + normalizedValue: _verticalCapsule / 100, + labelKey: const ValueKey('slider-vertical-capsule-label'), ), - SliderVerticalExampleType.capsuleScaleRange => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _subsection(context, '带刻度的胶囊型垂直滑块'), - verticalSlider( - slider: TRangeSlider( - key: const ValueKey('slider-vertical-capsule-scale-range'), - value: _verticalCapsuleScaleRange, - min: 0, - max: 100, - divisions: 5, - variant: TSliderVariant.capsule, - onChanged: (value) => - setState(() => _verticalCapsuleScaleRange = value), - ), - showMarks: true, - marksKey: const ValueKey('slider-vertical-capsule-scale-labels'), - height: 218, - ), - ], + SliderVerticalExampleType.capsuleScaleRange => verticalSlider( + slider: TRangeSlider( + key: const ValueKey('slider-vertical-capsule-scale-range'), + value: _verticalCapsuleScaleRange, + min: 0, + max: 100, + divisions: 5, + variant: TSliderVariant.capsule, + onChanged: (value) => + setState(() => _verticalCapsuleScaleRange = value), + ), + capsule: true, + showMarks: true, + marksKey: const ValueKey('slider-vertical-capsule-scale-labels'), + extraSpace: 18, ), }; } - Widget _subsection(BuildContext context, String text) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: TText(text, font: context.tTheme.fontBodyMedium), - ); - double _vertical = 35; static String _integer(double value) => value.round().toString(); diff --git a/tdesign-component/example/test/fonts/README.md b/tdesign-component/example/test/fonts/README.md index fa57ccc6e9..2a2cd26e8c 100644 --- a/tdesign-component/example/test/fonts/README.md +++ b/tdesign-component/example/test/fonts/README.md @@ -5,7 +5,7 @@ 缺少 CJK fallback 而把中文渲染成缺字符号。字符清单见 `component_demo_glyphs.txt`;上游为 Noto Sans SC 2.004,子集工具为 HarfBuzz 11.4.5,子集 SHA-256 为 -`b73d6dd1806f3daa23f2cf30114942cfb5ce52a5e02550f40cd7c39c14f50315`。 +`c22bfd8a9f169d5807298a2df3fd5560ddaaa32079199c2664bf000291fc0197`。 它不会打包进 Example 或组件产物。 共享 Demo Golden 工具统一加载 Roboto、Material Icons、Cupertino Icons、 @@ -203,6 +203,8 @@ ActionSheet、Dialog、DropdownMenu 或 NoticeBar 页面文案时,更新 feedb 2026-10-27 补齐迁移示例“概览”中的“概”(U+6982)。上游 SHA-256 与上述 Noto Sans SC 2.004 一致;使用 HarfBuzz 11.4.5 的 `hb-subset <上游字体> --text-file=component_demo_glyphs.txt --output-file=TDesignGoldenCJK-Regular.otf` 生成。既有 1182 个码点的轮廓、横向/纵向度量及全局字体度量逐项一致,仅新增一个码点,不更新 Golden 基线。 +2026-10-27 补齐 Slider 示例注释“光晕”中的“晕”(U+6655);源码字形检查也覆盖注释。沿用上述 Noto Sans SC 2.004 与 HarfBuzz 11.4.5,使用相同 `hb-subset` 命令生成。既有 1183 个码点的轮廓、横向/纵向度量及全局字体度量逐项一致,仅新增一个码点。 + ## 公开页面与内部调试区块 公开 Demo 的功能与视觉测试分别通过组件包下的 `dart run tool/run_example_regression.dart`、`dart run tool/run_visual_regression.dart` 执行。两者自动传入 `--dart-define=TDESIGN_DEMO_TEST_MODULES=false`,让组件数量、代码入口、布局和截图只包含公开示例;功能调度器随后单独运行 `internal-demo` 标签,验证不传开关时 Debug 默认展示的契约。 diff --git a/tdesign-component/example/test/fonts/TDesignGoldenCJK-Regular.otf b/tdesign-component/example/test/fonts/TDesignGoldenCJK-Regular.otf index 09f1e19327..f238b3a0c1 100644 Binary files a/tdesign-component/example/test/fonts/TDesignGoldenCJK-Regular.otf and b/tdesign-component/example/test/fonts/TDesignGoldenCJK-Regular.otf differ diff --git a/tdesign-component/example/test/fonts/component_demo_glyphs.txt b/tdesign-component/example/test/fonts/component_demo_glyphs.txt index 1933baa8f9..a1fb882d55 100644 --- a/tdesign-component/example/test/fonts/component_demo_glyphs.txt +++ b/tdesign-component/example/test/fonts/component_demo_glyphs.txt @@ -2,3 +2,4 @@ &·—…↑→、。〇「」一七三上下不与专且业东丝丢两严个中串临丹为主举久义之乘九也买了予事二于云互五些交产享京亮亳人什仅今介仍从仓他代令以们件价任份企优会传估伸但位低住体何余佛作你使例供依侧侵便促保信修倍倒候借值偏做停偶偿储像允元充先光免兜入全八公六兰共关兴兵其具典兼内册再冒写农冬冲决况冻准减凑几出击函分切划列刘则刚创初删判利别到制刷刻前剩剪副割力办功加务劣动助劳势勇勾勿匀包化北匹区匿十升午半华协单南占卡危即卸厂历压厚原去县参叉及友双反发取受变叠口句另只可台右叶号各合吉同名后向吗君吞否含听启吸呈告员周味命和品哈响哨哪唤售唯商喀善喜嘉器囊四回因团围固国图圆圈在地圳场址均坐块坝坪垂型城域埠基堂堆填境增声壳处备复夏外多大天央失头奇契奔奖套女好如妥始姓委威娇媒子字存孜学它守安完宏官定宝实宫宵家容宽宿密富寒察寸对导封射将小少尔尚尝就尺尽尾局层居屉屏展属山岗岭岳峰崇嵌州巢工左差己已巴市布师带帧帮常幂幕干平年并幽广庄庆序库应底度康延建廿开异弃式引张弧弱弹强归当录形彩影往径待很得御循微德徽心必快忽态性总恒恢恩息恰悬悲情惊惯意感慎慢懒成我或截户所扁手才打托扣执扩扫扰批找承技把投抗折抚抛护报抵抹抽拆拇拉拍拒拖拟拥拦拨择括拷拼拽拿持挂指按挡挤捕换据捷掉排接控推描提插揭搜携摄撑撒播操支收改放故效敛散数整文斑斥断斯新方施旋族无既日旦旧早时昆昌明易星映春是显晋晚普景暂暑暗暮暴曲更替最月有朋服朗望朝期未末本术机杂权束条来杭松板极构析林枚果架柄某染查柱栅标栈栏树校样核根格桂框案桌档桥检棒楚槽模横樽次欢款止正此步武死殊残段毁母每比毕毫气水永求汉汕江池污沙没沧河治沿法泛泡波注洁洛津洲活派流浅浈测济浏浙浮海消涉润淀淡深混添清渐渡港渲游湖湘源溢溪滁滑滚满滤滨漂演潮澳激火灯灰灵点烁热焦然照父片版牌物特状独猜率环现珠理琴甘生用田由电男画界留略登白百的盐监盒盖盘目直相省看真着瞬知矩短石码础硬确碍碰磁示祖神禁福离秀私秋种秒积称移程稳稿空穿突窄窗立竖站端符第等策筛签简算管箭箱籍类粒粗粤精糕糟系素索紧累红约级纯纳纵纹线组细织终经绑结绕绘给络绝统继续维综绿缀缓编缘缩缺罄网罗罩置翻老考者而耦聊职联聚肃肤背胶能脉脑脚腊腾自至致航般舱色节芒花若英范荆草荐莞莫获菊菜营落著蒙蓟薄藏虑虚虽蚌蛇行衔街补表被裁装裹西要覆见观规视览觉角解触言警计认让议讯记许设证评识诊词译试诚话询该详语误说请读课调谨谷象贝负责败货质购贯贴贵费资赋赖赣赤走起超越足跑距跟跨路跳踪身轨转轮轴轻载较辅辑输边达迁过运近返还这进远连迟迪迭述追退送适逃逆选透逐递途通速造逸逻遇遍道遮遵避那邮邻部郴都鄂配醒采释里重量金针钟钮钳银铺链销锁错锚键锯镜长门闪闭问闰间阅阈阜队阱防阳阴阵阶阻阿附际陈降限除险陷隆随隐隔隙障雄集雨雪零需霜露青静非靠面鞍音韶页顶项顺须顿预领频颗题颜额风食饰馈首香马驱驻验骤骨高魔鸡麓黄黑默鼠齐齿龄龙(),:;?~ 废 概 +晕 diff --git a/tdesign-component/example/test/loading/goldens/loading_page_dark.png b/tdesign-component/example/test/loading/goldens/loading_page_dark.png index dbdd240bf8..ace8d62140 100644 Binary files a/tdesign-component/example/test/loading/goldens/loading_page_dark.png and b/tdesign-component/example/test/loading/goldens/loading_page_dark.png differ diff --git a/tdesign-component/example/test/loading/goldens/loading_page_light.png b/tdesign-component/example/test/loading/goldens/loading_page_light.png index e23b9d3e8c..b49688d816 100644 Binary files a/tdesign-component/example/test/loading/goldens/loading_page_light.png and b/tdesign-component/example/test/loading/goldens/loading_page_light.png differ diff --git a/tdesign-component/example/test/loading/goldens/loading_speed_post_action_dark.png b/tdesign-component/example/test/loading/goldens/loading_speed_post_action_dark.png index 5fcf97fef6..76fe752304 100644 Binary files a/tdesign-component/example/test/loading/goldens/loading_speed_post_action_dark.png and b/tdesign-component/example/test/loading/goldens/loading_speed_post_action_dark.png differ diff --git a/tdesign-component/example/test/loading/goldens/loading_speed_post_action_light.png b/tdesign-component/example/test/loading/goldens/loading_speed_post_action_light.png index 9a5ce22d6e..a63d67ac2f 100644 Binary files a/tdesign-component/example/test/loading/goldens/loading_speed_post_action_light.png and b/tdesign-component/example/test/loading/goldens/loading_speed_post_action_light.png differ diff --git a/tdesign-component/example/test/slider/goldens/slider_boundaries_dark.png b/tdesign-component/example/test/slider/goldens/slider_boundaries_dark.png new file mode 100644 index 0000000000..35b1654859 Binary files /dev/null and b/tdesign-component/example/test/slider/goldens/slider_boundaries_dark.png differ diff --git a/tdesign-component/example/test/slider/goldens/slider_boundaries_light.png b/tdesign-component/example/test/slider/goldens/slider_boundaries_light.png new file mode 100644 index 0000000000..1a4629c0e3 Binary files /dev/null and b/tdesign-component/example/test/slider/goldens/slider_boundaries_light.png differ diff --git a/tdesign-component/example/test/slider/goldens/slider_changed_dark.png b/tdesign-component/example/test/slider/goldens/slider_changed_dark.png deleted file mode 100644 index e29c7c768c..0000000000 Binary files a/tdesign-component/example/test/slider/goldens/slider_changed_dark.png and /dev/null differ diff --git a/tdesign-component/example/test/slider/goldens/slider_changed_light.png b/tdesign-component/example/test/slider/goldens/slider_changed_light.png deleted file mode 100644 index 81ea04b8cd..0000000000 Binary files a/tdesign-component/example/test/slider/goldens/slider_changed_light.png and /dev/null differ diff --git a/tdesign-component/example/test/slider/goldens/slider_dragged_dark.png b/tdesign-component/example/test/slider/goldens/slider_dragged_dark.png deleted file mode 100644 index e29c7c768c..0000000000 Binary files a/tdesign-component/example/test/slider/goldens/slider_dragged_dark.png and /dev/null differ diff --git a/tdesign-component/example/test/slider/goldens/slider_dragged_light.png b/tdesign-component/example/test/slider/goldens/slider_dragged_light.png deleted file mode 100644 index 81ea04b8cd..0000000000 Binary files a/tdesign-component/example/test/slider/goldens/slider_dragged_light.png and /dev/null differ diff --git a/tdesign-component/example/test/slider/goldens/slider_endpoints_dark.png b/tdesign-component/example/test/slider/goldens/slider_endpoints_dark.png new file mode 100644 index 0000000000..b310235f9a Binary files /dev/null and b/tdesign-component/example/test/slider/goldens/slider_endpoints_dark.png differ diff --git a/tdesign-component/example/test/slider/goldens/slider_endpoints_light.png b/tdesign-component/example/test/slider/goldens/slider_endpoints_light.png new file mode 100644 index 0000000000..1daf238691 Binary files /dev/null and b/tdesign-component/example/test/slider/goldens/slider_endpoints_light.png differ diff --git a/tdesign-component/example/test/slider/goldens/slider_page_dark.png b/tdesign-component/example/test/slider/goldens/slider_page_dark.png index 435bbb7a4b..5eb3af23ee 100644 Binary files a/tdesign-component/example/test/slider/goldens/slider_page_dark.png and b/tdesign-component/example/test/slider/goldens/slider_page_dark.png differ diff --git a/tdesign-component/example/test/slider/goldens/slider_page_light.png b/tdesign-component/example/test/slider/goldens/slider_page_light.png index b76a1b543b..a36e2aeed0 100644 Binary files a/tdesign-component/example/test/slider/goldens/slider_page_light.png and b/tdesign-component/example/test/slider/goldens/slider_page_light.png differ diff --git a/tdesign-component/example/test/slider/slider_boundary_golden_test.dart b/tdesign-component/example/test/slider/slider_boundary_golden_test.dart new file mode 100644 index 0000000000..19d37b6eff --- /dev/null +++ b/tdesign-component/example/test/slider/slider_boundary_golden_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +import '../demo_page_test_utils.dart'; +import 'slider_demo_test_spec.dart'; +import 'slider_golden_matrix.dart'; + +void main() { + setUpAll(() => loadDemoGoldenFonts(sliderDemoPageTestSpec)); + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('slider boundaries matrix ${mode.name}', (tester) async { + final matrix = await captureSliderBoundaryMatrix(tester, mode); + addTearDown(matrix.image.dispose); + if (const bool.fromEnvironment('SLIDER_REVIEW_CAPTURE')) { + await tester.runAsync( + () => matrix.saveEvidence('slider_boundaries_${mode.name}'), + ); + } + await expectLater( + matrix.image, + matchesGoldenFile('goldens/slider_boundaries_${mode.name}.png'), + ); + }, tags: 'golden'); + } +} diff --git a/tdesign-component/example/test/slider/slider_boundary_sources.dart b/tdesign-component/example/test/slider/slider_boundary_sources.dart new file mode 100644 index 0000000000..0e3798570d --- /dev/null +++ b/tdesign-component/example/test/slider/slider_boundary_sources.dart @@ -0,0 +1,112 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +import '../demo_page_test_utils.dart'; +import 'slider_constraints_fixture.dart'; +import 'slider_demo_test_spec.dart'; + +// Test-only sources. Public Demo widgets and component implementations stay +// unchanged. The first two cases retain the original 320px content width. +Future pumpSliderGeometryBoundarySources( + WidgetTester tester, + ThemeMode mode, +) async { + tester.view.physicalSize = const Size(375, 260); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + Widget item(String title, Widget child) => Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Column( + children: [ + Text(title, style: const TextStyle(fontFamily: 'Roboto', fontSize: 12)), + SizedBox(width: 320, height: 92, child: child), + ], + ), + ); + final light = withDemoGoldenFonts( + TThemeBuilder.light(TThemeData.defaultData()), + sliderDemoPageTestSpec, + ); + final dark = withDemoGoldenFonts( + TThemeBuilder.dark(TThemeData.defaultData()), + sliderDemoPageTestSpec, + ); + await tester.pumpWidget( + MaterialApp( + theme: light, + darkTheme: dark, + themeMode: mode, + home: RepaintBoundary( + key: const ValueKey('geometry-boundary-source'), + child: Scaffold( + body: Column( + children: [ + item( + 'normal: trackHeight=24, value=0.02', + Theme( + data: (mode == ThemeMode.dark ? dark : light).mergeExtension( + const TSliderThemeData(trackHeight: 24), + ), + child: TSlider( + key: const ValueKey('boundary-near-edge'), + value: .02, + showThumbValue: true, + onChanged: (_) {}, + ), + ), + ), + item( + 'narrow discrete range: width=48', + Center( + child: SizedBox( + width: 48, + child: TRangeSlider( + key: const ValueKey('boundary-discrete-48'), + value: const RangeValues(0, 1), + divisions: 5, + variant: TSliderVariant.capsule, + onChanged: (_) {}, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +Future pumpSliderConstraintBoundarySource( + WidgetTester tester, + ThemeMode mode, +) async { + tester.view.physicalSize = const Size(390, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + theme: withDemoGoldenFonts( + TThemeBuilder.light(TThemeData.defaultData()), + sliderDemoPageTestSpec, + ), + darkTheme: withDemoGoldenFonts( + TThemeBuilder.dark(TThemeData.defaultData()), + sliderDemoPageTestSpec, + ), + themeMode: mode, + home: const RepaintBoundary( + key: ValueKey('constraint-boundary-source'), + child: Scaffold(body: SliderConstraintsFixture()), + ), + ), + ); + await tester.pumpAndSettle(); + // Reuse the actual sustained-drag/resize regression, then sample only the + // visual boundaries. Callback and lifecycle states keep their own tests. + await exerciseSliderConstraintsFixture(tester, 100); +} diff --git a/tdesign-component/example/test/slider/slider_constraints_fixture.dart b/tdesign-component/example/test/slider/slider_constraints_fixture.dart new file mode 100644 index 0000000000..ee91fd7858 --- /dev/null +++ b/tdesign-component/example/test/slider/slider_constraints_fixture.dart @@ -0,0 +1,196 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +class SliderConstraintsFixture extends StatefulWidget { + const SliderConstraintsFixture({super.key}); + @override + State createState() => + _SliderConstraintsFixtureState(); +} + +class _SliderConstraintsFixtureState extends State { + final values = { + for (final id in ['8', '24', '48', '320', 'resize']) + id: const RangeValues(.2, .8), + }; + final verticalValues = {'normal': 35, 'capsule': 35}; + double dynamicWidth = 320; + void resize(double width) => setState(() => dynamicWidth = width); + + Widget range(String id, double width) => SizedBox( + height: 82, + child: Column( + children: [ + Text( + 'width=$id range=${values[id]!.start.toStringAsFixed(1)}..${values[id]!.end.toStringAsFixed(1)}', + style: const TextStyle(fontSize: 12), + ), + SizedBox( + width: width, + height: 48, + child: TRangeSlider( + key: ValueKey('constraints-$id'), + value: values[id]!, + variant: TSliderVariant.capsule, + onChanged: (v) => setState(() => values[id] = v), + ), + ), + ], + ), + ); + + Widget vertical(String id, {required bool capsule}) => Builder( + builder: (context) { + final inherited = Theme.of(context); + final theme = capsule + ? inherited.mergeExtension( + context.tTheme.copyWith(spacerMap: const {'spacer3': 240}), + ) + : inherited.mergeExtension(const TSliderThemeData(trackHeight: 192)); + return Theme( + data: theme, + // Ordinary consumer composition: public theme + constrained rotated slider. + // These are Flutter container dimensions, not a TSlider length option. + child: Column( + children: [ + Text( + capsule ? 'capsule: spacer3 = 240' : 'normal: trackHeight = 192', + style: const TextStyle(fontSize: 12), + ), + Text( + 'value: ${verticalValues[id]!.round()}', + style: const TextStyle(fontSize: 12), + ), + const SizedBox(height: 12), + SizedBox( + width: 48, + height: 232, + child: RotatedBox( + quarterTurns: 1, + child: TSlider( + key: ValueKey('constraints-vertical-$id'), + value: verticalValues[id]!, + max: 100, + variant: capsule + ? TSliderVariant.capsule + : TSliderVariant.normal, + onChanged: (v) => setState(() => verticalValues[id] = v), + ), + ), + ), + ], + ), + ); + }, + ); + + @override + Widget build(BuildContext context) => Column( + children: [ + const SizedBox(height: 16), + const Text( + 'Narrow ranges: drag to 0, then back to 0.4', + style: TextStyle(fontSize: 14), + ), + const SizedBox(height: 12), + for (final width in [8, 24, 48, 320]) range('$width', width.toDouble()), + const Text( + 'Resize during one gesture: 320 -> 8 -> 24', + style: TextStyle(fontSize: 14), + ), + range('resize', dynamicWidth), + const Text( + 'Test container: 48px wide x 232px tall', + style: TextStyle(fontSize: 14), + ), + const Text( + 'Flutter layout; no slider length parameter', + style: TextStyle(fontSize: 12), + ), + const SizedBox(height: 18), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + vertical('normal', capsule: false), + vertical('capsule', capsule: true), + ], + ), + const SizedBox(height: 28), + const Text( + 'Test container height: 8px', + style: TextStyle(fontSize: 12), + ), + const SizedBox(height: 16), + SizedBox( + width: 320, + height: 8, + child: TSlider(value: .4, onChanged: (_) {}), + ), + ], + ); +} + +Future exerciseSliderConstraintsFixture( + WidgetTester t, + int endpoint, +) async { + Finder native(String id) => find.descendant( + of: find.byKey(ValueKey('constraints-$id')), + matching: find.byWidgetPredicate((w) => w is Slider || w is RangeSlider), + ); + Offset at(String id, double ratio) { + final f = native(id), + box = t.renderObject(f), + theme = SliderTheme.of(t.element(f)); + final rect = t.widget(f) is Slider + ? theme.trackShape!.getPreferredRect(parentBox: box, sliderTheme: theme) + : theme.rangeTrackShape!.getPreferredRect( + parentBox: box, + sliderTheme: theme, + ); + return box.localToGlobal( + Offset(rect.left + ratio * rect.width, rect.center.dy), + ); + } + + for (final id in ['8', '24', '48', '320']) { + final gesture = await t.startGesture(at(id, .2)); + await gesture.moveBy(const Offset(-40, 0)); + await t.pumpAndSettle(); + await gesture.moveTo(at(id, .4)); + await t.pumpAndSettle(); + await gesture.up(); + await t.pumpAndSettle(); + expect(t.widget(native(id)).values.start, closeTo(.4, 1e-9)); + } + final state = t.state<_SliderConstraintsFixtureState>( + find.byType(SliderConstraintsFixture), + ); + final gesture = await t.startGesture(at('resize', .2)); + await gesture.moveBy(const Offset(-40, 0)); + await t.pumpAndSettle(); + state.resize(8); + await t.pumpAndSettle(); + await gesture.moveTo(at('resize', .4)); + await t.pumpAndSettle(); + expect(state.values['resize']!.start, closeTo(.4, 1e-9)); + state.resize(24); + await t.pumpAndSettle(); + await gesture.moveTo(at('resize', .4)); + await t.pumpAndSettle(); + await gesture.up(); + await t.pumpAndSettle(); + for (final id in ['vertical-normal', 'vertical-capsule']) { + final from = at(id, .35), to = at(id, endpoint / 100); + await t.dragFrom(from, to - from); + await t.pumpAndSettle(); + expect( + t.widget(native(id)).value, + closeTo(endpoint.toDouble(), 1e-9), + ); + // Assert the consumer's Flutter constraints, without consulting internal metrics. + final box = t.renderObject(native(id)); + expect(box.size, const Size(232, 48)); + } + expect(t.takeException(), isNull); +} diff --git a/tdesign-component/example/test/slider/slider_demo_golden_test.dart b/tdesign-component/example/test/slider/slider_demo_golden_test.dart index 6d25c120e9..b838cdc511 100644 --- a/tdesign-component/example/test/slider/slider_demo_golden_test.dart +++ b/tdesign-component/example/test/slider/slider_demo_golden_test.dart @@ -1,33 +1,6 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:tdesign_flutter/tdesign_flutter.dart'; - import '../demo_page_test_utils.dart'; import 'slider_demo_test_spec.dart'; void main() { registerDemoGoldenTests(sliderDemoPageTestSpec); - - for (final mode in [ThemeMode.light, ThemeMode.dark]) { - testWidgets('slider changed ${mode.name} golden', (tester) async { - await pumpFullDemoPage(tester, sliderDemoPageTestSpec, mode); - final slider = find.descendant( - of: find.byKey(const ValueKey('slider-single')), - matching: find.byType(Slider), - ); - await tester.drag(slider, const Offset(80, 0)); - await tester.pumpAndSettle(); - - expect( - tester - .widget(find.byKey(const ValueKey('slider-single'))) - .value, - greaterThan(23), - ); - await expectLater( - find.byKey(const ValueKey('slider-demo-page')), - matchesGoldenFile('goldens/slider_changed_${mode.name}.png'), - ); - await disposeDemoPage(tester); - }, tags: 'golden'); - } } diff --git a/tdesign-component/example/test/slider/slider_demo_test.dart b/tdesign-component/example/test/slider/slider_demo_test.dart index 71e2d68c2a..df27d148ac 100644 --- a/tdesign-component/example/test/slider/slider_demo_test.dart +++ b/tdesign-component/example/test/slider/slider_demo_test.dart @@ -5,9 +5,45 @@ import 'package:tdesign_flutter_example/page/slider/slider_capsule_example.dart' import 'package:tdesign_flutter_example/page/slider/slider_disabled_example.dart'; import '../demo_page_test_utils.dart'; +import '../support/review_golden.dart'; +import 'slider_constraints_fixture.dart'; import 'slider_demo_test_spec.dart'; +import 'slider_endpoint_test_utils.dart'; void main() { + for (final endpoint in [0, 100]) { + testWidgets('constraint fixture real gestures endpoint $endpoint', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + theme: withDemoGoldenFonts( + TThemeBuilder.light(TThemeData.defaultData()), + sliderDemoPageTestSpec, + ), + home: const Scaffold(body: SliderConstraintsFixture()), + ), + ); + await tester.pumpAndSettle(); + await exerciseSliderConstraintsFixture(tester, endpoint); + if (const bool.fromEnvironment('BOUNDARY_REVIEW_CAPTURE')) { + const label = String.fromEnvironment('BOUNDARY_CAPTURE_LABEL'); + await captureGoldenEvidence( + tester, + find.byType(Scaffold), + 'boundary_runtime_${label}_$endpoint.png', + ); + } + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }, tags: 'demo'); + } + registerDemoStructureTests(sliderDemoPageTestSpec); testWidgets('Slider Demo keeps official scenarios in order', (tester) async { @@ -209,15 +245,16 @@ void main() { .tTheme .bgColorContainer; final verticalSectionSurface = find.ancestor( - of: verticalSectionTitle, + of: find.byKey(const ValueKey('slider-vertical')), matching: find.byWidgetPredicate( (widget) => widget is ColoredBox && widget.color == surfaceColor, ), ); expect(verticalSectionSurface, findsOneWidget); + // 区块标题由页面统一渲染,位于示例内容(surface)上方。 expect( - tester.getTopLeft(verticalSectionTitle).dx, - tester.getTopLeft(verticalSectionSurface).dx, + tester.getBottomLeft(verticalSectionTitle).dy, + lessThanOrEqualTo(tester.getTopLeft(verticalSectionSurface).dy), ); final verticalTrackRect = tester.getRect( find.ancestor( @@ -225,13 +262,36 @@ void main() { matching: find.byType(RotatedBox), ), ); + // 普通轨道的刻度覆盖完整垂直行程,端点不随 divisions 内缩。 + const labelInset = 10.5; expect( tester.getCenter(scaleLabelFinder.at(0)).dy, - closeTo(verticalTrackRect.top + 16, 0.01), + closeTo(verticalTrackRect.top + labelInset, 0.01), ); expect( tester.getCenter(scaleLabelFinder.at(5)).dy, - closeTo(verticalTrackRect.bottom - 16, 0.01), + closeTo(verticalTrackRect.bottom - labelInset, 0.01), + ); + // 胶囊数值与 thumb 中心同高,不再上下错开。 + final capsuleTrackRect = tester.getRect( + find.ancestor( + of: find.byKey(const ValueKey('slider-vertical-capsule')), + matching: find.byType(RotatedBox), + ), + ); + const capsuleInset = 23.5; + expect( + tester + .getCenter( + find.byKey(const ValueKey('slider-vertical-capsule-label')), + ) + .dy, + closeTo( + capsuleTrackRect.top + + capsuleInset + + 0.35 * (capsuleTrackRect.height - capsuleInset * 2), + 0.01, + ), ); for (final key in [ 'slider-vertical-label', @@ -267,4 +327,151 @@ void main() { } await disposeDemoPage(tester); }, tags: 'demo'); + for (final end in [0, 100]) { + testWidgets( + 'endpoint $end keeps vertical values and scale centers aligned', + (tester) async { + await pumpFullDemoPage(tester, sliderDemoPageTestSpec, ThemeMode.dark); + await moveSliderDemoToEndpoints(tester, end); + for (final id in ['vertical', 'vertical-capsule']) { + final native = find.descendant( + of: find.byKey(ValueKey('slider-$id')), + matching: find.byType(Slider), + ); + final box = tester.renderObject(native); + // The visible track is 200px; 10.5px at each end belongs to the + // complete thumb hit area rather than being deducted from the track. + expect(box.size.width, 221, reason: id); + final theme = SliderTheme.of(tester.element(native)); + final travel = theme.trackShape!.getPreferredRect( + parentBox: box, + sliderTheme: theme, + ); + expect(travel.width, id.contains('capsule') ? 174 : 200, reason: id); + final start = id.contains('capsule') ? 23.5 : 10.5; + final expected = box.localToGlobal( + Offset( + start + end / 100 * (box.size.width - start * 2), + box.size.height / 2, + ), + ); + expect( + tester.getCenter(find.byKey(ValueKey('slider-$id-label'))).dy, + closeTo(expected.dy, 0.01), + ); + } + for (final id in [ + 'vertical-scale-range', + 'vertical-capsule-scale-range', + ]) { + final native = find.descendant( + of: find.byKey(ValueKey('slider-$id')), + matching: find.byType(RangeSlider), + ); + final box = tester.renderObject(native); + final start = id.contains('capsule') ? 23.5 : 10.5; + final labelKey = + 'slider-${id.replaceFirst('-scale-range', '-scale-labels')}'; + final root = find.byKey(ValueKey(labelKey)); + for (var index = 0; index <= 5; index++) { + final expected = box.localToGlobal( + Offset( + start + index / 5 * (box.size.width - start * 2), + box.size.height / 2, + ), + ); + expect( + tester + .getCenter( + find.descendant( + of: root, + matching: find.text('${index * 20}'), + ), + ) + .dy, + closeTo(expected.dy, 0.01), + ); + } + } + await disposeDemoPage(tester); + }, + tags: 'demo', + ); + } + for (final height in [8.0, 24.0]) { + for (final direction in [TextDirection.ltr, TextDirection.rtl]) { + for (final end in [0, 100]) { + testWidgets( + 'custom height $height $direction endpoint $end stays aligned', + (tester) async { + await pumpFullDemoPage( + tester, + sliderGeometryDemoSpec(height, direction), + ThemeMode.dark, + ); + await moveSliderDemoToEndpoints(tester, end); + for (final id in ['vertical', 'vertical-capsule']) { + final native = find.descendant( + of: find.byKey(ValueKey('slider-$id')), + matching: find.byType(Slider), + ); + expect( + Directionality.of(tester.element(native)), + TextDirection.ltr, + ); + final box = tester.renderObject(native); + final start = id.contains('capsule') ? 23.5 : 10.5; + final expected = box.localToGlobal( + Offset( + start + end / 100 * (box.size.width - 2 * start), + box.size.height / 2, + ), + ); + expect( + tester.getCenter(find.byKey(ValueKey('slider-$id-label'))).dy, + closeTo(expected.dy, .01), + ); + } + for (final id in [ + 'vertical-scale-range', + 'vertical-capsule-scale-range', + ]) { + final native = find.descendant( + of: find.byKey(ValueKey('slider-$id')), + matching: find.byType(RangeSlider), + ); + final box = tester.renderObject(native); + final start = id.contains('capsule') ? 23.5 : 10.5; + final root = find.byKey( + ValueKey( + 'slider-${id.replaceFirst('-scale-range', '-scale-labels')}', + ), + ); + for (var index = 0; index <= 5; index++) { + final expected = box.localToGlobal( + Offset( + start + index / 5 * (box.size.width - 2 * start), + box.size.height / 2, + ), + ); + expect( + tester + .getCenter( + find.descendant( + of: root, + matching: find.text('${index * 20}'), + ), + ) + .dy, + closeTo(expected.dy, .01), + ); + } + } + await disposeDemoPage(tester); + }, + tags: 'demo', + ); + } + } + } } diff --git a/tdesign-component/example/test/slider/slider_dragged_golden_test.dart b/tdesign-component/example/test/slider/slider_dragged_golden_test.dart deleted file mode 100644 index c206387d4a..0000000000 --- a/tdesign-component/example/test/slider/slider_dragged_golden_test.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:tdesign_flutter/tdesign_flutter.dart'; - -import '../demo_page_test_utils.dart'; -import 'slider_demo_test_spec.dart'; - -void main() { - registerDemoGoldenTests(sliderDemoPageTestSpec); - - for (final mode in [ThemeMode.light, ThemeMode.dark]) { - testWidgets('slider dragged ${mode.name} golden', (tester) async { - await pumpFullDemoPage(tester, sliderDemoPageTestSpec, mode); - final slider = find.descendant( - of: find.byKey(const ValueKey('slider-single')), - matching: find.byType(Slider), - ); - await tester.drag(slider, const Offset(80, 0)); - await tester.pumpAndSettle(); - expect( - tester - .widget(find.byKey(const ValueKey('slider-single'))) - .value, - greaterThan(25), - ); - await expectLater( - find.byKey(const ValueKey('slider-demo-page')), - matchesGoldenFile('goldens/slider_dragged_${mode.name}.png'), - ); - await disposeDemoPage(tester); - }, tags: 'golden'); - } -} diff --git a/tdesign-component/example/test/slider/slider_endpoint_golden_test.dart b/tdesign-component/example/test/slider/slider_endpoint_golden_test.dart new file mode 100644 index 0000000000..987c90f198 --- /dev/null +++ b/tdesign-component/example/test/slider/slider_endpoint_golden_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +import '../demo_page_test_utils.dart'; +import 'slider_demo_test_spec.dart'; +import 'slider_golden_matrix.dart'; + +void main() { + setUpAll(() => loadDemoGoldenFonts(sliderDemoPageTestSpec)); + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('slider endpoints matrix ${mode.name}', (tester) async { + final matrix = await captureSliderEndpointMatrix(tester, mode); + addTearDown(matrix.image.dispose); + if (const bool.fromEnvironment('SLIDER_REVIEW_CAPTURE')) { + await tester.runAsync( + () => matrix.saveEvidence('slider_endpoints_${mode.name}'), + ); + } + await expectLater( + matrix.image, + matchesGoldenFile('goldens/slider_endpoints_${mode.name}.png'), + ); + }, tags: 'golden'); + } +} diff --git a/tdesign-component/example/test/slider/slider_endpoint_test_utils.dart b/tdesign-component/example/test/slider/slider_endpoint_test_utils.dart new file mode 100644 index 0000000000..b7e4a6a7a4 --- /dev/null +++ b/tdesign-component/example/test/slider/slider_endpoint_test_utils.dart @@ -0,0 +1,125 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +import '../demo_page_test_utils.dart'; +import 'slider_demo_test_spec.dart'; + +const _endpointIds = [ + 'single', + 'range', + 'labeled', + 'labeled-range', + 'scale', + 'scale-range', + 'capsule', + 'capsule-range', + 'capsule-labeled-range', + 'capsule-scale', + 'capsule-scale-range', + 'vertical', + 'vertical-scale-range', + 'vertical-capsule', + 'vertical-capsule-scale-range', +]; +Finder _native(String id) => find.descendant( + of: find.byKey(ValueKey('slider-$id')), + matching: find.byWidgetPredicate((w) => w is Slider || w is RangeSlider), +); +Rect _trackRect(WidgetTester t, Finder f) { + final box = t.renderObject(f); + final theme = SliderTheme.of(t.element(f)); + final widget = t.widget(f); + final discrete = widget is Slider + ? widget.divisions != null + : (widget as RangeSlider).divisions != null; + return widget is Slider + ? theme.trackShape!.getPreferredRect( + parentBox: box, + sliderTheme: theme, + isEnabled: true, + isDiscrete: discrete, + ) + : theme.rangeTrackShape!.getPreferredRect( + parentBox: box, + sliderTheme: theme, + isEnabled: true, + isDiscrete: discrete, + ); +} + +Offset _thumbCenter(WidgetTester t, Finder f, double value) { + final box = t.renderObject(f); + final r = _trackRect(t, f); + final rtl = Directionality.of(t.element(f)) == TextDirection.rtl; + return box.localToGlobal( + Offset(r.left + (rtl ? 1 - value : value) * r.width, r.center.dy), + ); +} + +Future moveSliderDemoToEndpoints(WidgetTester t, int end) async { + for (final id in _endpointIds) { + final f = _native(id); + final w = t.widget(f); + final box = t.renderObject(f); + final r = _trackRect(t, f); + final rtl = Directionality.of(t.element(f)) == TextDirection.rtl; + final minX = rtl ? r.right + 35 : r.left - 35; + final maxX = rtl ? r.left - 35 : r.right + 35; + if (w is Slider) { + final from = _thumbCenter(t, f, (w.value - w.min) / (w.max - w.min)); + final to = box.localToGlobal(Offset(end == 0 ? minX : maxX, r.center.dy)); + await t.dragFrom(from, to - from); + await t.pumpAndSettle(); + expect( + (t.widget(f) as Slider).value, + closeTo(end.toDouble(), .001), + reason: id, + ); + } else { + var range = w as RangeSlider; + var from = _thumbCenter( + t, + f, + (range.values.start - range.min) / (range.max - range.min), + ); + var to = box.localToGlobal(Offset(minX, r.center.dy)); + await t.dragFrom(from, to - from); + await t.pumpAndSettle(); + range = t.widget(f) as RangeSlider; + from = _thumbCenter( + t, + f, + (range.values.end - range.min) / (range.max - range.min), + ); + to = box.localToGlobal(Offset(maxX, r.center.dy)); + await t.dragFrom(from, to - from); + await t.pumpAndSettle(); + range = t.widget(f) as RangeSlider; + expect(range.values.start, 0, reason: id); + expect(range.values.end, 100, reason: id); + } + } +} + +DemoPageTestSpec sliderGeometryDemoSpec( + double height, + TextDirection direction, +) => DemoPageTestSpec( + name: 'slider', + title: sliderDemoPageTestSpec.title, + page: Builder( + builder: (context) => Theme( + data: Theme.of( + context, + ).mergeExtension(TSliderThemeData(trackHeight: height)), + child: Directionality( + textDirection: direction, + child: sliderDemoPageTestSpec.page, + ), + ), + ), + expectedTexts: sliderDemoPageTestSpec.expectedTexts, + useAlignmentCjkFont: true, + supplementalCjkFontFamily: sliderDemoPageTestSpec.supplementalCjkFontFamily, + supplementalCjkFontPath: sliderDemoPageTestSpec.supplementalCjkFontPath, +); diff --git a/tdesign-component/example/test/slider/slider_golden_matrix.dart b/tdesign-component/example/test/slider/slider_golden_matrix.dart new file mode 100644 index 0000000000..b7a1ba1b52 --- /dev/null +++ b/tdesign-component/example/test/slider/slider_golden_matrix.dart @@ -0,0 +1,374 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; +import 'package:tdesign_flutter_example/page/slider/slider_capsule_example.dart'; +import 'package:tdesign_flutter_example/page/slider/slider_labeled_example.dart'; +import 'package:tdesign_flutter_example/page/slider/slider_labeled_range_example.dart'; +import 'package:tdesign_flutter_example/page/slider/slider_scale_example.dart'; +import 'package:tdesign_flutter_example/page/slider/slider_scale_range_example.dart'; +import 'package:tdesign_flutter_example/page/slider/slider_vertical_example.dart'; + +import '../demo_page_test_utils.dart'; +import 'slider_boundary_sources.dart'; +import 'slider_demo_test_spec.dart'; +import 'slider_endpoint_test_utils.dart'; + +class SliderDefectMatrix { + const SliderDefectMatrix(this.image, this.scenes); + final ui.Image image; + final List> scenes; + + Future saveEvidence(String name) async { + final directory = Directory('test/failures')..createSync(recursive: true); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + File( + '${directory.path}/$name.png', + ).writeAsBytesSync(bytes!.buffer.asUint8List()); + File('${directory.path}/$name.json').writeAsStringSync( + const JsonEncoder.withIndent(' ').convert({ + 'scenes': scenes, + 'size': [image.width, image.height], + 'scale': 1, + }), + ); + } +} + +class _Frame { + const _Frame(this.image, this.origin, this.name); + final ui.Image image; + final Offset origin; + final String name; +} + +class _Tile { + const _Tile(this.frame, this.rect, this.label, this.vertical, this.record); + final _Frame frame; + final Rect rect; + final String label; + final bool vertical; + final Map record; +} + +// This packs real rendered content, without reproducing slider geometry or +// changing its layout. Sampling is explicit by defect, not a parameter product. +class _Collector { + final frames = <_Frame>[]; + final tiles = <_Tile>[]; + + Future<_Frame> grab(WidgetTester tester, Finder root, String name) async { + final origin = tester.getTopLeft(root); + final image = (await tester.runAsync(() async { + final image = await tester + .renderObject(root) + .toImage(pixelRatio: 1); + if (const bool.fromEnvironment('SLIDER_REVIEW_CAPTURE')) { + final directory = Directory('test/failures') + ..createSync(recursive: true); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + File( + '${directory.path}/source_$name.png', + ).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + return image; + }))!; + final frame = _Frame(image, origin, name); + frames.add(frame); + return frame; + } + + void add( + WidgetTester tester, + Finder body, + _Frame frame, { + required String id, + required String label, + required String defect, + bool vertical = false, + EdgeInsets padding = EdgeInsets.zero, + }) { + final bounds = tester.getRect(body).shift(-frame.origin); + final rect = + Rect.fromLTRB( + (bounds.left - padding.left).floorToDouble(), + (bounds.top - padding.top).floorToDouble(), + (bounds.right + padding.right).ceilToDouble(), + (bounds.bottom + padding.bottom).ceilToDouble(), + ).intersect( + Rect.fromLTWH( + 0, + 0, + frame.image.width.toDouble(), + frame.image.height.toDouble(), + ), + ); + expect(rect.isEmpty, isFalse, reason: id); + tiles.add( + _Tile(frame, rect, label, vertical, { + 'id': id, + 'defect': defect, + 'source_name': frame.name, + 'source_rect': [rect.left, rect.top, rect.right, rect.bottom], + }), + ); + } + + Future finish( + WidgetTester tester, + ThemeMode mode, + int columns, + ) async { + final ordered = [ + ...tiles.where((t) => !t.vertical), + ...tiles.where((t) => t.vertical), + ]; + const columnWidth = 410, padding = 10, caption = 38.0; + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder) + ..drawColor( + mode == ThemeMode.dark + ? const Color(0xff202124) + : const Color(0xfff3f5f8), + BlendMode.src, + ); + var y = 10.0; + for (var start = 0; start < ordered.length; start += columns) { + var height = 0.0; + for (var i = start; i < start + columns && i < ordered.length; i++) { + if (ordered[i].rect.height > height) { + height = ordered[i].rect.height; + } + } + for (var i = start; i < start + columns && i < ordered.length; i++) { + final tile = ordered[i]; + final x = padding + (i - start) * columnWidth.toDouble(); + final painter = TextPainter( + text: TextSpan( + text: tile.label, + style: TextStyle( + fontFamily: 'Roboto', + fontSize: 13, + color: mode == ThemeMode.dark ? Colors.white : Colors.black, + ), + ), + textDirection: TextDirection.ltr, + )..layout(maxWidth: columnWidth - padding * 2.0); + painter.paint(canvas, Offset(x, y)); + painter.dispose(); + final target = + Offset( + x + + ((columnWidth - padding * 2 - tile.rect.width) / 2) + .floorToDouble(), + y + caption + ((height - tile.rect.height) / 2).floorToDouble(), + ) & + tile.rect.size; + canvas.drawImageRect( + tile.frame.image, + tile.rect, + target, + Paint()..filterQuality = FilterQuality.none, + ); + tile.record['matrix_rect'] = [ + target.left, + target.top, + target.right, + target.bottom, + ]; + } + y += height + caption + padding; + } + final picture = recorder.endRecording(); + final image = (await tester.runAsync( + () => picture.toImage(columns * columnWidth + padding, y.ceil()), + ))!; + picture.dispose(); + return SliderDefectMatrix(image, ordered.map((t) => t.record).toList()); + } + + void dispose() { + for (final frame in frames) { + frame.image.dispose(); + } + } +} + +Finder _example(String id) => find + .ancestor( + of: find.byKey(ValueKey('slider-$id')), + matching: find.byWidgetPredicate( + (w) => + w is SliderLabeledExample || + w is SliderLabeledRangeExample || + w is SliderScaleExample || + w is SliderScaleRangeExample || + w is SliderCapsuleExample || + w is SliderVerticalExample, + ), + ) + .first; + +Future captureSliderEndpointMatrix( + WidgetTester tester, + ThemeMode mode, +) async { + final collector = _Collector(); + try { + for (final end in [0, 100]) { + await pumpFullDemoPage(tester, sliderDemoPageTestSpec, mode); + await moveSliderDemoToEndpoints(tester, end); + final frame = await collector.grab( + tester, + find.byKey(const ValueKey('slider-demo-page')), + 'slider_endpoint_${end}_${mode.name}', + ); + for (final id in [ + 'labeled', + 'labeled-range', + 'scale', + 'scale-range', + 'capsule', + 'capsule-labeled-range', + 'capsule-scale', + 'capsule-scale-range', + 'vertical', + 'vertical-scale-range', + 'vertical-capsule', + 'vertical-capsule-scale-range', + ]) { + // A full-range control has both endpoints in one scene. + if (end == 100 && id.contains('range')) { + continue; + } + collector.add( + tester, + _example(id), + frame, + id: '$id/$end', + label: '$id\n${id.contains('range') ? 'range 0..100' : 'value $end'}', + defect: id.startsWith('vertical') + ? 'vertical value/tick alignment' + : 'thumb/label endpoints and discrete gaps', + vertical: id.startsWith('vertical'), + ); + } + await disposeDemoPage(tester); + } + expect(collector.tiles.length, 18); + return await collector.finish(tester, mode, 3); + } finally { + collector.dispose(); + } +} + +Future captureSliderBoundaryMatrix( + WidgetTester tester, + ThemeMode mode, +) async { + final collector = _Collector(); + try { + await pumpSliderGeometryBoundarySources(tester, mode); + var frame = await collector.grab( + tester, + find.byKey(const ValueKey('geometry-boundary-source')), + 'geometry_boundary_${mode.name}', + ); + for (final entry in [ + ( + 'near-edge', + 'boundary-near-edge', + 'normal: theme trackHeight=24\nvalue 0.02; value label', + ), + ( + 'narrow-discrete', + 'boundary-discrete-48', + 'capsule discrete range\ncontainer width=48', + ), + ]) { + collector.add( + tester, + find.byKey(ValueKey(entry.$2)), + frame, + id: entry.$1, + label: entry.$3, + defect: entry.$1 == 'near-edge' + ? 'thick track near endpoint' + : 'narrow discrete paint', + padding: entry.$1 == 'narrow-discrete' + ? const EdgeInsets.symmetric(horizontal: 6) + : EdgeInsets.zero, + ); + } + await disposeDemoPage(tester); + await pumpFullDemoPage( + tester, + sliderGeometryDemoSpec(24, TextDirection.rtl), + mode, + ); + await moveSliderDemoToEndpoints(tester, 0); + frame = await collector.grab( + tester, + find.byKey(const ValueKey('slider-demo-page')), + 'rtl_boundary_${mode.name}', + ); + for (final id in ['scale', 'capsule-scale']) { + collector.add( + tester, + _example(id), + frame, + id: 'rtl-$id', + label: '$id | RTL | value 0\nnormal theme trackHeight=24', + defect: 'RTL thumb, ticks and capsule gaps', + ); + } + await disposeDemoPage(tester); + await pumpSliderConstraintBoundarySource(tester, mode); + frame = await collector.grab( + tester, + find.byKey(const ValueKey('constraint-boundary-source')), + 'slider_constraints_100_${mode.name}', + ); + collector.add( + tester, + find.byKey(const ValueKey('constraints-8')), + frame, + id: 'narrow-continuous', + label: 'capsule continuous range\ncontainer width=8; after real drag', + defect: 'thumb adapts to longitudinal constraint', + padding: const EdgeInsets.symmetric(horizontal: 6), + ); + collector.add( + tester, + find.byWidgetPredicate( + (w) => w is TSlider && w.value == .4 && w.max == 1, + ), + frame, + id: 'thin-cross-axis', + label: 'normal slider\ncontainer height=8', + defect: 'thumb adapts to cross-axis constraint', + padding: const EdgeInsets.symmetric(vertical: 8), + ); + for (final id in ['normal', 'capsule']) { + collector.add( + tester, + find.byKey(ValueKey('constraints-vertical-$id')), + frame, + id: 'large-$id', + label: + '$id: ${id == 'normal' ? 'theme trackHeight=192' : 'theme spacer3=240'}\ncontainer 48 x 232; value 100', + defect: 'large thickness obeys cross-axis constraint', + vertical: true, + padding: const EdgeInsets.all(8), + ); + } + await disposeDemoPage(tester); + expect(collector.tiles.length, 8); + return await collector.finish(tester, mode, 2); + } finally { + collector.dispose(); + } +} diff --git a/tdesign-component/example/test/swiper/goldens/swiper_changed_dark.png b/tdesign-component/example/test/swiper/goldens/swiper_changed_dark.png index ac3cddd941..62b6f13e90 100644 Binary files a/tdesign-component/example/test/swiper/goldens/swiper_changed_dark.png and b/tdesign-component/example/test/swiper/goldens/swiper_changed_dark.png differ diff --git a/tdesign-component/example/test/swiper/goldens/swiper_changed_light.png b/tdesign-component/example/test/swiper/goldens/swiper_changed_light.png index aef91cdcdc..8d050ad3b7 100644 Binary files a/tdesign-component/example/test/swiper/goldens/swiper_changed_light.png and b/tdesign-component/example/test/swiper/goldens/swiper_changed_light.png differ diff --git a/tdesign-component/example/test/swiper/goldens/swiper_next_page_dark.png b/tdesign-component/example/test/swiper/goldens/swiper_next_page_dark.png index 46470fb457..ecf5709cc6 100644 Binary files a/tdesign-component/example/test/swiper/goldens/swiper_next_page_dark.png and b/tdesign-component/example/test/swiper/goldens/swiper_next_page_dark.png differ diff --git a/tdesign-component/example/test/swiper/goldens/swiper_next_page_light.png b/tdesign-component/example/test/swiper/goldens/swiper_next_page_light.png index 0ce18935ca..deb8360c53 100644 Binary files a/tdesign-component/example/test/swiper/goldens/swiper_next_page_light.png and b/tdesign-component/example/test/swiper/goldens/swiper_next_page_light.png differ diff --git a/tdesign-component/example/test/swiper/goldens/swiper_page_dark.png b/tdesign-component/example/test/swiper/goldens/swiper_page_dark.png index dd0dae4c4c..85a5de64d2 100644 Binary files a/tdesign-component/example/test/swiper/goldens/swiper_page_dark.png and b/tdesign-component/example/test/swiper/goldens/swiper_page_dark.png differ diff --git a/tdesign-component/example/test/swiper/goldens/swiper_page_light.png b/tdesign-component/example/test/swiper/goldens/swiper_page_light.png index a02f785d2c..e4602b90fe 100644 Binary files a/tdesign-component/example/test/swiper/goldens/swiper_page_light.png and b/tdesign-component/example/test/swiper/goldens/swiper_page_light.png differ diff --git a/tdesign-component/lib/src/components/slider/t_slider.dart b/tdesign-component/lib/src/components/slider/t_slider.dart index e1ac83e74d..9822b2b6ba 100644 --- a/tdesign-component/lib/src/components/slider/t_slider.dart +++ b/tdesign-component/lib/src/components/slider/t_slider.dart @@ -1,6 +1,9 @@ +import 'dart:async'; import 'dart:math' as math; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import '../../theme/t_colors.dart'; import '../../theme/t_fonts.dart'; @@ -9,6 +12,7 @@ import '../../theme/t_spacers.dart'; import '../../theme/t_theme.dart'; import '../text/t_text_resolve.dart'; import '../text/t_text_styled.dart'; +import 't_slider_geometry.dart'; import 't_slider_theme.dart'; /// 格式化滑块提示文案。 @@ -18,20 +22,155 @@ import 't_slider_theme.dart'; /// 显示在滑块提示中的格式化文案。 typedef TSliderThumbFormatter = String Function(double value); -/// Slider visual structure. +/// 滑块视觉结构。 enum TSliderVariant { - /// Standard thin track. + /// 标准细轨道。 normal, - /// Capsule track with a 3px inset active segment and 20px thumbs. + /// 胶囊轨道,默认激活区四周内缩 3px,20px thumb 沿轨道方向两端各留 3px,默认横截面两侧各留 2px。 + /// 空间充足时轨道高度至少为 24px;厚度受横轴约束,行程不足时按比例缩小。 capsule, } -const double _kScaleTickRadius = 3; -const double _kCapsuleTrackInset = 3; -const double _kCapsuleGapWidth = 2; +const double _kThumbRadius = SliderGeometry.thumbDiameter / 2; + +// Each component owns this size cache. Native track layout supplies the actual +// RenderBox before painting the range thumb, whose API has no parentBox. +class _SliderGeometryLayout { + _SliderGeometryLayout(this.geometry) : _size = geometry.preferredSize; + final SliderGeometry geometry; + Size _size; + _SliderPointerState pointer = _SliderPointerState(); + RenderBox? viewport; + ValueChanged? pointerMove; + void suppressUnavailableInteraction() { + pointer.suppressed = true; + final id = pointer.id; + if (id != null) { + GestureBinding.instance.cancelPointer(id); + } + } + + SliderMetrics get metrics => geometry.forSize(_size); + SliderMetrics forConstraints(BoxConstraints constraints) { + _size = geometry.sizeForConstraints(constraints); + return metrics; + } + + SliderMetrics forBox(RenderBox box) { + _size = box.size; + return metrics; + } +} + +class _SliderPointerState { + int? id; + Offset? position; + bool singleActive = false; + bool nativeAction = false; + bool rangeActive = false; + bool suppressed = false; + double? singleValue; +} + +// A disappearing viewport ends its pointer interaction. Positive resizes keep +// the same native gesture; no zero-length coordinate division is dispatched. +class _SliderGeometryViewport extends SingleChildRenderObjectWidget { + const _SliderGeometryViewport({required this.layout, required super.child}); + final _SliderGeometryLayout layout; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderSliderGeometryViewport(layout); + + @override + void updateRenderObject( + BuildContext context, + _RenderSliderGeometryViewport renderObject, + ) { + renderObject.updateLayoutMetrics(layout); + } +} + +class _RenderSliderGeometryViewport extends RenderProxyBox { + _RenderSliderGeometryViewport(this._layout); + _SliderGeometryLayout _layout; + final _pointers = {}; -SliderThemeData _sliderThemeFromTDesign(BuildContext context) { + void updateLayoutMetrics(_SliderGeometryLayout value) { + value.pointer = _layout.pointer; + value.viewport = this; + _layout = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) => + _layout.geometry.preferredSize.width; + @override + double computeMaxIntrinsicWidth(double height) => + _layout.geometry.preferredSize.width; + @override + double computeMinIntrinsicHeight(double width) => + _layout.geometry.preferredSize.height; + @override + double computeMaxIntrinsicHeight(double width) => + _layout.geometry.preferredSize.height; + @override + Size computeDryLayout(BoxConstraints constraints) => + _layout.geometry.sizeForConstraints(constraints); + + // Like the native slider, this control has no text baseline. Do not ask + // the constraint builder to run a speculative layout for baseline queries. + @override + double? computeDryBaseline( + BoxConstraints constraints, + TextBaseline baseline, + ) => null; + + @override + void performLayout() { + super.performLayout(); + _layout.viewport = this; + if (!_layout.forBox(this).hasTravel && + (_layout.pointer.singleActive || _layout.pointer.rangeActive)) { + for (final pointer in _pointers) { + GestureBinding.instance.cancelPointer(pointer); + } + _pointers.clear(); + } + } + + @override + void handleEvent(PointerEvent event, BoxHitTestEntry entry) { + if (event is PointerDownEvent) { + _pointers.add(event.pointer); + _layout.pointer.id ??= event.pointer; + } + final pointer = _layout.pointer; + if (pointer.id == event.pointer && event is! PointerCancelEvent) { + pointer.position = event.position; + if (event is PointerMoveEvent && pointer.singleActive) { + _layout.pointerMove?.call(event.position); + } + } + if (event is PointerUpEvent || event is PointerCancelEvent) { + _pointers.remove(event.pointer); + scheduleMicrotask(() { + if (pointer.id == event.pointer) { + pointer.id = null; + } + }); + } + super.handleEvent(event, entry); + } +} + +SliderThemeData _sliderThemeFromTDesign( + BuildContext context, + SliderGeometry geometry, + _SliderGeometryLayout layout, +) { final token = context.tTheme; final theme = Theme.of(context).extension(); final brand = token.brandColor; @@ -49,8 +188,10 @@ SliderThemeData _sliderThemeFromTDesign(BuildContext context) { // Preserve current TD layout; zero padding would shrink the native render box. // ignore: deprecated_member_use year2023: true, - overlayShape: const RoundSliderOverlayShape(overlayRadius: 24), - minThumbSeparation: 8, + overlayShape: const RoundSliderOverlayShape( + overlayRadius: SliderGeometry.hitRadius, + ), + minThumbSeparation: SliderGeometry.minimumRangeSeparation, allowedInteraction: SliderInteraction.tapAndSlide, activeTrackColor: activeTrack, inactiveTrackColor: inactiveTrack, @@ -62,19 +203,17 @@ SliderThemeData _sliderThemeFromTDesign(BuildContext context) { inactiveTickMarkColor: inactiveTrack, disabledActiveTickMarkColor: disabledBrand, disabledInactiveTickMarkColor: disabledComponent, - tickMarkShape: const RoundSliderTickMarkShape( - tickMarkRadius: _kScaleTickRadius, - ), - rangeTickMarkShape: const RoundRangeSliderTickMarkShape( - tickMarkRadius: _kScaleTickRadius, - ), + tickMarkShape: _TDesignSliderTickMarkShape(layout), + rangeTickMarkShape: _TDesignRangeSliderTickMarkShape(layout), thumbColor: theme?.thumbColor ?? token.textColorAnti, disabledThumbColor: theme?.disabledThumbColor ?? token.textColorAnti, thumbShape: _TDesignSliderThumbShape( + layout: layout, borderColor: thumbBorder, disabledBorderColor: disabledThumbBorder, ), rangeThumbShape: _TDesignRangeSliderThumbShape( + layout: layout, borderColor: thumbBorder, disabledBorderColor: disabledThumbBorder, ), @@ -98,31 +237,32 @@ SliderThemeData _resolveSliderTheme( BuildContext context, TSliderVariant variant, int? divisions, + SliderGeometry geometry, + _SliderGeometryLayout layout, ) { - final componentTheme = Theme.of(context).extension(); - final base = _sliderThemeFromTDesign(context); - final token = context.tTheme; + final base = _sliderThemeFromTDesign(context, geometry, layout); if (variant == TSliderVariant.normal) { return base.copyWith( - trackHeight: componentTheme?.trackHeight ?? 4.0, - trackShape: _TDesignSliderTrackShape(horizontalInset: token.spacer2), + trackHeight: geometry.trackHeight, + trackShape: _TDesignSliderTrackShape(geometry: geometry, layout: layout), rangeTrackShape: _TDesignRangeSliderTrackShape( - horizontalInset: token.spacer2, + geometry: geometry, + layout: layout, ), ); } return base.copyWith( - trackHeight: token.spacer3, + trackHeight: geometry.trackHeight, trackShape: _CapsuleSliderTrackShape( - horizontalInset: token.spacer2, - outerColor: base.inactiveTrackColor ?? token.componentBorder, - disabledOuterColor: token.bgColorComponent, + layout: layout, + outerColor: base.inactiveTrackColor ?? context.tTheme.componentBorder, + disabledOuterColor: context.tTheme.bgColorComponent, divisions: divisions, ), rangeTrackShape: _CapsuleRangeSliderTrackShape( - horizontalInset: token.spacer2, - outerColor: base.inactiveTrackColor ?? token.componentBorder, - disabledOuterColor: token.bgColorComponent, + layout: layout, + outerColor: base.inactiveTrackColor ?? context.tTheme.componentBorder, + disabledOuterColor: context.tTheme.bgColorComponent, divisions: divisions, ), tickMarkShape: SliderTickMarkShape.noTickMark, @@ -177,7 +317,7 @@ class TSlider extends StatelessWidget { final double max; /// 离散刻度数;null 表示连续。 - /// 非 null 时必须大于 0。 + /// 非 null 时必须大于 0;输入值吸附到最近刻度,显示数值使用吸附后的值。 final int? divisions; /// 是否持续显示拇指上方数值。 @@ -197,20 +337,106 @@ class TSlider extends StatelessWidget { @override Widget build(BuildContext context) { + final effectiveValue = SliderGeometry.effectiveValue( + value, + min, + max, + divisions, + ); + final geometry = SliderGeometry.resolve( + context, + capsule: variant == TSliderVariant.capsule, + ); final label = showThumbValue ? (thumbFormatter?.call(value) ?? value.toStringAsFixed(2)) : null; + final layout = _SliderGeometryLayout(geometry); + void pointerChanged(Offset position) { + final pointer = layout.pointer; + if (!pointer.singleActive || !layout.metrics.hasTravel) { + return; + } + final value = layout.metrics.valueAt( + layout.viewport!.globalToLocal(position).dx, + min: min, + max: max, + divisions: divisions, + direction: Directionality.of(context), + ); + if (pointer.singleValue != value) { + pointer.singleValue = value; + onChanged?.call(value); + } + } + + layout.pointerMove = pointerChanged; final slider = Slider( - value: value, - onChanged: onChanged, - onChangeStart: onChangeStart, - onChangeEnd: onChangeEnd, + value: effectiveValue, + onChanged: onChanged == null + ? null + : (value) { + // Pointer values come from the current geometry. Native values remain + // authoritative for keyboard and accessibility actions. + if (!layout.pointer.suppressed && + (!layout.pointer.singleActive || + layout.pointer.nativeAction)) { + if (layout.pointer.nativeAction) { + layout.pointer.singleValue = value; + } + onChanged!(value); + } + }, + onChangeStart: (value) { + final pointer = layout.pointer; + if (!layout.metrics.hasTravel) { + layout.suppressUnavailableInteraction(); + return; + } + if (pointer.singleActive) { + pointer.nativeAction = true; + onChangeStart?.call(value); + return; + } + if (pointer.id != null) { + pointer.singleActive = true; + pointer.singleValue = effectiveValue; + onChangeStart?.call(effectiveValue); + pointerChanged(pointer.position!); + } else { + onChangeStart?.call(value); + } + }, + onChangeEnd: (value) { + final pointer = layout.pointer; + if (pointer.suppressed) { + pointer.suppressed = false; + pointer.singleActive = false; + pointer.nativeAction = false; + return; + } + if (pointer.nativeAction) { + pointer.nativeAction = false; + onChangeEnd?.call(value); + } else if (pointer.singleActive) { + final last = pointer.singleValue!; + pointer.singleActive = false; + onChangeEnd?.call(last); + } else { + onChangeEnd?.call(value); + } + }, min: min, max: max, divisions: divisions, label: label, ); - final baseTheme = _resolveSliderTheme(context, variant, divisions); + final baseTheme = _resolveSliderTheme( + context, + variant, + divisions, + geometry, + layout, + ); final sliderTheme = baseTheme.copyWith( showValueIndicator: showThumbValue ? ShowValueIndicator.never : null, thumbShape: label == null @@ -226,7 +452,10 @@ class TSlider extends StatelessWidget { ).extension()?.decoration; final themedSlider = Theme( data: TInternalMaterialTheme.resolve(context), - child: SliderTheme(data: sliderTheme, child: slider), + child: _SliderGeometryViewport( + layout: layout, + child: SliderTheme(data: sliderTheme, child: slider), + ), ); final sliderContent = showThumbValue ? Padding( @@ -243,6 +472,7 @@ class TSlider extends StatelessWidget { max: max, divisions: safeDivisions, formatter: scaleFormatter, + geometry: geometry, slider: sliderContent, ) : sliderContent; @@ -273,6 +503,8 @@ class TRangeSlider extends StatelessWidget { assert(!showScaleValue || divisions != null); /// 受控范围值;两端值应处于 min 与 max 指定的范围内,父组件需回传新值。 + /// 连续拖动在正常尺寸下保留 8 逻辑像素最小中心间距;空间不足时随几何缩小,且不超过当前区间行程。 + /// 离散同刻度、直接传入的相等端点仍受支持。 final RangeValues value; /// 范围变更回调;为 null 时禁用。 @@ -293,7 +525,7 @@ class TRangeSlider extends StatelessWidget { final double max; /// 离散刻度数;null 表示连续。 - /// 非 null 时必须大于 0。 + /// 非 null 时必须大于 0;输入值吸附到最近刻度,显示数值使用吸附后的值。 final int? divisions; /// 是否持续显示拇指上方数值。 @@ -314,23 +546,66 @@ class TRangeSlider extends StatelessWidget { @override Widget build(BuildContext context) { assert(value.start >= min && value.end <= max); + final effectiveStart = SliderGeometry.effectiveValue( + value.start, + min, + max, + divisions, + ); + final effectiveEnd = SliderGeometry.effectiveValue( + value.end, + min, + max, + divisions, + ); + final geometry = SliderGeometry.resolve( + context, + capsule: variant == TSliderVariant.capsule, + ); final labels = showThumbValue ? RangeLabels( thumbFormatter?.call(value.start) ?? value.start.toStringAsFixed(2), thumbFormatter?.call(value.end) ?? value.end.toStringAsFixed(2), ) : null; + final layout = _SliderGeometryLayout(geometry); final slider = RangeSlider( - values: value, - onChanged: onChanged, - onChangeStart: onChangeStart, - onChangeEnd: onChangeEnd, + values: RangeValues(effectiveStart, effectiveEnd), + onChanged: onChanged == null + ? null + : (values) { + if (!layout.pointer.suppressed) { + onChanged!(values); + } + }, + onChangeStart: (values) { + if (!layout.metrics.hasTravel) { + layout.suppressUnavailableInteraction(); + return; + } + layout.pointer.rangeActive = true; + onChangeStart?.call(values); + }, + onChangeEnd: (values) { + layout.pointer.rangeActive = false; + if (layout.pointer.suppressed) { + layout.pointer.suppressed = false; + return; + } + onChangeEnd?.call(values); + }, min: min, max: max, divisions: divisions, labels: labels, ); - final baseTheme = _resolveSliderTheme(context, variant, divisions); + final baseTheme = _resolveSliderTheme( + context, + variant, + divisions, + geometry, + layout, + ); final sliderTheme = baseTheme.copyWith( showValueIndicator: showThumbValue ? ShowValueIndicator.never : null, rangeThumbShape: labels == null @@ -346,7 +621,23 @@ class TRangeSlider extends StatelessWidget { ).extension()?.decoration; final themedSlider = Theme( data: TInternalMaterialTheme.resolve(context), - child: SliderTheme(data: sliderTheme, child: slider), + child: _SliderGeometryViewport( + layout: layout, + child: LayoutBuilder( + builder: (context, constraints) { + final metrics = layout.forConstraints(constraints); + return SliderTheme( + data: sliderTheme.copyWith( + minThumbSeparation: metrics.rangeSeparation( + (effectiveStart - min) / (max - min), + (effectiveEnd - min) / (max - min), + ), + ), + child: slider, + ); + }, + ), + ), ); final sliderContent = showThumbValue ? Padding( @@ -363,6 +654,7 @@ class TRangeSlider extends StatelessWidget { max: max, divisions: safeDivisions, formatter: scaleFormatter, + geometry: geometry, slider: sliderContent, ) : sliderContent; @@ -372,58 +664,12 @@ class TRangeSlider extends StatelessWidget { } } -Rect _preferredTrackRect({ - required RenderBox parentBox, - required Offset offset, - required SliderThemeData sliderTheme, - required double horizontalInset, -}) { - final trackHeight = sliderTheme.trackHeight ?? 0; - final trackWidth = math.max(0.0, parentBox.size.width - 2 * horizontalInset); - return Rect.fromLTWH( - offset.dx + horizontalInset, - offset.dy + (parentBox.size.height - trackHeight) / 2, - trackWidth, - trackHeight, - ); -} - -Rect _capsuleMaterialTrackRect({ - required RenderBox parentBox, - required Offset offset, - required SliderThemeData sliderTheme, - required double horizontalInset, -}) { - final visualRect = _preferredTrackRect( - parentBox: parentBox, - offset: offset, - sliderTheme: sliderTheme, - horizontalInset: horizontalInset, - ); - // Material reserves half a track height at each end for discrete values. - // Extend its coordinate track so values map across the visible inset track. - final extension = visualRect.height / 2 - _kCapsuleTrackInset; - return Rect.fromLTRB( - visualRect.left - extension, - visualRect.top, - visualRect.right + extension, - visualRect.bottom, - ); -} - -Rect _capsuleVisualTrackRect(Rect materialRect) { - final extension = materialRect.height / 2 - _kCapsuleTrackInset; - return Rect.fromLTRB( - materialRect.left + extension, - materialRect.top, - materialRect.right - extension, - materialRect.bottom, - ); -} - void _paintCapsuleSegments( Canvas canvas, { required Rect trackRect, + required Rect thumbTrack, + required double innerInset, + required double gapWidth, required Color outerColor, required Color inactiveColor, required Color activeColor, @@ -435,7 +681,7 @@ void _paintCapsuleSegments( RRect.fromRectAndRadius(trackRect, Radius.circular(trackRect.height / 2)), Paint()..color = outerColor, ); - final innerRect = trackRect.deflate(_kCapsuleTrackInset); + final innerRect = trackRect.deflate(innerInset); if (innerRect.isEmpty) { return; } @@ -445,23 +691,21 @@ void _paintCapsuleSegments( RRect.fromRectAndRadius(innerRect, Radius.circular(innerRect.height / 2)), ); final count = divisions ?? 1; - // Figma spaces divider centers across the outer capsule's usable width; - // the painted inner track keeps its 3px inset on every side. - final dividerLeft = trackRect.left + _kCapsuleTrackInset / 2; - final dividerStep = (trackRect.width - _kCapsuleTrackInset) / count; - final gapHalf = dividerStep > _kCapsuleGapWidth ? _kCapsuleGapWidth / 2 : 0.0; + // 分段边界对齐 thumb 落点:分段在 thumb 行程上等分。 + final dividerLeft = thumbTrack.left; + final dividerStep = thumbTrack.width / count; + final gapHalf = dividerStep > gapWidth ? gapWidth / 2 : 0.0; for (var index = 0; index < count; index++) { // Leave a 2px unpainted gap when the division is wide enough. - final left = math.max( - innerRect.left, - index == 0 ? innerRect.left : dividerLeft + index * dividerStep + gapHalf, - ); - final right = math.min( - innerRect.right, - index == count - 1 - ? innerRect.right - : dividerLeft + (index + 1) * dividerStep - gapHalf, - ); + final left = index == 0 + ? innerRect.left + : math.max(innerRect.left, dividerLeft + index * dividerStep + gapHalf); + final right = index == count - 1 + ? innerRect.right + : math.min( + innerRect.right, + dividerLeft + (index + 1) * dividerStep - gapHalf, + ); if (right <= left) { continue; } @@ -477,10 +721,77 @@ void _paintCapsuleSegments( canvas.restore(); } -class _TDesignSliderTrackShape extends RoundedRectSliderTrackShape { - const _TDesignSliderTrackShape({required this.horizontalInset}); +void _paintNormalTrack( + Canvas canvas, + Rect rect, + SliderThemeData theme, + Animation animation, { + required double activeLeft, + required double activeRight, +}) { + if (rect.isEmpty) { + return; + } + final border = RRect.fromRectAndRadius( + rect, + Radius.circular(math.min(rect.width, rect.height) / 2), + ); + final inactive = Color.lerp( + theme.disabledInactiveTrackColor, + theme.inactiveTrackColor, + animation.value, + )!; + final active = Color.lerp( + theme.disabledActiveTrackColor, + theme.activeTrackColor, + animation.value, + )!; + final left = activeLeft.clamp(rect.left, rect.right).toDouble(); + final right = activeRight.clamp(left, rect.right).toDouble(); + void segment(double start, double end, Color color) { + if (end <= start) { + return; + } + canvas.save(); + canvas.clipRect( + Rect.fromLTRB(start, rect.top - 1, end, rect.bottom + 1), + doAntiAlias: false, + ); + canvas.drawRRect(border, Paint()..color = color); + canvas.restore(); + } + + segment(rect.left, left, inactive); + segment(left, right, active); + segment(right, rect.right, inactive); +} + +class _TDesignSliderTickMarkShape extends RoundSliderTickMarkShape { + const _TDesignSliderTickMarkShape(this.layout); + final _SliderGeometryLayout layout; + @override + double get tickMarkRadius => layout.metrics.tickRadius; +} + +class _TDesignRangeSliderTickMarkShape extends RoundRangeSliderTickMarkShape { + const _TDesignRangeSliderTickMarkShape(this.layout); + final _SliderGeometryLayout layout; + @override + double get tickMarkRadius => layout.metrics.tickRadius; +} + +class _TDesignSliderTrackShape extends SliderTrackShape { + const _TDesignSliderTrackShape({ + required this.geometry, + required this.layout, + }); + + final _SliderGeometryLayout layout; - final double horizontalInset; + final SliderGeometry geometry; + + @override + bool get isRounded => false; @override Rect getPreferredRect({ @@ -489,12 +800,7 @@ class _TDesignSliderTrackShape extends RoundedRectSliderTrackShape { required SliderThemeData sliderTheme, bool isEnabled = false, bool isDiscrete = false, - }) => _preferredTrackRect( - parentBox: parentBox, - offset: offset, - sliderTheme: sliderTheme, - horizontalInset: horizontalInset, - ); + }) => layout.forBox(parentBox).coordinateRect(parentBox.size, offset); @override void paint( @@ -510,26 +816,33 @@ class _TDesignSliderTrackShape extends RoundedRectSliderTrackShape { bool isEnabled = false, double additionalActiveTrackHeight = 2, }) { - super.paint( - context, - offset, - parentBox: parentBox, - sliderTheme: sliderTheme, - enableAnimation: enableAnimation, - textDirection: textDirection, - thumbCenter: thumbCenter, - secondaryOffset: secondaryOffset, - isDiscrete: isDiscrete, - isEnabled: isEnabled, - additionalActiveTrackHeight: 0, + final rect = layout.forBox(parentBox).visualRect(parentBox.size, offset); + _paintNormalTrack( + context.canvas, + rect, + sliderTheme, + enableAnimation, + activeLeft: textDirection == TextDirection.ltr + ? rect.left + : thumbCenter.dx, + activeRight: textDirection == TextDirection.ltr + ? thumbCenter.dx + : rect.right, ); } } -class _TDesignRangeSliderTrackShape extends RoundedRectRangeSliderTrackShape { - const _TDesignRangeSliderTrackShape({required this.horizontalInset}); +class _TDesignRangeSliderTrackShape extends RangeSliderTrackShape { + const _TDesignRangeSliderTrackShape({ + required this.geometry, + required this.layout, + }); - final double horizontalInset; + final SliderGeometry geometry; + final _SliderGeometryLayout layout; + + @override + bool get isRounded => false; @override Rect getPreferredRect({ @@ -538,12 +851,7 @@ class _TDesignRangeSliderTrackShape extends RoundedRectRangeSliderTrackShape { required SliderThemeData sliderTheme, bool isEnabled = false, bool isDiscrete = false, - }) => _preferredTrackRect( - parentBox: parentBox, - offset: offset, - sliderTheme: sliderTheme, - horizontalInset: horizontalInset, - ); + }) => layout.forBox(parentBox).coordinateRect(parentBox.size, offset); @override void paint( @@ -559,35 +867,35 @@ class _TDesignRangeSliderTrackShape extends RoundedRectRangeSliderTrackShape { required TextDirection textDirection, double additionalActiveTrackHeight = 2, }) { - super.paint( - context, - offset, - parentBox: parentBox, - sliderTheme: sliderTheme, - enableAnimation: enableAnimation, - startThumbCenter: startThumbCenter, - endThumbCenter: endThumbCenter, - isEnabled: isEnabled, - isDiscrete: isDiscrete, - textDirection: textDirection, - additionalActiveTrackHeight: 0, + final rect = layout.forBox(parentBox).visualRect(parentBox.size, offset); + _paintNormalTrack( + context.canvas, + rect, + sliderTheme, + enableAnimation, + activeLeft: math.min(startThumbCenter.dx, endThumbCenter.dx), + activeRight: math.max(startThumbCenter.dx, endThumbCenter.dx), ); } } class _CapsuleSliderTrackShape extends RoundedRectSliderTrackShape { const _CapsuleSliderTrackShape({ - required this.horizontalInset, + required this.layout, required this.outerColor, required this.disabledOuterColor, required this.divisions, }); - final double horizontalInset; + final _SliderGeometryLayout layout; final Color outerColor; final Color disabledOuterColor; final int? divisions; + // Geometry owns the complete travel in continuous and discrete modes. + @override + bool get isRounded => false; + @override Rect getPreferredRect({ required RenderBox parentBox, @@ -595,12 +903,7 @@ class _CapsuleSliderTrackShape extends RoundedRectSliderTrackShape { required SliderThemeData sliderTheme, bool isEnabled = false, bool isDiscrete = false, - }) => _capsuleMaterialTrackRect( - parentBox: parentBox, - offset: offset, - sliderTheme: sliderTheme, - horizontalInset: horizontalInset, - ); + }) => layout.forBox(parentBox).coordinateRect(parentBox.size, offset); @override void paint( @@ -616,15 +919,9 @@ class _CapsuleSliderTrackShape extends RoundedRectSliderTrackShape { bool isEnabled = false, double additionalActiveTrackHeight = 2, }) { - final trackRect = _capsuleVisualTrackRect( - getPreferredRect( - parentBox: parentBox, - offset: offset, - sliderTheme: sliderTheme, - isEnabled: isEnabled, - isDiscrete: isDiscrete, - ), - ); + final metrics = layout.forBox(parentBox); + final trackRect = metrics.visualRect(parentBox.size, offset); + final innerInset = metrics.capsuleInset; if (trackRect.isEmpty) { return; } @@ -638,10 +935,13 @@ class _CapsuleSliderTrackShape extends RoundedRectSliderTrackShape { sliderTheme.activeTrackColor, enableAnimation.value, )!; - final innerRect = trackRect.deflate(_kCapsuleTrackInset); + final innerRect = trackRect.deflate(innerInset); _paintCapsuleSegments( context.canvas, trackRect: trackRect, + thumbTrack: metrics.coordinateRect(parentBox.size, offset), + innerInset: innerInset, + gapWidth: metrics.capsuleGapWidth, outerColor: Color.lerp( disabledOuterColor, outerColor, @@ -662,17 +962,21 @@ class _CapsuleSliderTrackShape extends RoundedRectSliderTrackShape { class _CapsuleRangeSliderTrackShape extends RoundedRectRangeSliderTrackShape { const _CapsuleRangeSliderTrackShape({ - required this.horizontalInset, + required this.layout, required this.outerColor, required this.disabledOuterColor, required this.divisions, }); - final double horizontalInset; + final _SliderGeometryLayout layout; final Color outerColor; final Color disabledOuterColor; final int? divisions; + /// 同 [_CapsuleSliderTrackShape]。 + @override + bool get isRounded => false; + @override Rect getPreferredRect({ required RenderBox parentBox, @@ -680,12 +984,7 @@ class _CapsuleRangeSliderTrackShape extends RoundedRectRangeSliderTrackShape { required SliderThemeData sliderTheme, bool isEnabled = false, bool isDiscrete = false, - }) => _capsuleMaterialTrackRect( - parentBox: parentBox, - offset: offset, - sliderTheme: sliderTheme, - horizontalInset: horizontalInset, - ); + }) => layout.forBox(parentBox).coordinateRect(parentBox.size, offset); @override void paint( @@ -701,15 +1000,9 @@ class _CapsuleRangeSliderTrackShape extends RoundedRectRangeSliderTrackShape { required TextDirection textDirection, double additionalActiveTrackHeight = 2, }) { - final trackRect = _capsuleVisualTrackRect( - getPreferredRect( - parentBox: parentBox, - offset: offset, - sliderTheme: sliderTheme, - isEnabled: isEnabled, - isDiscrete: isDiscrete, - ), - ); + final metrics = layout.forBox(parentBox); + final trackRect = metrics.visualRect(parentBox.size, offset); + final innerInset = metrics.capsuleInset; if (trackRect.isEmpty) { return; } @@ -728,6 +1021,9 @@ class _CapsuleRangeSliderTrackShape extends RoundedRectRangeSliderTrackShape { _paintCapsuleSegments( context.canvas, trackRect: trackRect, + thumbTrack: metrics.coordinateRect(parentBox.size, offset), + innerInset: innerInset, + gapWidth: metrics.capsuleGapWidth, outerColor: Color.lerp( disabledOuterColor, outerColor, @@ -767,16 +1063,18 @@ class _CapsuleRangeSliderTickMarkShape extends RangeSliderTickMarkShape { class _TDesignSliderThumbShape extends SliderComponentShape { const _TDesignSliderThumbShape({ + required this.layout, required this.borderColor, required this.disabledBorderColor, }); + final _SliderGeometryLayout layout; final Color borderColor; final Color disabledBorderColor; @override Size getPreferredSize(bool isEnabled, bool isDiscrete) => - const Size.fromRadius(10); + layout.metrics.thumbSize; @override void paint( @@ -796,6 +1094,7 @@ class _TDesignSliderThumbShape extends SliderComponentShape { _paintTDesignThumb( context.canvas, center, + radius: layout.forBox(parentBox).thumbRadius, enableAnimation: enableAnimation, activationAnimation: activationAnimation, sliderTheme: sliderTheme, @@ -807,16 +1106,18 @@ class _TDesignSliderThumbShape extends SliderComponentShape { class _TDesignRangeSliderThumbShape extends RangeSliderThumbShape { const _TDesignRangeSliderThumbShape({ + required this.layout, required this.borderColor, required this.disabledBorderColor, }); + final _SliderGeometryLayout layout; final Color borderColor; final Color disabledBorderColor; @override Size getPreferredSize(bool isEnabled, bool isDiscrete) => - const Size.fromRadius(10); + layout.metrics.thumbSize; @override void paint( @@ -835,6 +1136,7 @@ class _TDesignRangeSliderThumbShape extends RangeSliderThumbShape { _paintTDesignThumb( context.canvas, center, + radius: layout.metrics.thumbRadius, enableAnimation: enableAnimation, activationAnimation: activationAnimation, sliderTheme: sliderTheme, @@ -852,8 +1154,11 @@ void _paintTDesignThumb( required SliderThemeData sliderTheme, required Color borderColor, required Color disabledBorderColor, + required double radius, }) { - const radius = 10.0; + if (radius <= 0) { + return; + } final fill = Color.lerp( sliderTheme.disabledThumbColor, sliderTheme.thumbColor, @@ -872,13 +1177,14 @@ void _paintTDesignThumb( true, ); canvas.drawCircle(center, radius, Paint()..color = fill); + final strokeWidth = SliderGeometry.thumbBorderWidth * radius / _kThumbRadius; canvas.drawCircle( center, - radius, + math.max(0, radius - strokeWidth / 2), Paint() ..color = border ..style = PaintingStyle.stroke - ..strokeWidth = 1, + ..strokeWidth = strokeWidth, ); } @@ -1027,6 +1333,7 @@ class _SliderWithScaleLabels extends StatelessWidget { required this.max, required this.divisions, required this.formatter, + required this.geometry, required this.slider, }); @@ -1034,29 +1341,51 @@ class _SliderWithScaleLabels extends StatelessWidget { final double max; final int divisions; final TSliderThumbFormatter? formatter; + final SliderGeometry geometry; final Widget slider; @override Widget build(BuildContext context) { + final direction = Directionality.of(context) == TextDirection.ltr + ? 1.0 + : -1.0; final labels = List.generate(divisions + 1, (index) { final value = min + (max - min) * index / divisions; final text = formatter?.call(value) ?? value.toString(); final isFirst = index == 0; final isLast = index == divisions; + final alignment = isFirst + ? AlignmentDirectional.centerStart + : isLast + ? AlignmentDirectional.centerEnd + : Alignment.center; return Expanded( flex: isFirst || isLast ? 1 : 2, child: Align( - alignment: isFirst - ? AlignmentDirectional.centerStart - : isLast - ? AlignmentDirectional.centerEnd - : Alignment.center, - child: TTextStyled( - text, - style: null, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, + alignment: alignment, + child: OverflowBox( + fit: OverflowBoxFit.deferToChild, + minWidth: 0, + maxWidth: double.infinity, + alignment: alignment, + child: FractionalTranslation( + translation: Offset( + (isFirst + ? -0.5 + : isLast + ? 0.5 + : 0.0) * + direction, + 0, + ), + child: TTextStyled( + text, + style: null, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), ), ), ); @@ -1064,15 +1393,91 @@ class _SliderWithScaleLabels extends StatelessWidget { return Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: context.tTheme.spacer2), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: labels, - ), + _SliderLabelPadding( + geometry: geometry, + child: Row(children: labels), ), slider, ], ); } } + +// Width-aware padding with intrinsic sizing, shared by single and range labels. +class _SliderLabelPadding extends SingleChildRenderObjectWidget { + const _SliderLabelPadding({required this.geometry, required super.child}); + final SliderGeometry geometry; + @override + RenderObject createRenderObject(BuildContext context) => + _RenderSliderLabelPadding(geometry); + @override + void updateRenderObject( + BuildContext context, + _RenderSliderLabelPadding renderObject, + ) { + renderObject.geometry = geometry; + } +} + +class _RenderSliderLabelPadding extends RenderShiftedBox { + _RenderSliderLabelPadding(this._geometry) : super(null); + SliderGeometry _geometry; + SliderGeometry get geometry => _geometry; + set geometry(SliderGeometry value) { + if (identical(value, _geometry)) { + return; + } + _geometry = value; + markNeedsLayout(); + } + + double get _intrinsicInset => + _geometry.forWidth(_geometry.preferredSize.width).thumbInset; + @override + double computeMinIntrinsicWidth(double height) => + child!.getMinIntrinsicWidth(height) + 2 * _intrinsicInset; + @override + double computeMaxIntrinsicWidth(double height) => + child!.getMaxIntrinsicWidth(height) + 2 * _intrinsicInset; + @override + double computeMinIntrinsicHeight(double width) => + child!.getMinIntrinsicHeight( + math.max(0.0, width - _geometry.forWidth(width).thumbInset * 2), + ); + @override + double computeMaxIntrinsicHeight(double width) => + child!.getMaxIntrinsicHeight( + math.max(0.0, width - _geometry.forWidth(width).thumbInset * 2), + ); + double _inset(BoxConstraints constraints) => _geometry + .forWidth( + constraints.hasBoundedWidth + ? constraints.maxWidth + : computeMaxIntrinsicWidth(double.infinity), + ) + .thumbInset; + @override + Size computeDryLayout(BoxConstraints constraints) { + final inset = _inset(constraints); + final size = child!.getDryLayout( + constraints.deflate(EdgeInsets.symmetric(horizontal: inset)), + ); + return constraints.constrain(Size(size.width + 2 * inset, size.height)); + } + + @override + void performLayout() { + final inset = _inset(constraints); + child!.layout( + constraints.deflate(EdgeInsets.symmetric(horizontal: inset)), + parentUsesSize: true, + ); + size = constraints.constrain( + Size(child!.size.width + 2 * inset, child!.size.height), + ); + (child!.parentData! as BoxParentData).offset = Offset( + (size.width - child!.size.width) / 2, + 0, + ); + } +} diff --git a/tdesign-component/lib/src/components/slider/t_slider_geometry.dart b/tdesign-component/lib/src/components/slider/t_slider_geometry.dart new file mode 100644 index 0000000000..fe0319a5ec --- /dev/null +++ b/tdesign-component/lib/src/components/slider/t_slider_geometry.dart @@ -0,0 +1,243 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../theme/t_spacers.dart'; +import '../../theme/t_theme.dart'; +import 't_slider_theme.dart'; + +// Axis is a layout input of the internal vertical composition, not a theme +// override or a public component option. +class SliderGeometryScope extends InheritedWidget { + const SliderGeometryScope({ + required this.axis, + required super.child, + super.key, + }); + + final Axis axis; + + static Axis axisOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.axis ?? + Axis.horizontal; + + @override + bool updateShouldNotify(SliderGeometryScope oldWidget) => + axis != oldWidget.axis; +} + +// Internal implementation shared with the repository's vertical composition. +// This library is deliberately not exported by tdesign_flutter.dart. +class SliderGeometry { + const SliderGeometry({ + required this.capsule, + required this.trackHeight, + required this.horizontalInset, + }); + + factory SliderGeometry.resolve( + BuildContext context, { + required bool capsule, + }) { + final token = context.tTheme; + final theme = Theme.of(context).extension(); + final axis = SliderGeometryScope.axisOf(context); + final requestedHeight = capsule ? token.spacer3 : theme?.trackHeight ?? 4; + return SliderGeometry( + capsule: capsule, + trackHeight: capsule + ? math.max(thumbDiameter + 4, _nonNegative(requestedHeight)) + : _nonNegative(requestedHeight), + horizontalInset: axis == Axis.horizontal + ? _nonNegative(token.spacer2) + : thumbDiameter / 2 + thumbBorderWidth / 2, + ); + } + + static const double thumbDiameter = 20; + static const double thumbBorderWidth = 1; + static const double capsuleBorderWidth = 3; + // Preserve the existing continuous-range interaction at normal sizes. + static const double minimumRangeSeparation = 8; + static const double hitRadius = 24; + static const double tickRadius = 3; + static const double capsuleGapWidth = 2; + + final bool capsule; + final double trackHeight; + final double horizontalInset; + + // Native slider sizing: a 144px track plus the 48px overlay, with a + // cross axis large enough for that overlay or the requested track. + Size get preferredSize => + Size(144 + hitRadius * 2, math.max(hitRadius * 2, trackHeight)); + + Size sizeForConstraints(BoxConstraints constraints) => constraints.constrain( + Size( + constraints.hasBoundedWidth ? constraints.maxWidth : preferredSize.width, + constraints.hasBoundedHeight + ? constraints.maxHeight + : preferredSize.height, + ), + ); + + // A declared vertical track length excludes the thumb hit area. Both layout + // and labels use these metrics, so reserving hit space never shortens it. + SliderMetrics forTrackLength( + double length, { + double crossExtent = double.infinity, + }) { + final visible = _nonNegative(length); + final crossScale = _crossScale(crossExtent); + final minimumLength = capsule ? _minimumControlExtent : thumbDiameter; + final scale = math.min(crossScale, visible / minimumLength); + // Below the minimum unscaled track length, forWidth has consumed optional + // spacing. Use the same limiting spacing when converting back to a box. + final compact = visible < minimumLength * crossScale; + final minimumInset = capsule ? 0.0 : _thumbHitInset; + final inset = + (compact + ? minimumInset + : math.max(_nonNegative(horizontalInset), minimumInset)) * + scale; + return forWidth(visible + inset * 2, crossExtent: crossExtent); + } + + static const double _thumbHitInset = thumbDiameter / 2 + thumbBorderWidth / 2; + double get _minimumControlExtent => capsule + ? thumbDiameter * 2 + capsuleBorderWidth * 2 + : thumbDiameter + _thumbHitInset * 2; + + double _crossScale(double extent) => extent == double.infinity + ? 1 + : math.min( + 1, + _nonNegative(extent) / (capsule ? thumbDiameter + 4 : thumbDiameter), + ); + + SliderMetrics forSize(Size size) => + forWidth(size.width, crossExtent: size.height); + + SliderMetrics forWidth(double width, {double crossExtent = double.infinity}) { + final available = _nonNegative(width); + final scale = math.min( + _crossScale(crossExtent), + available / _minimumControlExtent, + ); + final height = math.min( + _nonNegative(trackHeight) * scale, + crossExtent == double.infinity + ? double.infinity + : _nonNegative(crossExtent), + ); + final radius = thumbDiameter / 2 * scale; + final minimumInset = capsule ? 0.0 : _thumbHitInset * scale; + final maximumInset = math.max( + minimumInset, + (available - (capsule ? _minimumControlExtent : thumbDiameter) * scale) / + 2, + ); + final visualInset = (_nonNegative(horizontalInset) * scale) + .clamp(minimumInset, maximumInset) + .toDouble(); + // The capsule border is 3px on the travel axis; track thickness controls + // the cross axis only. Marks never change the thumb's endpoints. + final endInset = capsule ? radius + capsuleBorderWidth * scale : 0.0; + final thumbInset = visualInset + endInset; + return SliderMetrics( + width: available, + trackHeight: height, + thumbRadius: radius, + visualInset: visualInset, + thumbInset: thumbInset, + ); + } + + static double effectiveValue( + double value, + double min, + double max, + int? divisions, + ) { + final normalized = ((value - min) / (max - min)).clamp(0.0, 1.0); + if (divisions == null || divisions <= 0) { + return value; + } + final step = (normalized * divisions).round(); + return min + (max - min) * step / divisions; + } + + static double _nonNegative(double value) => + value.isFinite ? math.max(0.0, value) : 0.0; +} + +class SliderMetrics { + const SliderMetrics({ + required this.width, + required this.trackHeight, + required this.thumbRadius, + required this.visualInset, + required this.thumbInset, + }); + + final double width; + final double trackHeight; + final double thumbRadius; + final double visualInset; + final double thumbInset; + + // Keep the native gap whenever both thumbs can move within their bounds. + double rangeSeparation(double start, double end) => math.min( + SliderGeometry.minimumRangeSeparation * scale, + math.max(0.0, width - thumbInset * 2) * + math.min(end.clamp(0.0, 1.0), 1 - start.clamp(0.0, 1.0)), + ); + + Size get thumbSize => Size.fromRadius(thumbRadius); + bool get hasTravel => thumbRadius > 0 && width > thumbInset * 2; + double get scale => thumbRadius / (SliderGeometry.thumbDiameter / 2); + double get tickRadius => SliderGeometry.tickRadius * scale; + double get capsuleGapWidth => SliderGeometry.capsuleGapWidth * scale; + + double get capsuleInset => SliderGeometry.capsuleBorderWidth * scale; + + double thumbOffset(double ratio) => + thumbInset + ratio * (width - thumbInset * 2); + + double valueAt( + double position, { + required double min, + required double max, + required int? divisions, + required TextDirection direction, + }) { + assert(hasTravel); + final visual = ((position - thumbInset) / (width - thumbInset * 2)).clamp( + 0.0, + 1.0, + ); + final ratio = direction == TextDirection.rtl ? 1 - visual : visual; + return SliderGeometry.effectiveValue( + min + ratio * (max - min), + min, + max, + divisions, + ); + } + + Rect visualRect(Size size, Offset offset) => Rect.fromLTWH( + offset.dx + visualInset, + offset.dy + (size.height - trackHeight) / 2, + math.max(0.0, width - visualInset * 2), + trackHeight, + ); + + // A zero-height coordinate rect prevents native SDK padding from becoming a + // second source of thumb geometry in either supported Flutter version. + Rect coordinateRect(Size size, Offset offset) => Rect.fromLTRB( + offset.dx + thumbInset, + offset.dy + size.height / 2, + offset.dx + width - thumbInset, + offset.dy + size.height / 2, + ); +} diff --git a/tdesign-component/lib/src/components/slider/t_slider_theme.dart b/tdesign-component/lib/src/components/slider/t_slider_theme.dart index dc213eb5b2..db395b270b 100644 --- a/tdesign-component/lib/src/components/slider/t_slider_theme.dart +++ b/tdesign-component/lib/src/components/slider/t_slider_theme.dart @@ -49,7 +49,7 @@ class TSliderThemeData extends ThemeExtension { final Color? valueIndicatorTextColor; /// 普通轨道粗细;胶囊形态仍使用其内置规格。 - /// null 时为 4 逻辑像素。 + /// null 时为 4 逻辑像素;非有限或负值按 0 处理;实际厚度不超过可用横轴空间。 final double? trackHeight; /// 滑块外层装饰。 diff --git a/tdesign-component/test/components/slider/goldens/t_slider_material.png b/tdesign-component/test/components/slider/goldens/t_slider_material.png index 7ad19025f7..6315ced1c1 100644 Binary files a/tdesign-component/test/components/slider/goldens/t_slider_material.png and b/tdesign-component/test/components/slider/goldens/t_slider_material.png differ diff --git a/tdesign-component/test/components/slider/t_slider_constraints_test.dart b/tdesign-component/test/components/slider/t_slider_constraints_test.dart new file mode 100644 index 0000000000..27c81ae7c8 --- /dev/null +++ b/tdesign-component/test/components/slider/t_slider_constraints_test.dart @@ -0,0 +1,743 @@ +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tdesign_flutter/src/components/slider/t_slider_geometry.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +void main() { + testWidgets( + 'continuous range retains the legacy gap and valid close callbacks', + (t) async { + for (final axis in Axis.values) { + for (final capsule in [false, true]) { + var width = 320.0; + var values = const RangeValues(30, 90); + RangeValues? started, ended; + late StateSetter resize; + await t.pumpWidget( + MaterialApp( + theme: TThemeBuilder.light(TThemeData.defaultData()), + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (context, setState) { + resize = setState; + return SizedBox( + width: axis == Axis.horizontal ? width : 48, + height: axis == Axis.horizontal ? 48 : width, + child: RotatedBox( + quarterTurns: axis == Axis.vertical ? 1 : 0, + child: TRangeSlider( + min: 10, + max: 110, + value: values, + variant: capsule + ? TSliderVariant.capsule + : TSliderVariant.normal, + onChangeStart: (v) => started = v, + onChangeEnd: (v) => ended = v, + onChanged: (v) { + expect( + v.start.isFinite && v.end.isFinite, + isTrue, + ); + expect( + v.start >= 10 && + v.start < v.end && + v.end <= 110, + isTrue, + ); + setState(() => values = v); + }, + ), + ), + ); + }, + ), + ), + ), + ), + ); + await t.pumpAndSettle(); + final f = find.byType(RangeSlider); + Rect rect() { + final box = t.renderObject(f), + theme = SliderTheme.of(t.element(f)); + return theme.rangeTrackShape!.getPreferredRect( + parentBox: box, + sliderTheme: theme, + ); + } + + Offset at(double ratio) { + final r = rect(), box = t.renderObject(f); + return box.localToGlobal( + Offset(r.left + r.width * ratio, r.center.dy), + ); + } + + final gesture = await t.startGesture(at(.2)); + await gesture.moveTo(at(-.2)); + await t.pumpAndSettle(); + for (final extent in [320.0, 48.0, 24.0, 8.0, 1.0, 320.0]) { + resize(() => width = extent); + await t.pumpAndSettle(); + final before = values; + final theme = SliderTheme.of(t.element(f)); + final gap = theme.minThumbSeparation!; + if (extent == 320) { + expect(gap, closeTo(8, 1e-9)); + } + expect(gap, greaterThan(0)); + expect( + gap, + lessThanOrEqualTo( + (before.end - before.start) / 100 * rect().width + 1e-9, + ), + ); + await gesture.moveTo(at(1.2)); + await t.pumpAndSettle(); + expect(values.end, 90); + expect( + (values.end - values.start) / 100 * rect().width, + closeTo(gap, 1e-8), + ); + await gesture.moveTo(at(-.2)); + await t.pumpAndSettle(); + expect(values.start, 10); + } + await gesture.up(); + await t.pumpAndSettle(); + expect(started, const RangeValues(30, 90)); + expect(ended, values); + expect(t.takeException(), isNull); + } + } + }, + ); + + testWidgets( + 'discrete overlap and controlled equal input retain existing support', + (t) async { + for (final capsule in [false, true]) { + var values = const RangeValues(20, 80); + await t.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 320, + height: 48, + child: StatefulBuilder( + builder: (context, setState) => TRangeSlider( + value: values, + max: 100, + divisions: 5, + variant: capsule + ? TSliderVariant.capsule + : TSliderVariant.normal, + onChanged: (v) => setState(() => values = v), + ), + ), + ), + ), + ), + ), + ); + await t.pumpAndSettle(); + final f = find.byType(RangeSlider), + box = t.renderObject(find.byType(RangeSlider)); + final theme = SliderTheme.of(t.element(f)); + final r = theme.rangeTrackShape!.getPreferredRect( + parentBox: box, + sliderTheme: theme, + ); + Offset at(double ratio) => + box.localToGlobal(Offset(r.left + r.width * ratio, r.center.dy)); + final gesture = await t.startGesture(at(.2)); + await gesture.moveTo(at(0)); + await t.pumpAndSettle(); + await gesture.moveTo(at(.8)); + await t.pumpAndSettle(); + await gesture.up(); + await t.pumpAndSettle(); + expect(values, const RangeValues(80, 80)); + await t.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 8, + height: 48, + child: TRangeSlider( + value: const RangeValues(.5, .5), + variant: capsule + ? TSliderVariant.capsule + : TSliderVariant.normal, + onChanged: (_) {}, + ), + ), + ), + ), + ), + ); + await t.pumpAndSettle(); + expect( + t.widget(find.byType(RangeSlider)).values, + const RangeValues(.5, .5), + ); + expect(t.takeException(), isNull); + } + }, + ); + + testWidgets('collapse before gesture recognition emits no invalid sequence', ( + t, + ) async { + for (final range in [false, true]) { + for (final capsule in [false, true]) { + var width = 48.0; + late StateSetter resize; + var callbacks = 0; + await t.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (context, setState) { + resize = setState; + final variant = capsule + ? TSliderVariant.capsule + : TSliderVariant.normal; + return GestureDetector( + onHorizontalDragUpdate: (_) {}, + child: SizedBox( + width: width, + height: 48, + child: range + ? TRangeSlider( + value: const RangeValues(.2, .8), + variant: variant, + onChanged: (_) => callbacks++, + onChangeStart: (_) => callbacks++, + onChangeEnd: (_) => callbacks++, + ) + : TSlider( + value: .4, + variant: variant, + onChanged: (_) => callbacks++, + onChangeStart: (_) => callbacks++, + onChangeEnd: (_) => callbacks++, + ), + ), + ); + }, + ), + ), + ), + ), + ); + await t.pumpAndSettle(); + final f = range ? find.byType(RangeSlider) : find.byType(Slider); + final gesture = await t.startGesture(t.getCenter(f)); + resize(() => width = 0); + await t.pump(); + await gesture.moveBy(const Offset(-40, 0)); + await t.pumpAndSettle(); + await gesture.up(); + await t.pumpAndSettle(); + expect(callbacks, 0); + expect(t.takeException(), isNull); + } + } + }); + testWidgets('zero slider viewport preserves an ancestor scroll gesture', ( + t, + ) async { + for (final enabled in [false, true]) { + for (final range in [false, true]) { + var width = 320.0; + late StateSetter resize; + final scroll = ScrollController(); + await t.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + resize = setState; + return SingleChildScrollView( + controller: scroll, + child: Column( + children: [ + const SizedBox(height: 200), + SizedBox( + width: width, + height: 48, + child: range + ? TRangeSlider( + value: const RangeValues(.2, .8), + onChanged: enabled ? (_) {} : null, + ) + : TSlider( + value: .4, + onChanged: enabled ? (_) {} : null, + ), + ), + const SizedBox(height: 2000), + ], + ), + ); + }, + ), + ), + ), + ); + await t.pumpAndSettle(); + final f = range ? find.byType(RangeSlider) : find.byType(Slider); + final gesture = await t.startGesture(t.getCenter(f)); + await gesture.moveBy(const Offset(0, -80)); + await t.pump(); + await gesture.moveBy(const Offset(0, -40)); + await t.pump(); + expect(scroll.offset, greaterThan(0)); + resize(() => width = 0); + await t.pumpAndSettle(); + final before = scroll.offset; + await gesture.moveBy(const Offset(0, -80)); + await t.pump(); + expect( + scroll.offset, + greaterThan(before + 20), + reason: 'enabled=$enabled range=$range', + ); + await gesture.up(); + await t.pumpAndSettle(); + await t.pumpWidget(const SizedBox.shrink()); + await t.pumpAndSettle(); + scroll.dispose(); + expect(t.takeException(), isNull); + } + } + }); + testWidgets('large normal track paint stays inside declared bounds', ( + t, + ) async { + const boundaryKey = ValueKey('paint-extent'); + for (final range in [false, true]) { + for (final end in [0.0, .5, 1.0]) { + await t.pumpWidget( + MaterialApp( + theme: TThemeBuilder.dark(TThemeData.defaultData()).mergeExtension( + const TSliderThemeData( + trackHeight: 192, + activeTrackColor: Color(0xFFFF0000), + inactiveTrackColor: Color(0xFFFF0000), + ), + ), + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: boundaryKey, + child: SizedBox( + width: 260, + height: 100, + child: Center( + child: SliderGeometryScope( + axis: Axis.vertical, + child: SizedBox( + width: 221, + height: 48, + child: range + ? TRangeSlider( + value: const RangeValues(0, 1), + onChanged: (_) {}, + ) + : TSlider(value: end, onChanged: (_) {}), + ), + ), + ), + ), + ), + ), + ), + ), + ); + await t.pumpAndSettle(); + final native = range ? find.byType(RangeSlider) : find.byType(Slider); + final offset = + t.getTopLeft(native) - t.getTopLeft(find.byKey(boundaryKey)); + final boundary = t.renderObject( + find.byKey(boundaryKey), + ); + final bounds = (await t.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 2); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!.buffer.asUint8List(); + final xs = [], ys = []; + for (var y = 0; y < image.height; y++) { + for (var x = 0; x < image.width; x++) { + final i = (y * image.width + x) * 4; + if (bytes[i] > 220 && bytes[i + 1] < 50 && bytes[i + 2] < 50) { + xs.add(x); + ys.add(y); + } + } + } + xs.sort(); + ys.sort(); + image.dispose(); + return Rect.fromLTRB( + xs.first / 2, + ys.first / 2, + (xs.last + 1) / 2, + (ys.last + 1) / 2, + ); + }))!; + expect(bounds.left - offset.dx, greaterThanOrEqualTo(10)); + expect(bounds.right - offset.dx, lessThanOrEqualTo(211)); + if (!range && end == .5) { + // At the midpoint neither track cap is hidden by the thumb. + expect(bounds.left - offset.dx, closeTo(10.5, 1)); + expect(bounds.right - offset.dx, closeTo(210.5, 1)); + } + expect(bounds.height, lessThanOrEqualTo(48)); + expect(t.takeException(), isNull); + } + } + }); + test('declared track length survives thickness and cross constraints', () { + for (final capsule in [false, true]) { + for (final height in [0.0, 4.0, 24.0, 48.0, 192.0, 240.0]) { + for (final inset in [0.0, 10.5, 16.0, 64.0]) { + for (final length in [ + 0.0, + 1.0, + 8.0, + 24.0, + 40.0, + 46.0, + 200.0, + 320.0, + ]) { + for (final cross in [0.0, 8.0, 24.0, 48.0, double.infinity]) { + final geometry = SliderGeometry( + capsule: capsule, + trackHeight: height, + horizontalInset: inset, + ); + final metrics = geometry.forTrackLength( + length, + crossExtent: cross, + ); + final rect = metrics.visualRect( + Size(metrics.width, 48), + Offset.zero, + ); + expect( + rect.width, + closeTo(length, 1e-9), + reason: '$capsule $height $inset $length $cross', + ); + expect(metrics.trackHeight, lessThanOrEqualTo(cross)); + expect(metrics.thumbSize.height, lessThanOrEqualTo(cross)); + expect(metrics.width, greaterThanOrEqualTo(0)); + expect( + metrics.thumbInset * 2, + lessThanOrEqualTo(metrics.width + 1e-9), + ); + } + } + } + } + } + }); + + for (final axis in Axis.values) { + for (final capsule in [false, true]) { + for (final discrete in [false, true]) { + for (final range in [false, true]) { + for (final direction in TextDirection.values) { + testWidgets( + 'continuous resize $axis $capsule $discrete $range $direction', + (t) async { + var extent = 320.0; + var value = 50.0; + var values = const RangeValues(30, 90); + final outputs = []; + var starts = 0, ends = 0; + late StateSetter resize; + final effectiveDirection = axis == Axis.vertical + ? TextDirection.ltr + : direction; + await t.pumpWidget( + MaterialApp( + theme: TThemeBuilder.light(TThemeData.defaultData()), + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (context, setState) { + resize = setState; + final variant = capsule + ? TSliderVariant.capsule + : TSliderVariant.normal; + final child = range + ? TRangeSlider( + min: 10, + max: 110, + value: values, + divisions: discrete ? 5 : null, + variant: variant, + onChangeStart: (_) => starts++, + onChangeEnd: (v) { + expect( + v.start.isFinite && v.end.isFinite, + isTrue, + ); + expect( + v.start >= 10 && + v.end <= 110 && + v.start <= v.end, + isTrue, + ); + ends++; + }, + onChanged: (v) { + expect( + v.start.isFinite && v.end.isFinite, + isTrue, + ); + expect( + v.start >= 10 && + v.end <= 110 && + v.start <= v.end, + isTrue, + ); + outputs.add(v); + setState(() => values = v); + }, + ) + : TSlider( + min: 10, + max: 110, + value: value, + divisions: discrete ? 5 : null, + variant: variant, + onChangeStart: (_) => starts++, + onChangeEnd: (v) { + expect( + v.isFinite && v >= 10 && v <= 110, + isTrue, + ); + ends++; + }, + onChanged: (v) { + expect( + v.isFinite && v >= 10 && v <= 110, + isTrue, + ); + outputs.add(v); + setState(() => value = v); + }, + ); + return SizedBox( + width: axis == Axis.vertical ? 48 : extent, + height: axis == Axis.vertical ? extent : 48, + child: SliderGeometryScope( + axis: axis, + child: Directionality( + textDirection: effectiveDirection, + child: RotatedBox( + quarterTurns: axis == Axis.vertical ? 1 : 0, + child: child, + ), + ), + ), + ); + }, + ), + ), + ), + ), + ); + await t.pumpAndSettle(); + final f = range + ? find.byType(RangeSlider) + : find.byType(Slider); + Offset at(double ratio) { + final box = t.renderObject(f), + theme = SliderTheme.of(t.element(f)); + final rect = range + ? theme.rangeTrackShape!.getPreferredRect( + parentBox: box, + sliderTheme: theme, + ) + : theme.trackShape!.getPreferredRect( + parentBox: box, + sliderTheme: theme, + ); + return box.localToGlobal( + Offset( + rect.left + + (effectiveDirection == TextDirection.rtl + ? 1 - ratio + : ratio) * + rect.width, + rect.center.dy, + ), + ); + } + + final gesture = await t.startGesture(at(range ? .2 : .4)); + await gesture.moveTo(at(-.3)); + await t.pumpAndSettle(); + expect(starts, 1); + for (final width in [48.0, 24.0, 8.0, 1.0, 24.0, 320.0]) { + final count = outputs.length; + resize(() => extent = width); + await t.pumpAndSettle(); + expect( + outputs.length, + count, + reason: 'layout cannot emit value changes', + ); + expect(starts, 1); + expect(ends, 0); + final box = t.renderObject(f), + theme = SliderTheme.of(t.element(f)); + final preferred = range + ? theme.rangeThumbShape!.getPreferredSize(true, discrete) + : theme.thumbShape!.getPreferredSize(true, discrete); + final expected = SliderGeometry( + capsule: capsule, + trackHeight: capsule ? 24 : 4, + horizontalInset: axis == Axis.vertical ? 10.5 : 16, + ).forSize(box.size); + expect(preferred, expected.thumbSize); + if (!capsule && discrete) { + final tickSize = range + ? theme.rangeTickMarkShape!.getPreferredSize( + sliderTheme: theme, + isEnabled: true, + ) + : theme.tickMarkShape!.getPreferredSize( + sliderTheme: theme, + isEnabled: true, + ); + expect(tickSize.width, closeTo(preferred.width * .3, 1e-9)); + } + + expect( + theme.minThumbSeparation, + range + ? expected.rangeSeparation( + (values.start - 10) / 100, + (values.end - 10) / 100, + ) + : SliderGeometry.minimumRangeSeparation, + ); + await gesture.moveTo(at(-.3)); + await t.pumpAndSettle(); + expect(range ? values.start : value, 10); + await gesture.moveTo(at(.4)); + await t.pumpAndSettle(); + expect(range ? values.start : value, closeTo(50, 1e-7)); + } + resize(() => extent = 0); + await t.pumpAndSettle(); + await t.idle(); + await t.pumpAndSettle(); + expect(ends, 1, reason: 'zero extent cancels exactly once'); + final count = outputs.length; + await gesture.moveBy(const Offset(40, 0)); + await t.pumpAndSettle(); + expect(outputs.length, count); + await gesture.up(); + await t.pumpAndSettle(); + expect(ends, 1); + resize(() => extent = 48); + await t.pumpAndSettle(); + final again = await t.startGesture(at(.4)); + await again.moveTo(at(-.5)); + await t.pumpAndSettle(); + expect(range ? values.start : value, 10); + await again.moveTo(at(.4)); + await t.pumpAndSettle(); + expect(range ? values.start : value, closeTo(50, 1e-7)); + await again.up(); + await t.pumpAndSettle(); + expect(starts, 2); + expect(ends, 2); + expect(t.takeException(), isNull); + }, + ); + } + } + } + } + } + + testWidgets('pointer mapping preserves native keyboard input', (t) async { + var value = .4; + final focus = FocusNode(); + addTearDown(focus.dispose); + await t.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => Focus( + autofocus: true, + focusNode: focus, + child: TSlider( + value: value, + divisions: 5, + onChanged: (v) => setState(() => value = v), + ), + ), + ), + ), + ), + ); + await t.pumpAndSettle(); + final nativeFocus = t + .widgetList( + find.descendant( + of: find.byType(Slider), + matching: find.byType(Focus), + ), + ) + .firstWhere((widget) => widget.focusNode != null); + nativeFocus.focusNode!.requestFocus(); + await t.pumpAndSettle(); + await t.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await t.pumpAndSettle(); + expect(value, closeTo(.6, 1e-9)); + final f = find.byType(Slider); + Offset at(double ratio) { + final box = t.renderObject(f); + final theme = SliderTheme.of(t.element(f)); + final rect = theme.trackShape!.getPreferredRect( + parentBox: box, + sliderTheme: theme, + ); + return box.localToGlobal( + Offset(rect.left + ratio * rect.width, rect.center.dy), + ); + } + + final gesture = await t.startGesture(at(.6)); + await gesture.moveTo(at(.4)); + await t.pumpAndSettle(); + expect(value, closeTo(.4, 1e-9)); + await t.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await t.pumpAndSettle(); + expect(value, closeTo(.6, 1e-9)); + await gesture.moveTo(at(.2)); + await t.pumpAndSettle(); + expect(value, closeTo(.2, 1e-9)); + await gesture.up(); + await t.pumpAndSettle(); + }); +} diff --git a/tdesign-component/test/components/slider/t_slider_geometry_test.dart b/tdesign-component/test/components/slider/t_slider_geometry_test.dart new file mode 100644 index 0000000000..c01447f8b4 --- /dev/null +++ b/tdesign-component/test/components/slider/t_slider_geometry_test.dart @@ -0,0 +1,703 @@ +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tdesign_flutter/src/components/slider/t_slider_geometry.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +void main() { + test('effective values share native discrete normalization', () { + expect(SliderGeometry.effectiveValue(23, 0, 100, 5), 20); + expect(SliderGeometry.effectiveValue(77, 0, 100, 5), 80); + expect(SliderGeometry.effectiveValue(33, 10, 110, 5), 30); + expect(SliderGeometry.effectiveValue(10, 10, 110, 5), 10); + expect(SliderGeometry.effectiveValue(110, 10, 110, 5), 110); + expect(SliderGeometry.effectiveValue(33, 10, 110, null), 33); + expect(SliderGeometry.effectiveValue(33, 10, 110, 0), 33); + }); + + test('adaptive metrics keep the thumb border within all finite widths', () { + for (final capsule in [false, true]) { + for (final width in [0.0, 1.0, 8.0, 24.0, 48.0, 320.0]) { + final metrics = SliderGeometry( + capsule: capsule, + trackHeight: capsule ? 24 : 4, + horizontalInset: 0, + ).forWidth(width); + final outerRadius = metrics.thumbRadius * 1.05; + expect( + metrics.thumbInset - outerRadius, + greaterThanOrEqualTo(-.000001), + ); + expect( + width - metrics.thumbInset + outerRadius, + lessThanOrEqualTo(width + .000001), + ); + } + } + }); + + const boundaryKey = ValueKey('geometry-audit'); + Future pump( + WidgetTester tester, + Widget child, { + double width = 320, + double spacer3 = 24, + double spacer2 = 16, + double? height, + Axis axis = Axis.horizontal, + TextDirection direction = TextDirection.ltr, + Color? borderColor, + }) async { + final token = TThemeData.defaultData().copyWith( + spacerMap: {'spacer2': spacer2, 'spacer3': spacer3}, + ); + await tester.pumpWidget( + MaterialApp( + theme: TThemeBuilder.dark(token).mergeExtension( + TSliderThemeData( + trackHeight: height, + thumbColor: Colors.white, + disabledThumbColor: Colors.white, + thumbBorderColor: borderColor, + ), + ), + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: boundaryKey, + child: Padding( + padding: const EdgeInsets.all(20), + child: SizedBox( + width: width, + height: 120, + child: SliderGeometryScope( + axis: axis, + child: Directionality( + textDirection: direction, + child: child, + ), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + + Future> thumbRuns(WidgetTester tester) async { + final native = find + .byWidgetPredicate((w) => w is Slider || w is RangeSlider) + .first; + final box = tester.renderObject(native); + if (box.size.width == 0) { + return []; + } + final offset = + tester.getTopLeft(native) - tester.getTopLeft(find.byKey(boundaryKey)); + final boundary = tester.renderObject( + find.byKey(boundaryKey), + ); + return (await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 1); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!.buffer.asUint8List(); + final y = (offset.dy + box.size.height / 2).floor(); + final groups = >[]; + for (var x = -12; x < box.size.width + 12; x++) { + final i = (y * image.width + (offset.dx + x).floor()) * 4; + if (bytes[i] > 245 && bytes[i + 1] > 245 && bytes[i + 2] > 245) { + if (groups.isEmpty || x > groups.last.last + 1) { + groups.add([x]); + } else { + groups.last.add(x); + } + } + } + image.dispose(); + return groups.map((g) => (g.first.toDouble(), g.last + 1.0)).toList(); + }))!; + } + + testWidgets('custom normal track height preserves linear continuous travel', ( + tester, + ) async { + final centers = []; + for (final value in [0.0, .02, .08, .2, 1.0]) { + await pump(tester, TSlider(value: value, onChanged: (_) {}), height: 24); + final run = (await thumbRuns(tester)).single; + final center = (run.$1 + run.$2) / 2; + expect(center, closeTo(16 + value * 288, .75)); + centers.add(center); + } + expect(centers[1], greaterThan(centers[0])); + }); + + testWidgets('20px thumb includes its border rather than extending it', ( + tester, + ) async { + for (final variant in TSliderVariant.values) { + await pump( + tester, + TSlider(value: 0, variant: variant, onChanged: (_) {}), + borderColor: Colors.red, + ); + final native = find.byType(Slider); + final offset = + tester.getTopLeft(native) - + tester.getTopLeft(find.byKey(boundaryKey)); + final box = tester.renderObject(native); + final boundary = tester.renderObject( + find.byKey(boundaryKey), + ); + final bounds = (await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 4); + final data = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!.buffer.asUint8List(); + final y = ((offset.dy + box.size.height / 2) * 4).floor(); + final red = []; + for (var x = 0; x < image.width; x++) { + final i = (y * image.width + x) * 4; + if (data[i] > 240 && data[i + 1] < 180 && data[i + 2] < 180) { + red.add(x); + } + } + image.dispose(); + return (red.first / 4 - offset.dx, (red.last + 1) / 4 - offset.dx); + }))!; + final center = variant == TSliderVariant.capsule ? 29.0 : 16.0; + expect(bounds.$1, closeTo(center - 10, .25)); + expect(bounds.$2, closeTo(center + 10, .25)); + } + }); + + testWidgets('small capsule tokens retain room for the thumb in both modes', ( + tester, + ) async { + for (final height in [8.0, 16.0, 20.0, 24.0, 28.0]) { + for (final divisions in [null, 5]) { + for (final end in [0.0, 1.0]) { + await pump( + tester, + TSlider( + value: end, + onChanged: (_) {}, + variant: TSliderVariant.capsule, + divisions: divisions, + ), + spacer3: height, + ); + final actualHeight = SliderTheme.of( + tester.element(find.byType(Slider)), + ).trackHeight!; + expect(actualHeight, height < 24 ? 24 : height); + final run = (await thumbRuns(tester)).single; + final center = (run.$1 + run.$2) / 2; + const inset = 29.0; + expect(center, closeTo(end == 0 ? inset : 320 - inset, .75)); + expect(run.$1, greaterThanOrEqualTo(16)); + expect(run.$2, lessThanOrEqualTo(304)); + } + } + } + }); + + testWidgets('narrow and zero constraints have safe bounded geometry', ( + tester, + ) async { + for (final width in [48.0, 24.0, 8.0, 0.0]) { + for (final discrete in [false, true]) { + for (final range in [false, true]) { + final child = range + ? TRangeSlider( + value: const RangeValues(0, 1), + onChanged: (_) {}, + variant: TSliderVariant.capsule, + divisions: discrete ? 5 : null, + ) + : TSlider( + value: 0, + onChanged: (_) {}, + variant: TSliderVariant.capsule, + divisions: discrete ? 5 : null, + ); + await pump(tester, child, width: width); + for (final run in await thumbRuns(tester)) { + expect(run.$1, greaterThanOrEqualTo(0)); + expect(run.$2, lessThanOrEqualTo(width)); + } + } + } + } + }); + + testWidgets( + 'small and invalid spacing cannot move the thumb outside the box', + (tester) async { + for (final inset in [0.0, 4.0, -4.0, double.nan, double.infinity]) { + await pump( + tester, + TSlider(value: 0, onChanged: (_) {}), + spacer2: inset, + ); + final run = (await thumbRuns(tester)).single; + expect(run.$1, greaterThanOrEqualTo(0)); + } + for (final height in [-1.0, double.nan, double.infinity]) { + await pump(tester, const TSlider(value: .5), height: height); + expect( + SliderTheme.of(tester.element(find.byType(Slider))).trackHeight, + 0, + ); + } + }, + ); + + testWidgets('off-grid values keep formatter inputs while positions snap', ( + tester, + ) async { + final formatted = []; + await pump( + tester, + TSlider( + value: 23, + min: 0, + max: 100, + divisions: 5, + showThumbValue: true, + thumbFormatter: (v) { + formatted.add(v); + return '$v'; + }, + ), + ); + expect(tester.widget(find.byType(Slider)).value, 20); + expect(formatted, [23]); + final dynamic singleShape = SliderTheme.of( + tester.element(find.byType(Slider)), + ).thumbShape; + expect(singleShape.label, '23.0'); + await pump( + tester, + const TRangeSlider( + value: RangeValues(23, 77), + min: 0, + max: 100, + divisions: 5, + showThumbValue: true, + ), + ); + expect( + tester.widget(find.byType(RangeSlider)).values, + const RangeValues(20, 80), + ); + final dynamic rangeShape = SliderTheme.of( + tester.element(find.byType(RangeSlider)), + ).rangeThumbShape; + expect(rangeShape.labels.start, '23.00'); + expect(rangeShape.labels.end, '77.00'); + formatted.clear(); + await pump( + tester, + TRangeSlider( + value: const RangeValues(23, 77), + min: 0, + max: 100, + divisions: 5, + showThumbValue: true, + thumbFormatter: (v) { + formatted.add(v); + return '$v'; + }, + ), + ); + expect(formatted, isNotEmpty); + expect(formatted.toSet(), {23, 77}); + }); + + testWidgets('normal-sized narrow controlled range retains native gap', ( + tester, + ) async { + for (final variant in TSliderVariant.values) { + var values = const RangeValues(.40, .41); + final changes = []; + await pump( + tester, + StatefulBuilder( + builder: (context, setState) => TRangeSlider( + value: values, + variant: variant, + onChanged: (next) { + changes.add(next); + setState(() => values = next); + }, + ), + ), + ); + final range = find.byType(RangeSlider); + final theme = SliderTheme.of(tester.element(range)); + expect(theme.minThumbSeparation, 8); + final box = tester.renderObject(range); + final track = theme.rangeTrackShape!.getPreferredRect( + parentBox: box, + offset: Offset.zero, + sliderTheme: theme, + isEnabled: true, + isDiscrete: false, + ); + final start = + tester.getTopLeft(range) + + Offset(track.left + track.width * .40, box.size.height / 2); + final gesture = await tester.startGesture(start); + await gesture.moveBy(const Offset(-20, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(40, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(changes, isNotEmpty); + expect(changes.last.start, greaterThanOrEqualTo(0)); + expect(changes.last.end, lessThanOrEqualTo(1)); + expect( + (changes.last.end - changes.last.start) * track.width, + closeTo(8, .00001), + ); + } + }); + + test('gap is bounded by domain edges rather than current interval', () { + final metrics = const SliderGeometry( + capsule: false, + trackHeight: 4, + horizontalInset: 0, + ).forWidth(320); + expect(metrics.rangeSeparation(.4, .41), 8); + expect(metrics.rangeSeparation(.5, .5), 8); + expect(metrics.rangeSeparation(0, .001), lessThan(8)); + expect(metrics.rangeSeparation(.999, 1), lessThan(8)); + }); + testWidgets('intrinsic and unbounded compositions retain native sizing', ( + tester, + ) async { + for (final range in [false, true]) { + for (final labels in [false, true]) { + final child = range + ? TRangeSlider( + value: const RangeValues(.2, .8), + divisions: labels ? 5 : null, + showScaleValue: labels, + ) + : TSlider( + value: .2, + divisions: labels ? 5 : null, + showScaleValue: labels, + ); + await pump( + tester, + Center( + child: IntrinsicWidth(child: IntrinsicHeight(child: child)), + ), + ); + final native = find + .byWidgetPredicate((w) => w is Slider || w is RangeSlider) + .first; + final box = tester.renderObject(native); + expect(box.size.width, greaterThan(0)); + expect(box.getMaxIntrinsicWidth(double.infinity), greaterThan(0)); + expect(box.getMinIntrinsicWidth(double.infinity), greaterThan(0)); + final host = tester.renderObject(find.byWidget(child)); + expect(host.getMaxIntrinsicWidth(double.infinity), greaterThan(0)); + expect(host.getMinIntrinsicHeight(double.infinity), greaterThan(0)); + if (!labels) { + expect( + host.getDryBaseline( + BoxConstraints.tight(host.size), + TextBaseline.alphabetic, + ), + isNull, + ); + } + } + } + }); + testWidgets('default endpoint scale text is complete and centered', ( + tester, + ) async { + await pump( + tester, + const TSlider(value: 1, divisions: 5, showScaleValue: true), + ); + final label = find.text('1.0'); + expect( + tester.renderObject(label).didExceedMaxLines, + isFalse, + ); + final native = find.byType(Slider); + expect( + tester.getCenter(label).dx - tester.getTopLeft(native).dx, + closeTo(304, .01), + ); + await pump( + tester, + const TSlider(value: 100, max: 100, divisions: 5, showScaleValue: true), + ); + expect( + tester + .renderObject(find.text('100.0')) + .didExceedMaxLines, + isFalse, + ); + }); + for (final axis in Axis.values) { + for (final capsule in [false, true]) { + testWidgets('measured MiniProgram endpoints $axis capsule=$capsule', ( + tester, + ) async { + for (final height in [4.0, 8.0, 24.0]) { + for (final divisions in [null, 5]) { + for (final range in [false, true]) { + for (final direction in TextDirection.values) { + for (final enabled in [false, true]) { + for (final end in [0.0, 1.0]) { + final variant = capsule + ? TSliderVariant.capsule + : TSliderVariant.normal; + final child = range + ? TRangeSlider( + value: const RangeValues(0, 1), + divisions: divisions, + variant: variant, + onChanged: enabled ? (_) {} : null, + ) + : TSlider( + value: end, + divisions: divisions, + variant: variant, + onChanged: enabled ? (_) {} : null, + ); + await pump( + tester, + child, + axis: axis, + direction: direction, + height: height, + ); + final native = find.byWidgetPredicate( + (w) => w is Slider || w is RangeSlider, + ); + final box = tester.renderObject(native); + final theme = SliderTheme.of(tester.element(native)); + final rect = range + ? theme.rangeTrackShape!.getPreferredRect( + parentBox: box, + sliderTheme: theme, + ) + : theme.trackShape!.getPreferredRect( + parentBox: box, + sliderTheme: theme, + ); + // Independent measured contract: MP capsule center = 10 + 3; + // horizontal layout margin = 16, vertical thumb hit margin = 10.5. + final start = + (axis == Axis.horizontal ? 16.0 : 10.5) + + (capsule ? 13 : 0); + expect(rect.left, start); + expect(rect.right, 320 - start); + final expected = range + ? [start, 320 - start] + : [ + (end == 0) == (direction == TextDirection.ltr) + ? start + : 320 - start, + ]; + final runs = await thumbRuns(tester); + expect(runs, hasLength(expected.length)); + for (var i = 0; i < runs.length; i++) { + expect( + (runs[i].$1 + runs[i].$2) / 2, + closeTo(expected[i], .75), + reason: + '$axis $capsule $height $divisions $range $direction $enabled $end', + ); + } + } + } + } + } + } + } + }); + } + } + testWidgets('48px controls still respond to real endpoint drags', ( + tester, + ) async { + var value = .5; + var values = const RangeValues(.2, .8); + for (final range in [false, true]) { + await pump( + tester, + StatefulBuilder( + builder: (context, setState) => range + ? TRangeSlider( + value: values, + onChanged: (v) => setState(() => values = v), + variant: TSliderVariant.capsule, + ) + : TSlider( + value: value, + onChanged: (v) => setState(() => value = v), + variant: TSliderVariant.capsule, + ), + ), + width: 48, + ); + final finder = range ? find.byType(RangeSlider) : find.byType(Slider); + final box = tester.renderObject(finder); + final theme = SliderTheme.of(tester.element(finder)); + final rect = range + ? theme.rangeTrackShape!.getPreferredRect( + parentBox: box, + sliderTheme: theme, + ) + : theme.trackShape!.getPreferredRect( + parentBox: box, + sliderTheme: theme, + ); + Offset at(double ratio) => box.localToGlobal( + Offset(rect.left + ratio * rect.width, rect.center.dy), + ); + var from = at(range ? .2 : .5); + await tester.dragFrom(from, at(0) - from - const Offset(30, 0)); + await tester.pumpAndSettle(); + if (range) { + expect(values.start, 0); + } else { + expect(value, 0); + } + from = at(range ? .8 : 0); + await tester.dragFrom(from, at(1) - from + const Offset(30, 0)); + await tester.pumpAndSettle(); + if (range) { + expect(values.end, 1); + } else { + expect(value, 1); + } + } + }); + + testWidgets('rotated and horizontal drags reach nonzero range endpoints', ( + tester, + ) async { + for (final axis in Axis.values) { + for (final capsule in [false, true]) { + for (final divisions in [null, 5]) { + for (final range in [false, true]) { + for (final direction in TextDirection.values) { + var value = 50.0; + var values = const RangeValues(30, 90); + final effectiveDirection = axis == Axis.vertical + ? TextDirection.ltr + : direction; + await tester.pumpWidget( + MaterialApp( + theme: TThemeBuilder.light(TThemeData.defaultData()), + home: Directionality( + textDirection: direction, + child: Scaffold( + body: Center( + child: SizedBox( + width: axis == Axis.vertical ? 48 : 320, + height: axis == Axis.vertical ? 200 : 48, + child: SliderGeometryScope( + axis: axis, + child: Directionality( + textDirection: effectiveDirection, + child: RotatedBox( + quarterTurns: axis == Axis.vertical ? 1 : 0, + child: StatefulBuilder( + builder: (context, setState) => range + ? TRangeSlider( + min: 10, + max: 110, + value: values, + divisions: divisions, + variant: capsule + ? TSliderVariant.capsule + : TSliderVariant.normal, + onChanged: (v) => + setState(() => values = v), + ) + : TSlider( + min: 10, + max: 110, + value: value, + divisions: divisions, + variant: capsule + ? TSliderVariant.capsule + : TSliderVariant.normal, + onChanged: (v) => + setState(() => value = v), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final native = range + ? find.byType(RangeSlider) + : find.byType(Slider); + final box = tester.renderObject(native); + final theme = SliderTheme.of(tester.element(native)); + final rect = range + ? theme.rangeTrackShape!.getPreferredRect( + parentBox: box, + sliderTheme: theme, + ) + : theme.trackShape!.getPreferredRect( + parentBox: box, + sliderTheme: theme, + ); + Offset at(double ratio) => box.localToGlobal( + Offset( + rect.left + + (effectiveDirection == TextDirection.rtl + ? 1 - ratio + : ratio) * + rect.width, + rect.center.dy, + ), + ); + var from = at(range ? .2 : .4); + await tester.dragFrom(from, at(-.25) - from); + await tester.pumpAndSettle(); + expect(range ? values.start : value, 10); + from = at(range ? .8 : 0); + await tester.dragFrom(from, at(1.25) - from); + await tester.pumpAndSettle(); + expect( + range ? values.end : value, + 110, + reason: + '$axis capsule=$capsule divisions=$divisions range=$range $direction', + ); + expect(tester.takeException(), isNull); + } + } + } + } + } + }); +} diff --git a/tdesign-component/test/components/slider/t_slider_test.dart b/tdesign-component/test/components/slider/t_slider_test.dart index 193d19104b..ddb864c429 100644 --- a/tdesign-component/test/components/slider/t_slider_test.dart +++ b/tdesign-component/test/components/slider/t_slider_test.dart @@ -245,7 +245,8 @@ void main() { ); expect(trackRect.left, 16); expect(trackRect.right, renderBox.size.width - 16); - expect(trackRect.height, 4); + expect(trackRect.height, 0); + expect(theme.trackHeight, 4); }); testWidgets('track geometry follows custom spacing tokens', (tester) async { @@ -301,10 +302,9 @@ void main() { expect(normalTrackRect.left, 18); expect(normalTrackRect.right, normalBox.size.width - 18); expect(capsuleTheme.trackHeight, 28); - // Material's discrete centers are inset by half the track height; - // the visual capsule still starts 18px from each edge. - expect(capsuleTrackRect.left, 7); - expect(capsuleTrackRect.right, capsuleBox.size.width - 7); + // Coordinate tracks directly describe the actual thumb travel. + expect(capsuleTrackRect.left, 31); + expect(capsuleTrackRect.right, capsuleBox.size.width - 31); expect( capsuleTickShape.getPreferredSize( sliderTheme: capsuleTheme, @@ -455,7 +455,7 @@ void main() { final tickShape = SliderTheme.of( tester.element(find.byType(Slider)), ).tickMarkShape; - expect(tickShape, const RoundSliderTickMarkShape(tickMarkRadius: 3)); + expect(tickShape, isA()); }); testWidgets('capsule variant owns inset track and 20px thumb geometry', ( @@ -502,11 +502,10 @@ void main() { isEnabled: true, isDiscrete: true, ); - expect(trackRect.left, 7); - expect(trackRect.right, renderBox.size.width - 7); - expect(trackRect.height, 24); - expect(trackRect.left + trackRect.height / 2, 19); - expect(trackRect.right - trackRect.height / 2, renderBox.size.width - 19); + // No native padding is applied to the coordinate track. + expect(trackRect.left, 29); + expect(trackRect.right, renderBox.size.width - 29); + expect(trackRect.height, 0); }); testWidgets('capsule ticks divide only the interior of the track', ( @@ -546,6 +545,7 @@ void main() { ); final sliderOffset = tester.getTopLeft(sliderFinder) - tester.getTopLeft(boundaryFinder); + // Coordinate endpoints already define the complete thumb travel. final first = trackRect.left + trackRect.height / 2; final last = trackRect.right - trackRect.height / 2; final interior = first + (last - first) * 2 / 5; @@ -580,10 +580,10 @@ void main() { expect(pixel(last), isNot(Colors.white)); expect(pixel(interior), isNot(pixel(interior + 4))); if (slider is TSlider) { - final visualLeft = trackRect.left + trackRect.height / 2 - 3; - final visualWidth = trackRect.width - trackRect.height + 6; - final dividerLeft = visualLeft + 1.5; - final dividerStep = (visualWidth - 3) / 5; + // Dividers follow the thumb travel: the first thumb center, then one + // step of the usable travel per division. + final dividerLeft = first; + final dividerStep = (last - first) / 5; final selectedGap = (dividerLeft + 2 * dividerStep).floorToDouble(); final inactiveGap = (dividerLeft + 4 * dividerStep).floorToDouble(); final outerColor = TThemeData.defaultData().componentBorder; @@ -614,6 +614,317 @@ void main() { ); }); + testWidgets('capsule thumbs stay inside the visible capsule', ( + tester, + ) async { + const boundaryKey = ValueKey('capsule-thumb-bounds'); + + Future expectThumbsInsideCapsule(Widget slider) async { + await tester.pumpWidget( + wrap( + RepaintBoundary( + key: boundaryKey, + child: SizedBox(width: 320, height: 48, child: slider), + ), + ), + ); + await tester.pumpAndSettle(); + + final boundaryFinder = find.byKey(boundaryKey); + final sliderFinder = find.byWidgetPredicate( + (widget) => widget is Slider || widget is RangeSlider, + ); + final sliderBox = tester.renderObject(sliderFinder); + // 可见胶囊的两端就是水平内缩 token,与实现的坐标轨道换算无关。 + final inset = TThemeData.defaultData().spacer2; + final sliderOffset = + tester.getTopLeft(sliderFinder) - tester.getTopLeft(boundaryFinder); + final y = (sliderOffset.dy + sliderBox.size.height / 2).round(); + + final boundary = tester.renderObject( + boundaryFinder, + ); + final pixels = await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 1); + final data = await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ); + final result = ( + width: image.width, + bytes: data!.buffer.asUint8List(), + ); + image.dispose(); + return result; + }); + double? firstPainted; + double? lastPainted; + for (var x = 0; x < pixels!.width; x++) { + // 阈值 64 忽略 thumb 投影的极淡外缘,只统计轨道与 thumb 本体。 + if (pixels.bytes[(y * pixels.width + x) * 4 + 3] > 64) { + firstPainted ??= x.toDouble(); + lastPainted = x.toDouble(); + } + } + + expect(firstPainted, isNotNull, reason: 'capsule track is painted'); + // 允许 1px 抗锯齿:thumb 不应越过可见胶囊的左右边界。 + expect( + firstPainted! + 1, + greaterThanOrEqualTo(inset + sliderOffset.dx), + ); + expect( + lastPainted! - 1, + lessThanOrEqualTo(sliderBox.size.width - inset + sliderOffset.dx), + ); + + // 小程序实测沿行程两端各留 3px,默认横截面各留 2px。 + final theme = SliderTheme.of(tester.element(sliderFinder)); + final isRange = slider is TRangeSlider; + final thumbSize = isRange + ? theme.rangeThumbShape!.getPreferredSize(true, false) + : theme.thumbShape!.getPreferredSize(true, false); + final trackRect = isRange + ? theme.rangeTrackShape!.getPreferredRect( + parentBox: sliderBox, + sliderTheme: theme, + isEnabled: true, + isDiscrete: false, + ) + : theme.trackShape!.getPreferredRect( + parentBox: sliderBox, + sliderTheme: theme, + isEnabled: true, + isDiscrete: false, + ); + // 坐标轨道就是 thumb 行程,两端即 0 / 最大值时的 thumb 中心。 + final firstCenter = trackRect.left; + final lastCenter = trackRect.right; + final verticalMargin = (theme.trackHeight! - thumbSize.height) / 2; + expect(verticalMargin, 2); + expect(firstCenter - thumbSize.width / 2 - inset, 3); + expect( + sliderBox.size.width - inset - (lastCenter + thumbSize.width / 2), + 3, + ); + } + + await expectThumbsInsideCapsule( + TSlider(value: 0, variant: TSliderVariant.capsule, onChanged: (_) {}), + ); + await expectThumbsInsideCapsule( + TSlider(value: 1, variant: TSliderVariant.capsule, onChanged: (_) {}), + ); + await expectThumbsInsideCapsule( + TRangeSlider( + value: const RangeValues(0, 1), + variant: TSliderVariant.capsule, + onChanged: (_) {}, + ), + ); + }); + + testWidgets( + 'capsule endpoint pixels and scale centers agree in LTR and RTL', + (tester) async { + const boundaryKey = ValueKey('endpoint-pixels'); + for (final direction in [TextDirection.ltr, TextDirection.rtl]) { + for (final discrete in [false, true]) { + for (final range in [false, true]) { + for (final end in range ? [0] : [0, 1]) { + final child = range + ? TRangeSlider( + value: const RangeValues(0, 1), + onChanged: (_) {}, + variant: TSliderVariant.capsule, + divisions: discrete ? 5 : null, + showScaleValue: discrete, + scaleFormatter: (v) => (v * 100).round().toString(), + ) + : TSlider( + value: end.toDouble(), + onChanged: (_) {}, + variant: TSliderVariant.capsule, + divisions: discrete ? 5 : null, + showScaleValue: discrete, + scaleFormatter: (v) => (v * 100).round().toString(), + ); + await tester.pumpWidget( + MaterialApp( + theme: TThemeBuilder.dark(TThemeData.defaultData()), + home: Directionality( + textDirection: direction, + child: Scaffold( + body: Center( + child: RepaintBoundary( + key: boundaryKey, + child: SizedBox( + width: 320, + height: 100, + child: child, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final native = range + ? find.byType(RangeSlider) + : find.byType(Slider); + final box = tester.renderObject(native); + final origin = tester.getTopLeft(find.byKey(boundaryKey)); + final offset = tester.getTopLeft(native) - origin; + final boundary = tester.renderObject( + find.byKey(boundaryKey), + ); + final pixels = await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 1); + final data = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!.buffer.asUint8List(); + final result = (width: image.width, bytes: data); + image.dispose(); + return result; + }); + final y = (offset.dy + box.size.height / 2).floor(); + final runs = >[]; + for (var x = 0; x < box.size.width; x++) { + final i = (y * pixels!.width + (offset.dx + x).floor()) * 4; + if (pixels.bytes[i] > 180 && + pixels.bytes[i + 1] > 180 && + pixels.bytes[i + 2] > 180) { + if (runs.isEmpty || x > runs.last.last + 1) { + runs.add([x]); + } else { + runs.last.add(x); + } + } + } + final centers = runs + .where((r) => r.length >= 8) + .map((r) => (r.first + r.last + 1) / 2) + .toList(); + final token = TThemeData.defaultData(); + final start = token.spacer2 + 13; + final finish = box.size.width - start; + final expected = range + ? [start, finish] + : [ + (end == 0) == (direction == TextDirection.ltr) + ? start + : finish, + ]; + expect( + centers, + hasLength(expected.length), + reason: '$direction discrete=$discrete range=$range end=$end', + ); + for (var i = 0; i < centers.length; i++) { + expect(centers[i], closeTo(expected[i], 0.5)); + } + if (discrete) { + for (var index = 0; index <= 5; index++) { + final label = find.text('${index * 20}'); + final ratio = direction == TextDirection.ltr + ? index / 5 + : 1 - index / 5; + final target = start + ratio * (finish - start); + expect( + tester.getCenter(label).dx - tester.getTopLeft(native).dx, + closeTo(target, .01), + ); + } + } + } + } + } + } + }, + ); + + testWidgets('capsule thumb maps the value linearly across the travel', ( + tester, + ) async { + const boundaryKey = ValueKey('capsule-thumb-linear'); + const width = 320.0; + const value = 0.08; + + await tester.pumpWidget( + MaterialApp( + theme: TThemeBuilder.dark(TThemeData.defaultData()), + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: boundaryKey, + child: SizedBox( + width: width, + height: 48, + child: TSlider( + value: value, + variant: TSliderVariant.capsule, + onChanged: (_) {}, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + const boundaryFinder = boundaryKey; + final sliderFinder = find.byType(Slider); + final sliderOffset = + tester.getTopLeft(sliderFinder) - + tester.getTopLeft(find.byKey(boundaryFinder)); + final boundary = tester.renderObject( + find.byKey(boundaryFinder), + ); + final y = + tester.getTopLeft(sliderFinder).dy - + tester.getTopLeft(find.byKey(boundaryFinder)).dy + + 24; + final pixels = await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 1); + final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + final result = (width: image.width, bytes: data!.buffer.asUint8List()); + image.dispose(); + return result; + }); + + // 深色主题下轨道是灰、thumb 是白,用亮度区分 thumb 本体。 + double? thumbLeft; + double? thumbRight; + for (var x = 0; x < pixels!.width; x++) { + final index = (y.round() * pixels.width + x) * 4; + final alpha = pixels.bytes[index + 3]; + final brightness = + (pixels.bytes[index] + + pixels.bytes[index + 1] + + pixels.bytes[index + 2]) / + 3; + if (alpha > 200 && brightness > 200) { + thumbLeft ??= x.toDouble(); + thumbRight = x.toDouble(); + } + } + expect(thumbLeft, isNotNull, reason: 'thumb is painted'); + + final token = TThemeData.defaultData(); + const thumbRadius = 10.0; + const margin = 3.0; + final travelLeft = token.spacer2 + margin + thumbRadius; + final travelRight = width - travelLeft; + expect( + (thumbLeft! + thumbRight!) / 2, + closeTo( + travelLeft + value * (travelRight - travelLeft) + sliderOffset.dx, + 1, + ), + ); + }); + testWidgets('dense capsule divisions do not invert painted segments', ( tester, ) async { @@ -658,8 +969,8 @@ void main() { expect(find.text('50%'), findsOneWidget); expect(find.text('100%'), findsOneWidget); final sliderRect = tester.getRect(find.byType(Slider)); - expect(tester.getRect(find.text('0%')).left, sliderRect.left + 16); - expect(tester.getRect(find.text('100%')).right, sliderRect.right - 16); + expect(tester.getCenter(find.text('0%')).dx, sliderRect.left + 16); + expect(tester.getCenter(find.text('100%')).dx, sliderRect.right - 16); expect( tester.getCenter(find.text('50%')).dx, closeTo(sliderRect.center.dx, 0.01), @@ -792,7 +1103,7 @@ void main() { final tickShape = SliderTheme.of( tester.element(find.byType(RangeSlider)), ).rangeTickMarkShape; - expect(tickShape, const RoundRangeSliderTickMarkShape(tickMarkRadius: 3)); + expect(tickShape, isA()); }); testWidgets('range capsule variant uses component-owned geometry', ( diff --git a/tdesign-component/test/components/theme/goldens/m3_isolation_controls.png b/tdesign-component/test/components/theme/goldens/m3_isolation_controls.png index ad00005246..4c0173f963 100644 Binary files a/tdesign-component/test/components/theme/goldens/m3_isolation_controls.png and b/tdesign-component/test/components/theme/goldens/m3_isolation_controls.png differ diff --git a/tdesign-component/tool/component_test_manifest.dart b/tdesign-component/tool/component_test_manifest.dart index 592a25a44e..43981797fc 100644 --- a/tdesign-component/tool/component_test_manifest.dart +++ b/tdesign-component/tool/component_test_manifest.dart @@ -950,7 +950,11 @@ const componentTestManifests = [ ComponentTestManifest( name: 'slider', coverageTargets: ['lib/src/components/slider/'], - componentTests: ['test/components/slider/t_slider_test.dart'], + componentTests: [ + 'test/components/slider/t_slider_test.dart', + 'test/components/slider/t_slider_geometry_test.dart', + 'test/components/slider/t_slider_constraints_test.dart', + ], exampleTests: ['test/slider/slider_demo_test.dart'], visualTests: [ VisualTestManifest( @@ -964,7 +968,8 @@ const componentTestManifests = [ workingDirectory: 'example', testFiles: [ 'test/slider/slider_demo_golden_test.dart', - 'test/slider/slider_dragged_golden_test.dart', + 'test/slider/slider_endpoint_golden_test.dart', + 'test/slider/slider_boundary_golden_test.dart', ], ), ],