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
相关产品推荐
相关产品推荐

