Flutter for OpenHarmony 三方库适配教学 - image_editor_dove 浮动文字组件
三方库开源地址:https://atomgit.com/nutpi/flutter_ohos_image_editor_dove
前言
图片编辑器里的文字功能看起来简单,但实际做起来还挺有意思的。用户要能添加文字、拖动文字、删除文字,还要能调整字体大小和颜色。这篇文章就来聊聊 image_editor_dove 是怎么实现这些功能的。
我觉得这部分代码最有意思的地方是拖拽删除的交互设计——把文字拖到垃圾桶上就能删除,这个体验做得挺好的。

整体思路
浮动文字功能的实现思路其实不复杂:
- 用一个数据模型来存储文字的内容、位置、样式
- 用一个 Widget 来显示文字,支持拖拽
- 用一个 Mixin 来管理所有的文字,处理添加、删除、刷新
- 用一个单独的页面来让用户输入文字
听起来很简单对吧?但魔鬼在细节里。
数据模型设计
BaseFloatModel - 浮动元素的基类
先看基类的设计:
abstract class BaseFloatModel {
double left;
double top;
Size? floatSize;
BaseFloatModel({
required this.left,
required this.top,
this.floatSize,
});
}
为什么要有基类? 因为将来可能不只有文字,还可能有贴纸、图片等其他浮动元素。有了基类,这些元素就能共享位置管理的逻辑。
left 和 top 是元素在画布上的位置,floatSize 是元素的尺寸。尺寸这个属性后面会用到,主要是用来做碰撞检测的。
FloatTextModel - 文字模型
class FloatTextModel extends BaseFloatModel {
final String text;
final TextStyle style;
bool isSelected;
Size? size;
FloatTextModel({
required this.text,
required double top,
required double left,
required this.style,
this.isSelected = false,
this.size,
}) : super(left: left, top: top);
Size? get floatSize => size;
}
text 是文字内容,style 是文字样式(包含颜色、大小、粗细等),isSelected 标记文字是否被选中(选中时会显示虚线边框)。
这里有个细节:size 属性不是在创建时就有的,而是在 Widget 渲染完成后才能获取到。这是因为文字的实际尺寸取决于字体、字号、内容长度等因素,只有渲染之后才能知道。
FloatTextWidget - 文字显示组件
这个 Widget 负责显示单个文字:
class FloatTextWidget extends StatefulWidget {
final FloatTextModel textModel;
const FloatTextWidget({
Key? key,
required this.textModel,
}) : super(key: key);
State<StatefulWidget> createState() => FloatTextWidgetState();
}
很简单的一个 StatefulWidget,接收一个 FloatTextModel 作为参数。
获取渲染后的尺寸
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
if (mounted) {
RenderObject? ro = context.findRenderObject();
if (ro is RenderBox) {
widget.textModel.size ??= ro.size;
}
}
});
}
这段代码做了什么? 在 Widget 渲染完成后,通过 findRenderObject() 获取到 RenderBox,然后从中取出尺寸信息,存到 model 里。
为什么要用 addPostFrameCallback? 因为在 initState 执行的时候,Widget 还没有被渲染,这时候去获取尺寸是拿不到的。addPostFrameCallback 会在当前帧渲染完成后执行回调,这时候就能拿到正确的尺寸了。
mounted 检查是干嘛的? 防止 Widget 已经被销毁了还去访问 context,这是个好习惯。
显示文字和选中边框
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(4),
constraints: BoxConstraints(
minWidth: 10,
maxWidth: 335,
),
decoration: BoxDecoration(
border: model.isSelected
? ImageEditor.uiDelegate.textSelectedBorder
: null,
),
child: Text(
model.text,
style: model.style,
),
);
}
constraints 限制了文字的最小和最大宽度。最小宽度 10 是为了防止空文字导致的问题,最大宽度 335 是为了防止文字太长超出屏幕。
border 根据 isSelected 状态来决定是否显示边框。边框的样式是通过 ImageEditor.uiDelegate 获取的,这样就能支持自定义了。
DashBorder - 虚线边框
选中文字时显示的是虚线边框,这个效果需要自己实现:
class DashBorder extends Border {
DashBorder({
this.gap = 4.0,
this.strokeWidth = 2.0,
this.dashColor = Colors.white,
// ...
});
final double gap;
final double strokeWidth;
final Color dashColor;
}
gap 是虚线的间隔,strokeWidth 是线的宽度,dashColor 是线的颜色。
虚线的绘制原理是这样的:
Path getDashedPath({
required math.Point<double> a,
required math.Point<double> b,
required double gap,
}) {
final Path path = Path();
path.moveTo(a.x, a.y);
bool shouldDraw = true;
math.Point<double> currentPoint = math.Point<double>(a.x, a.y);
final num radians = math.atan(size.height / size.width);
final num dx = math.cos(radians) * gap;
final num dy = math.sin(radians) * gap;
while (currentPoint.x <= b.x && currentPoint.y <= b.y) {
shouldDraw
? path.lineTo(currentPoint.x, currentPoint.y)
: path.moveTo(currentPoint.x, currentPoint.y);
shouldDraw = !shouldDraw;
currentPoint = math.Point(currentPoint.x + dx, currentPoint.y + dy);
}
return path;
}
原理:从起点到终点,每隔一段距离就切换一次"画"和"不画"的状态。shouldDraw 为 true 时用 lineTo 画线,为 false 时用 moveTo 跳过。
为什么要算 radians? 因为虚线可能是斜的(比如矩形的对角线),需要根据角度来计算每一段的 dx 和 dy。
TextCanvasBinding - 文字管理 Mixin
这个 Mixin 负责管理所有的浮动文字:
mixin TextCanvasBinding<T extends StatefulWidget> on State<T> {
late StateSetter textSetter;
final List<FloatTextModel> textModels = [];
textModels 存储所有的文字模型,textSetter 是用来刷新文字画布的。
添加和删除文字
void addText(FloatTextModel model) {
textModels.add(model);
refreshTextCanvas();
}
void deleteTextWidget(FloatTextModel target) {
textModels.remove(target);
refreshTextCanvas();
}
void refreshTextCanvas() {
textSetter.call(() {});
}
逻辑很简单:添加就往列表里加,删除就从列表里移除,然后刷新画布。
为什么用 StateSetter 而不是 setState? 因为文字画布是用 StatefulBuilder 包裹的,它有自己的 StateSetter。用这个 setter 可以只刷新文字画布,不影响其他部分。
跳转到文字编辑页
void toTextEditorPage() {
realState?._panelController.hidePanel();
Navigator.of(context)
.push(PageRouteBuilder(
opaque: false,
pageBuilder: (context, animation, secondaryAnimation) {
return TextEditorPage();
},
))
.then((value) {
realState?._panelController.showPanel();
if (value is FloatTextModel) {
addText(value);
}
});
}
opaque: false 这个参数很关键。设成 false 之后,新页面就是半透明的,能看到底下的图片。这样用户在输入文字的时候就能看到效果,体验会好很多。
hidePanel 和 showPanel 是用来隐藏和显示底部操作栏的。进入文字编辑页时隐藏,退出时显示。
构建文字画布
Widget buildTextCanvas() {
return StatefulBuilder(builder: (tCtx, setter) {
textSetter = setter;
return Stack(
alignment: Alignment.center,
children: textModels
.map<Widget>((e) => Positioned(
child: _wrapWithGesture(FloatTextWidget(textModel: e), e),
left: e.left,
top: e.top,
))
.toList(),
);
});
}
Stack + Positioned 的组合可以让每个文字自由定位。left 和 top 就是文字在画布上的位置。
_wrapWithGesture 给每个文字包上手势识别,让它能被拖动。
拖拽手势处理
Widget _wrapWithGesture(Widget child, FloatTextModel model) {
return GestureDetector(
child: child,
onPanStart: (_) {
realState?._panelController.moveText(model);
},
onPanUpdate: (details) {
model.isSelected = true;
model.left += details.delta.dx;
model.top += details.delta.dy;
refreshTextCanvas();
realState?._panelController.hidePanel();
},
onPanEnd: (d) {
pointerDetach(d);
},
onPanCancel: () {
pointerDetach(null);
},
);
}
onPanStart - 开始拖动时,通知控制器当前在移动哪个文字
onPanUpdate - 拖动过程中,更新文字的位置,刷新画布,隐藏操作栏
onPanEnd / onPanCancel - 拖动结束或取消时,检查是否需要删除文字
拖动结束的处理
void pointerDetach(DragEndDetails? details) {
if (details != null) {
realState?._panelController.releaseText(details, model, () {
deleteTextWidget(model);
});
} else {
realState?._panelController.doIdle();
}
model.isSelected = false;
refreshTextCanvas();
realState?._panelController.showPanel();
}
releaseText 会检查文字是否被拖到了垃圾桶上,如果是就调用删除回调。
垃圾桶删除交互
这是我觉得这个库做得比较好的一个交互设计。
碰撞检测
bool isThrowText(Offset pointer, BaseFloatModel target) {
final Rect textR = Rect.fromCenter(
center: pointer,
width: target.floatSize?.width ?? 1,
height: target.floatSize?.height ?? 1,
);
final Rect tcR = Rect.fromLTWH(
screenSize!.width - trashCanPosition.dx,
screenSize!.height - trashCanPosition.dy - tcSize.height,
tcSize.width,
tcSize.height,
);
return textR.overlaps(tcR);
}
原理:创建两个矩形,一个是文字的区域,一个是垃圾桶的区域,然后用 overlaps 方法检查是否重叠。
为什么用 pointer 而不是文字的 left/top? 因为用户拖动时,手指的位置更能代表用户的意图。
垃圾桶颜色变化
void pointerMoving(PointerMoveEvent event) {
pointerUpPosition = event.localPosition;
switch (moveStuff) {
case MoveStuff.non:
break;
case MoveStuff.text:
if (movingTarget is FloatTextModel) {
switchTrashCanColor(isThrowText(event.localPosition, movingTarget!));
}
break;
}
}
void switchTrashCanColor(bool isInside) {
trashColor.value = isInside ? Colors.red : defaultTrashColor;
}
当文字被拖到垃圾桶上方时,垃圾桶会变成红色,给用户一个视觉反馈。这个细节做得挺好的。
TextEditorPage - 文字编辑页面
这是用户输入文字的页面:
class TextEditorPage extends StatefulWidget {
State<StatefulWidget> createState() => TextEditorPageState();
}
毛玻璃背景效果
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.black38,
),
),
)
BackdropFilter 可以对它后面的内容应用滤镜效果。这里用的是模糊滤镜,sigmaX 和 sigmaY 控制模糊程度。
ClipRect 是必须的,它限制了滤镜的作用范围。如果不加,滤镜会应用到整个屏幕。
文字输入框
TextField(
key: filedKey,
maxLines: 50,
minLines: 1,
controller: _controller,
focusNode: _node,
cursorColor: configModel.cursorColor,
style: TextStyle(
color: _textColor,
fontSize: _size,
fontWeight: _fontWeight,
),
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
),
)
maxLines: 50 允许用户输入多行文字,minLines: 1 初始只显示一行。
isCollapsed: true 去掉输入框的默认内边距,border: InputBorder.none 去掉边框。这样输入框看起来就像是直接在图片上打字。
自动获取焦点
void initState() {
super.initState();
Future.delayed(const Duration(milliseconds: 160), () {
if (mounted) _node.requestFocus();
});
}
为什么要延迟 160ms? 因为页面有个进入动画,如果立即获取焦点,键盘弹出的时机可能不太对。延迟一下可以让动画先完成。
构建文字模型
FloatTextModel buildModel() {
RenderObject? ro = filedKey.currentContext?.findRenderObject();
Offset offset = Offset(100, 200);
if (ro is RenderBox) {
offset = ro.localToGlobal(Offset.zero)
.translate(0, -(44 + windowStatusBarHeight));
}
return FloatTextModel(
text: _controller.text,
top: offset.dy,
left: offset.dx,
style: TextStyle(
fontSize: _size,
color: _textColor,
fontWeight: _fontWeight,
),
);
}
localToGlobal 把输入框的本地坐标转换成全局坐标。这样文字添加到画布上时,位置就和用户在编辑页看到的一样。
translate 调整一下位置,减去状态栏和导航栏的高度。
OpenHarmony 适配:调用系统输入法
在鸿蒙系统上,如果需要对输入法进行一些特殊控制(比如设置输入类型、监听键盘高度等),可以通过插件来实现。
Dart 端定义方法
const platform = MethodChannel('image_editor');
Future<void> showKeyboard({String inputType = 'text'}) async {
try {
await platform.invokeMethod('showKeyboard', {
'inputType': inputType,
});
} catch (e) {
print('Failed to show keyboard: $e');
}
}
Future<double> getKeyboardHeight() async {
try {
final height = await platform.invokeMethod('getKeyboardHeight');
return height?.toDouble() ?? 0.0;
} catch (e) {
print('Failed to get keyboard height: $e');
return 0.0;
}
}
showKeyboard 可以指定输入类型,比如 ‘text’、‘number’、‘email’ 等。
getKeyboardHeight 获取键盘高度,可以用来调整布局。
鸿蒙端实现
在 ImageEditorPlugin.ets 中添加方法处理:
import { inputMethod } from '@kit.IMEKit';
onMethodCall(call: MethodCall, result: MethodResult): void {
if (call.method == "getPlatformVersion") {
result.success("HarmonyOS");
} else if (call.method == "showKeyboard") {
this.showKeyboard(call, result);
} else if (call.method == "getKeyboardHeight") {
this.getKeyboardHeight(result);
} else {
result.notImplemented();
}
}
inputMethod 是鸿蒙提供的输入法管理模块,可以用来控制软键盘的显示和隐藏。
显示键盘的实现:
private showKeyboard(call: MethodCall, result: MethodResult): void {
try {
const inputType = call.arguments['inputType'] as string;
// 获取输入法控制器
const controller = inputMethod.getController();
// 显示软键盘
controller.showSoftKeyboard().then(() => {
result.success(true);
}).catch((err: Error) => {
result.error("KeyboardError", err.message, null);
});
} catch (e) {
result.error("KeyboardError", e.toString(), null);
}
}
getController() 获取输入法控制器实例。
showSoftKeyboard() 是一个异步方法,返回 Promise。成功后调用 result.success(),失败则调用 result.error()。
获取键盘高度的实现:
private getKeyboardHeight(result: MethodResult): void {
try {
const controller = inputMethod.getController();
// 监听键盘高度变化
controller.on('keyboardHeightChange', (height: number) => {
result.success(height);
});
} catch (e) {
result.error("KeyboardError", e.toString(), null);
}
}
on(‘keyboardHeightChange’, …) 监听键盘高度变化事件。当键盘弹出或收起时,会触发这个回调。
监听键盘事件
如果需要持续监听键盘状态,可以使用 EventChannel:
import {
EventChannel,
EventSink,
StreamHandler,
} from '@ohos/flutter_ohos';
private setupKeyboardEventChannel(binding: FlutterPluginBinding): void {
const eventChannel = new EventChannel(
binding.getBinaryMessenger(),
"image_editor/keyboard_events"
);
eventChannel.setStreamHandler({
onListen: (args: Object, events: EventSink) => {
const controller = inputMethod.getController();
controller.on('keyboardHeightChange', (height: number) => {
events.success(height);
});
},
onCancel: (args: Object) => {
const controller = inputMethod.getController();
controller.off('keyboardHeightChange');
}
} as StreamHandler);
}
EventChannel 和 MethodChannel 的区别是:MethodChannel 是一次性调用,EventChannel 是持续的事件流。
onListen 在 Dart 端开始监听时调用,onCancel 在停止监听时调用。
Dart 端监听键盘事件:
const eventChannel = EventChannel('image_editor/keyboard_events');
void listenKeyboardHeight() {
eventChannel.receiveBroadcastStream().listen((height) {
print('Keyboard height: $height');
// 根据键盘高度调整布局
});
}
receiveBroadcastStream() 返回一个 Stream,可以用 listen 来监听事件。
一些实现细节
为什么文字编辑页要用 PageRouteBuilder?
Navigator.of(context).push(PageRouteBuilder(
opaque: false,
pageBuilder: (context, animation, secondaryAnimation) {
return TextEditorPage();
},
))
如果用 MaterialPageRoute,新页面会完全遮挡住下面的内容。设置 opaque: false 之后,新页面就是半透明的,用户可以看到底下的图片,输入文字时能实时预览效果。
为什么要用 StatefulBuilder?
Widget buildTextCanvas() {
return StatefulBuilder(builder: (tCtx, setter) {
textSetter = setter;
return Stack(...);
});
}
StatefulBuilder 可以创建一个局部的状态管理。当文字位置变化时,只需要刷新这个 StatefulBuilder 内部的内容,不需要刷新整个编辑器。这样性能会好很多。
为什么碰撞检测用 overlaps?
return textR.overlaps(tcR);
Rect.overlaps() 是 Flutter 提供的方法,用来检测两个矩形是否有重叠。这比自己写碰撞检测逻辑要简单得多,而且性能也有保证。
小结
浮动文字功能的核心实现:
- FloatTextModel - 存储文字的内容、位置、样式
- FloatTextWidget - 显示文字,支持选中边框
- DashBorder - 自定义虚线边框
- TextCanvasBinding - 管理所有文字,处理添加、删除、拖拽
- TextEditorPage - 文字输入页面,支持颜色、大小、粗细调节
- 碰撞检测 - 实现拖拽到垃圾桶删除的交互
整个设计把数据、显示、交互分得很清楚,扩展起来也方便。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)