Flutter最佳实践:键盘辅助视图输入框天添加图片附件

一、需求来源

最近遇到一个需求:资讯添加评论功能,评论要支持图片附件。

最终效果如下:

二、使用示例

dart 复制代码
class _DiscussListPageState extends State<DiscussListPage>
    with SingleTickerProviderStateMixin, NInputAccessoryViewOneMixin {
    
...
onTap: () => showInputView(onSend: onSend),//调用键盘
...
/// 发送按钮点击事件
Future<void> onSend(InputAccessoryViewSession accessorySession) async {
  DLog.d([
    "发送评论",
    accessorySession.inputText,
    accessorySession.selectedModels.map((e) => e.toJson()),
  ]);

  final model = DiscussDetailModel(
    customerInfo: CustomerInfoModel(
      avatar: "",
      nickname: "君行",
    ),
    message: accessorySession.inputText,
    imageUrls: accessorySession.selectedUrls,
    createTime: DateTimeExt.stringFromDate(date: DateTime.now()),
    showTime: DateTime.now().toString().timeDescription,
  );

  NInputAccessoryViewOne.clearContent();
  final items = [...refreshController.items];
  items.insert(targetIndex, model);
  discussProvider.total += 1;
  // UI更新
}

三、源码

dart 复制代码
//
//  NInputAccessoryViewOneOne.dart
//  projects
//
//  Created by shang on 2026/4/29 16:51.
//  Copyright © 2026/4/29 shang. All rights reserved.
//

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_templet_project/basicWidget/elevated_btn.dart';
import 'package:flutter_templet_project/basicWidget/input_accessory_view/asset_picker_manager/asset_picker_manager.dart';
import 'package:flutter_templet_project/basicWidget/input_accessory_view/chat_input_bar.dart';
import 'package:flutter_templet_project/basicWidget/overlay/n_overlay_dialog.dart';
import 'package:flutter_templet_project/basicWidget/upload/asset_upload_box.dart';
import 'package:flutter_templet_project/basicWidget/upload/asset_upload_model.dart';
import 'package:flutter_templet_project/generated/assets.dart';
import 'package:flutter_templet_project/util/dlog.dart';
import 'package:flutter_templet_project/util/n_screen_manager.dart';
import 'package:flutter_templet_project/util/theme/theme_provider.dart';
import 'package:flutter_templet_project/util/tool_util.dart';
import 'package:provider/provider.dart';

/// 键盘辅助视图会话状态,避免 Overlay 重建时丢失已选图片
class InputAccessoryViewSession {
  FocusNode? focusNode;
  TextEditingController? controller;
  TextInputType? keyboardType;
  String? hintText;
  List<TextInputFormatter>? inputFormatters;
  int? maxLines;
  int? maxLength;
  VoidCallback? onPhoto;
  VoidCallback? onEmoji;
  VoidCallback? onConfirm;
  List<AssetUploadModel> selectedModels = <AssetUploadModel>[];
  String inputText = '';
  bool visible = true;
  bool isShowing = false;
  bool isPickingAssets = false;

  /// selectedModels 对应的url数组
  List<String> get selectedUrls =>
      selectedModels.map((e) => e.url ?? '').where((e) => e.startsWith("http") == true).whereType<String>().toList();

  void update({
    required FocusNode focusNode,
    required TextEditingController controller,
    TextInputType? keyboardType,
    String? hintText,
    List<TextInputFormatter>? inputFormatters,
    int? maxLines,
    int? maxLength,
    VoidCallback? onPhoto,
    VoidCallback? onEmoji,
    VoidCallback? onConfirm,
    List<AssetUploadModel>? selectedModels,
    bool? visible,
  }) {
    this.focusNode = focusNode;
    this.controller = controller;
    this.keyboardType = keyboardType;
    this.hintText = hintText;
    this.inputFormatters = inputFormatters;
    this.maxLines = maxLines;
    this.maxLength = maxLength;
    this.onPhoto = onPhoto;
    this.onEmoji = onEmoji;
    this.onConfirm = onConfirm;
    if (selectedModels != null) {
      this.selectedModels = List<AssetUploadModel>.from(selectedModels);
    }
    if (visible != null) {
      this.visible = visible;
    }
  }
}

final InputAccessoryViewSession accessorySession = InputAccessoryViewSession();

/// 键盘辅助视图
class NInputAccessoryViewOne extends StatefulWidget {
  const NInputAccessoryViewOne({
    super.key,
    this.focusNode,
    required this.controller,
    this.hintText,
    this.keyboardType,
    this.maxLines = 3,
    this.maxLength,
    this.inputFormatters,
    this.textFieldBuilder,
    required this.onConfirm,
    this.onPhoto,
    this.onEmoji,
    this.selectedModels = const [],
  });

  final FocusNode? focusNode;

  final TextEditingController controller;

  final TextInputType? keyboardType;

  final String? hintText;

  final int? maxLines;
  final int? maxLength;

  final List<TextInputFormatter>? inputFormatters;

  final TextField Function(TextField v)? textFieldBuilder;

  final VoidCallback onConfirm;

  final VoidCallback? onPhoto;

  final VoidCallback? onEmoji;

  final List<AssetUploadModel> selectedModels;

  static void _onKeyboardMetricsChanged() {
    if (NOverlayDialog.isShowing) {
      NOverlayDialog.build();
    }
  }

  static Widget _buildOverlayEntry(BuildContext ctx) {
    final bottom = NScreenManager.mediaQueryData.viewInsets.bottom;

    onHideKeyBorad() {
      accessorySession.inputText = accessorySession.controller?.text ?? '';
      accessorySession.focusNode?.unfocus();
      dismiss();
    }

    final child = Padding(
      padding: EdgeInsets.only(bottom: bottom),
      child: SizedBox(
        width: MediaQuery.sizeOf(ctx).width,
        child: Offstage(
          offstage: !accessorySession.visible,
          child: TapRegion(
            onTapOutside: (event) => onHideKeyBorad(),
            child: NInputAccessoryViewOne(
              key: const ValueKey<String>('n_input_accessory_view_one'),
              focusNode: accessorySession.focusNode,
              controller: accessorySession.controller!,
              keyboardType: accessorySession.keyboardType,
              hintText: accessorySession.hintText,
              maxLines: accessorySession.maxLines,
              maxLength: accessorySession.maxLength,
              inputFormatters: accessorySession.inputFormatters,
              onConfirm: () {
                accessorySession.onConfirm?.call();
                dismiss();
              },
              onPhoto: accessorySession.onPhoto,
              onEmoji: accessorySession.onEmoji,
              selectedModels: accessorySession.selectedModels,
            ),
          ),
        ),
      ),
    );

    return Stack(
      children: [
        if (accessorySession.visible)
          Positioned.fill(
            child: GestureDetector(
              behavior: HitTestBehavior.opaque,
              onTap: onHideKeyBorad,
              child: const ColoredBox(color: Colors.transparent),
            ),
          ),
        child,
      ],
    );
  }

  /// 刷新或重新展示 Overlay(选图路由变化可能导致 Overlay 被销毁)
  static void refreshOverlay({BuildContext? context}) {
    if (accessorySession.focusNode == null || accessorySession.controller == null) {
      return;
    }
    if (NOverlayDialog.isShowing) {
      NOverlayDialog.build();
      return;
    }
    NScreenManager.addListener(_onKeyboardMetricsChanged);
    final contextNew = context ?? ToolUtil.navigator.context;
    accessorySession.isShowing = true;
    NOverlayDialog.show(
      contextNew,
      hideBarrier: true,
      barrierDismissible: false,
      from: Alignment.bottomCenter,
      builder: _buildOverlayEntry,
    );
  }

  /// 选图前隐藏辅助视图,选图结束后重新展示
  static Future<void> executePickAssets({int maxCount = 3}) async {
    if (accessorySession.isPickingAssets) {
      return;
    }
    accessorySession.isPickingAssets = true;
    accessorySession.visible = false;
    NScreenManager.removeListener(_onKeyboardMetricsChanged);
    NOverlayDialog.dismiss(immediately: true);
    await WidgetsBinding.instance.endOfFrame;
    try {
      final pickedModels = await AssetPickerManager.pickAssets(
        context: ToolUtil.globalContext,
        currentModels: accessorySession.selectedModels,
        maxCount: maxCount,
      );
      accessorySession.selectedModels = List<AssetUploadModel>.from(pickedModels);
    } finally {
      accessorySession.visible = true;
      refreshOverlay();
      accessorySession.focusNode?.requestFocus();
      accessorySession.isPickingAssets = false;
    }
  }

  /// 选图前隐藏辅助视图,选图结束后重新展示
  static Future<void> hide() async {
    accessorySession.visible = false;
    NScreenManager.removeListener(_onKeyboardMetricsChanged);
    NOverlayDialog.dismiss(immediately: true);
    await WidgetsBinding.instance.endOfFrame;
  }

  /// 选图前隐藏辅助视图,选图结束后重新展示
  static Future<void> reShow() async {
    accessorySession.visible = true;
    refreshOverlay();
    accessorySession.focusNode?.requestFocus();
    accessorySession.isPickingAssets = false;
  }

  static void show({
    BuildContext? context,
    required FocusNode focusNode,
    required TextEditingController controller,
    TextInputType? keyboardType,
    String? hintText = "请输入",
    List<TextInputFormatter>? inputFormatters,
    int? maxLines,
    int? maxLength,
    VoidCallback? onPhoto,
    VoidCallback? onEmoji,
    VoidCallback? onConfirm,
    List<AssetUploadModel>? selectedModels,
    bool visible = true,
  }) {
    if (!NOverlayDialog.isShowing) {
      accessorySession.isShowing = false;
    }
    accessorySession.update(
      focusNode: focusNode,
      controller: controller,
      keyboardType: keyboardType,
      hintText: hintText,
      inputFormatters: inputFormatters,
      maxLines: maxLines,
      maxLength: maxLength,
      onPhoto: onPhoto,
      onEmoji: onEmoji,
      onConfirm: onConfirm,
      selectedModels: selectedModels,
      visible: visible,
    );
    if (controller.text != accessorySession.inputText) {
      controller.text = accessorySession.inputText;
    }
    refreshOverlay(context: context);
  }

  static void dismiss({bool force = false}) {
    if (accessorySession.isPickingAssets && !force) {
      return;
    }
    accessorySession.isShowing = false;
    accessorySession.visible = true;
    NScreenManager.removeListener(_onKeyboardMetricsChanged);
    NOverlayDialog.dismiss(immediately: true);
  }

  /// 清除输入内容和已选图片
  static void clearContent() {
    accessorySession.inputText = '';
    accessorySession.selectedModels = <AssetUploadModel>[];
    accessorySession.controller?.clear();
    if (NOverlayDialog.isShowing) {
      NOverlayDialog.build();
    }
  }

  @override
  State<NInputAccessoryViewOne> createState() => _NInputAccessoryViewOneState();
}

class _NInputAccessoryViewOneState extends State<NInputAccessoryViewOne> {
  late FocusNode focusNode = widget.focusNode ?? FocusNode();
  var inputType = InputType.text;
  final uploadController = AssetUploadBoxController();
  var selectedModels = <AssetUploadModel>[];
  List<String> urls = <String>[];

  @override
  void initState() {
    super.initState();
    syncSelectedModels(widget.selectedModels);
    focusNode.addListener(handleFocusChange);
  }

  @override
  void dispose() {
    focusNode.removeListener(handleFocusChange);
    super.dispose();
  }

  void handleFocusChange() {
    if (focusNode.hasFocus && inputType == InputType.emoji) {
      inputType = InputType.text;
      setState(() {});
    }
  }

  void syncSelectedModels(List<AssetUploadModel> models) {
    selectedModels = List<AssetUploadModel>.from(models);
    urls = selectedModels
        .where((AssetUploadModel model) => model.url?.startsWith('http') == true)
        .map((AssetUploadModel model) => model.url ?? '')
        .toList();
  }

  @override
  void didUpdateWidget(covariant NInputAccessoryViewOne oldWidget) {
    super.didUpdateWidget(oldWidget);
    final entityIds = widget.selectedModels.map((model) => model.entity?.id).join(',');
    final oldEntityIds = oldWidget.selectedModels.map((model) => model.entity?.id).join(',');
    final itemUrls = widget.selectedModels.map((model) => model.url ?? '').join(',');
    final oldUrls = oldWidget.selectedModels.map((model) => model.url ?? '').join(',');
    if (entityIds != oldEntityIds || itemUrls != oldUrls) {
      syncSelectedModels(widget.selectedModels);
      setState(() {});
    }
  }

  void updateSessionInputText(String v) {
    accessorySession.inputText = v;
  }

  void updateSessionSelectedModels(List<AssetUploadModel> models) {
    syncSelectedModels(models);
    accessorySession.selectedModels = List<AssetUploadModel>.from(models);
    if (mounted) {
      setState(() {});
    }
  }

  @override
  Widget build(BuildContext context) {
    late final themeProvider = context.read<ThemeProvider>();

    final color242434OrF6F6F6 = themeProvider.color242434OrF6F6F6;

    final textField = TextField(
      focusNode: widget.focusNode,
      controller: widget.controller,
      inputFormatters: widget.inputFormatters,
      minLines: 1,
      maxLines: widget.maxLines,
      maxLength: widget.maxLength,
      keyboardType: widget.keyboardType,
      style: const TextStyle(fontSize: 16),
      decoration: InputDecoration(
        border: InputBorder.none,
        counterText: '',
        contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6).copyWith(bottom: 8),
        hintText: widget.hintText,
        fillColor: color242434OrF6F6F6,
        filled: true,
      ),
    );
    final Widget textFieldNew = widget.textFieldBuilder?.call(textField) ?? textField;
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 13),
      decoration: BoxDecoration(
        color: themeProvider.color242434OrWhite,
        // border: Border.all(color: Colors.blue),
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            decoration: BoxDecoration(
              color: color242434OrF6F6F6,
              borderRadius: BorderRadius.circular(8),
            ),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                textFieldNew,
                Container(
                  padding: const EdgeInsets.symmetric(horizontal: 10).copyWith(bottom: 8),
                  // decoration: BoxDecoration(
                  //   border: Border.all(color: Colors.blue),
                  // ),
                  child: AssetUploadBox(
                    key: ValueKey<String>(
                      selectedModels.map((model) => model.entity?.id ?? model.url ?? '').join(','),
                    ),
                    controller: uploadController,
                    maxCount: 3,
                    rowCount: 5,
                    radius: 4,
                    items: selectedModels,
                    canTakePhoto: true,
                    onPickRequest: (int maxCount) => NInputAccessoryViewOne.executePickAssets(maxCount: maxCount),
                    onChanged: (List<AssetUploadModel> items) {
                      updateSessionSelectedModels(items);
                    },
                    onTap: (urls, index) async {
                      await NInputAccessoryViewOne.hide();
                      await AssetUploadBox.jumpImagePreview(urls: urls, index: index);
                      NInputAccessoryViewOne.reShow();
                    },
                  ),
                ),
              ],
            ),
          ),
          const SizedBox(height: 12),
          buildMenuBar(themeProvider: themeProvider),
          SizedBox(
            height: inputType == InputType.emoji ? 280 : 0,
            child: inputType == InputType.emoji
                ? EmojiInputView(inputType: inputType, textEditingController: widget.controller)
                : const SizedBox.shrink(),
          ),
        ],
      ),
    );
  }

  Widget buildMenuBar({required ThemeProvider themeProvider}) {
    final iconColor = themeProvider.isDark ? Colors.white : null;
    final emojiPath = inputType == InputType.emoji ? Assets.inputBarIcTextKeyboard : Assets.inputBarIcEmoji;
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        Row(
          children: [
            if (widget.onPhoto != null)
              GestureDetector(
                onTap: () async {
                  await NInputAccessoryViewOne.executePickAssets();
                  widget.onPhoto?.call();
                },
                child: Padding(
                  padding: const EdgeInsets.only(right: 16),
                  child: Image(
                    image: const AssetImage(Assets.inputBarIcPhoto),
                    width: 24,
                    height: 24,
                    color: iconColor,
                  ),
                ),
              ),
            if (widget.onEmoji != null)
              GestureDetector(
                onTap: () {
                  //只关闭键盘
                  SystemChannels.textInput.invokeMethod('TextInput.hide');
                  setState(() {
                    inputType = inputType == InputType.emoji ? InputType.text : InputType.emoji;
                    if (inputType == InputType.emoji) {
                      focusNode.unfocus();
                    } else {
                      focusNode.requestFocus();
                    }
                    widget.onEmoji?.call();
                  });
                },
                child: Image(
                  image: AssetImage(emojiPath),
                  width: 24,
                  height: 24,
                  color: iconColor,
                ),
              ),
          ],
        ),
        ValueListenableBuilder(
          valueListenable: widget.controller,
          builder: (context, value, child) {
            final disable = value.text.isEmpty;
            updateSessionInputText(value.text);
            return ElevatedBtn(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
              radius: 4,
              onPressed: disable
                  ? null
                  : () {
                      // DLog.d("发送 ${value.text}");
                      // DLog.d(["发送 ${value.text}", selectedModels.map((e) => e.toJson())]);
                      widget.onConfirm();
                      widget.focusNode?.unfocus();
                    },
              title: "发送",
            );
          },
        ),
      ],
    );
  }
}

