2

I basically have a chat in where at the beginning only a part is loaded. Now i need to add more data (widgets) in both the top and the buttom.

Now in my example i've already implemented, that when the user scrolls up and 50 or less widgets are remaining it calls the backend to load more data (so when the user scrolls up even further already the loading is in progress or finished). Works fine, as soon as the data got loaded the user can scroll further up. Its like in WhatsApp and more apps with a feed.

However also new messages need to be loaded. These ones get added at the buttom. However that moves every single item in the listview which is a huge problem cause when the user watches the history, we dont want to "auto scroll" by the amount of new widgets...

How can i add widgets in both direction without "moving" the content?

### Small backend simulation ###

import 'dart:math';

class Msg {
  int i; // index of message
  String data;
  Msg({required this.i}) : data = 'Msg ${i + 1}';
}

class Backend {
  static const
      FORWARD = 1,
      BACKWARD = -1,
      LIMIT = 100;

  /// simulate backend call to fetch history or new data
  Future<List<int>> _loadData(int index, int direction) async {
    final random = new Random();

    // // 1000ms - 4000ms response time
    final waitMillis = 1000 + random.nextInt(4101 - 1000);
    await Future.delayed(Duration(milliseconds: waitMillis));

    if (direction == BACKWARD) {
      // backward (last 50): index - LIMIT - 1 -> ... -> index - 1
      // LOOKS LIKE: 50, 51, 52, 53, ... index - 1

      if (index <= 0) return []; // no messages left

      // prevent that count is higher than amount of messages left before current index
      // so minimal index is 0
      final count = index < LIMIT ? index : LIMIT;

      return List<int>.generate(count, (i) => index - count + i);
    }
    // forward (next 50): index + 1 -> ... -> index + LIMIT
    // LOOKS LIKE: index + 1, 50, 51, 53, ...

    // 40% 0, 20% 1, 20% 2 and 20% 3 new messages
    final randI = random.nextInt(6);
    return List<int>.generate(randI < 2 ? 0 : (randI - 2), (i) => index + i + 1);
  }

  /// calls the backend simulation and parses the data
  Future<List<Msg>> loadEntries(int index, int direction) async {
    if (direction == BACKWARD && index <= 0) return [];
    final data = await _loadData(index, direction);
    return data.map<Msg>((i) => Msg(i: i)).toList();
  }
}

### My custom "listview" ###

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:list/backend.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';

class CustomListWidget extends StatefulWidget {
  const CustomListWidget({Key? key}) : super(key: key);

  @override
  _CustomListWidgetState createState() => _CustomListWidgetState();
}

class _CustomListWidgetState extends State<CustomListWidget> {
  // when the user scrolls up and x messages are left more history gets loaded
  static const MIN_HISTORY_LOADING_TRIGGER = 50;

  static const _SPIN_KIT = SpinKitRing(
    lineWidth: 1.5,
    color: Colors.black,
    size: 20.0,
  );

  final _itemScrollController = ItemScrollController();
  final _itemPositionListener = ItemPositionsListener.create();

  final _backend = Backend();
  bool _isLoadingHistory = false, _showLoading = false, _disposed = false;

  // startIndex - LIMIT + 1 -> ... -> startIndex
  // 400 to begin with for testing
  List<Msg> entries = List<Msg>.generate(Backend.LIMIT, (i) => Msg(i: 400 - Backend.LIMIT + i + 1));

  /// loads more history data
  Future<void> _loadMoreHistory() async {
    if (entries.first.i == 0) return; // no messages left
    _isLoadingHistory = true;
    final newMsgsPending =  _backend.loadEntries(entries.first.i, Backend.BACKWARD);
    bool isLoadingHistoryThisInstance = true;
    Future.delayed(Duration(milliseconds: 1000)).then((_) {
      // show loading if after 1000 seconds not loaded yet
      if (isLoadingHistoryThisInstance) setState(() => _showLoading = true);
    });
    final List<Msg> newMsgs = await newMsgsPending;
    isLoadingHistoryThisInstance = false;
    if (newMsgs.isNotEmpty) {
      setState(() {
        entries = newMsgs + entries;
        _showLoading = false;
      });
    } else if (_showLoading) setState(() => _showLoading = false);
    _isLoadingHistory = false;
  }

  /// loads new data
  Future<void> _loadUpdates() async {
    while (!_disposed) {
      final newMsgs = await _backend.loadEntries(entries.last.i, Backend.FORWARD);
      if (newMsgs.isNotEmpty) {
        setState(() {
          entries = entries + newMsgs;
        });
      }
    }
  }

  @override
  void setState(VoidCallback fn) {
    if (_disposed) return;
    super.setState(fn);
  }

  @override
  void dispose() {
    super.dispose();
    _disposed = true;
  }

  @override
  void initState() {
    super.initState();
    _itemPositionListener.itemPositions.addListener(_scrollListener);
    _loadUpdates();
  }

  /// memory efficient list widget with widget tracking
  Widget _getScrollablePositionedList() {
    return Scrollbar(
      child: ScrollablePositionedList.builder(
        reverse: true,
        itemCount: entries.length,
        itemBuilder: (context, index) => ListTile(
          title: Text(entries[entries.length - index - 1].data),
        ),
        itemScrollController: _itemScrollController,
        itemPositionsListener: _itemPositionListener,
      ),
    );
  }

  /// loading indicator when backend call takes longer
  Widget _getLoadingWidget() {
    return Positioned(
      top: 15,
      right: 15,
      child: Opacity(
        opacity: _showLoading ? 1.0 : 0.0,
        child: _SPIN_KIT,
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        _getScrollablePositionedList(),
        _getLoadingWidget(),
      ],
    );
  }

  /// called while scrolling to track what the position is, calls to load more history
  void _scrollListener() {
    if (!_isLoadingHistory) {
      // topPosition is 1 when "oldest" or first widget of entries got loaded, so we can use it to
      // determine if we should load more data
      final topPosition = (_itemPositionListener.itemPositions.value.last.index - entries.length) * -1;
      if (topPosition <= MIN_HISTORY_LOADING_TRIGGER) {
        // x entries remaining, load more history
        _loadMoreHistory();
      }
    }
    // could also verify which widgets have been viewed for tracking and reading verifications
  }
}

enter image description here

enter image description here

trueToastedCode
  • 175
  • 2
  • 10

1 Answers1

2

You can use a custom scroll view with 2 sliver lists here. One for holding the existing chats and the other one to hold new chats. You shall pass a key of the first sliver list to the custom scroll's centre parameter so that it won't scroll when you add a new chat to the second list.

CustomScrollView(
  center: existingChatSliverKey,
  slivers: <Widget>[
    SliverList( //sliver list to hold your old/existing chats
      key: existingChatSliverKey, // Key passed as center
      ...
    ),
    SliverList( //sliver list to hold your new chats
      ...
    ),
)

Reference StackOverflow issue, github issue

Rashid Abdulla
  • 295
  • 2
  • 10