如何在Flutter中在列表顶部添加新元素后保持滚动位置

huangapple go评论73阅读模式
英文:

How to keep scroll position after adding new elements on top of the list in flutter

问题

列表视图的滚动位置在从列表顶部添加多个元素时发生了变化。当我们在列表底部插入新元素时,插入操作运作正常。

使用案例是我的应用中有一个聊天模块,我需要在其中实现双向分页(上下)。如果用户向上滚动,分页流程正常,项目添加在列表底部,所以一切正常。但如果用户向下滚动,新项目会添加到列表顶部,并且滚动位置会改变。

我已经在所有地方搜索过并尝试了所有解决方案,但没有找到合适的解决方案,许多人也遇到了相同的问题。

我附上一个关于此问题的 DartPad 链接:打开 DartPad

重现步骤:

  • 运行应用程序,滚动到列表末尾。

  • 现在点击添加图标,它会在列表顶部添加30个项目,然后您会注意到滚动位置在此之后发生了变化。

  • 在此示例中,我使用了 setState,但即使使用任何状态管理解决方案,相同的事情也会发生。

  • 我期望如果我从列表顶部添加元素,滚动位置不会发生变化。

英文:

scroll position of listview changed if I add multiple elements from top in list. It's working fine for insert operation when we inster new elements in the bottom of the list.

Usecase is there is one chat module in my application & I have to implement both side pagination (up & down) in that. If user scroll up then normal pagination flow, items added in the bottom of the list so it's working fine. But if user user scroll down then new items added at the top of the list & scroll position changed.

I have searched in all place & tried all solution but didn't found any proper solution & many people also faced the same issue.

I am attaching a one dartpad link of this issue : open dartpad

Step to reproduce:

  • run the app, scroll to end of the list

  • now click on add icon, it will add 30 items in top of the list & you will notice scroll position will change after that

  • in this example I'm using setState but the same thing will happen even after using any state management solution.

  • I'm expecting not to change scroll position if I add elements from top of the list

答案1

得分: 5

以下是翻译好的部分:

  1. 比较旧的和新的最大滚动范围之间的差异,并使用addPostFrameCallback跳转到差异,如下:
final double old = _controller.position.pixels;
final double oldMax = _controller.position.maxScrollExtent;

WidgetsBinding.instance.addPostFrameCallback((_) {
  if (old > 0.0) {
    final diff = _controller.position.maxScrollExtent - oldMax;
    _controller.jumpTo(old + diff);
  }
});

这种方法可以满足您的需求,但在两个帧之间实际上会出现绘制闪烁,因为您通常执行JumpTo操作。参见视频链接

  1. 在布局阶段对齐像素差异。这种方法会扩展ScrollController并创建一个自定义的ScrollPosition,以在视口在performLayout()期间调用ViewportOffset.applyContentDimensions时对齐像素差异。最终,您可以调用RetainableScrollController.retainOffset()来保持在列表视图顶部插入新项时的滚动位置。
class RetainableScrollController extends ScrollController {
  RetainableScrollController({
    super.initialScrollOffset,
    super.keepScrollOffset,
    super.debugLabel,
  });

  @override
  ScrollPosition createScrollPosition(
    ScrollPhysics physics,
    ScrollContext context,
    ScrollPosition? oldPosition,
  ) {
    return RetainableScrollPosition(
      physics: physics,
      context: context,
      initialPixels: initialScrollOffset,
      keepScrollOffset: keepScrollOffset,
      oldPosition: oldPosition,
      debugLabel: debugLabel,
    );
  }

  void retainOffset() {
    position.retainOffset();
  }

  @override
  RetainableScrollPosition get position =>
      super.position as RetainableScrollPosition;
}

class RetainableScrollPosition extends ScrollPositionWithSingleContext {
  RetainableScrollPosition({
    required super.physics,
    required super.context,
    super.initialPixels = 0.0,
    super.keepScrollOffset,
    super.oldPosition,
    super.debugLabel,
  });

  double? _oldPixels;
  double? _oldMaxScrollExtent;

  bool get shouldRestoreRetainedOffset =>
      _oldMaxScrollExtent != null && _oldPixels != null;

  void retainOffset() {
    if (!hasPixels) return;
    _oldPixels = pixels;
    _oldMaxScrollExtent = maxScrollExtent;
  }

  // 当视口布局其子项时,它会调用[applyContentDimensions]来更新[minScrollExtent]和[maxScrollExtent]。
  // 当发生这种情况时,[shouldRestoreRetainedOffset]将确定是否校正当前[pixels],
  // 以便最终滚动偏移匹配先前项目的滚动偏移。
  // 因此,当在列表的第一个索引中插入新项时,可以避免向下/向上滚动。
  @override
  bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
    final applied =
        super.applyContentDimensions(minScrollExtent, maxScrollExtent);

    bool isPixelsCorrected = false;

    if (shouldRestoreRetainedOffset) {
      final diff = maxScrollExtent - _oldMaxScrollExtent!;
      if (_oldPixels! > minScrollExtent && diff > 0) {
        correctPixels(pixels + diff);
        isPixelsCorrected = true;
      }
      _oldMaxScrollExtent = null;
      _oldPixels = null;
    }

    return applied && !isPixelsCorrected;
  }
}

演示视频可以在此处找到。

  1. 实现您目标的最佳方法是使用特殊的ScrollPhysics。通过这种方式,您无需更改现有的代码,只需在列表视图中传递physics: const PositionRetainedScrollPhysics()
class PositionRetainedScrollPhysics extends ScrollPhysics {
  final bool shouldRetain;
  const PositionRetainedScrollPhysics({super.parent, this.shouldRetain = true});

  @override
  PositionRetainedScrollPhysics applyTo(ScrollPhysics? ancestor) {
    return PositionRetainedScrollPhysics(
      parent: buildParent(ancestor),
      shouldRetain: shouldRetain,
    );
  }

  @override
  double adjustPositionForNewDimensions({
    required ScrollMetrics oldPosition,
    required ScrollMetrics newPosition,
    required bool isScrolling,
    required double velocity,
  }) {
    final position = super.adjustPositionForNewDimensions(
      oldPosition: oldPosition,
      newPosition: newPosition,
      isScrolling: isScrolling,
      velocity: velocity,
    );

    final diff = newPosition.maxScrollExtent - oldPosition.maxScrollExtent;

    if (oldPosition.pixels > oldPosition.minScrollExtent &&
        diff > 0 &&
        shouldRetain) {
      return position + diff;
    } else {
      return position;
    }
  }
}

您还可以使用positioned_scroll_observer来使用PositionRetainedScrollPhysics,以及其他功能,例如在任何滚动视图中滚动到特定索引。

英文:

Actually, the problem is that the viewport would layout its slivers using the new maxScrollExtent (that increased due to the newly added items). However, the ScrollPosition.pixels is still unchanged, while existing slivers have received their new scroll offset in their SliverGeometry.

Consequently, slivers would paint their items using the new scroll offset and the old ScrollPosition.pixels (that would be updated once the painting is completed for the current frame).

Therefore, we have three ways to align the new scroll offset and the old pixels.

  1. Comparing the diff between the old and new max scroll extent, and jumpTo(diff) using addPostFrameCallback, like below:
    final double old = _controller.position.pixels;
    final double oldMax = _controller.position.maxScrollExtent;

    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (old > 0.0) {
        final diff = _controller.position.maxScrollExtent - oldMax;
        _controller.jumpTo(old + diff);
      }
    });

This way would meet your requirements but the painting may flicker between the two frames since you actually do JumpTo normally. See the video link.

  1. Align the pixel difference during the layout phase. This way would extend the ScrollController and create a custom ScrollPosition to align the pixel difference when the viewport invokes ViewportOffset.applyContentDimensions during performLayout(). Eventually, you could invoke RetainableScrollController.retainOffset() to keep the scroll position when inserting new items at the top of the list view.
class RetainableScrollController extends ScrollController {
  RetainableScrollController({
    super.initialScrollOffset,
    super.keepScrollOffset,
    super.debugLabel,
  });

  @override
  ScrollPosition createScrollPosition(
    ScrollPhysics physics,
    ScrollContext context,
    ScrollPosition? oldPosition,
  ) {
    return RetainableScrollPosition(
      physics: physics,
      context: context,
      initialPixels: initialScrollOffset,
      keepScrollOffset: keepScrollOffset,
      oldPosition: oldPosition,
      debugLabel: debugLabel,
    );
  }

  void retainOffset() {
    position.retainOffset();
  }

  @override
  RetainableScrollPosition get position =>
      super.position as RetainableScrollPosition;
}

