smart_keyboard_insets 0.2.0
smart_keyboard_insets: ^0.2.0 copied to clipboard
A Flutter plugin that provides accurate keyboard height and safe area bottom inset detection on Android and iOS. Perfect for chat apps.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:smart_keyboard_insets/smart_keyboard_insets.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Smart Keyboard Insets Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const ChatScreen(),
);
}
}
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final TextEditingController _textController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final FocusNode _focusNode = FocusNode();
StreamSubscription<KeyboardMetrics>? _subscription;
bool _showStickerPanel = false;
double _lastKeyboardHeight = 300; // Default height for sticker panel
// Dummy messages for demonstration
final List<String> _messages = [
'Hey! How are you?',
'I\'m doing great, thanks for asking!',
'Did you see the new Flutter update?',
'Yes! The keyboard handling is so much better now.',
'This plugin makes it even easier.',
'Totally agree! No more manual calculations.',
'The animation is smooth too.',
'Try the sticker button! π',
];
@override
void initState() {
super.initState();
// Initialize the stream listener to start receiving keyboard updates
_subscription = SmartKeyboardInsets.instance.metricsStream.listen((
metrics,
) {
// Store keyboard height for sticker panel sizing
if (metrics.isKeyboardVisible && metrics.keyboardHeight > 0) {
setState(() {
_lastKeyboardHeight = metrics.keyboardHeight;
_showStickerPanel = false; // Hide sticker panel when keyboard opens
});
}
});
}
@override
void dispose() {
_subscription?.cancel();
_textController.dispose();
_scrollController.dispose();
_focusNode.dispose();
super.dispose();
}
void _sendMessage([String? emoji]) {
final text = emoji ?? _textController.text.trim();
if (text.isNotEmpty) {
setState(() {
_messages.add(text);
});
if (emoji == null) _textController.clear();
}
}
void _toggleStickerPanel() {
if (_showStickerPanel) {
_switchToKeyboard();
} else {
_focusNode.unfocus();
setState(() => _showStickerPanel = true);
}
}
/// Keeps the sticker panel on screen until the keyboard reports it is
/// visible (the stream listener then hides the panel), so the composer
/// doesn't drop down and jump back up while the keyboard is opening.
void _switchToKeyboard() {
_focusNode.requestFocus();
Future.delayed(const Duration(milliseconds: 600), () {
// Fallback for when no software keyboard appears (e.g. a hardware keyboard).
if (mounted &&
_showStickerPanel &&
!SmartKeyboardInsets
.instance
.metricsNotifier
.value
.isKeyboardVisible) {
setState(() => _showStickerPanel = false);
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: const Text('Chat Demo'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Column(
children: [
_buildDebugOverlay(),
Expanded(
child: ListView.builder(
controller: _scrollController,
reverse: true,
padding: const EdgeInsets.all(16),
itemCount: _messages.length,
itemBuilder: (context, index) {
final actualIndex = _messages.length - 1 - index;
final isMe = actualIndex % 2 == 1;
return _buildMessageBubble(_messages[actualIndex], isMe);
},
),
),
// Composer + Sticker Panel
_buildBottomArea(),
],
),
);
}
Widget _buildBottomArea() {
return ValueListenableBuilder<KeyboardMetrics>(
valueListenable: SmartKeyboardInsets.instance.metricsNotifier,
builder: (context, metrics, child) {
// The sticker panel and the keyboard share one bottom slot, so
// switching between them never stacks both heights.
final double bottomHeight;
if (_showStickerPanel) {
bottomHeight = _stickerPanelHeight;
} else if (metrics.isKeyboardVisible) {
bottomHeight = metrics.keyboardHeight;
} else {
bottomHeight = metrics.safeAreaBottom;
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Composer
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
children: [
// Sticker/Emoji button
IconButton(
onPressed: _toggleStickerPanel,
icon: Icon(
_showStickerPanel
? Icons.keyboard
: Icons.emoji_emotions_outlined,
),
),
Expanded(
child: TextField(
controller: _textController,
focusNode: _focusNode,
decoration: InputDecoration(
hintText: 'Type a message...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
onTap: () {
if (_showStickerPanel) _switchToKeyboard();
},
onSubmitted: (_) => _sendMessage(),
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: () => _sendMessage(),
icon: const Icon(Icons.send),
),
],
),
),
// Bottom slot: sticker panel, keyboard space, or safe area
AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
height: bottomHeight,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: _showStickerPanel
? OverflowBox(
alignment: Alignment.topCenter,
minHeight: _stickerPanelHeight,
maxHeight: _stickerPanelHeight,
child: _buildStickerPanel(),
)
: null,
),
],
);
},
);
}
// Use the last known keyboard height for consistent UX
double get _stickerPanelHeight =>
_lastKeyboardHeight > 0 ? _lastKeyboardHeight : 300.0;
Widget _buildStickerPanel() {
final panelHeight = _stickerPanelHeight;
return Container(
height: panelHeight,
color: Colors.grey.shade100,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: Text(
'Sticker Panel (height: ${panelHeight.toStringAsFixed(0)})',
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
),
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemCount: _emojis.length,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () => _sendMessage(_emojis[index]),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
_emojis[index],
style: const TextStyle(fontSize: 24),
),
),
);
},
),
),
],
),
);
}
static const _emojis = [
'π',
'π',
'π',
'π',
'π
',
'π',
'π€£',
'π',
'π',
'π',
'π',
'π',
'π',
'π',
'π₯°',
'π',
'π',
'π',
'π',
'π',
'π',
'π',
'π€ͺ',
'π',
'π€',
'π€',
'π€',
'π€«',
'π€',
'π€',
'π€¨',
'π',
'π',
'πΆ',
'π',
'π',
'π',
'π¬',
'π€₯',
'π',
'π',
'π',
'π',
'π',
'π€',
'π',
'β€οΈ',
'π₯',
];
Widget _buildDebugOverlay() {
return ValueListenableBuilder<KeyboardMetrics>(
valueListenable: SmartKeyboardInsets.instance.metricsNotifier,
builder: (context, metrics, child) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: Colors.grey.shade200,
child: Text(
'keyboardHeight: ${metrics.keyboardHeight.toStringAsFixed(1)} | '
'safeAreaBottom: ${metrics.safeAreaBottom.toStringAsFixed(1)} | '
'visible: ${metrics.isKeyboardVisible}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
textAlign: TextAlign.center,
),
);
},
);
}
Widget _buildMessageBubble(String message, bool isMe) {
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: isMe
? Theme.of(context).colorScheme.primary
: Colors.grey.shade300,
borderRadius: BorderRadius.circular(20),
),
child: Text(
message,
style: TextStyle(color: isMe ? Colors.white : Colors.black87),
),
),
);
}
}