feat: 改进聊天弹窗和遮罩层交互,优化状态管理和绘制逻辑

This commit is contained in:
2025-09-23 09:53:49 +08:00
parent b9ab384fde
commit 6523f8e512
2 changed files with 197 additions and 100 deletions

View File

@@ -1,46 +1,52 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart';
/// 遮罩层icon 区域挖洞可交互 // 一个带洞的遮罩层点击洞外区域触发onTap事件
class HoleOverlay extends StatelessWidget { class HoleOverlay extends StatelessWidget {
final Offset iconPosition; final Offset iconPosition;
final double iconSize; final double iconSize;
final VoidCallback? onTap; final VoidCallback? onTap;
final Widget? child;
const HoleOverlay({ const HoleOverlay({
super.key, super.key,
required this.iconPosition, required this.iconPosition,
required this.iconSize, required this.iconSize,
this.onTap, this.onTap,
this.child,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Listener( debugPrint('🔧 HoleOverlay build: iconPosition=$iconPosition, iconSize=$iconSize');
behavior: HitTestBehavior.translucent,
onPointerDown: (PointerDownEvent event) { final holeRect = Rect.fromLTWH(
final RenderBox box = context.findRenderObject() as RenderBox; iconPosition.dx,
final Offset local = box.globalToLocal(event.position); iconPosition.dy,
final iconRect = Rect.fromLTWH( iconSize,
iconPosition.dx, iconSize,
iconPosition.dy, );
iconSize,
iconSize, return CustomPaint(
); painter: _HolePainter(
if (!iconRect.contains(local)) { iconPosition: iconPosition,
// 遮罩区域,拦截并关闭弹窗 iconSize: iconSize,
if (onTap != null) { ),
onTap!(); child: GestureDetector(
onTapDown: (details) {
final tapPosition = details.localPosition;
final isInHole = holeRect.contains(tapPosition);
debugPrint('🎯 HoleOverlay onTapDown: position=$tapPosition, holeRect=$holeRect, isInHole=$isInHole');
if (!isInHole) {
debugPrint('🔥 HoleOverlay: 点击洞外触发onTap');
onTap?.call();
} else {
debugPrint('🚫 HoleOverlay: 点击洞内不触发onTap');
} }
} },
// 如果点在icon区域什么都不做事件会传递到下层 child: child,
}, behavior: HitTestBehavior.translucent,
child: CustomPaint(
size: Size.infinite,
painter: _HolePainter(
iconPosition: iconPosition,
iconSize: iconSize,
),
), ),
); );
} }
@@ -50,31 +56,51 @@ class _HolePainter extends CustomPainter {
final Offset iconPosition; final Offset iconPosition;
final double iconSize; final double iconSize;
_HolePainter({required this.iconPosition, required this.iconSize}); _HolePainter({
required this.iconPosition,
required this.iconSize,
});
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withOpacity(0.0)
..blendMode = BlendMode.srcOver;
// 画全屏遮罩
canvas.drawRect(Offset.zero & size, paint);
// 挖洞
final holeRect = Rect.fromLTWH( final holeRect = Rect.fromLTWH(
iconPosition.dx, iconPosition.dx,
iconPosition.dy, iconPosition.dy,
iconSize, iconSize,
iconSize, iconSize,
); );
final holePath = Path()..addOval(holeRect);
paint.blendMode = BlendMode.clear; debugPrint('🎨 HolePainter paint: size=$size, holeRect=$holeRect');
canvas.drawPath(holePath, paint);
// 使用 saveLayer 确保混合模式正确
canvas.saveLayer(Offset.zero & size, Paint());
// 画半透明背景
final backgroundPaint = Paint()
..color = Colors.black.withOpacity(0.3);
canvas.drawRect(Offset.zero & size, backgroundPaint);
debugPrint('🔲 HolePainter paint: 绘制背景完成');
// 挖洞:使用 BlendMode.clear
final holePaint = Paint()
..blendMode = BlendMode.clear;
canvas.drawOval(holeRect, holePaint);
debugPrint('⭕ HolePainter paint: 挖洞完成holeRect=$holeRect');
canvas.restore();
// 调试:画红色边框显示洞的位置
final debugPaint = Paint()
..color = Colors.red
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
canvas.drawOval(holeRect, debugPaint);
debugPrint('🔴 HolePainter paint: 绘制调试边框完成');
} }
@override @override
bool shouldRepaint(covariant _HolePainter oldDelegate) { bool shouldRepaint(covariant _HolePainter oldDelegate) {
return iconPosition != oldDelegate.iconPosition || iconSize != oldDelegate.iconSize; return oldDelegate.iconPosition != iconPosition ||
oldDelegate.iconSize != iconSize;
} }
} }

View File

@@ -26,9 +26,9 @@ class ChatPopup extends StatefulWidget {
} }
class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMixin { class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMixin {
OverlayEntry? _overlayEntry;
Offset _iconPosition = const Offset(10, 120); Offset _iconPosition = const Offset(10, 120);
final double _iconSizeDefault = 80.0; final double _iconSizeDefault = 80.0;
bool _isShowingPopup = false;
late AnimationController _partScreenAnimationController; late AnimationController _partScreenAnimationController;
@override @override
@@ -42,71 +42,47 @@ class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMix
@override @override
void dispose() { void dispose() {
_removeOverlay();
_partScreenAnimationController.dispose(); _partScreenAnimationController.dispose();
super.dispose(); super.dispose();
} }
void _openFullScreen() async {
final messageService = context.read<MessageService>();
_removeOverlay();
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ChangeNotifierProvider.value(
value: messageService, // 传递同一个单例实例
child: const FullScreenPage(),
),
),
);
}
void _insertOverlay() { void _insertOverlay() {
if (_overlayEntry != null) return; if (_isShowingPopup) return;
_overlayEntry = OverlayEntry( setState(() {
builder: (context) => _buildOverlayContent(), _isShowingPopup = true;
); });
Overlay.of(context).insert(_overlayEntry!);
_partScreenAnimationController.forward(); _partScreenAnimationController.forward();
} }
void _removeOverlay() { void _removeOverlay() {
if (_overlayEntry == null) return; if (!_isShowingPopup) return;
_partScreenAnimationController.reverse().then((_) { _partScreenAnimationController.reverse().then((_) {
_overlayEntry?.remove(); if (mounted) {
_overlayEntry = null; setState(() {
_isShowingPopup = false;
});
}
}); });
} }
Widget _buildOverlayContent() { @override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
value: MessageService.instance, value: MessageService.instance,
child: Material( child: LayoutBuilder(
color: Colors.transparent, builder: (context, constraints) {
child: LayoutBuilder( return Stack(
builder: (context, constraints) { children: [
final position = _calculatePosition(constraints); // 1. 底层:弹窗内容(只有在显示时才渲染)
final double minHeight = constraints.maxHeight * 0.16; if (_isShowingPopup) ...[
final double maxHeight = constraints.maxHeight * 0.4;
final double chatWidth = constraints.maxWidth * 0.94;
return Stack(
children: [
_buildPopupBackground(), _buildPopupBackground(),
Positioned( _buildPopupContent(constraints),
left: position.left,
right: position.right,
bottom: position.bottom,
child: ChatWindowContent(
animationController: _partScreenAnimationController,
minHeight: minHeight,
maxHeight: maxHeight,
chatWidth: chatWidth,
),
),
], ],
); // 2. 顶层FloatingIcon始终在最上层事件不被遮挡
}, _buildFloatingIcon(),
), ],
);
},
), ),
); );
} }
@@ -120,23 +96,125 @@ class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMix
messageService.abortReply(); messageService.abortReply();
messageService.initializeEmpty(); messageService.initializeEmpty();
}, },
child: Container( child: SizedBox.expand()
color: Colors.black.withOpacity(0.1), ),
), );
}
Widget _buildPopupContent(BoxConstraints constraints) {
final position = _calculatePosition(constraints);
final double minHeight = constraints.maxHeight * 0.16;
final double maxHeight = constraints.maxHeight * 0.4;
final double chatWidth = constraints.maxWidth * 0.94;
return Positioned(
left: position.left,
right: position.right,
bottom: position.bottom,
child: ChatWindowContent(
animationController: _partScreenAnimationController,
minHeight: minHeight,
maxHeight: maxHeight,
chatWidth: chatWidth,
), ),
); );
} }
Widget _buildFloatingIcon() { Widget _buildFloatingIcon() {
// 如果外部传入了 child优先使用它的属性
if (widget.child != null) {
return ChatFloatingIcon(
iconSize: widget.child!.iconSize ?? widget.iconSize ?? _iconSizeDefault,
icon: widget.child!.icon ?? widget.icon,
onTap: () async {
debugPrint('🖱️ FloatingIcon onTap triggered! (using child properties)');
// 先执行外部传入的 onTap如果有
if (widget.child!.onTap != null) {
widget.child!.onTap!();
} else {
// 默认行为:关闭弹窗并打开全屏
final messageService = context.read<MessageService>();
_removeOverlay();
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ChangeNotifierProvider.value(
value: messageService,
child: const FullScreenPage(),
),
),
);
}
},
onLongPress: () async {
debugPrint('⏳ FloatingIcon onLongPress triggered! (using child properties)');
// 先执行外部传入的 onLongPress如果有
if (widget.child!.onLongPress != null) {
widget.child!.onLongPress!();
}
// 执行内部业务逻辑
final messageService = MessageService.instance;
_insertOverlay();
await messageService.startVoiceInput();
},
onLongPressUp: () async {
debugPrint('⏫ FloatingIcon onLongPressUp triggered! (using child properties)');
// 先执行外部传入的 onLongPressUp如果有
if (widget.child!.onLongPressUp != null) {
widget.child!.onLongPressUp!();
}
// 执行内部业务逻辑
final messageService = MessageService.instance;
await messageService.stopAndProcessVoiceInput();
final hasMessage = messageService.messages.isNotEmpty;
if (!hasMessage) {
_removeOverlay();
}
},
onPositionChanged: (position) {
debugPrint('📍 FloatingIcon position changed: $position (using child properties)');
// 先执行外部传入的 onPositionChanged如果有
if (widget.child!.onPositionChanged != null) {
widget.child!.onPositionChanged!(position);
}
// 执行内部业务逻辑
setState(() {
_iconPosition = position;
});
},
// 传递其他可能的属性
onLongPressEnd: widget.child!.onLongPressEnd,
onDragStart: widget.child!.onDragStart,
onDragEnd: widget.child!.onDragEnd,
);
}
// 如果没有传入 child使用原来的逻辑
return ChatFloatingIcon( return ChatFloatingIcon(
iconSize: widget.iconSize ?? _iconSizeDefault, iconSize: widget.iconSize ?? _iconSizeDefault,
icon: widget.icon, icon: widget.icon,
onTap: () async {
debugPrint('🖱️ FloatingIcon onTap triggered!');
final messageService = context.read<MessageService>();
_removeOverlay();
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ChangeNotifierProvider.value(
value: messageService, // 传递同一个单例实例
child: const FullScreenPage(),
),
),
);
},
onLongPress: () async { onLongPress: () async {
debugPrint('⏳ FloatingIcon onLongPress triggered!');
final messageService = MessageService.instance; final messageService = MessageService.instance;
_insertOverlay(); _insertOverlay();
await messageService.startVoiceInput(); await messageService.startVoiceInput();
}, },
onLongPressUp: () async { onLongPressUp: () async {
debugPrint('⏫ FloatingIcon onLongPressUp triggered!');
final messageService = MessageService.instance; final messageService = MessageService.instance;
await messageService.stopAndProcessVoiceInput(); await messageService.stopAndProcessVoiceInput();
final hasMessage = messageService.messages.isNotEmpty; final hasMessage = messageService.messages.isNotEmpty;
@@ -145,11 +223,10 @@ class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMix
} }
}, },
onPositionChanged: (position) { onPositionChanged: (position) {
debugPrint('📍 FloatingIcon position changed: $position');
setState(() { setState(() {
_iconPosition = position; _iconPosition = position;
}); });
// 重新构建 overlay 以更新弹窗位置
_overlayEntry?.markNeedsBuild();
}, },
); );
} }
@@ -199,10 +276,4 @@ class _ChatPopupState extends State<ChatPopup> with SingleTickerProviderStateMix
bottom: chatBottom, bottom: chatBottom,
); );
} }
@override
Widget build(BuildContext context) {
// 只渲染 FloatingIconOverlay 弹窗内容在长按时插入
return _buildFloatingIcon();
}
} }