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

Flutter中如何实现类Facebook Chat Head的floating overlay widget

Flutter实现类Facebook聊天头像浮动悬浮组件

核心实现逻辑

  • 用Flutter自带的Overlay作为悬浮层载体,组件会浮在所有页面之上,不受路由跳转影响
  • 通过GestureDetector监听拖拽手势,实时更新组件坐标,实现随手拖动效果
  • 松手后触发边缘吸附动画,自动贴靠屏幕左右两侧,和Facebook交互逻辑一致
  • 多头像层叠效果用Stack配合固定偏移量实现,还原Facebook多个未读聊天头像堆叠的视觉效果

完整实现代码

首先写全局悬浮管理类,统一处理显示、隐藏逻辑:

import 'package:flutter/material.dart';

class FloatingAvatarOverlay {
  static OverlayEntry? _overlayEntry;
  static Offset _position = const Offset(20, 120);
  static const double _avatarSize = 58;
  static const double _stackOffset = 14;

  static void show(BuildContext context, {required List<ImageProvider> avatars, VoidCallback? onTap}) {
    if (_overlayEntry != null) return;
    _overlayEntry = OverlayEntry(
      builder: (context) => _DraggableFloatingAvatar(
        initPos: _position,
        size: _avatarSize,
        offset: _stackOffset,
        avatars: avatars,
        onPosChange: (newPos) => _position = newPos,
        onTap: onTap,
      ),
    );
    Overlay.of(context).insert(_overlayEntry!);
  }

  static void hide() {
    _overlayEntry?.remove();
    _overlayEntry = null;
  }
}

然后实现可拖拽、带吸附效果、堆叠头像的组件本体:

class _DraggableFloatingAvatar extends StatefulWidget {
  final Offset initPos;
  final double size;
  final double offset;
  final List<ImageProvider> avatars;
  final ValueChanged<Offset> onPosChange;
  final VoidCallback? onTap;

  const _DraggableFloatingAvatar({
    required this.initPos,
    required this.size,
    required this.offset,
    required this.avatars,
    required this.onPosChange,
    this.onTap,
  });

  @override
  State<_DraggableFloatingAvatar> createState() => _DraggableFloatingAvatarState();
}

class _DraggableFloatingAvatarState extends State<_DraggableFloatingAvatar> with SingleTickerProviderStateMixin {
  late Offset _currentPos;
  late AnimationController _adAnimCtrl;
  Animation<Offset>? _adAnim;

  @override
  void initState() {
    super.initState();
    _currentPos = widget.initPos;
    _adAnimCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 180));
  }

  @override
  void dispose() {
    _adAnimCtrl.dispose();
    super.dispose();
  }

  void _snapToEdge() {
    final screenW = MediaQuery.of(context).size.width;
    final safeTop = MediaQuery.of(context).padding.top + 10;
    final safeBottom = MediaQuery.of(context).size.height - widget.size - MediaQuery.of(context).padding.bottom - 10;
    final targetX = _currentPos.dx + widget.size/2 < screenW/2 ? 12.0 : screenW - widget.size - 12;
    final targetY = _currentPos.dy.clamp(safeTop, safeBottom);

    _adAnim = Tween<Offset>(begin: _currentPos, end: Offset(targetX, targetY.toDouble()))
        .animate(CurvedAnimation(parent: _adAnimCtrl, curve: Curves.easeOutCubic))
      ..addListener(() {
        setState(() => _currentPos = _adAnim!.value);
        widget.onPosChange(_currentPos);
      });
    _adAnimCtrl.forward(from: 0);
  }

  @override
  Widget build(BuildContext context) {
    final screenSize = MediaQuery.of(context).size;
    return Positioned(
      left: _currentPos.dx,
      top: _currentPos.dy,
      child: GestureDetector(
        onPanStart: (_) => _adAnimCtrl.stop(),
        onPanUpdate: (details) {
          setState(() {
            _currentPos = Offset(
              (_currentPos.dx + details.delta.dx).clamp(0.0, screenSize.width - widget.size),
              (_currentPos.dy + details.delta.dy).clamp(
                MediaQuery.of(context).padding.top,
                screenSize.height - widget.size - MediaQuery.of(context).padding.bottom
              ),
            );
          });
          widget.onPosChange(_currentPos);
        },
        onPanEnd: (_) => _snapToEdge(),
        onTap: widget.onTap,
        child: _buildStackAvatars(),
      ),
    );
  }

  Widget _buildStackAvatars() {
    final showCount = widget.avatars.length > 3 ? 3 : widget.avatars.length;
    final childList = <Widget>[];
    for (int i = showCount -1; i >=0; i--) {
      childList.add(
        Positioned(
          left: i * widget.offset,
          child: Container(
            width: widget.size,
            height: widget.size,
            padding: const EdgeInsets.all(2.5),
            decoration: BoxDecoration(
              color: Colors.white,
              shape: BoxShape.circle,
              boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.12), blurRadius: 8, offset: const Offset(0,3))]
            ),
            child: CircleAvatar(backgroundImage: widget.avatars[i]),
          ),
        )
      );
    }
    if (widget.avatars.length > 3) {
      childList.add(
        Positioned(
          left: showCount * widget.offset,
          child: Container(
            width: widget.size,
            height: widget.size,
            padding: const EdgeInsets.all(2.5),
            decoration: BoxDecoration(
              color: Colors.white,
              shape: BoxShape.circle,
              boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.12), blurRadius: 8, offset: const Offset(0,3))]
            ),
            child: CircleAvatar(
              backgroundColor: Colors.grey[200],
              child: Text("+${widget.avatars.length - 2}", style: const TextStyle(fontWeight: FontWeight.w600)),
            ),
          ),
        )
      );
    }
    return SizedBox(
      width: widget.size + (showCount + (widget.avatars.length>3?1:0)-1)*widget.offset,
      height: widget.size,
      child: Stack(children: childList),
    );
  }
}

调用方法

在需要显示悬浮头像的页面,首帧渲染完成后调用显示方法,页面销毁时记得隐藏避免内存泄漏:

@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) {
    FloatingAvatarOverlay.show(
      context,
      avatars: [
        const AssetImage("assets/avatar1.png"),
        const AssetImage("assets/avatar2.png"),
        const AssetImage("assets/avatar3.png"),
        const AssetImage("assets/avatar4.png"),
      ],
      onTap: () {
        // 这里写点击头像跳转聊天列表的业务逻辑
      }
    );
  });
}

@override
void dispose() {
  FloatingAvatarOverlay.hide();
  super.dispose();
}

自定义调整说明

  • 头像尺寸、堆叠间距可以直接修改FloatingAvatarOverlay里的_avatarSize、_stackOffset参数
  • 吸附动画时长、曲线可以在_adAnimCtrl的初始化参数里调整
  • 头像阴影、白边宽度直接修改_buildStackAvatars里的Container装饰属性即可
  • 如果需要拖拽时的缩放、透明度反馈,在onPanUpdate回调里增加对应的动画值即可

当前开发进度截图

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.30 11:51:22