Skip to content

UI stutter on rendering large list due to the use of Column widget #290

Open
@lhengl

Description

@lhengl
Contributor

Describe the bug
The UI appear to be stuttering when there is a large enough data set being loaded into the UI. This is because the UI favours the use of columns over a more efficient ListView or builder widgets that renders on the fly.

To Reproduce
Steps to reproduce the behavior:

  1. Create a ChatView and inject large list of messages each time - either in the initiaMessageList or during loadMoreData

I've created a demo app to demonstrate this: https://github.com/lhengl/chatview_errors

  1. Tap ScrollController Error list tile
  2. Change the states to see the stutter

Expected behavior
There should be no stutter

Screenshots
If applicable, add screenshots to help explain your problem.

Desktop (please complete the following information):
N/A

Smartphone (please complete the following information):
N/A

Additional context
N/A

Activity

officialismailshah

officialismailshah commented on Jan 9, 2025

@officialismailshah

did you resolve this issue @lhengl

lhengl

lhengl commented on Jan 9, 2025

@lhengl
ContributorAuthor

did you resolve this issue @lhengl

No, I haven't resolved this issue yet. Also, for some reason, I can't fork to attempt a fix... it just won't create a fork ok github for me.

officialismailshah

officialismailshah commented on Jan 11, 2025

@officialismailshah

did you resolve this issue @lhengl

No, I haven't resolved this issue yet. Also, for some reason, I can't fork to attempt a fix... it just won't create a fork ok github for me.

sure here you can fork this repo I have forked it just now
https://github.com/officialismailshah/flutter_chatview

lhengl

lhengl commented on May 14, 2025

@lhengl
ContributorAuthor

I'm trying to fix this issue because the stuttering is unbearable, so I had a look at this issue today and it's little bit confusing how this seem to work. It looks like the inner most ChatGroupedListWidget does use a ListView.builder(). However the parent widget wraps it inside a column, which in turn is wrapped by a SingleChildScrollView. This is an anti pattern, because I don't think that a SingleScrollView is meant to wrap a more efficient ListView.builder.

Until someone can confirm why this decision was made, I'm reluctant to make any changes because it might break something. But from what I can see moving it to a CustomScrollView with SliverList will likely make it much more efficient. Might have to lift the StreamBuilder higher up on the widget tree as well.

This is kind of a big change but a critical optimisation. Does anyone with a much more expertise on this package that can comment?

Here's the said code:

@override
  Widget build(BuildContext context) {
    final suggestionsListConfig =
        suggestionsConfig?.listConfig ?? const SuggestionListConfig();
    return SingleChildScrollView(
      reverse: true,
      // When reaction popup is being appeared at that user should not scroll.
      physics: showPopUp ? const NeverScrollableScrollPhysics() : null,
      controller: widget.scrollController,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          GestureDetector(
            onHorizontalDragUpdate: (details) =>
                isEnableSwipeToSeeTime && !showPopUp
                    ? _onHorizontalDrag(details)
                    : null,
            onHorizontalDragEnd: (details) =>
                isEnableSwipeToSeeTime && !showPopUp
                    ? _animationController?.reverse()
                    : null,
            onTap: widget.onChatListTap,
            child: _animationController != null
                ? AnimatedBuilder(
                    animation: _animationController!,
                    builder: (context, child) {
                      return _chatStreamBuilder;
                    },
                  )
                : _chatStreamBuilder,
          ),
          if (chatController != null)
            ValueListenableBuilder(
              valueListenable: chatController!.typingIndicatorNotifier,
              builder: (context, value, child) => TypingIndicator(
                typeIndicatorConfig: chatListConfig.typeIndicatorConfig,
                chatBubbleConfig:
                    chatListConfig.chatBubbleConfig?.inComingChatBubbleConfig,
                showIndicator: value,
              ),
            ),
          if (chatController != null)
            Flexible(
              child: Align(
                alignment: suggestionsListConfig.axisAlignment.alignment,
                child: const SuggestionList(),
              ),
            ),

          // Adds bottom space to the message list, ensuring it is displayed
          // above the message text field.
          SizedBox(
            height: chatTextFieldHeight,
          ),
        ],
      ),
    );
  }
 Widget get _chatStreamBuilder {
    DateTime lastMatchedDate = DateTime.now();
    return StreamBuilder<List<Message>>(
      stream: chatController?.messageStreamController.stream,
      builder: (context, snapshot) {
        if (!snapshot.connectionState.isActive) {
          return Center(
            child: chatBackgroundConfig.loadingWidget ??
                const CircularProgressIndicator(),
          );
        } else {
          final messages = chatBackgroundConfig.sortEnable
              ? sortMessage(snapshot.data!)
              : snapshot.data!;

          final enableSeparator =
              featureActiveConfig?.enableChatSeparator ?? false;

          Map<int, DateTime> messageSeparator = {};

          if (enableSeparator) {
            /// Get separator when date differ for two messages
            (messageSeparator, lastMatchedDate) = _getMessageSeparator(
              messages,
              lastMatchedDate,
            );
          }

          /// [count] that indicates how many separators
          /// needs to be display in chat
          var count = 0;

          return ListView.builder(
            key: widget.key,
            physics: const NeverScrollableScrollPhysics(),
            padding: EdgeInsets.zero,
            shrinkWrap: true,
            itemCount: (enableSeparator
                ? messages.length + messageSeparator.length
                : messages.length),
            itemBuilder: (context, index) {
              /// By removing [count] from [index] will get actual index
              /// to display message in chat
              var newIndex = index - count;

              /// Check [messageSeparator] contains group separator for [index]
              if (enableSeparator && messageSeparator.containsKey(index)) {
                /// Increase counter each time
                /// after separating messages with separator
                count++;
                return _groupSeparator(
                  messageSeparator[index]!,
                );
              }

              return ValueListenableBuilder<String?>(
                valueListenable: _replyId,
                builder: (context, state, child) {
                  final message = messages[newIndex];
                  final enableScrollToRepliedMsg = chatListConfig
                          .repliedMessageConfig
                          ?.repliedMsgAutoScrollConfig
                          .enableScrollToRepliedMsg ??
                      false;
                  return ChatBubbleWidget(
                    key: message.key,
                    message: message,
                    slideAnimation: _slideAnimation,
                    onLongPress: (yCoordinate, xCoordinate) =>
                        widget.onChatBubbleLongPress(
                      yCoordinate,
                      xCoordinate,
                      message,
                    ),
                    onSwipe: widget.assignReplyMessage,
                    shouldHighlight: state == message.id,
                    onReplyTap: enableScrollToRepliedMsg
                        ? (replyId) => _onReplyTap(replyId, snapshot.data)
                        : null,
                  );
                },
              );
            },
          );
        }
      },
    );
  }
