Skip to content

How to handle smooth transition between keyboard and custom composer panels #881

@KiddoV

Description

@KiddoV

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")
                        ],
                      );
                    },
                  ),
                )
              ],
            ),
          ),
        ),
      );
    }
  );
},

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions