You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter键盘弹出时FAB上移问题解决求助

Flutter中FloatingActionButton固定在屏幕底部不受键盘影响的解决方案

问题描述

在Flutter应用中尝试让FloatingActionButton(FAB)在键盘弹出时固定在屏幕底部,但键盘出现时FAB始终会上移。

相关代码

class RecentDmConversationsPageBody extends Stateful Widget {
  const RecentDmConversationsPageBody({super.key});
  @override
  State<RecentDmConversationsPageBody> createState() => _RecentDmConversationsPageBodyState();
}
class _RecentDmConversationsPageBodyState extends State<RecentDmConversationsPageBody> with PerAccountStoreAwareStateMixin<RecentDmConversationsPageBody>{
  RecentDmConversationsView? model;
  Unreads? unreadsModel;
  final TextEditingController _searchController = TextEditingController();
  List<DmNarrow> _filteredConversations = [];
  bool _isSearching = false;
  @override
  void onNewStore() {
    model?.removeListener(_modelChanged);
    model = PerAccountStoreWidget.of(context).recentDmConversationsView
      ..addListener(_modelChanged);
    unreadsModel?.removeListener(_modelChanged);
    unreadsModel = PerAccountStoreWidget.of(context).unreads
      ..addListener(_modelChanged);
    _applySearchFilter();
  }
  @override
  void initState() {
    super.initState();
    _searchController.addListener(_applySearchFilter);
  }
  @override
  void dispose() {
    model?.removeListener(_modelChanged);
    unreadsModel?.removeListener(_modelChanged);
    _searchController.dispose();
    super.dispose();
  }
  void _modelChanged() {
    setState(() {
      // The actual state lives in [model] and [unreadsModel].
      // This method was called because one of those just changed.      
      _applySearchFilter();
    });
  }
  void _applySearchFilter() {
    final query = _searchController.text.toLowerCase();
    if (query.isEmpty) {
      _filteredConversations = List.from(model!.sorted);
    } else {
      _filteredConversations = model!.sorted.where((narrow) {
        final store = PerAccountStoreWidget.of(context);
        final selfUser = store.users[store.selfUserId]!;
        final otherRecipientIds = narrow.otherRecipientIds;
        if (otherRecipientIds.isEmpty) {
          return selfUser.fullName.toLowerCase().contains(query);
        } else {
          return otherRecipientIds.any((id) {
            final user = store.users[id];
            return user?.fullName.toLowerCase().contains(query) ?? false;
          });
        }
      }).toList();
    }
    setState(() {
      _isSearching = query.isNotEmpty;
    });
  }
  @override
  Widget build(BuildContext context) {
    // Check if there are any DMs at all in the original model
    if (model!.sorted.isEmpty) {
      return const EmptyDmState();
    }
    return Scaffold(
      backgroundColor: DesignVariables.of(context).mainBackground,
      resizeToAvoidBottomInset: true,
      body: SafeArea(
        // Don't pad the bottom here; we want the list content to do that.
          bottom: false,
          child: Stack(
            children: [Column(
              children: [
                SearchRow(controller: _searchController),
                Expanded(
                  child: ListView.builder(
                      itemCount: _filteredConversations.length + (_isSearching ? 1 : 0),
                      itemBuilder: (context, index) {
                        if(index < _filteredConversations.length) {
                          final narrow = _filteredConversations[index];
                          return RecentDmConversationsItem(
                            narrow: narrow,
                            unreadCount: unreadsModel!.countInDmNarrow(narrow),
                            searchQuery: _searchController.text,
                          );
                        }
                        else{
                          return const NewDirectMessageButton();
                        }
                      }),
                )
              ],
            ),
            ],
          )),
      floatingActionButton: Visibility(
        visible: !_isSearching,
        child: const NewDmButton(),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
    );
  }
}

class NewDirectMessageButton extends StatelessWidget {
  const NewDirectMessageButton({super.key});
  @override
  Widget build(BuildContext context) {
    final designVariables = DesignVariables.of(context);
    return Container(
      margin: const EdgeInsets.fromLTRB(24, 8.0, 24, 8),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(12), // Match the button's shape
        color: designVariables.contextMenuItemBg.withAlpha(30) //12% opacity
      ),
      child: FilledButton.icon(
        style: FilledButton.styleFrom(
          minimumSize: const Size(137, 44),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(12),
          ),
          backgroundColor: Colors.transparent,
        ),
        onPressed: (){
          Navigator.of(context).push(
              NewDmScreen.buildRoute(context: context)
          );
        },
        icon: Icon(Icons.add, color: designVariables.contextMenuItemIcon, size: 24),
        label: Text(
          'New Direct Message',
          style: TextStyle(color: designVariables.contextMenuItemText, fontSize: 20, fontWeight: FontWeight.w600),
        ),
      ),
    );
  }
}
class NewDmButton extends StatelessWidget {
  const NewDmButton({super.key});
  @override
  Widget build(BuildContext context) {
    final designVariables = DesignVariables.of(context);
    return Container(
      padding: const EdgeInsets.fromLTRB(12, 8.0, 16.0, 16),
      decoration: BoxDecoration(
        boxShadow: const [
          BoxShadow(
            color: Color(0x662B0E8A), // 40% opacity for #2B0E8A
            offset: Offset(0, 4), // X: 0, Y: 4
            blurRadius: 16, // Blur: 16
            spreadRadius: 0, // Spread: 0
          ),
        ],
        borderRadius: BorderRadius.circular(28), // Match the button's shape
      ),
      child: FilledButton.icon(
        style: FilledButton.styleFrom(
          minimumSize: const Size(137, 48),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(28),
          ),
          backgroundColor: designVariables.fabBg,
        ),
        onPressed: (){
          Navigator.of(context).push(
              NewDmScreen.buildRoute(context: context)
          );
        },
        icon: const Icon(Icons.add, color: Colors.white, size: 24),
        label: Text(
          'New DM',
          style: TextStyle(color: designVariables.fabLabel, fontSize: 20, fontWeight: FontWeight.w500),
        ),
      ),
    );
  }
}

