Skip to content

Commit 31bb20c

Browse files
committed
FEAT: 优化词组选区手柄拖拽体验
1 parent 528cbad commit 31bb20c

3 files changed

Lines changed: 266 additions & 24 deletions

File tree

TASKS.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
# Echo Loop 任务清单
22

3-
> 最后更新:2026-07-04(词典面板查询标题与多词含义标题
3+
> 最后更新:2026-07-05(词组选区手柄拖拽放大镜
44
> 当前焦点:Android 结束录音闪退(离线 ASR / Silero VAD)——**仍未解决**
55
6+
## 已完成:词组选区手柄拖拽放大镜
7+
8+
响应真机文本选区反馈:拖拽词组选区手柄时手指会挡住文字,接入 Flutter 内置平台自适应文本放大镜。
9+
10+
- [x] **放大镜接入**`SelectableSentenceText` 的手柄拖拽开始时显示 `TextMagnifier.adaptiveMagnifierConfiguration`,拖动中按当前词边界更新 `MagnifierInfo`,松手/取消/清选区/dispose 时隐藏 overlay。
11+
- [x] **几何对齐**:复用 `RenderParagraph.getBoxesForSelection` 计算 caret 与文本 bounds,保持词级吸附;触点 Y 归一到 caret 中心,避免结束手柄在文字下方时触发 iOS 放大镜隐藏阈值。
12+
- [x] **手柄命中区**:圆点视觉位置保持贴词边界,透明命中区覆盖圆点 + 选区竖线;命中区尽量收进组件 bounds,避免左手柄实际可抓面积变小。
13+
- [x] **测试**:补充 Android 平台拖拽显示/松手隐藏、取消拖拽清理 overlay、左手柄命中区与竖线命中范围回归用例。
14+
- [x] **验证**`flutter analyze lib/widgets/practice/selectable_sentence_text.dart test/widgets/practice/selectable_sentence_text_test.dart` 0 问题;`flutter test test/widgets/practice/selectable_sentence_text_test.dart` 全过(16 例)。
15+
16+
**完成时间**: 2026-07-05
17+
618
## 已完成:词典面板查询标题与多词含义标题
719

820
响应词典面板真机截图反馈:长查询表达不再缩小成过小单行,并补齐多词 AI 结果的含义区标题。

lib/widgets/practice/selectable_sentence_text.dart

Lines changed: 174 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
/// ([DictionaryPanelHost.activeOwnerOf] 变化)时自动清除。
1717
library;
1818

19+
import 'dart:async';
20+
1921
import 'package:flutter/gestures.dart';
2022
import 'package:flutter/material.dart';
2123
import 'package:flutter/rendering.dart';
@@ -124,6 +126,11 @@ class _SelectableSentenceTextState
124126
/// 选区边界竖线宽度
125127
static const double _kCaretWidth = 2;
126128

129+
/// Flutter 内置文本放大镜:移动端按平台渲染 iOS/Android 样式,桌面端自动不显示。
130+
final MagnifierController _magnifierController = MagnifierController();
131+
final ValueNotifier<MagnifierInfo> _magnifierInfo =
132+
ValueNotifier<MagnifierInfo>(MagnifierInfo.empty);
133+
127134
/// 已注册豁免区域的宿主(组件卸载时按同一实例注销)
128135
DictionaryPanelHostState? _host;
129136

@@ -183,6 +190,8 @@ class _SelectableSentenceTextState
183190
@override
184191
void dispose() {
185192
_host?.unregisterTapThroughHitTest(_hitsTapThrough);
193+
_hideMagnifier();
194+
_magnifierInfo.dispose();
186195
super.dispose();
187196
}
188197

@@ -201,26 +210,63 @@ class _SelectableSentenceTextState
201210
_handleHitRect(isStartHandle: false).contains(local);
202211
}
203212

204-
/// 手柄命中区矩形(组件局部坐标),与 [_buildHandle] 的定位公式一致:
205-
/// 36dp 见方、以圆点为中心;无锚点(无选区/未完成 post-frame 定位)时为空
213+
/// 手柄命中区矩形(组件局部坐标),与 [_buildHandle] 的定位公式一致。
214+
/// 视觉圆点贴词边界,但命中区覆盖圆点 + 选区竖线,而不是只围绕圆点;
215+
/// 这样抓蓝色竖线附近也能拖动,左右手柄体感更接近系统文本选择。
206216
Rect _handleHitRect({required bool isStartHandle}) {
207217
final anchor = isStartHandle ? _startAnchor : _endAnchor;
208218
if (anchor == null) return Rect.zero;
219+
final dotCenter = _handleDotCenter(isStartHandle: isStartHandle);
220+
if (dotCenter == null) return Rect.zero;
221+
final obj = context.findRenderObject();
222+
final bounds = obj is RenderBox && obj.hasSize
223+
? Offset.zero & obj.size
224+
: Rect.zero;
225+
return _shiftRectInsideBounds(
226+
Rect.fromLTRB(
227+
dotCenter.dx - _kHandleHitSize / 2,
228+
isStartHandle ? dotCenter.dy - _kHandleHitSize / 2 : anchor.top,
229+
dotCenter.dx + _kHandleHitSize / 2,
230+
isStartHandle ? anchor.bottom : dotCenter.dy + _kHandleHitSize / 2,
231+
),
232+
bounds,
233+
);
234+
}
235+
236+
/// 手柄圆点视觉中心(组件局部坐标)。命中区可偏移,视觉位置不可偏移,
237+
/// 否则选区边界会看起来脱离文字。
238+
Offset? _handleDotCenter({required bool isStartHandle}) {
239+
final anchor = isStartHandle ? _startAnchor : _endAnchor;
240+
if (anchor == null) return null;
209241
final x = isStartHandle ? anchor.left : anchor.right;
210242
final dotCenterY = isStartHandle
211243
? anchor.top - _kHandleDotSize / 2
212244
: anchor.bottom + _kHandleDotSize / 2;
213-
return Rect.fromCenter(
214-
center: Offset(x, dotCenterY),
215-
width: _kHandleHitSize,
216-
height: _kHandleHitSize,
217-
);
245+
return Offset(x, dotCenterY);
246+
}
247+
248+
Rect _shiftRectInsideBounds(Rect rect, Rect bounds) {
249+
if (bounds.isEmpty) return rect;
250+
var dx = 0.0;
251+
if (rect.left < bounds.left) {
252+
dx = bounds.left - rect.left;
253+
} else if (rect.right > bounds.right) {
254+
dx = bounds.right - rect.right;
255+
}
256+
var dy = 0.0;
257+
if (rect.top < bounds.top) {
258+
dy = bounds.top - rect.top;
259+
} else if (rect.bottom > bounds.bottom) {
260+
dy = bounds.bottom - rect.bottom;
261+
}
262+
return rect.shift(Offset(dx, dy));
218263
}
219264

220265
void _clearSelection() {
221266
_selection = null;
222267
_startAnchor = null;
223268
_endAnchor = null;
269+
_hideMagnifier();
224270
}
225271

226272
// -- 查词触发 --
@@ -260,6 +306,15 @@ class _SelectableSentenceTextState
260306

261307
// -- 手柄拖拽 --
262308

309+
/// 手柄拖拽开始:显示 Flutter 平台自适应放大镜,降低手指遮挡文字的问题。
310+
void _handleDragStart(bool isStartHandle, Offset globalPosition) {
311+
_showOrUpdateMagnifier(
312+
isStartHandle,
313+
globalPosition,
314+
selection: _selection,
315+
);
316+
}
317+
263318
/// 手柄拖拽中把手指位置换算为文本内字符偏移并做词级吸附
264319
void _updateSelectionFromDrag(bool isStartHandle, Offset globalPosition) {
265320
final para = _paragraph;
@@ -283,10 +338,12 @@ class _SelectableSentenceTextState
283338
setState(() => _selection = next);
284339
_scheduleAnchorUpdate();
285340
}
341+
_showOrUpdateMagnifier(isStartHandle, globalPosition, selection: next);
286342
}
287343

