功能需求
最近需求开发中遇到一个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;
}
}
最终效果
最后实现效果如下,通过移动光标可显示放大镜功能,松开手势就是操作栏显示恢复。
总结
要想成为架构师,那就不要局限在编码,业务,要会选型、扩展,提升编程思维。此外,良好的职业规划也很重要,学习的习惯很重要,但是最重要的还是要能持之以恒,任何不能坚持落实的计划都是空谈。
如果你没有方向,这里给大家分享一套由阿里高级架构师编写的《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%免费】↓↓↓