/// NInputAccessoryViewOne 输入框混入
mixin NInputAccessoryViewOneMixin<T extends StatefulWidget> on State<T> {
  final _focusNode = FocusNode();
  final _textEditingController = TextEditingController();
  // 展示礼物辅助视图
  Future<void> showInputView({String? hintText, required ValueChanged<InputAccessoryViewSession> onSend}) async {
    NInputAccessoryViewOne.show(
      context: context,
      focusNode: _focusNode,
      controller: _textEditingController,
      hintText: hintText ?? "请输入",
      maxLength: 1000,
      onEmoji: () {
        DLog.d("选择表情");
      },
      onPhoto: () {
        DLog.d("选择图片");
      },
      onConfirm: () {
        onSend(accessorySession);
      },
    );

    await Future.delayed(const Duration(milliseconds: 150));
    _focusNode.requestFocus();
    return;
  }
}

最后、总结

核心原理是用一个对象持有数据,每次视图展示时,填充之前的数据,可以手动清理数据。

优点:基于 OverlayEntry 的键盘辅助视图,代码脱离具体页面,任何页面都可以使用。

缺点:实现比较复杂,如果未来发现bug 修复比较复杂,如果当前页面只需要一个输入框,那么你并不需要它。

github

相关推荐
kyriewen1 小时前
我看完这篇安全论文——AI推荐的npm包,92%是编出来的
前端·javascript·ai编程
心中有国也有家2 小时前
AtomGit Flutter 鸿蒙客户端:Canvas 绘制进阶-路径、渐变与混合模式
android·javascript·flutter·华为·harmonyos
小心我捶你啊3 小时前
数据采集和Web解锁不是一回事,从用途到规则区分
前端·爬虫·网络协议
hunterandroid3 小时前
[鸿蒙从零到一] ArkUI 基础组件实战:Text、Image、Button 与 TextInput
前端
fsssb3 小时前
Chromium 源码学习笔记(五):一次点击,在进入 JS 之前先经历了什么?
前端
hunterandroid3 小时前
WorkManager 可靠性实战:唯一任务、重试与幂等设计
android·前端
鹿目3 小时前
使用 Vant CLI 搭建 UI 组件库:从 H5 到小程序跨平台
前端·javascript
默_笙3 小时前
😭 我花了两小时找了一个数据结构 bug,才把"迷你 Cursor"跑起来,绝望(bushi)
前端·javascript