1616/// ([DictionaryPanelHost.activeOwnerOf] 变化)时自动清除。
1717library ;
1818
19+ import 'dart:async' ;
20+
1921import 'package:flutter/gestures.dart' ;
2022import 'package:flutter/material.dart' ;
2123import '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 回调载体)
557703class _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