feat: 改进聊天弹窗和遮罩层交互,优化状态管理和绘制逻辑
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
|
||||||
// 只渲染 FloatingIcon,Overlay 弹窗内容在长按时插入
|
|
||||||
return _buildFloatingIcon();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user