288344
/// 手柄松手:以当前选区文本查询(与上次查询相同则不重复触发)
289345
void _handleDragEnd() {
346+
_hideMagnifier();
290347
final sel = _selection;
291348
if (sel == null) return;
292349
widget.onBeforeLookup?.call();
@@ -295,6 +352,84 @@ class _SelectableSentenceTextState
295352
).show(widget.origin.queryFor(sel.textOf(_fullText, _tokens)), owner: this);
296353
}
297354

355+
void _handleDragCancel() {
356+
_hideMagnifier();
357+
}
358+
359+
/// 显示或更新放大镜。坐标按 Flutter [TextMagnifier] 约定转换到 root overlay;
360+
/// 触点 Y 使用 caret 中心,避免结束手柄位于文字下方时被 iOS 阈值判断为隐藏。
361+
void _showOrUpdateMagnifier(
362+
bool isStartHandle,
363+
Offset globalPosition, {
364+
required WordSelection? selection,
365+
}) {
366+
final info = _magnifierInfoForHandle(
367+
isStartHandle,
368+
globalPosition,
369+
selection: selection,
370+
);
371+
if (info == null) return;
372+
_magnifierInfo.value = info;
373+
if (_magnifierController.overlayEntry != null) return;
374+
375+
final builtMagnifier = TextMagnifier.adaptiveMagnifierConfiguration
376+
.magnifierBuilder(context, _magnifierController, _magnifierInfo);
377+
if (builtMagnifier == null) return;
378+
_magnifierController.show(
379+
context: context,
380+
debugRequiredFor: widget,
381+
builder: (_) => builtMagnifier,
382+
);
383+
}
384+
385+
MagnifierInfo? _magnifierInfoForHandle(
386+
bool isStartHandle,
387+
Offset globalPosition, {
388+
required WordSelection? selection,
389+
}) {
390+
final para = _paragraph;
391+
if (para == null || selection == null) return null;
392+
final overlay = Overlay.of(
393+
context,
394+
rootOverlay: true,
395+
).context.findRenderObject();
396+
if (overlay is! RenderBox) return null;
397+
398+
final token =
399+
_tokens[isStartHandle ? selection.startToken : selection.endToken];
400+
final boxes = para.getBoxesForSelection(
401+
TextSelection(baseOffset: token.start, extentOffset: token.end),
402+
);
403+
if (boxes.isEmpty) return null;
404+
405+
final anchor = isStartHandle ? boxes.first.toRect() : boxes.last.toRect();
406+
final caretX = isStartHandle ? anchor.left : anchor.right;
407+
final localCaret = Rect.fromLTWH(caretX, anchor.top, 0, anchor.height);
408+
final transformToOverlay = para.getTransformTo(overlay);
409+
final caretRect = MatrixUtils.transformRect(transformToOverlay, localCaret);
410+
final fieldBounds = MatrixUtils.transformRect(
411+
transformToOverlay,
412+
para.paintBounds,
413+
);
414+
final overlayGesture = overlay.globalToLocal(globalPosition);
415+
416+
return MagnifierInfo(
417+
globalGesturePosition: Offset(overlayGesture.dx, caretRect.center.dy),
418+
caretRect: caretRect,
419+
fieldBounds: fieldBounds,
420+
currentLineBoundaries: Rect.fromLTRB(
421+
fieldBounds.left,
422+
caretRect.top,
423+
fieldBounds.right,
424+
caretRect.bottom,
425+
),
426+
);
427+
}
428+
429+
void _hideMagnifier() {
430+
unawaited(_magnifierController.hide());
431+
}
432+
298433
// -- 手柄几何 --
299434