class RetainableScrollPosition extends ScrollPositionWithSingleContext {
  RetainableScrollPosition({
    required super.physics,
    required super.context,
    super.initialPixels = 0.0,
    super.keepScrollOffset,
    super.oldPosition,
    super.debugLabel,
  });

  double? _oldPixels;
  double? _oldMaxScrollExtent;

  bool get shouldRestoreRetainedOffset =>
      _oldMaxScrollExtent != null && _oldPixels != null;

  void retainOffset() {
    if (!hasPixels) return;
    _oldPixels = pixels;
    _oldMaxScrollExtent = maxScrollExtent;
  }

  /// when the viewport layouts its children, it would invoke [applyContentDimensions] to
  /// update the [minScrollExtent] and [maxScrollExtent].
  /// When it happens, [shouldRestoreRetainedOffset] would determine if correcting the current [pixels],
  /// so that the final scroll offset is matched to the previous items' scroll offsets.
  /// Therefore, avoiding scrolling down/up when the new item is inserted into the first index of the list.
  @override
  bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) {
    final applied =
        super.applyContentDimensions(minScrollExtent, maxScrollExtent);

    bool isPixelsCorrected = false;

    if (shouldRestoreRetainedOffset) {
      final diff = maxScrollExtent - _oldMaxScrollExtent!;
      if (_oldPixels! > minScrollExtent && diff > 0) {
        correctPixels(pixels + diff);
        isPixelsCorrected = true;
      }
      _oldMaxScrollExtent = null;
      _oldPixels = null;
    }

    return applied && !isPixelsCorrected;
  }
}

The demo video could be found [here]

  1. The best way to achieve your goal is to use a special ScrollPhysics. Though this way, you do not need to change your existing codes, and just pass physics: const PositionRetainedScrollPhysics() in your list view.
class PositionRetainedScrollPhysics extends ScrollPhysics {
  final bool shouldRetain;
  const PositionRetainedScrollPhysics({super.parent, this.shouldRetain = true});

  @override
  PositionRetainedScrollPhysics applyTo(ScrollPhysics? ancestor) {
    return PositionRetainedScrollPhysics(
      parent: buildParent(ancestor),
      shouldRetain: shouldRetain,
    );
  }

  @override
  double adjustPositionForNewDimensions({
    required ScrollMetrics oldPosition,
    required ScrollMetrics newPosition,
    required bool isScrolling,
    required double velocity,
  }) {
    final position = super.adjustPositionForNewDimensions(
      oldPosition: oldPosition,
      newPosition: newPosition,
      isScrolling: isScrolling,
      velocity: velocity,
    );

    final diff = newPosition.maxScrollExtent - oldPosition.maxScrollExtent;

    if (oldPosition.pixels > oldPosition.minScrollExtent &&
        diff > 0 &&
        shouldRetain) {
      return position + diff;
    } else {
      return position;
    }
  }
}

> you could also use positioned_scroll_observer to use the PositionRetainedScrollPhysics and also other functions, like scrolling to a specific index in any scroll view.

答案2

得分: 1

你只需要再添加一个名为 scrollTop 的函数,需要在 _incrementCounter 函数内部调用它。

void scrollTop() {
    controller.animateTo(
      0,
      duration: const Duration(milliseconds: 500),
      curve: Curves.easeOut,
    );
}

演示:

<img src="https://i.stack.imgur.com/Rgc0b.gif" width="320">

下面修复了你的示例代码:

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  List<String> listItem = [];
  ScrollController controller = ScrollController();

  @override
  void initState() {
    for (int i = 30; i >= 0; i--) {
      listItem.add('Message ------> $i');
    }
    super.initState();
  }

  void _incrementCounter() {
    final startIndex = listItem.length - 1;
    final endIndex = listItem.length + 30;
    for (int i = startIndex; i <= endIndex; i++) {
      listItem.insert(0, 'Message ------> $i');
    }

    setState(() {});
    scrollTop();
  }

  void scrollTop() {
    controller.animateTo(
      0,
      duration: const Duration(milliseconds: 500),
      curve: Curves.easeOut,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: ListView.builder(
        itemCount: listItem.length,
        shrinkWrap: true,
        controller: controller,
        itemBuilder: (context, index) => Container(
          margin: const EdgeInsets.all(8),
          color: Colors.deepPurple,
          height: 50,
          width: 100,
          child: Center(
            child: Text(
              listItem[index],
              style: const TextStyle(
                color: Colors.white,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: const Icon(Icons.add),
      ),
    );
  }
}
英文:

you just need one more function called scrollTop that needs to call inside _incrementCounter function

void scrollTop() {
controller.animateTo(
0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeOut,
);
}

Demo:

<img src="https://i.stack.imgur.com/Rgc0b.gif" width="320">

Below is fixed your code example:

import &#39;package:flutter/material.dart&#39;;
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: &#39;Flutter Demo&#39;,
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: &#39;Flutter Demo Home Page&#39;),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
State&lt;MyHomePage&gt; createState() =&gt; _MyHomePageState();
}
class _MyHomePageState extends State&lt;MyHomePage&gt; {
List&lt;String&gt; listItem = [];
ScrollController controller = ScrollController();
@override
void initState() {
for (int i = 30; i &gt;= 0; i--) {
listItem.add(&#39;Message -------&gt; $i&#39;);
}
super.initState();
}
void _incrementCounter() {
final startIndex = listItem.length - 1;
final endIndex = listItem.length + 30;
for (int i = startIndex; i &lt;= endIndex; i++) {
listItem.insert(0, &#39;Message -------&gt; $i&#39;);
}
setState(() {});
scrollTop();
}
void scrollTop() {
controller.animateTo(
0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeOut,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: ListView.builder(
itemCount: listItem.length,
shrinkWrap: true,
controller: controller,
itemBuilder: (context, index) =&gt; Container(
margin: const EdgeInsets.all(8),
color: Colors.deepPurple,
height: 50,
width: 100,
child: Center(
child: Text(
listItem[index],
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
child: const Icon(Icons.add),
),
);
}
}

答案3

得分: 1

请尝试以下代码,并将其应用到您的列表视图中。

import 'package:flutter/material.dart';

class PositionRetainedScrollPhysics extends ScrollPhysics {
  final bool shouldRetain;
  const PositionRetainedScrollPhysics(
      {ScrollPhysics? parent, this.shouldRetain = true})
      : super(parent: parent);

  @override
  PositionRetainedScrollPhysics applyTo(ScrollPhysics? ancestor) {
    return PositionRetainedScrollPhysics(
      parent: buildParent(ancestor),
      shouldRetain: shouldRetain,
    );
  }

  @override
  double adjustPositionForNewDimensions({
    required ScrollMetrics oldPosition,
    required ScrollMetrics newPosition,
    required bool isScrolling,
    required double velocity,
  }) {
    final position = super.adjustPositionForNewDimensions(
      oldPosition: oldPosition,
      newPosition: newPosition,
      isScrolling: isScrolling,
      velocity: velocity,
    );

    final diff = newPosition.maxScrollExtent - oldPosition.maxScrollExtent;
    if (oldPosition.pixels == 0) {
      if (newPosition.maxScrollExtent > oldPosition.maxScrollExtent &&
          diff > 0 &&
          shouldRetain) {
        return diff;
      } else {
        return position;
      }
    } else {
      return position;
    }
  }
}

这是您提供的代码的中文翻译。如果您有任何其他问题或需要进一步的帮助,请随时告诉我。

英文:

Try this code and give the physics to your listview.

    import &#39;package:flutter/material.dart&#39;;
class PositionRetainedScrollPhysics extends ScrollPhysics {
final bool shouldRetain;
const PositionRetainedScrollPhysics(
{ScrollPhysics? parent, this.shouldRetain = true})
: super(parent: parent);
@override
PositionRetainedScrollPhysics applyTo(ScrollPhysics? ancestor) {
return PositionRetainedScrollPhysics(
parent: buildParent(ancestor),
shouldRetain: shouldRetain,
);
}
@override
double adjustPositionForNewDimensions({
required ScrollMetrics oldPosition,
required ScrollMetrics newPosition,
required bool isScrolling,
required double velocity,
}) {
final position = super.adjustPositionForNewDimensions(
oldPosition: oldPosition,
newPosition: newPosition,
isScrolling: isScrolling,
velocity: velocity,
);
final diff = newPosition.maxScrollExtent - oldPosition.maxScrollExtent;
if (oldPosition.pixels == 0) {
if (newPosition.maxScrollExtent &gt; oldPosition.maxScrollExtent &amp;&amp;
diff &gt; 0 &amp;&amp;
shouldRetain) {
return diff;
} else {
return position;
}
} else {
return position;
}
}
}

huangapple
  • 本文由 发表于 2023年3月1日 15:50:06
  • 转载请务必保留本文链接:https://go.coder-hub.com/75600840.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定