lhengl

lhengl commented on May 14, 2025

@lhengl
ContributorAuthor

An update on this. After I changed the SingleChildScrollView to a CustomScrollView, I noticed a massive improvement in the speed and quality of the ChatView. For example loading hundreds and thousands of messages now are instant. Before it would have caused about a one second stutter delay. This is especially noticeable when opening the chat for the first time through routing, you'll see the animation of the routing is stuttering, indicating too much work is being done.

However, the logic in how the list is ordered is now incorrect. Before, the SingleChildScrollView's reverse property do not need to reverse the list in order to work because it laid out all its child view already. So the order of the list remain in tact. Now that we want to move to a build on the go list of sliver children, the ordering will need to be reversed in order to build the item one by one.

At the moment reversing the list seems to somewhat work, but:

  1. The date separators are now incorrectly placed in the list
  2. Scrolling may cause a RangeError. E.g.: RangeError (length): Invalid value: Not in inclusive range 0..11: -1
  3. And the latest message indicator seems to be applied to all message temporarily as we scroll.

I need help with this. Again, anyone with some expertise on the package can help would be great, because this is such a critical update to optimising the package. It's extremely inefficient to lay out the entire list in a single child scroll view.

Here's the forked branch for this: fix/ui-stutter

lhengl

lhengl commented on May 16, 2025

@lhengl
ContributorAuthor

I've managed to fix the issues! I can now load hundreds and thousands of messages without any stuttering or memory inefficiency. I'm gonna play around with it a bit more to ensure it doesn't break anything. Feel free to test the fork here: fix/ui-stutter-caused-by-single-child-scrollview

lhengl

lhengl commented on May 30, 2025

@lhengl
ContributorAuthor

Can someone from @SimformSolutionsPvtLtd please make a comment on their stance on this issue? I'd like to contribute to fixing this issue, but I'd really like your support because it will be a big change due to the fundamental change in how scrolling works.

I'd really like to optimise this package to support a more efficient scrolling logic. At the moment it is very inefficient use of a SingleScrollView that is pre-rendered from top to bottom, but the scrolling is reversed. Instead, this should be inside a CustomScrollView with the item built on demand in reversed order to align with the reversed scrolling.

@rishabhsimformsolutions, @Sahil-Simform?

linked a pull request that will close this issue on Jul 7, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Development

      Participants

      @officialismailshah@lhengl

      Issue actions

        UI stutter on rendering large list due to the use of Column widget · Issue #290 · SimformSolutionsPvtLtd/chatview