-
Notifications
You must be signed in to change notification settings - Fork 846
Open
Description
Hi,
I’m trying to build a custom composer that can smoothly switch between the system keyboard and a custom image picker (similar to Facebook Messenger), but I’m stuck and the transition looks very bad (flickering / jumping).
Here’s a short video showing the issue:
video.mp4
I couldn’t find any documentation or examples for handling this use case. The current examples only use ImagePicker to open a new screen/modal, not an inline bottom panel that replaces the keyboard.
Is there a recommended way to handle this with flutter_chat_ui, or is this something the package doesn’t support yet?
My current implementation:
Show code
composerBuilder: (ctx) {
final bucket = PageStorage.of(ctx);
final mainIconBtnIconColor = Theme.of(ctx).colorScheme.primary;
final defaultTextFieldHeight = AppTheme.getDefaultInputHeight(context: ctx, textStyle: Theme.of(ctx).textTheme.bodyMedium);
///
return HookBuilder(
builder: (context) {
final key = useMemoized(() => GlobalKey());
final txtCtrl = useTextEditingController();
final focusNode = useFocusNode();
final hasText = useState(false);
final isLeftButtonsVisible = useState(true); // Show by default
final composerMode = useState(ComposerMode.text);
///
void handleSubmit(String text) {
if (text.trim().isEmpty) return;
context.read<chat_ui.OnMessageSendCallback?>()?.call(text.trim());
txtCtrl.clear();
bucket.writeState(ctx, "", identifier: draftKey);
}
///
useEffect(() {
// Report the composer height after layout
WidgetsBinding.instance.addPostFrameCallback((_) {
final box = key.currentContext?.findRenderObject() as RenderBox?;
if (box != null) {
final height = box.size.height;
final bottomSafe = MediaQuery.paddingOf(context).bottom;
context.read<chat_ui.ComposerHeightNotifier>().setHeight(height - bottomSafe);
}
});
// Load draft from bucket
final saved = bucket.readState(ctx, identifier: draftKey) as String?;
if (saved != null && saved.isNotEmpty) txtCtrl.text = saved;
// Listen to input text changes
void textListener() {
hasText.value = txtCtrl.text.trim().isNotEmpty;
bucket.writeState(ctx, txtCtrl.text, identifier: draftKey);
if (txtCtrl.text.isNotEmpty) isLeftButtonsVisible.value = false; // Hide when user start typing
}
txtCtrl.addListener(textListener);
// Listen to focus node
void focusListener() {
WidgetsBinding.instance.addPostFrameCallback((_) {
isLeftButtonsVisible.value = !focusNode.hasFocus; // Hide left buttons when focus
});
}
focusNode.addListener(focusListener);
// Dispose
return () {
txtCtrl.removeListener(textListener);
focusNode.removeListener(focusListener);
};
}, []);
///
return Positioned.fill(
top: null,
child: Container(
key: key,
color: Theme.of(context).navigationBarTheme.backgroundColor,
child: Padding(
padding: EdgeInsets.fromLTRB(10, 5, 10, 5 + MediaQuery.paddingOf(context).bottom),
child: Column(
children: [
// Main composer area
Row(
spacing: 5,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Left buttons
AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutBack, // Messenger-like bounce
child: (isLeftButtonsVisible.value)
? Row(
key: const ValueKey("left-row-buttons"),
spacing: 5,
children: [
SizedBox.square(
dimension: defaultTextFieldHeight.field,
child: IconButton(
style: IconButton.styleFrom(
foregroundColor: mainIconBtnIconColor,
disabledForegroundColor: mainIconBtnIconColor.withValues(alpha: 0.5),
),
icon: LayoutBuilder(
builder: (ctx, cons) => FaIcon(FontAwesomeIcons.solidCamera, size: cons.biggest.width)
),
constraints: BoxConstraints(minWidth: defaultTextFieldHeight.field, minHeight: defaultTextFieldHeight.field),
onPressed: () {
},
),
),
SizedBox.square(
dimension: defaultTextFieldHeight.field,
child: IconButton(
style: IconButton.styleFrom(
foregroundColor: mainIconBtnIconColor,
disabledForegroundColor: mainIconBtnIconColor.withValues(alpha: 0.5),
),
icon: LayoutBuilder(
builder: (ctx, cons) => FaIcon(FontAwesomeIcons.solidImage, size: cons.biggest.width)
),
constraints: BoxConstraints(minWidth: defaultTextFieldHeight.field, minHeight: defaultTextFieldHeight.field),
onPressed: () {
composerMode.value = ComposerMode.image;
focusNode.unfocus(); // Un focus text input
},
),
),
],
)
: SizedBox.square(
key: const ValueKey("chevron-btn"),
dimension: defaultTextFieldHeight.field,
child: IconButton(
style: IconButton.styleFrom(
foregroundColor: mainIconBtnIconColor,
disabledForegroundColor: mainIconBtnIconColor.withValues(alpha: 0.5),
),
icon: LayoutBuilder(
builder: (ctx, cons) => FaIcon(FontAwesomeIcons.chevronRight, size: cons.biggest.width)
),
onPressed: () {
isLeftButtonsVisible.value = true;
},
),
),
),
// Message field
Expanded(
child: Shortcuts(
shortcuts: {LogicalKeySet(.shift, .enter) : const ActivateIntent()},
child: Actions(
actions: {
ActivateIntent: CallbackAction(onInvoke: (_) {
handleSubmit(txtCtrl.text);
return null;
})
},
child: AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: TextField(
controller: txtCtrl,
focusNode: focusNode,
minLines: 1,
maxLines: 4,
decoration: InputDecoration(
hintText: "${tr("body.type_a_message")}...",
border: const OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.all(Radius.circular(24)),
),
filled: true,
fillColor: Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.4),
hoverColor: Colors.transparent,
),
style: Theme.of(context).textTheme.bodyMedium,
autocorrect: true,
textCapitalization: TextCapitalization.sentences,
onTap: () {
isLeftButtonsVisible.value = false;
composerMode.value = ComposerMode.text;
},
onSubmitted: handleSubmit,
),
),
),
),
),
// Send button
SizedBox.square(
dimension: defaultTextFieldHeight.field,
child: IconButton(
style: IconButton.styleFrom(
foregroundColor: mainIconBtnIconColor,
disabledForegroundColor: mainIconBtnIconColor.withValues(alpha: 0.5),
),
icon: LayoutBuilder(
builder: (ctx, cons) => FaIcon(FontAwesomeIcons.solidPaperPlane, size: cons.biggest.width)
),
constraints: BoxConstraints(minWidth: defaultTextFieldHeight.field, minHeight: defaultTextFieldHeight.field),
onPressed: !hasText.value ? null : () => handleSubmit(txtCtrl.text),
),
)
],
),
// Image picker area
AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
height: composerMode.value == .image ? MediaQuery.of(context).size.longestSide * 0.4 : MediaQuery.of(context).viewInsets.bottom,
child: HookBuilder(
builder: (ctx) {
return Column(
children: [
Text("Test")
],
);
},
),
)
],
),
),
),
);
}
);
},Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels