Internet Explorer browsers are not fully supported

Flutter: Scroll Offset in the Chat Component

Mihajlo Popara, Web Developer

6 min readNov 24, 2020.

Adding chat functionality to your Flutter app can be a tough task. If you have ever tried to do the same, you probably noticed the issue with the vertical scroll offset after loading new items. Getting already displayed messages to stay in the same position (instead of quickly jumping up and then back down) can be quite difficult to do.
We can present the issue using the three pictures below.

desc

desc

desc

Using a SingleChildScrollView widget, we scroll up until we reach the top. When the vertical scroll offset reaches zero, we need to load more items (in our case, items are messages - picture one).

However, at that point, we are presented with our problem, as seen in pictures two and three.

Here, the scroll offset is the same as it was before loading. And it should change for the summed heights of new (loaded) items. To counteract this, we should adjust the offset to keep the previously displayed messages in the same position on the screen.

The question here is how to calculate the new offset? The offset should be calculated from the top, but we don't know the height of new (loaded) items until they are added and rendered. If the heights are equal, we can easily calculate the combined value. But what if they are different? In that case, it would look like all the elements on the screen have twitched, which can be seen as a bad user experience.  

Implementation

My idea was to use two containers (widgets) and switch visibility between them. After the first rebuild, when the data is loaded, we can calculate the new scroll offset and keep the preview of the container with the old items visible until the calculation is done. Then we trigger rebuild and switch visibility between the containers.

For this, we would use WidgetsBinding.instance.addPostFrameCallback which schedules a callback for the end of this frame.

Now, look at the state of the widget. In initState, we just add a listener to the scroll controller. Then we can control the data loading. When scroll to the top _scrollController.position.pixels is equal to zero, we trigger the load data.

@override
  void initState() {
    super.initState();    _scrollController.addListener(() {
      if (_scrollController.position.pixels == 0 && hasMore) {
         _fetchData();
      }
    });    _fetchData();
  }

After that, _fetchData calls the async action which we call service (it can be either an API call or a database service).

void _fetchData() async {
    setLoadingState(true);    try {
      var data = await service();
      items.insertAll(0, data['items']);
      hasMore = data['has_more'];
      setLoadingState(false);
    } catch(ex) {
      setLoadingState(false);
    }
  }

We use setLoadingState to set loading. It is true while loading data.

void setLoadingState(bool val) {
    setState(() {
      loading = val;
    });
  }

Next, we need to define our build method and buildContent.

buildContent() {
    return Column(
      children: <Widget>[
        if (loading)
          Container(
            height: 80,
            child: CircularProgressIndicator(
              backgroundColor: Colors.red,
            ),
          ),
        if (list.isEmpty)
          EmptyWidget(),
        ...list.map((item) => SingleItemWidget(
                item: item,
              )).toList(),
      ],
    );
  }

EmptyWidget is a custom widget that will be rendered when there are no items. A progress indicator will be displayed while loading data.

@override
  Widget build(BuildContext context) {
    setOnLoadContentOrAddPOstframeCallback();    return SingleChildScrollView(
      controller: _scrollController,
      child: Stack(
        children: <Widget>[
          Visibility(
            maintainAnimation: true,
            maintainSize: true,
            maintainState: true,
            visible: markedAsVisible,
            child: buildContent(),
          ),
          if (!markedAsVisible)
            Positioned(
              top: 0,
              left: 0,
              right: 0,
              child: onLoad == null ? Container() : onLoadContent,
            ),
        ],
      ),
    );
  }

This build method is rather simple. It returns the scroll view with stacked visibility and a positioned widget. As we said, we want to keep content at the end of the calculation offset, so that we can swap visibility and change the scroll controller offset. And everything is done using setOnLoadContentOrAddPOstframeCallback method.

void setOnLOadContentOrAddPOstframeCallback() {
    if (loading) {
      markedAsVisible = false;
      onLoadContent = buildContent();
    } else {
        if (initialized) {
          offsetFromBottom = widget.controller.position.maxScrollExtent;
        }        SchedulerBinding.instance.addPostFrameCallback((_) {
          setState(() {
            markedAsVisible = true;
            initialized = true;
          });          _scrollController.jumpTo(_scrollController.position.maxScrollExtent - offsetFromBottom);
        });
    }
  }

To sum up

It might look a bit complicated at first, but once you do it, it makes perfect sense. When we start loading data, we mark the widget as invisible, and the widget is rendered (but not visible) and it has a certain height. Then we can see the total size of the scroll area. Also, while the data is loading, we initializeonLoadContent. After the data is loaded, we can set loading to false and that triggers a rebuild of the widget. Then we call setOnLoadContentOrAddPOstframeCallback.

At this point, we can calculate offset from the bottom and add postframeCallback. In postframeCallback, we can see the height of the invisible widget - widget with new items. That means that now we know the new height and we can easily calculate offset. Then we just need to set the calculated scroll offset with the jumpTo scroll controller method and to change visibility. What is exactly what we did in the post-frame callback.

And that’s it. There might be a better solution out there, but this one solved the problem at hand rather well.

working at a laptop

Other known issues

Aside from the problem with adjusting the scroll offset, uploading images of desired formats (extensions like JPG, SVG, PNG, etc.) can also pose a problem. For this, you can use Flutter Multi Image Picker but the library doesn’t support extension filtering. It would be much better if they would add this feature as well.

Scroll offset and picture formats aren’t the only problems you might encounter during development, there are also issues with animations and stack navigations – it’s a rocky road to a fully developed Flutter app. But once you overcome all these difficulties, it works very well. It’s not surprising that Flutter for mobile was number one on the list of the Fastest growing skills among Software Engineers in 2019. Maybe the documentation isn’t perfect (they still update it fairly regularly), but the community using it is growing every day and there are more and more useful fixes.