Flutter開發之支持放大鏡的輸入框功能實現
功能需求
最近需求開發中遇到一個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((MapEntry<int, _TextSelectionToolbarItemData> entry) { 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, List<TextSelectionPoint> endpoints, 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<TextSelectionPoint> 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: <Widget>[ 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的資料請關註WalkonNet其它相關文章!
推薦閱讀:
- Flutter給控件實現鉆石般的微光特效
- Flutter實現矩形取色器的封裝
- flutter封裝單選點擊菜單工具欄組件
- 詳解Android Flutter如何自定義動畫路由
- Android Flutter實現精靈圖的使用詳解