三方库开源地址:https://atomgit.com/nutpi/flutter_ohos_image_editor_dove

前言

图片编辑器里的文字功能看起来简单,但实际做起来还挺有意思的。用户要能添加文字、拖动文字、删除文字,还要能调整字体大小和颜色。这篇文章就来聊聊 image_editor_dove 是怎么实现这些功能的。

我觉得这部分代码最有意思的地方是拖拽删除的交互设计——把文字拖到垃圾桶上就能删除,这个体验做得挺好的。

请添加图片描述

整体思路

浮动文字功能的实现思路其实不复杂:

  1. 用一个数据模型来存储文字的内容、位置、样式
  2. 用一个 Widget 来显示文字,支持拖拽
  3. 用一个 Mixin 来管理所有的文字,处理添加、删除、刷新
  4. 用一个单独的页面来让用户输入文字

听起来很简单对吧?但魔鬼在细节里。


数据模型设计

BaseFloatModel - 浮动元素的基类

先看基类的设计:

abstract class BaseFloatModel {
  double left;
  double top;
  Size? floatSize;

  BaseFloatModel({
    required this.left,
    required this.top,
    this.floatSize,
  });
}

为什么要有基类? 因为将来可能不只有文字,还可能有贴纸、图片等其他浮动元素。有了基类,这些元素就能共享位置管理的逻辑。

lefttop 是元素在画布上的位置,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 的组合可以让每个文字自由定位。lefttop 就是文字在画布上的位置。

_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 可以对它后面的内容应用滤镜效果。这里用的是模糊滤镜,sigmaXsigmaY 控制模糊程度。

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);
}

EventChannelMethodChannel 的区别是: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 提供的方法,用来检测两个矩形是否有重叠。这比自己写碰撞检测逻辑要简单得多,而且性能也有保证。


小结

浮动文字功能的核心实现:

  1. FloatTextModel - 存储文字的内容、位置、样式
  2. FloatTextWidget - 显示文字,支持选中边框
  3. DashBorder - 自定义虚线边框
  4. TextCanvasBinding - 管理所有文字,处理添加、删除、拖拽
  5. TextEditorPage - 文字输入页面,支持颜色、大小、粗细调节
  6. 碰撞检测 - 实现拖拽到垃圾桶删除的交互

整个设计把数据、显示、交互分得很清楚,扩展起来也方便。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