【Flutter】支持八倍镜的输入框功能实现

功能需求

最近需求开发中遇到一个Flutter开发问题,为了优化用户输入体验。产品同学希望能够在输入框支持在移动光标过程中可以出现放大镜功能。原先以为是一个小需求,因为原生系统上iOS和安卓印象中是自带这个功能的。在实施开发时才发现原来并不是这样的,Flutter好像并没有去支持原有的功能。

image.png

需求调研

为了确认官方是否支持了输入框放大镜功能,去github项目上搜索issue后发现这个问题在18年就有人提到过,但官方却一直没有去支持实现。 image.png 既然官方没有支持,秉承有轮子我就用的思想继续通过github搜索是否有开发者自定义实现了这个功能。

搜索Magnifier找到了一篇文章是对放大镜的实现,但他并不是在输入框上的实现,只对屏幕手势触摸的地方进行放大。

image.png 因为找不到完全实现输入框放大镜功能,那么只能自行去实现该功能了。可以根据Magnifier来为输入框实现放大镜功能。

需求实现

通过对TextField的使用会发现,当使用光标双击或是长按会出现TextToolBar功能栏,随着光标的移动,上方的编辑栏也会跟着光标进行移动。这个发现正好能够在放大镜功能上运用:跟随光标移动+放大就能够实现最终期望的效果了。

image.png

image.png

源码解读

那么在功能实现之前就需要阅读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是如何实现的。在_ExtendedTextFieldStatebuild方法中可以找到textSelectionControls默认创建。由于安卓和iOS平台存在差异性,因此有cupertinoTextSelectionControlsmaterialTextSelectionControls两个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布局。

放大镜实现方法主要是BackdropFilterImageFilter来实现的,根据Matrix4scaletranslate操作完成放大功能。

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中,需要去监听onPanStartonPanUpdateonPanEndonPanCancel这几个状态。

状态行动
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;
  }
} 

最终效果

最后实现效果如下,通过移动光标可显示放大镜功能,松开手势就是操作栏显示恢复。

3009dba73bd7725ac368ca0dc02747b7.gif

总结

要想成为架构师,那就不要局限在编码,业务,要会选型、扩展,提升编程思维。此外,良好的职业规划也很重要,学习的习惯很重要,但是最重要的还是要能持之以恒,任何不能坚持落实的计划都是空谈。

如果你没有方向,这里给大家分享一套由阿里高级架构师编写的《Android八大模块进阶笔记》,帮大家将杂乱、零散、碎片化的知识进行体系化的整理,让大家系统而高效地掌握Android开发的各个知识点。
在这里插入图片描述
相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照知识体系编排的。

一、架构师筑基必备技能

1、深入理解Java泛型
2、注解深入浅出
3、并发编程
4、数据传输与序列化
5、Java虚拟机原理
6、高效IO
……

在这里插入图片描述

二、Android百大框架源码解析

1.Retrofit 2.0源码解析
2.Okhttp3源码解析
3.ButterKnife源码解析
4.MPAndroidChart 源码解析
5.Glide源码解析
6.Leakcanary 源码解析
7.Universal-lmage-Loader源码解析
8.EventBus 3.0源码解析
9.zxing源码分析
10.Picasso源码解析
11.LottieAndroid使用详解及源码解析
12.Fresco 源码分析——图片加载流程

在这里插入图片描述

三、Android性能优化实战解析

  • 腾讯Bugly:对字符串匹配算法的一点理解
  • 爱奇艺:安卓APP崩溃捕获方案——xCrash
  • 字节跳动:深入理解Gradle框架之一:Plugin, Extension, buildSrc
  • 百度APP技术:Android H5首屏优化实践
  • 支付宝客户端架构解析:Android 客户端启动速度优化之「垃圾回收」
  • 携程:从智行 Android 项目看组件化架构实践
  • 网易新闻构建优化:如何让你的构建速度“势如闪电”?

在这里插入图片描述

四、高级kotlin强化实战

1、Kotlin入门教程
2、Kotlin 实战避坑指南
3、项目实战《Kotlin Jetpack 实战》

  • 从一个膜拜大神的 Demo 开始

  • Kotlin 写 Gradle 脚本是一种什么体验?

  • Kotlin 编程的三重境界

  • Kotlin 高阶函数

  • Kotlin 泛型

  • Kotlin 扩展

  • Kotlin 委托

  • 协程“不为人知”的调试技巧

  • 图解协程:suspend

在这里插入图片描述

五、Android高级UI开源框架进阶解密

1.SmartRefreshLayout的使用
2.Android之PullToRefresh控件源码解析
3.Android-PullToRefresh下拉刷新库基本用法
4.LoadSir-高效易用的加载反馈页管理框架
5.Android通用LoadingView加载框架详解
6.MPAndroidChart实现LineChart(折线图)
7.hellocharts-android使用指南
8.SmartTable使用指南
9.开源项目android-uitableview介绍
10.ExcelPanel 使用指南
11.Android开源项目SlidingMenu深切解析
12.MaterialDrawer使用指南
在这里插入图片描述

六、NDK模块开发

1、NDK 模块开发
2、JNI 模块
3、Native 开发工具
4、Linux 编程
5、底层图片处理
6、音视频开发
7、机器学习

在这里插入图片描述

七、Flutter技术进阶

1、Flutter跨平台开发概述
2、Windows中Flutter开发环境搭建
3、编写你的第一个Flutter APP
4、Flutter开发环境搭建和调试
5、Dart语法篇之基础语法(一)
6、Dart语法篇之集合的使用与源码解析(二)
7、Dart语法篇之集合操作符函数与源码分析(三)

在这里插入图片描述

八、微信小程序开发

1、小程序概述及入门
2、小程序UI开发
3、API操作
4、购物商场项目实战……

在这里插入图片描述

全套视频资料:

一、面试合集
在这里插入图片描述
二、源码解析合集

在这里插入图片描述
三、开源框架合集

在这里插入图片描述
欢迎大家一键三连支持,若需要文中资料,直接点击文末CSDN官方认证微信卡片免费领取【保证100%免费】↓↓↓
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值