300435
/// post-frame 重算手柄锚点(选区变化/布局完成后 RenderParagraph 才有最新 box)
@@ -481,6 +616,8 @@ class _SelectableSentenceTextState
481616
// 命中区矩形与屏障豁免判定共用同一公式([_handleHitRect]),保证
482617
// 「能拖到的位置」与「屏障放行的位置」永远一致
483618
final hitRect = _handleHitRect(isStartHandle: isStartHandle);
619+
final dotCenter = _handleDotCenter(isStartHandle: isStartHandle);
620+
if (dotCenter == null) return const SizedBox.shrink();
484621
return Positioned(
485622
left: hitRect.left,
486623
top: hitRect.top,
@@ -492,25 +629,34 @@ class _SelectableSentenceTextState
492629
ImmediateMultiDragGestureRecognizer
493630
>(ImmediateMultiDragGestureRecognizer.new, (recognizer) {
494631
recognizer.onStart = (offset) => _HandleDrag(
632+
onStart: () => _handleDragStart(isStartHandle, offset),
495633
onUpdate: (d) =>
496634
_updateSelectionFromDrag(isStartHandle, d.globalPosition),
497635
onEnd: _handleDragEnd,
636+
onCancel: _handleDragCancel,
498637
);
499638
}),
500639
},
501640
child: SizedBox(
502641
key: Key(isStartHandle ? 'word_handle_start' : 'word_handle_end'),
503-
width: _kHandleHitSize,
504-
height: _kHandleHitSize,
505-
child: Center(
506-
child: Container(
507-
width: _kHandleDotSize,
508-
height: _kHandleDotSize,
509-
decoration: BoxDecoration(
510-
color: theme.colorScheme.primary,
511-
shape: BoxShape.circle,
642+
width: hitRect.width,
643+
height: hitRect.height,
644+
child: Stack(
645+
clipBehavior: Clip.none,
646+
children: [
647+
Positioned(
648+
left: dotCenter.dx - hitRect.left - _kHandleDotSize / 2,
649+
top: dotCenter.dy - hitRect.top - _kHandleDotSize / 2,
650+
child: Container(
651+
width: _kHandleDotSize,
652+
height: _kHandleDotSize,
653+
decoration: BoxDecoration(
654+
color: theme.colorScheme.primary,
655+
shape: BoxShape.circle,
656+
),
657+
),
512658
),
513-
),
659+
],
514660
),
515661
),
516662
),
@@ -555,10 +701,19 @@ class _RenderUnboundedHitStack extends RenderStack {
555701

556702
/// 手柄拖拽会话(ImmediateMultiDrag 的 Drag 回调载体)
557703
class _HandleDrag extends Drag {
704+
final VoidCallback onStart;
558705
final void Function(DragUpdateDetails) onUpdate;
559706
final VoidCallback onEnd;
707+
final VoidCallback onCancel;
560708

561-
_HandleDrag({required this.onUpdate, required this.onEnd});
709+
_HandleDrag({
710+
required this.onStart,
711+
required this.onUpdate,
712+
required this.onEnd,
713+
required this.onCancel,
714+
}) {
715+
onStart();
716+
}
562717

563718
@override
564719
void update(DragUpdateDetails details) => onUpdate(details);
@@ -567,5 +722,5 @@ class _HandleDrag extends Drag {
567722
void end(DragEndDetails details) => onEnd();
568723

569724
@override
570-
void cancel() {}
725+
void cancel() => onCancel();
571726
}

0 commit comments

Comments
 (0)