功能需求
最近需求开发中遇到一个Flutter
开发问题,为了优化用户输入体验。产品同学希望能够在输入框支持在移动光标过程中可以出现放大镜功能。原先以为是一个小需求,因为原生系统上iOS和安卓印象中是自带这个功能的。在实施开发时才发现原来并不是这样的,Flutter
好像并没有去支持原有的功能。
需求调研
为了确认官方是否支持了输入框放大镜功能,去github
项目上搜索issue后发现这个问题在18年就有人提到过,但官方却一直没有去支持实现。
既然官方没有支持,秉承有轮子我就用的思想继续通过github
搜索是否有开发者自定义实现了这个功能。
搜索Magnifier
找到了一篇文章是对放大镜的实现,但他并不是在输入框上的实现,只对屏幕手势触摸的地方进行放大。
因为找不到完全实现输入框放大镜功能,那么只能自行去实现该功能了。可以根据Magnifier
来为输入框实现放大镜功能。
需求实现
通过对TextField
的使用会发现,当使用光标双击或是长按会出现TextToolBar
功能栏,随着光标的移动,上方的编辑栏也会跟着光标进行移动。这个发现正好能够在放大镜功能上运用:跟随光标移动+放大就能够实现最终期望的效果了。
源码解读
那么在功能实现之前就需要阅读TextField
源码了解光标上方的编辑栏是如何实现并且能够跟随光标的。
PS:源码解析使用的是extended_text_field,主因是项目中使用了富文本输入和显示。
ExtendedTextField
输入框组件源码找到ExtendedEditableText
中视图build
方法可以看到CompositedTransformTarget
和_toolbarLayerLink
。而这两个已经是实现放大镜功能的关键信息了。
关于CompositedTransformTarget
的使用可以在网上搜到很多,作用是来绑定两个View
视图。除了CompositedTransformTarget
之外还有CompositedTransformFollower
。简单理解就是CompositedTransformFollower
是绑定者,CompositedTransformTarget
是被绑定者,前者跟随后者。_toolbarLayerLink
就是跟随光标操作栏的绑定媒介。
return CompositedTransformTarget( link: _toolbarLayerLink, // 操作工具 child: Semantics( ... child: _Editable( key: _editableKey, startHandleLayerLink: _startHandleLayerLink, //左边光标位置 endHandleLayerLink: _endHandleLayerLink, //右边光标位置 textSpan: _buildTextSpan(context), value: _value, cursorColor: _cursorColor, ...... ), ), );
通过源码查询找到_toolbarLayerLink
另一个使用者ExtendedTextSelectionOverlay
。
void createSelectionOverlay({ //创建操作栏 ExtendedRenderEditable? renderObject, bool showHandles = true, }) { _selectionOverlay = ExtendedTextSelectionOverlay( clipboardStatus: _clipboardStatus, context: context, value: _value, debugRequiredFor: widget, toolbarLayerLink: _toolbarLayerLink, startHandleLayerLink: _startHandleLayerLink, endHandleLayerLink: _endHandleLayerLink, renderObject: renderObject ?? renderEditable, selectionControls: widget.selectionControls, ..... ); ...
通过源码查询可以找到CompositedTransformFollower
组件使用,可以通过代码看到selectionControls!.buildToolbar
就是编辑栏的实现。
return Directionality( textDirection: Directionality.of(this.context), child: FadeTransition( opacity: _toolbarOpacity, child: CompositedTransformFollower( // 操作栏的跟踪组件 link: toolbarLayerLink, showWhenUnlinked: false, offset: -editingRegion.topLeft, child: Builder( builder: (BuildContext context) { return selectionControls!.buildToolbar( context, editingRegion, renderObject.preferredLineHeight, midpoint, endpoints, selectionDelegate!, clipboardStatus!, renderObject.lastSecondaryTapDownPosition, ); }, ), ), ), );
然后返回去找selectionControls是如何实现的。在_ExtendedTextFieldState
中build
方法中可以找到textSelectionControls
默认创建。由于安卓和iOS平台存在差异性,因此有cupertinoTextSelectionControls
和materialTextSelectionControls
两个selectionControls。
switch (theme.platform) { case TargetPlatform.iOS: final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); forcePressEnabled = true; textSelectionControls ??= cupertinoTextSelectionControls; ...... break; ...... case TargetPlatform.android: case TargetPlatform.fuchsia: forcePressEnabled = false; textSelectionControls ??= materialTextSelectionControls; ..... break; .... }
这里就只看MaterialTextSelectionControls
源码实现。布局实现在_TextSelectionControlsToolbar
中。_TextSelectionHandlePainter
是绘制光标样式的方法。
@override Widget build(BuildContext context) { // 左右光标的定位位置 final TextSelectionPoint startTextSelectionPoint = widget.endpoints[0]; // 这里做了判断是否是两个光标 final TextSelectionPoint endTextSelectionPoint = widget.endpoints.length > 1 ? widget.endpoints[1] : widget.endpoints[0]; final Offset anchorAbove = Offset( widget.globalEditableRegion.left + widget.selectionMidpoint.dx, widget.globalEditableRegion.top + startTextSelectionPoint.point.dy - widget.textLineHeight - _kToolbarContentDistance, ); final Offset anchorBelow = Offset( widget.globalEditableRegion.left + widget.selectionMidpoint.dx, widget.globalEditableRegion.top + endTextSelectionPoint.point.dy + _kToolbarContentDistanceBelow, ); .... return TextSelectionToolbar( anchorAbove: anchorAbove, // 左边光标 anchorBelow: anchorBelow,// 右边光标 children: itemDatas.asMap().entries.map((MapEntryentry) { return TextSelectionToolbarTextButton( padding: TextSelectionToolbarTextButton.getPadding(entry.key, itemDatas.length), onPressed: entry.value.onPressed, child: Text(entry.value.label), ); }).toList(), // 每个编辑操作的按钮功能 ); } } /// 安卓选中样式绘制(默认是圆点加上一个箭头) class _TextSelectionHandlePainter extends CustomPainter { _TextSelectionHandlePainter({ required this.color }); final Color color; @override void paint(Canvas canvas, Size size) { final Paint paint = Paint()..color = color; final double radius = size.width/2.0; final Rect circle = Rect.fromCircle(center: Offset(radius, radius), radius: radius); final Rect point = Rect.fromLTWH(0.0, 0.0, radius, radius); final Path path = Path()..addOval(circle)..addRect(point); canvas.drawPath(path, paint); } @override bool shouldRepaint(_TextSelectionHandlePainter oldPainter) { return color != oldPainter.color; } }
功能复刻
了解源码功能之后就能拷贝MaterialTextSelectionControls
实现来完成放大镜功能了。同样是继承TextSelectionControls
,实现MaterialMagnifierControls
功能。
主要修改点在_MagnifierControlsToolbar
的实现以及MaterialMagnifier
功能
MagnifierControlsToolbar
其中的build方法返回了widget.endpoints
光标的定位信息,定位信息去计算出偏移量。最后将两个光标信息入参到MaterialMagnifier
组件。
const double _kHandleSize = 22.0; const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0; const double _kToolbarContentDistance = 8.0; class MaterialMagnifierControls extends TextSelectionControls { @override Size getHandleSize(double textLineHeight) => const Size(_kHandleSize, _kHandleSize); @override Widget buildToolbar( BuildContext context, Rect globalEditableRegion, double textLineHeight, Offset selectionMidpoint, Listendpoints, TextSelectionDelegate delegate, ClipboardStatusNotifier clipboardStatus, Offset? lastSecondaryTapDownPosition, ) { return _MagnifierControlsToolbar( globalEditableRegion: globalEditableRegion, textLineHeight: textLineHeight, selectionMidpoint: selectionMidpoint, endpoints: endpoints, delegate: delegate, clipboardStatus: clipboardStatus, ); } @override Widget buildHandle( BuildContext context, TextSelectionHandleType type, double textHeight, [VoidCallback? onTap, double? startGlyphHeight, double? endGlyphHeight]) { return const SizedBox(); } @override Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight, [double? startGlyphHeight, double? endGlyphHeight]) { switch (type) { case TextSelectionHandleType.left: return const Offset(_kHandleSize, 0); case TextSelectionHandleType.right: return Offset.zero; default: return const Offset(_kHandleSize / 2, -4); } } } class _MagnifierControlsToolbar extends StatefulWidget { const _MagnifierControlsToolbar({ Key? key, required this.clipboardStatus, required this.delegate, required this.endpoints, required this.globalEditableRegion, required this.selectionMidpoint, required this.textLineHeight, }) : super(key: key); final ClipboardStatusNotifier clipboardStatus; final TextSelectionDelegate delegate; final List endpoints; final Rect globalEditableRegion; final Offset selectionMidpoint; final double textLineHeight; @override _MagnifierControlsToolbarState createState() => _MagnifierControlsToolbarState(); } class _MagnifierControlsToolbarState extends State<_MagnifierControlsToolbar> with TickerProviderStateMixin { Offset offset1 = Offset.zero; Offset offset2 = Offset.zero; void _onChangedClipboardStatus() { setState(() { }); } @override void initState() { super.initState(); widget.clipboardStatus.addListener(_onChangedClipboardStatus); widget.clipboardStatus.update(); } @override void didUpdateWidget(_MagnifierControlsToolbar oldWidget) { super.didUpdateWidget(oldWidget); if (widget.clipboardStatus != oldWidget.clipboardStatus) { widget.clipboardStatus.addListener(_onChangedClipboardStatus); oldWidget.clipboardStatus.removeListener(_onChangedClipboardStatus); } widget.clipboardStatus.update(); } @override void dispose() { super.dispose(); if (!widget.clipboardStatus.disposed) { widget.clipboardStatus.removeListener(_onChangedClipboardStatus); } } @override Widget build(BuildContext context) { TextSelectionPoint point = widget.endpoints[0]; if(widget.endpoints.length > 1){ if(offset1 != widget.endpoints[0].point){ point = widget.endpoints[0]; offset1 = point.point; } if(offset2 != widget.endpoints[1].point){ point = widget.endpoints[1]; offset2 = point.point; } } final TextSelectionPoint startTextSelectionPoint = point; final Offset anchorAbove = Offset( widget.globalEditableRegion.left + startTextSelectionPoint.point.dx, widget.globalEditableRegion.top + startTextSelectionPoint.point.dy - widget.textLineHeight - _kToolbarContentDistance, ); final Offset anchorBelow = Offset( widget.globalEditableRegion.left + startTextSelectionPoint.point.dx, widget.globalEditableRegion.top + startTextSelectionPoint.point.dy + _kToolbarContentDistanceBelow, ); return MaterialMagnifier( anchorAbove: anchorAbove, anchorBelow: anchorBelow, textLineHeight: widget.textLineHeight, ); } } final TextSelectionControls materialMagnifierControls = MaterialMagnifierControls();
MaterialMagnifier
MaterialMagnifier
是参考Widget Magnifier
放大镜的实现。这里是引入了安卓的一些布局参数来实现,iOS是另外定制了布局参数可以参考Flutter官方源码定制iOS布局。
放大镜实现方法主要是BackdropFilter
和ImageFilter
来实现的,根据Matrix4
做scale
和translate
操作完成放大功能。
const double _kToolbarScreenPadding = 8.0; const double _kToolbarHeight = 44.0; class MaterialMagnifier extends StatelessWidget { const MaterialMagnifier({ Key? key, required this.anchorAbove, required this.anchorBelow, required this.textLineHeight, this.size = const Size(90, 50), this.scale = 1.7, }) : super(key: key); final Offset anchorAbove; final Offset anchorBelow; final Size size; final double scale; final double textLineHeight; @override Widget build(BuildContext context) { final double paddingAbove = MediaQuery.of(context).padding.top + _kToolbarScreenPadding; final double availableHeight = anchorAbove.dy - paddingAbove; final bool fitsAbove = _kToolbarHeight <= availableHeight; final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove); final Matrix4 updatedMatrix = Matrix4.identity() ..scale(1.1,1.1) ..translate(0.0,-50.0); Matrix4 _matrix = updatedMatrix; return Container( child: Padding( padding: EdgeInsets.fromLTRB( _kToolbarScreenPadding, paddingAbove, _kToolbarScreenPadding, _kToolbarScreenPadding, ), child: Stack( children:[ CustomSingleChildLayout( delegate: TextSelectionToolbarLayoutDelegate( anchorAbove: anchorAbove - localAdjustment, anchorBelow: anchorBelow - localAdjustment, fitsAbove: fitsAbove, ), child: ClipRRect( borderRadius: BorderRadius.circular(10), child: BackdropFilter( filter: ImageFilter.matrix(_matrix.storage), child: CustomPaint( painter: const MagnifierPainter(color: Color(0xFFdfdfdf)), size: size, ), ), ), ), ], ), ), ); } }
交互优化
实现放大镜功能之外还需要控制显示,由于在拖动状态下才显示放大镜,隐藏操作栏功能,因此需要去监听手势状态信息。
手势监听是在_TextSelectionHandleOverlayState
中,需要去监听onPanStart
、onPanUpdate
、onPanEnd
、onPanCancel
这几个状态。
状态 | 行动 |
---|---|
onPanStart | 隐藏操作栏、显示放大镜 |
onPanUpdate | 显示放大镜,获取到偏移信息 |
onPanEnd | 显示操作栏、隐藏放大镜 |
onPanCancel | 显示操作栏、隐藏放大镜 |
final Widget child = GestureDetector( behavior: HitTestBehavior.translucent, dragStartBehavior: widget.dragStartBehavior, onPanStart: _handleDragStart, onPanUpdate: _handleDragUpdate, onPanEnd: _handleDragEnd, onPanCancel: _handleDragCancel, onTap: _handleTap, child: Padding( padding: EdgeInsets.only( left: padding.left, top: padding.top, right: padding.right, bottom: padding.bottom, ), child: widget.selectionControls!.buildHandle( context, type, widget.renderObject.preferredLineHeight, () {}, ), ), );
在开始拓展手势时展示放大镜,隐藏操作。_builderMagnifier
嵌套在OverlayEntry
组件在Overlay
上插入,实现方式是和操作栏完全一样的。
void _handleDragStart(DragStartDetails details) { final Size handleSize = widget.selectionControls!.getHandleSize( widget.renderObject.preferredLineHeight, ); _dragPosition = details.globalPosition + Offset(0.0, -handleSize.height); widget.showMagnifierBarFunc(); // 回调展示放大镜功能 toolBarRecover = widget.hideToolbarFunc(); } void showMagnifierBar() { assert(_magnifier == null); _magnifier = OverlayEntry(builder: _builderMagnifier); Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! .insert(_magnifier!); }
同理在拖拽结束时去隐藏放大镜,重新创建操作栏恢复显示。
void _handleDragEnd(DragEndDetails details) { widget.hideMagnifierBarFunc(); if (toolBarRecover) { widget.showToolbarFunc(); toolBarRecover = false; } } void hideMagnifierBar() { if (_magnifier != null) { _magnifier!.remove(); _magnifier = null; } }
最终效果
最后实现效果如下,通过移动光标可显示放大镜功能,松开手势就是操作栏显示恢复。
以上就是Flutter开发之支持放大镜的输入框功能实现的详细内容,更多关于Flutter的资料请关注脚本之家其它相关文章!