Flutter - 实现聊天键盘与功能面板的丝滑切换
Flutter键盘面板切换抖动终极解决方案 摘要:针对Flutter官方长期未解决的键盘与面板切换抖动问题,本文提出一套完整解决方案。核心配置包括:1)禁用Scaffold自动调整;2)使用Column布局结合Expanded和ListView;3)定义枚举类型管理面板状态;4)通过ChatBottomPanelContainer组件实现类型安全的面板切换。该方案具有四大优势:类型安全、统一焦点管
🎯 问题背景
-
Flutter 官方 issue #121059 和 #32583 中长期存在的键盘与面板切换抖动问题
-
影响用户体验,特别是聊天类应用
-
官方多年未解决
💡 解决方案核心
1. 关键配置
dart
Scaffold(
resizeToAvoidBottomInset: false, // 必须设置为 false
body: Column(
children: [
Expanded(child: ListView.builder(...)),
_buildInputView(), // 输入框
_buildPanelContainer(), // 底部容器
],
),
)
2. 类型定义
dart
enum PanelType {
none, // 收起
keyboard, // 键盘
emoji, // 表情
tool, // 其它工具
}
3. 核心组件使用
dart
ChatBottomPanelContainer<PanelType>(
controller: controller,
inputFocusNode: inputFocusNode,
otherPanelWidget: (type) {
switch (type) {
case PanelType.emoji: return _buildEmojiPickerPanel();
case PanelType.tool: return _buildToolPanel();
default: return const SizedBox.shrink();
}
},
onPanelTypeChange: (panelType, data) {
// 处理面板类型变化
},
)
4. 切换逻辑
dart
// 切换到表情面板 controller.updatePanelType( ChatBottomPanelType.other, data: PanelType.emoji, ); // 切换到键盘 controller.updatePanelType(ChatBottomPanelType.keyboard);
🚀 技术亮点
-
类型安全: 使用泛型关联外部自定义面板类型
-
焦点管理: 通过
inputFocusNode统一管理键盘焦点 -
平滑过渡: 避免了布局重计算导致的抖动
-
灵活扩展: 支持多种自定义面板类型
📦 集成方式
yaml
dependencies: chat_bottom_container: ^latest_version
——————————————————————————————————————————
在移动端聊天应用中,键盘与功能面板(如表情、文件、相册等)的切换体验直接影响用户的使用感受。本文将深入探讨如何在Flutter中实现丝滑的键盘与面板切换效果。
一、核心原理分析
1.1 键盘与面板切换的本质
在Flutter中,键盘与功能面板的切换本质上涉及以下几个关键点:
-
布局调整:键盘弹出时,界面需要重新布局
-
动画过渡:面板与键盘之间的切换需要平滑动画
-
焦点管理:正确处理输入框的焦点状态
-
事件处理:处理触摸、滑动等交互事件
1.2 技术挑战
-
键盘高度获取:不同设备、不同输入法的键盘高度不同
-
平台差异:iOS和Android在键盘行为上有差异
-
性能优化:动画的流畅性要求
-
状态同步:键盘状态与面板状态的同步
二、基础实现方案
2.1 基础布局结构
dart
class ChatPage extends StatefulWidget {
@override
_ChatPageState createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> with SingleTickerProviderStateMixin {
final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode();
bool _showEmojiPanel = false;
bool _showMorePanel = false;
double _keyboardHeight = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('聊天'),
),
body: Column(
children: [
// 聊天消息列表
Expanded(
child: ListView.builder(
reverse: true,
itemCount: 20,
itemBuilder: (context, index) {
return _buildMessageItem(index);
},
),
),
// 输入区域
_buildInputArea(),
],
),
);
}
Widget _buildInputArea() {
return Container(
color: Colors.white,
child: Column(
children: [
// 输入框和发送按钮
_buildInputBar(),
// 表情面板或功能面板
_buildBottomPanel(),
],
),
);
}
}
2.2 键盘高度监听
dart
class _ChatPageState extends State<ChatPage> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_setupKeyboardListeners();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_focusNode.dispose();
_textController.dispose();
super.dispose();
}
@override
void didChangeMetrics() {
super.didChangeMetrics();
// 监听键盘变化
final viewInsets = WidgetsBinding.instance.window.viewInsets;
final bottomInset = viewInsets.bottom;
if (bottomInset > 0) {
// 键盘弹出
setState(() {
_keyboardHeight = bottomInset;
_showEmojiPanel = false;
_showMorePanel = false;
});
} else {
// 键盘收起
if (!_showEmojiPanel && !_showMorePanel) {
setState(() {
_keyboardHeight = 0;
});
}
}
}
void _setupKeyboardListeners() {
// iOS键盘事件监听(可选)
if (Platform.isIOS) {
_focusNode.addListener(() {
if (!_focusNode.hasFocus) {
// 输入框失去焦点时,可能是点击了表情或更多按钮
Future.delayed(const Duration(milliseconds: 100), () {
if (!_showEmojiPanel && !_showMorePanel) {
setState(() {
_keyboardHeight = 0;
});
}
});
}
});
}
}
}
三、进阶实现:使用Overlay和动画
3.1 Overlay方案的优势
使用Overlay可以实现面板在键盘上层显示,避免布局重排带来的跳动感。
dart
class SmoothChatInput extends StatefulWidget {
@override
_SmoothChatInputState createState() => _SmoothChatInputState();
}
class _SmoothChatInputState extends State<SmoothChatInput>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode();
late AnimationController _panelController;
late Animation<double> _panelAnimation;
PanelType _currentPanel = PanelType.none;
double _keyboardHeight = 0;
OverlayEntry? _panelOverlayEntry;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_panelController = AnimationController(
duration: const Duration(milliseconds: 250),
vsync: this,
);
_panelAnimation = CurvedAnimation(
parent: _panelController,
curve: Curves.fastOutSlowIn,
);
_setupFocusListeners();
}
void _setupFocusListeners() {
_focusNode.addListener(() {
if (_focusNode.hasFocus) {
// 输入框获取焦点,隐藏面板
_hidePanel();
}
});
}
void _showPanel(PanelType type) {
if (_currentPanel == type) {
// 点击当前已显示的按钮,切换回键盘
_hidePanel();
_focusNode.requestFocus();
return;
}
_currentPanel = type;
// 先收起键盘
_focusNode.unfocus();
// 延迟显示面板,确保键盘已收起
Future.delayed(const Duration(milliseconds: 100), () {
_createPanelOverlay();
_panelController.forward();
});
}
void _hidePanel() {
if (_currentPanel != PanelType.none) {
_panelController.reverse().then((_) {
_removePanelOverlay();
_currentPanel = PanelType.none;
});
}
}
void _createPanelOverlay() {
if (_panelOverlayEntry != null) return;
_panelOverlayEntry = OverlayEntry(
builder: (context) {
return Positioned(
bottom: 0,
left: 0,
right: 0,
child: SizeTransition(
sizeFactor: _panelAnimation,
axisAlignment: -1.0,
child: _buildPanelContent(),
),
);
},
);
Overlay.of(context).insert(_panelOverlayEntry!);
}
Widget _buildPanelContent() {
switch (_currentPanel) {
case PanelType.emoji:
return EmojiPanel(
onEmojiSelected: (emoji) {
_textController.text += emoji;
},
);
case PanelType.more:
return MoreFunctionPanel(
onFunctionSelected: _handleFunctionSelected,
);
default:
return Container();
}
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
padding: const EdgeInsets.all(8),
child: Row(
children: [
// 表情按钮
IconButton(
icon: Icon(
_currentPanel == PanelType.emoji
? Icons.keyboard
: Icons.emoji_emotions_outlined,
),
onPressed: () => _showPanel(PanelType.emoji),
),
// 输入框
Expanded(
child: TextField(
controller: _textController,
focusNode: _focusNode,
decoration: InputDecoration(
hintText: '输入消息...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
maxLines: 5,
minLines: 1,
),
),
// 发送按钮或更多按钮
if (_textController.text.isNotEmpty)
IconButton(
icon: Icon(Icons.send),
onPressed: _sendMessage,
)
else
IconButton(
icon: Icon(
_currentPanel == PanelType.more
? Icons.keyboard
: Icons.add_circle_outline,
),
onPressed: () => _showPanel(PanelType.more),
),
],
),
);
}
}
3.2 面板组件实现
dart
// 表情面板
class EmojiPanel extends StatelessWidget {
final ValueChanged<String> onEmojiSelected;
const EmojiPanel({Key? key, required this.onEmojiSelected}) : super(key: key);
@override
Widget build(BuildContext context) {
final panelHeight = MediaQuery.of(context).size.height * 0.35;
return Container(
height: panelHeight,
color: Colors.grey[100],
child: Column(
children: [
// 表情分类标签
_buildCategoryTabs(),
// 表情网格
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: _emojis.length,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () => onEmojiSelected(_emojis[index]),
child: Container(
alignment: Alignment.center,
child: Text(
_emojis[index],
style: const TextStyle(fontSize: 24),
),
),
);
},
),
),
],
),
);
}
Widget _buildCategoryTabs() {
return Container(
height: 48,
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.grey[300]!)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildCategoryTab('😀', true),
_buildCategoryTab('🚗', false),
_buildCategoryTab('🏳️', false),
_buildCategoryTab('⭐', false),
],
),
);
}
final List<String> _emojis = [
'😀', '😂', '😍', '😎', '😭', '😡', '😱',
'👍', '👎', '👏', '🙏', '💪', '👀', '👋',
'❤️', '💔', '💕', '💖', '💯', '🔥', '✨',
// ... 更多表情
];
}
// 更多功能面板
class MoreFunctionPanel extends StatelessWidget {
final ValueChanged<MoreFunction> onFunctionSelected;
const MoreFunctionPanel({Key? key, required this.onFunctionSelected}) : super(key: key);
@override
Widget build(BuildContext context) {
final panelHeight = MediaQuery.of(context).size.height * 0.3;
return Container(
height: panelHeight,
color: Colors.grey[100],
child: GridView.builder(
padding: const EdgeInsets.all(20),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 20,
mainAxisSpacing: 20,
),
itemCount: _functions.length,
itemBuilder: (context, index) {
final function = _functions[index];
return GestureDetector(
onTap: () => onFunctionSelected(function),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.2),
blurRadius: 5,
spreadRadius: 1,
),
],
),
child: Icon(
function.icon,
color: Colors.blue,
size: 28,
),
),
const SizedBox(height: 8),
Text(
function.label,
style: const TextStyle(fontSize: 12),
),
],
),
);
},
),
);
}
final List<MoreFunction> _functions = [
MoreFunction(Icons.photo, '相册'),
MoreFunction(Icons.camera_alt, '拍摄'),
MoreFunction(Icons.file_present, '文件'),
MoreFunction(Icons.location_on, '位置'),
MoreFunction(Icons.contact_phone, '联系人'),
MoreFunction(Icons.voice_chat, '语音输入'),
MoreFunction(Icons.redo, '转账'),
MoreFunction(Icons.card_giftcard, '红包'),
];
}
四、高级优化方案
4.1 使用KeyboardVisibilityBuilder
dart
class AdvancedChatInput extends StatefulWidget {
@override
_AdvancedChatInputState createState() => _AdvancedChatInputState();
}
class _AdvancedChatInputState extends State<AdvancedChatInput>
with TickerProviderStateMixin {
final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode();
late AnimationController _panelController;
late Animation<double> _panelHeightAnimation;
PanelState _panelState = PanelState.keyboard;
double _targetPanelHeight = 0;
@override
void initState() {
super.initState();
_panelController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_panelHeightAnimation = Tween<double>(
begin: 0,
end: 0,
).animate(CurvedAnimation(
parent: _panelController,
curve: Curves.easeInOut,
));
}
void _togglePanel(PanelType type) {
final panelHeight = _calculatePanelHeight(type);
if (_panelState == PanelState.keyboard) {
// 从键盘切换到面板
_switchFromKeyboardToPanel(type, panelHeight);
} else if (_panelState == PanelState.panel && _currentPanelType == type) {
// 当前面板切换到键盘
_switchFromPanelToKeyboard();
} else {
// 从一个面板切换到另一个面板
_switchBetweenPanels(type, panelHeight);
}
}
void _switchFromKeyboardToPanel(PanelType type, double height) {
_currentPanelType = type;
_targetPanelHeight = height;
// 先收起键盘
_focusNode.unfocus();
// 延迟开始面板动画
Future.delayed(const Duration(milliseconds: 50), () {
_panelState = PanelState.panel;
_startPanelAnimation(0, height);
});
}
void _switchFromPanelToKeyboard() {
_panelState = PanelState.keyboard;
_startPanelAnimation(_targetPanelHeight, 0).then((_) {
_focusNode.requestFocus();
_currentPanelType = null;
_targetPanelHeight = 0;
});
}
Future<void> _startPanelAnimation(double from, double to) {
_panelHeightAnimation = Tween<double>(
begin: from,
end: to,
).animate(CurvedAnimation(
parent: _panelController,
curve: Curves.easeInOut,
));
_panelController.value = 0;
return _panelController.forward();
}
double _calculatePanelHeight(PanelType type) {
final screenHeight = MediaQuery.of(context).size.height;
switch (type) {
case PanelType.emoji:
return screenHeight * 0.35;
case PanelType.more:
return screenHeight * 0.3;
default:
return 0;
}
}
@override
Widget build(BuildContext context) {
return KeyboardVisibilityBuilder(
builder: (context, isKeyboardVisible) {
// 键盘状态变化时的处理
if (isKeyboardVisible && _panelState == PanelState.panel) {
// 键盘弹出时,如果面板正在显示,则隐藏面板
_panelState = PanelState.keyboard;
_panelController.reverse();
}
return Column(
children: [
// 输入栏
_buildInputBar(),
// 面板区域
AnimatedBuilder(
animation: _panelController,
builder: (context, child) {
return Container(
height: _panelHeightAnimation.value,
child: _panelState == PanelState.panel
? _buildCurrentPanel()
: null,
);
},
),
],
);
},
);
}
}
4.2 使用CustomSingleChildLayout实现弹性布局
dart
class ElasticChatLayout extends StatefulWidget {
@override
_ElasticChatLayoutState createState() => _ElasticChatLayoutState();
}
class _ElasticChatLayoutState extends State<ElasticChatLayout>
with SingleTickerProviderStateMixin {
late AnimationController _elasticController;
double _currentPanelHeight = 0;
double _targetPanelHeight = 0;
@override
void initState() {
super.initState();
_elasticController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
// 使用弹性曲线
_elasticController.addListener(() {
setState(() {
// 计算弹性效果的高度
_currentPanelHeight = _calculateElasticHeight(
_elasticController.value,
_targetPanelHeight,
);
});
});
}
double _calculateElasticHeight(double animationValue, double targetHeight) {
// 使用弹性公式
final overshoot = 1.70158; // 弹性系数
final value = animationValue - 1;
return targetHeight * (value * value * ((overshoot + 1) * value + overshoot) + 1);
}
void _showPanelWithElasticEffect(double height) {
_targetPanelHeight = height;
_elasticController.forward(from: 0);
}
@override
Widget build(BuildContext context) {
return CustomSingleChildLayout(
delegate: _ChatLayoutDelegate(
panelHeight: _currentPanelHeight,
keyboardHeight: MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
children: [
// 消息列表
Expanded(
child: ListView(
children: [
// 消息内容
],
),
),
// 输入区域
_buildInputArea(),
// 面板
Container(
height: _currentPanelHeight,
child: _buildPanelContent(),
),
],
),
);
}
}
class _ChatLayoutDelegate extends SingleChildLayoutDelegate {
final double panelHeight;
final double keyboardHeight;
_ChatLayoutDelegate({
required this.panelHeight,
required this.keyboardHeight,
});
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return BoxConstraints(
maxWidth: constraints.maxWidth,
maxHeight: constraints.maxHeight,
);
}
@override
Offset getPositionForChild(Size size, Size childSize) {
// 计算子组件的位置,实现弹性效果
final contentHeight = size.height - keyboardHeight - panelHeight;
final availableHeight = contentHeight;
return Offset(0, size.height - childSize.height);
}
@override
bool shouldRelayout(covariant _ChatLayoutDelegate oldDelegate) {
return oldDelegate.panelHeight != panelHeight ||
oldDelegate.keyboardHeight != keyboardHeight;
}
}
五、性能优化与最佳实践
5.1 面板内容的懒加载
dart
class LazyLoadPanel extends StatefulWidget {
final PanelType type;
const LazyLoadPanel({Key? key, required this.type}) : super(key: key);
@override
_LazyLoadPanelState createState() => _LazyLoadPanelState();
}
class _LazyLoadPanelState extends State<LazyLoadPanel>
with AutomaticKeepAliveClientMixin {
bool _isLoaded = false;
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
if (!_isLoaded) {
// 延迟加载
return FutureBuilder(
future: Future.delayed(const Duration(milliseconds: 50)),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_isLoaded = true;
});
});
}
return _buildSkeleton();
},
);
}
return _buildActualContent();
}
Widget _buildSkeleton() {
return Container(
color: Colors.grey[100],
child: Center(
child: CircularProgressIndicator(),
),
);
}
Widget _buildActualContent() {
switch (widget.type) {
case PanelType.emoji:
return EmojiPanel(onEmojiSelected: (_) {});
case PanelType.more:
return MoreFunctionPanel(onFunctionSelected: (_) {});
default:
return Container();
}
}
}
5.2 使用ValueNotifier优化状态管理
dart
class ChatInputViewModel {
final ValueNotifier<PanelState> panelState =
ValueNotifier(PanelState.keyboard);
final ValueNotifier<double> panelHeight = ValueNotifier(0);
final ValueNotifier<PanelType?> currentPanel = ValueNotifier(null);
final TextEditingController textController = TextEditingController();
final FocusNode focusNode = FocusNode();
void togglePanel(PanelType type, double panelHeight) {
if (currentPanel.value == type) {
// 切换到键盘
hidePanel();
focusNode.requestFocus();
} else {
// 切换到面板
showPanel(type, panelHeight);
}
}
void showPanel(PanelType type, double height) {
focusNode.unfocus();
Future.delayed(const Duration(milliseconds: 50), () {
currentPanel.value = type;
panelHeight.value = height;
panelState.value = PanelState.panel;
});
}
void hidePanel() {
panelState.value = PanelState.keyboard;
panelHeight.value = 0;
currentPanel.value = null;
}
}
class OptimizedChatInput extends StatelessWidget {
final ChatInputViewModel viewModel;
const OptimizedChatInput({Key? key, required this.viewModel}) : super(key: key);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<PanelState>(
valueListenable: viewModel.panelState,
builder: (context, panelState, child) {
return Column(
children: [
// 输入栏
_buildInputBar(context),
// 面板区域
ValueListenableBuilder<double>(
valueListenable: viewModel.panelHeight,
builder: (context, height, child) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: height,
curve: Curves.easeInOut,
child: ValueListenableBuilder<PanelType?>(
valueListenable: viewModel.currentPanel,
builder: (context, panelType, child) {
return _buildPanelContent(panelType);
},
),
);
},
),
],
);
},
);
}
}
六、处理平台差异
6.1 iOS和Android差异处理
dart
class PlatformAwareChatInput extends StatefulWidget {
@override
_PlatformAwareChatInputState createState() => _PlatformAwareChatInputState();
}
class _PlatformAwareChatInputState extends State<PlatformAwareChatInput>
with WidgetsBindingObserver {
double _keyboardHeight = 0;
bool _isKeyboardVisible = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
if (Platform.isIOS) {
_setupIOSKeyboardListener();
} else {
_setupAndroidKeyboardListener();
}
}
void _setupIOSKeyboardListener() {
// iOS需要特殊处理
_focusNode.addListener(() {
if (!_focusNode.hasFocus) {
// iOS键盘收起动画较慢,需要延迟处理
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
setState(() {
_isKeyboardVisible = false;
_keyboardHeight = 0;
});
}
});
}
});
}
void _setupAndroidKeyboardListener() {
// Android处理相对简单
}
@override
void didChangeMetrics() {
super.didChangeMetrics();
final viewInsets = WidgetsBinding.instance.window.viewInsets;
final newKeyboardHeight = viewInsets.bottom;
if (Platform.isIOS) {
_handleIOSKeyboardChange(newKeyboardHeight);
} else {
_handleAndroidKeyboardChange(newKeyboardHeight);
}
}
void _handleIOSKeyboardChange(double newKeyboardHeight) {
// iOS键盘高度变化处理
if (newKeyboardHeight > 0 && !_isKeyboardVisible) {
_isKeyboardVisible = true;
_keyboardHeight = newKeyboardHeight;
} else if (newKeyboardHeight == 0 && _isKeyboardVisible) {
// 在iOS上,键盘收起可能不是立即的
// 我们通过焦点监听来处理
}
}
void _handleAndroidKeyboardChange(double newKeyboardHeight) {
// Android键盘高度变化处理
setState(() {
_keyboardHeight = newKeyboardHeight;
_isKeyboardVisible = newKeyboardHeight > 0;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: Platform.isIOS ? false : true,
body: SafeArea(
bottom: Platform.isAndroid,
child: _buildChatLayout(),
),
);
}
}
七、完整示例代码
dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const ChatApp());
}
class ChatApp extends StatelessWidget {
const ChatApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter 聊天',
theme: ThemeData(
primarySwatch: Colors.blue,
platform: TargetPlatform.iOS,
),
home: const CompleteChatPage(),
);
}
}
class CompleteChatPage extends StatefulWidget {
const CompleteChatPage({Key? key}) : super(key: key);
@override
_CompleteChatPageState createState() => _CompleteChatPageState();
}
class _CompleteChatPageState extends State<CompleteChatPage>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode();
late AnimationController _panelController;
late Animation<double> _panelHeightAnimation;
PanelType? _currentPanel;
double _keyboardHeight = 0;
bool _isKeyboardVisible = false;
final double _emojiPanelHeight = 280;
final double _morePanelHeight = 220;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_panelController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_panelHeightAnimation = Tween<double>(begin: 0, end: 0).animate(
CurvedAnimation(
parent: _panelController,
curve: Curves.easeInOut,
),
);
_setupKeyboardListeners();
}
void _setupKeyboardListeners() {
_focusNode.addListener(() {
if (_focusNode.hasFocus && _currentPanel != null) {
// 输入框获取焦点时,如果有面板显示,则隐藏面板
_hidePanel();
}
});
}
@override
void didChangeMetrics() {
super.didChangeMetrics();
final viewInsets = WidgetsBinding.instance.window.viewInsets;
final bottomInset = viewInsets.bottom;
if (bottomInset > 0) {
// 键盘弹出
_isKeyboardVisible = true;
_keyboardHeight = bottomInset;
if (_currentPanel != null) {
// 键盘弹出时隐藏面板
_hidePanel();
}
} else if (_isKeyboardVisible) {
// 键盘收起
_isKeyboardVisible = false;
_keyboardHeight = 0;
}
}
void _togglePanel(PanelType type) {
if (_currentPanel == type) {
// 切换到键盘
_hidePanel();
_focusNode.requestFocus();
} else {
// 切换到面板
_showPanel(type);
}
}
void _showPanel(PanelType type) {
_currentPanel = type;
// 先收起键盘
_focusNode.unfocus();
// 延迟显示面板
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
final targetHeight = type == PanelType.emoji
? _emojiPanelHeight
: _morePanelHeight;
_panelHeightAnimation = Tween<double>(
begin: 0,
end: targetHeight,
).animate(
CurvedAnimation(
parent: _panelController,
curve: Curves.easeInOut,
),
);
_panelController.forward(from: 0);
}
});
}
void _hidePanel() {
if (_currentPanel != null) {
_panelController.reverse().then((_) {
if (mounted) {
setState(() {
_currentPanel = null;
});
}
});
}
}
void _sendMessage() {
final text = _textController.text.trim();
if (text.isNotEmpty) {
// 发送消息逻辑
_textController.clear();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('聊天'),
elevation: 1,
),
body: GestureDetector(
onTap: () {
// 点击空白处收起键盘和面板
_focusNode.unfocus();
_hidePanel();
},
child: Column(
children: [
// 消息列表
Expanded(
child: ListView.builder(
reverse: true,
padding: const EdgeInsets.all(16),
itemCount: 20,
itemBuilder: (context, index) {
return _buildMessageBubble(index);
},
),
),
// 输入区域
_buildInputArea(),
// 面板区域
AnimatedBuilder(
animation: _panelController,
builder: (context, child) {
return Container(
height: _panelHeightAnimation.value,
child: _currentPanel != null
? _buildPanelContent(_currentPanel!)
: null,
);
},
),
],
),
),
);
}
Widget _buildInputArea() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade300)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// 语音/表情切换按钮
IconButton(
icon: Icon(
_currentPanel == PanelType.emoji
? Icons.keyboard
: Icons.emoji_emotions_outlined,
color: Colors.grey.shade600,
),
onPressed: () => _togglePanel(PanelType.emoji),
),
// 输入框
Expanded(
child: Container(
constraints: const BoxConstraints(
maxHeight: 120,
),
child: TextField(
controller: _textController,
focusNode: _focusNode,
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
decoration: InputDecoration(
hintText: '输入消息...',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
),
),
const SizedBox(width: 8),
// 发送/更多切换按钮
if (_textController.text.trim().isNotEmpty)
IconButton(
icon: const Icon(Icons.send, color: Colors.blue),
onPressed: _sendMessage,
)
else
IconButton(
icon: Icon(
_currentPanel == PanelType.more
? Icons.keyboard
: Icons.add_circle_outline,
color: Colors.grey.shade600,
),
onPressed: () => _togglePanel(PanelType.more),
),
],
),
);
}
Widget _buildPanelContent(PanelType type) {
switch (type) {
case PanelType.emoji:
return _buildEmojiPanel();
case PanelType.more:
return _buildMorePanel();
}
}
Widget _buildEmojiPanel() {
const emojis = [
'😀', '😂', '😍', '😎', '😭', '😡', '😱',
'👍', '👎', '👏', '🙏', '💪', '👀', '👋',
'❤️', '💔', '💕', '💖', '💯', '🔥', '✨',
];
return Container(
color: Colors.grey.shade50,
child: Column(
children: [
// 表情分类
Container(
height: 40,
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
child: Row(
children: [
_buildEmojiCategory('最近', isSelected: true),
_buildEmojiCategory('表情'),
_buildEmojiCategory('手势'),
_buildEmojiCategory('物品'),
],
),
),
// 表情网格
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: emojis.length,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () {
_textController.text += emojis[index];
},
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.white,
),
alignment: Alignment.center,
child: Text(
emojis[index],
style: const TextStyle(fontSize: 24),
),
),
);
},
),
),
],
),
);
}
Widget _buildEmojiCategory(String title, {bool isSelected = false}) {
return Expanded(
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
border: isSelected
? Border(bottom: BorderSide(color: Colors.blue, width: 2))
: null,
),
child: Text(
title,
style: TextStyle(
color: isSelected ? Colors.blue : Colors.grey.shade600,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
),
);
}
Widget _buildMorePanel() {
final functions = [
_MoreFunction(Icons.photo, '相册'),
_MoreFunction(Icons.camera_alt, '拍摄'),
_MoreFunction(Icons.file_present, '文件'),
_MoreFunction(Icons.location_on, '位置'),
_MoreFunction(Icons.contact_phone, '联系人'),
_MoreFunction(Icons.voice_chat, '语音输入'),
];
return Container(
color: Colors.grey.shade50,
padding: const EdgeInsets.all(16),
child: GridView.builder(
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: functions.length,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () {
// 处理功能点击
_handleFunctionClick(functions[index].label);
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
blurRadius: 6,
spreadRadius: 2,
),
],
),
child: Icon(
functions[index].icon,
color: Colors.blue,
size: 26,
),
),
const SizedBox(height: 8),
Text(
functions[index].label,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
);
},
),
);
}
void _handleFunctionClick(String label) {
// 处理功能点击
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('点击了: $label')),
);
}
Widget _buildMessageBubble(int index) {
final isMe = index % 3 == 0;
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.7,
),
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isMe ? Colors.blue.shade100 : Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'消息 $index ${isMe ? '(我)' : ''}',
style: const TextStyle(fontSize: 16),
),
),
);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_focusNode.dispose();
_textController.dispose();
_panelController.dispose();
super.dispose();
}
}
enum PanelType { emoji, more }
class _MoreFunction {
final IconData icon;
final String label;
_MoreFunction(this.icon, this.label);
}
八、常见问题与解决方案
8.1 键盘跳动问题
问题描述:面板与键盘切换时出现跳动
解决方案:
dart
// 使用键盘监听和动画同步
void _switchPanel(PanelType type) {
if (_isKeyboardVisible) {
// 键盘正在显示,先收起键盘
_focusNode.unfocus();
// 等待键盘动画完成
Future.delayed(const Duration(milliseconds: 200), () {
_showPanelWithAnimation(type);
});
} else {
_showPanelWithAnimation(type);
}
}
8.2 输入框遮挡问题
解决方案:
dart
// 使用ListView.builder的reverse参数
ListView.builder(
reverse: true, // 重要:反向列表
itemCount: messages.length,
itemBuilder: (context, index) {
return MessageItem(messages[index]);
},
)
8.3 内存泄漏问题
解决方案:
dart
@override
void dispose() {
// 清理所有监听器和控制器
_focusNode.dispose();
_textController.dispose();
_panelController.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
九、测试与调试
9.1 键盘测试工具
dart
class KeyboardTestPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Expanded(child: Container()),
TextField(
decoration: InputDecoration(hintText: '测试输入框'),
),
ElevatedButton(
onPressed: () {
// 打印键盘信息
final viewInsets = MediaQuery.of(context).viewInsets;
print('键盘高度: ${viewInsets.bottom}');
},
child: Text('检查键盘高度'),
),
],
),
);
}
}
9.2 性能监控
使用Flutter DevTools的Performance面板监控动画性能,确保帧率保持在60fps以上。
十、总结
实现Flutter中聊天键盘与功能面板的丝滑切换需要综合考虑以下几个方面:
-
布局策略:选择合适的布局方式(Overlay或内嵌)
-
动画控制:使用合适的动画曲线和持续时间
-
状态管理:正确处理键盘状态和面板状态的同步
-
性能优化:懒加载、缓存、避免不必要的重建
-
平台适配:处理iOS和Android的差异
更多推荐

所有评论(0)