已尝试的解决方案

  • 在Scaffold中设置resizeToAvoidBottomInset: false,可阻止页面主体调整大小,但FAB仍会上移;
  • 在Scaffold外部使用Stack搭配Positioned布局,FAB仍会随键盘弹出移位;
  • 通过MediaQuery.of(context).viewInsets.bottom检测键盘可见性并控制FAB显示,仅能隐藏FAB,无法阻止其显示时的移位。

期望效果

  • FAB始终固定在屏幕底部,不受键盘弹出影响;
  • 键盘弹出时不会将FAB向上推送。

可行解决方案

方法1:动态调整FAB的底部偏移

修改NewDmButton的布局,通过MediaQuery获取键盘高度,动态抵消键盘带来的底部偏移,让FAB始终贴在屏幕物理底部:

class NewDmButton extends StatelessWidget {
  const NewDmButton({super.key});
  @override
  Widget build(BuildContext context) {
    final designVariables = DesignVariables.of(context);
    final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
    return Container(
      padding: EdgeInsets.fromLTRB(12, 8.0, 16.0, 16 + keyboardHeight),
      decoration: BoxDecoration(
        boxShadow: const [
          BoxShadow(
            color: Color(0x662B0E8A),
            offset: Offset(0, 4),
            blurRadius: 16,
            spreadRadius: 0,
          ),
        ],
        borderRadius: BorderRadius.circular(28),
      ),
      child: FilledButton.icon(
        style: FilledButton.styleFrom(
          minimumSize: const Size(137, 48),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(28),
          ),
          backgroundColor: designVariables.fabBg,
        ),
        onPressed: (){
          Navigator.of(context).push(NewDmScreen.buildRoute(context: context));
        },
        icon: const Icon(Icons.add, color: Colors.white, size: 24),
        label: Text(
          'New DM',
          style: TextStyle(color: designVariables.fabLabel, fontSize: 20, fontWeight: FontWeight.w500),
        ),
      ),
    );
  }
}

同时保持Scaffold的resizeToAvoidBottomInset: true,确保页面内容正常上移避免被键盘遮挡。

方法2:将FAB挂载到全局Overlay

利用Flutter的Overlay组件,让FAB脱离页面布局层级,直接挂载到全局Overlay层,完全不受键盘调整影响:

class _RecentDmConversationsPageBodyState extends State<RecentDmConversationsPageBody> with PerAccountStoreAwareStateMixin<RecentDmConversationsPageBody>{
  // ... 原有变量
  OverlayEntry? _fabOverlayEntry;

  @override
  void initState() {
    super.initState();
    _searchController.addListener(_applySearchFilter);
    _fabOverlayEntry = OverlayEntry(builder: (context) {
      return Positioned(
        bottom: 16,
        left: MediaQuery.of(context).size.width / 2 - 68.5,
        child: Visibility(
          visible: !_isSearching,
          child: const NewDmButton(),
        ),
      );
    });
    WidgetsBinding.instance.addPostFrameCallback((_) {
      Overlay.of(context).insert(_fabOverlayEntry!);
    });
  }

  @override
  void dispose() {
    model?.removeListener(_modelChanged);
    unreadsModel?.removeListener(_modelChanged);
    _searchController.dispose();
    _fabOverlayEntry?.remove();
    super.dispose();
  }

  @override
  void setState(VoidCallback fn) {
    super.setState(fn);
    _fabOverlayEntry?.markNeedsBuild();
  }

  // ... 原有方法
}

然后移除原Scaffold中的floatingActionButton和floatingActionButtonLocation配置。

方法3:用bottomNavigationBar替代FAB

如果不需要FAB的特殊定位逻辑,可将其放在bottomNavigationBar中,配合resizeToAvoidBottomInset: false实现固定:

return Scaffold(
  backgroundColor: DesignVariables.of(context).mainBackground,
  resizeToAvoidBottomInset: false,
  body: SafeArea(
    // ... 原有body内容
  ),
  bottomNavigationBar: Visibility(
    visible: !_isSearching,
    child: Padding(
      padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
      child: const NewDmButton(),
    ),
  ),
);

注意调整NewDmButton内部的padding,避免多余边距。

内容的提问来源于stack exchange,提问作者Dhanesh Sawant

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 10:25:57