Flutter开发,scroll_to_index适配flutter_list_view

问题描述:

在使用Flutter做项目的过程中,使用了flutter_list_view作为消息展示,选择flutter_list_view的原因是,比起原生ListView,FlutterListView可以使消息反转,并且可以设置firstItemAlign: FirstItemAlign.end,属性,实现聊天最新消息在视图最下方,并且实现顶端对齐。但是在开发消息引用过程中,准备使用scroll_to_index实现消息跳转,但是它只支持原生ListView,却不支持FlutterListView,这里不使用ListView的原因是消息反转后无法实现顶端对齐。

为此,查阅了scroll_to_index源码,对其进行改造,使之适配FlutterListView。

Dart 复制代码
import 'dart:async';
import 'dart:math' as math;

import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_list_view/flutter_list_view.dart';
import 'package:scroll_to_index/util.dart';

// import 'util.dart';

const defaultScrollDistanceOffset = 100.0;
const defaultDurationUnit = 40;

const _millisecond = Duration(milliseconds: 1);
const _highlightDuration = const Duration(seconds: 3);
const scrollAnimationDuration = Duration(milliseconds: 250);

typedef Rect ViewportBoundaryGetter();
typedef double AxisValueGetter(Rect rect);

Rect defaultViewportBoundaryGetter() => Rect.zero;

abstract class AutoScrollController implements ScrollController {
  factory AutoScrollController(
      {double initialScrollOffset: 0.0,
        bool keepScrollOffset: true,
        double? suggestedRowHeight,
        ViewportBoundaryGetter viewportBoundaryGetter:
        defaultViewportBoundaryGetter,
        Axis? axis,
        String? debugLabel,
        AutoScrollController? copyTagsFrom}) {
    return SimpleAutoScrollController(
        initialScrollOffset: initialScrollOffset,
        keepScrollOffset: keepScrollOffset,
        suggestedRowHeight: suggestedRowHeight,
        viewportBoundaryGetter: viewportBoundaryGetter,
        beginGetter: axis == Axis.horizontal ? (r) => r.left : (r) => r.top,
        endGetter: axis == Axis.horizontal ? (r) => r.right : (r) => r.bottom,
        copyTagsFrom: copyTagsFrom,
        debugLabel: debugLabel);
  }

  /// used to quick scroll to a index if the row height is the same
  double? get suggestedRowHeight;

  /// used to make the additional boundary for viewport
  /// e.g. a sticky header which covers the real viewport of a list view
  ViewportBoundaryGetter get viewportBoundaryGetter;

  /// used to choose which direction you are using.
  /// e.g. axis == Axis.horizontal ? (r) => r.left : (r) => r.top
  AxisValueGetter get beginGetter;
  AxisValueGetter get endGetter;

  /// detect if it's in scrolling (scrolling is a async process)
  bool get isAutoScrolling;

  /// all layout out states will be put into this map
  Map<int, AutoScrollTagState> get tagMap;

  /// used to chaining parent scroll controller
  set parentController(ScrollController parentController);

  /// check if there is a parent controller
  bool get hasParentController;

  /// scroll to the giving index
  Future scrollToIndex(int index,
      {Duration duration: scrollAnimationDuration,
        AutoScrollPosition? preferPosition});

  /// highlight the item
  Future highlight(int index,
      {bool cancelExistHighlights: true,
        Duration highlightDuration: _highlightDuration,
        bool animated: true});

  /// cancel all highlight item immediately.
  void cancelAllHighlights();

  /// check if the state is created. that is, is the indexed widget is layout out.
  /// NOTE: state created doesn't mean it's in viewport. it could be a buffer range, depending on flutter's implementation.
  bool isIndexStateInLayoutRange(int index);
}

// 新增:为FlutterListView创建的控制器接口
abstract class FlutterListAutoScrollController implements FlutterListViewController {
  factory FlutterListAutoScrollController() {
    return SimpleFlutterListAutoScrollController();
  }

  /// all layout out states will be put into this map
  Map<int, AutoScrollTagState> get tagMap;

  /// detect if it's in scrolling (scrolling is a async process)
  bool get isAutoScrolling;

  /// scroll to the giving index
  Future scrollToIndex(int index,
      {Duration duration: scrollAnimationDuration,
        AutoScrollPosition? preferPosition});

  /// highlight the item
  Future highlight(int index,
      {bool cancelExistHighlights: true,
        Duration highlightDuration: _highlightDuration,
        bool animated: true});

  /// cancel all highlight item immediately.
  void cancelAllHighlights();

  /// check if the state is created. that is, is the indexed widget is layout out.
  /// NOTE: state created doesn't mean it's in viewport. it could be a buffer range, depending on flutter's implementation.
  bool isIndexStateInLayoutRange(int index);
}

class SimpleAutoScrollController extends ScrollController
    with AutoScrollControllerMixin {
  @override
  final double? suggestedRowHeight;
  @override
  final ViewportBoundaryGetter viewportBoundaryGetter;
  @override
  final AxisValueGetter beginGetter;
  @override
  final AxisValueGetter endGetter;

  SimpleAutoScrollController(
      {double initialScrollOffset: 0.0,
        bool keepScrollOffset: true,
        this.suggestedRowHeight,
        this.viewportBoundaryGetter: defaultViewportBoundaryGetter,
        required this.beginGetter,
        required this.endGetter,
        AutoScrollController? copyTagsFrom,
        String? debugLabel})
      : super(
      initialScrollOffset: initialScrollOffset,
      keepScrollOffset: keepScrollOffset,
      debugLabel: debugLabel) {
    if (copyTagsFrom != null) tagMap.addAll(copyTagsFrom.tagMap);
  }
}

// 新增:FlutterListView的控制器实现
class SimpleFlutterListAutoScrollController extends FlutterListViewController
    with FlutterListAutoScrollControllerMixin {
}

class PageAutoScrollController extends PageController
    with AutoScrollControllerMixin {
  @override
  final double? suggestedRowHeight;
  @override
  final ViewportBoundaryGetter viewportBoundaryGetter;
  @override
  final AxisValueGetter beginGetter = (r) => r.left;
  @override
  final AxisValueGetter endGetter = (r) => r.right;

  PageAutoScrollController(
      {int initialPage: 0,
        bool keepPage: true,
        double viewportFraction: 1.0,
        this.suggestedRowHeight,
        this.viewportBoundaryGetter: defaultViewportBoundaryGetter,
        AutoScrollController? copyTagsFrom,
        String? debugLabel})
      : super(
      initialPage: initialPage,
      keepPage: keepPage,
      viewportFraction: viewportFraction) {
    if (copyTagsFrom != null) tagMap.addAll(copyTagsFrom.tagMap);
  }
}

enum AutoScrollPosition { begin, middle, end }

// 新增一个类型定义,用于兼容两种控制器类型
typedef AutoScrollControllerBase = dynamic;

mixin AutoScrollControllerMixin on ScrollController
implements AutoScrollController {
  @override
  final Map<int, AutoScrollTagState> tagMap = <int, AutoScrollTagState>{};
  double? get suggestedRowHeight;
  ViewportBoundaryGetter get viewportBoundaryGetter;
  AxisValueGetter get beginGetter;
  AxisValueGetter get endGetter;

  bool __isAutoScrolling = false;
  set _isAutoScrolling(bool isAutoScrolling) {
    __isAutoScrolling = isAutoScrolling;
    if (!isAutoScrolling &&
        hasClients) //after auto scrolling, we should sync final scroll position without flag on
      notifyListeners();
  }

  @override
  bool get isAutoScrolling => __isAutoScrolling;

  ScrollController? _parentController;
  @override
  set parentController(ScrollController parentController) {
    if (_parentController == parentController) return;

    final isNotEmpty = positions.isNotEmpty;
    if (isNotEmpty && _parentController != null) {
      for (final p in _parentController!.positions)
        if (positions.contains(p)) _parentController!.detach(p);
    }

    _parentController = parentController;

    if (isNotEmpty && _parentController != null)
      for (final p in positions) _parentController!.attach(p);
  }

  @override
  bool get hasParentController => _parentController != null;

  @override
  void attach(ScrollPosition position) {
    super.attach(position);

    _parentController?.attach(position);
  }

  @override
  void detach(ScrollPosition position) {
    _parentController?.detach(position);

    super.detach(position);
  }

  static const maxBound = 30; // 0.5 second if 60fps
  @override
  Future scrollToIndex(int index,
      {Duration duration: scrollAnimationDuration,
        AutoScrollPosition? preferPosition: AutoScrollPosition.middle}) async {
    return co(
        this,
            () => _scrollToIndex(index,
            duration: duration, preferPosition: preferPosition));
  }

  Future _scrollToIndex(int index,
      {Duration duration: scrollAnimationDuration,
        AutoScrollPosition? preferPosition}) async {
    assert(duration > Duration.zero);

    // In listView init or reload case, widget state of list item may not be ready for query.
    // this prevent from over scrolling becoming empty screen or unnecessary scroll bounce.
    Future makeSureStateIsReady() async {
      for (var count = 0; count < maxBound; count++) {
        if (_isEmptyStates) {
          await _waitForWidgetStateBuild();
        } else
          return null;
      }

      return null;
    }

    await makeSureStateIsReady();

    if (!hasClients) return null;

    // two cases,
    // 1. already has state. it's in viewport layout
    // 2. doesn't have state yet. it's not in viewport so we need to start scrolling to make it into layout range.
    if (isIndexStateInLayoutRange(index)) {
      _isAutoScrolling = true;

      await _bringIntoViewportIfNeed(index, preferPosition,
              (double offset) async {
            await animateTo(offset, duration: duration, curve: Curves.ease);
            await _waitForWidgetStateBuild();
            return null;
          });

      _isAutoScrolling = false;
    } else {
      // the idea is scrolling based on either
      // 1. suggestedRowHeight or
      // 2. testDistanceOffset
      double prevOffset = offset - 1;
      double currentOffset = offset;
      bool contains = false;
      Duration spentDuration = const Duration();
      double lastScrollDirection = 0.5; // alignment, default center;
      final moveDuration = duration ~/ defaultDurationUnit;

      _isAutoScrolling = true;

      /// ideally, the suggest row height will move to the final corrent offset approximately in just one scroll(iteration).
      /// if the given suggest row height is the minimal/maximal height in variable row height enviroment,
      /// we can just use viewport calculation to reach the final offset in other iteration.
      bool usedSuggestedRowHeightIfAny = true;
      while (prevOffset != currentOffset &&
          !(contains = isIndexStateInLayoutRange(index))) {
        prevOffset = currentOffset;
        final nearest = _getNearestIndex(index);

        if (tagMap[nearest ?? 0] == null)
          return null;

        final moveTarget =
        _forecastMoveUnit(index, nearest, usedSuggestedRowHeightIfAny)!;

        // assume suggestRowHeight will move to correct offset in just one time.
        // if the rule doesn't work (in variable row height case), we will use backup solution (non-suggested way)
        final suggestedDuration =
        usedSuggestedRowHeightIfAny && suggestedRowHeight != null
            ? duration
            : null;
        usedSuggestedRowHeightIfAny = false; // just use once
        lastScrollDirection = moveTarget - prevOffset > 0 ? 1 : 0;
        currentOffset = moveTarget;
        spentDuration += suggestedDuration ?? moveDuration;
        final oldOffset = offset;
        await animateTo(currentOffset,
            duration: suggestedDuration ?? moveDuration, curve: Curves.ease);
        await _waitForWidgetStateBuild();
        if (!hasClients || offset == oldOffset) {
          // already scroll to begin or end
          contains = isIndexStateInLayoutRange(index);
          break;
        }
      }
      _isAutoScrolling = false;

      if (contains && hasClients) {
        await _bringIntoViewportIfNeed(
            index, preferPosition ?? _alignmentToPosition(lastScrollDirection),
                (finalOffset) async {
              if (finalOffset != offset) {
                _isAutoScrolling = true;
                final remaining = duration - spentDuration;
                await animateTo(finalOffset,
                    duration: remaining <= Duration.zero ? _millisecond : remaining,
                    curve: Curves.ease);
                await _waitForWidgetStateBuild();

                // not sure why it doesn't scroll to the given offset, try more within 3 times
                if (hasClients && offset != finalOffset) {
                  final count = 3;
                  for (var i = 0;
                  i < count && hasClients && offset != finalOffset;
                  i++) {
                    await animateTo(finalOffset,
                        duration: _millisecond, curve: Curves.ease);
                    await _waitForWidgetStateBuild();
                  }
                }
                _isAutoScrolling = false;
              }
            });
      }
    }

    return null;
  }

  @override
  Future highlight(int index,
      {bool cancelExistHighlights: true,
        Duration highlightDuration: _highlightDuration,
        bool animated: true}) async {
    final tag = tagMap[index];
    return tag == null
        ? null
        : await tag.highlight(
        cancelExisting: cancelExistHighlights,
        highlightDuration: highlightDuration,
        animated: animated);
  }

  @override
  void cancelAllHighlights() {
    _cancelAllHighlights();
  }

  @override
  bool isIndexStateInLayoutRange(int index) => tagMap[index] != null;

  /// this means there is no widget state existing, usually happened before build.
  /// we should wait for next frame.
  bool get _isEmptyStates => tagMap.isEmpty;

  /// wait until the [SchedulerPhase] in [SchedulerPhase.persistentCallbacks].
  /// it means if we do animation scrolling to a position, the Future call back will in [SchedulerPhase.midFrameMicrotasks].
  /// if we want to search viewport element depending on Widget State, we must delay it to [SchedulerPhase.persistentCallbacks].
  /// which is the phase widget build/layout/draw
  Future _waitForWidgetStateBuild() => SchedulerBinding.instance.endOfFrame;

  /// NOTE: this is used to forcase the nearestIndex. if the the index equals targetIndex,
  /// we will use the function, calling _directionalOffsetToRevealInViewport to get move unit.
  double? _forecastMoveUnit(
      int targetIndex, int? currentNearestIndex, bool useSuggested) {
    assert(targetIndex != currentNearestIndex);
    currentNearestIndex = currentNearestIndex ?? 0; //null as none of state

    final alignment = targetIndex > currentNearestIndex ? 1.0 : 0.0;
    double? absoluteOffsetToViewport;

    if (useSuggested && suggestedRowHeight != null) {
      final indexDiff = (targetIndex - currentNearestIndex);
      final offsetToLastState = _offsetToRevealInViewport(
          currentNearestIndex, indexDiff <= 0 ? 0 : 1)!;
      absoluteOffsetToViewport = math.max(
          offsetToLastState.offset + indexDiff * suggestedRowHeight!, 0);
    } else {
      final offsetToLastState =
      _offsetToRevealInViewport(currentNearestIndex, alignment);

      absoluteOffsetToViewport = offsetToLastState?.offset;
      if (absoluteOffsetToViewport == null)
        absoluteOffsetToViewport = defaultScrollDistanceOffset;
    }

    return absoluteOffsetToViewport;
  }

  int? _getNearestIndex(int index) {
    final list = tagMap.keys;
    if (list.isEmpty) return null;

    final sorted = list.toList()
      ..sort((int first, int second) => first.compareTo(second));
    final min = sorted.first;
    final max = sorted.last;
    return (index - min).abs() < (index - max).abs() ? min : max;
  }

  /// bring the state node (already created but all of it may not be fully in the viewport) into viewport
  Future _bringIntoViewportIfNeed(int index, AutoScrollPosition? preferPosition,
      Future move(double offset)) async {

    if (preferPosition != null) {
      double targetOffset = _directionalOffsetToRevealInViewport(
          index, _positionToAlignment(preferPosition));

      // The content preferred position might be impossible to reach
      // for items close to the edges of the scroll content, e.g.
      // we cannot put the first item at the end of the viewport or
      // the last item at the beginning. Trying to do so might lead
      // to a bounce at either the top or bottom, unless the scroll
      // physics are set to clamp. To prevent this, we limit the
      // offset to not overshoot the extent in either direction.
      targetOffset = targetOffset.clamp(
          position.minScrollExtent, position.maxScrollExtent);

      await move(targetOffset);
    } else {
      final begin = _directionalOffsetToRevealInViewport(index, 0);
      final end = _directionalOffsetToRevealInViewport(index, 1);

      final alreadyInViewport = offset < begin && offset > end;
      if (!alreadyInViewport) {
        double value;
        if ((end - offset).abs() < (begin - offset).abs())
          value = end;
        else
          value = begin;

        await move(value > 0 ? value : 0);
      }
    }
  }

  double _positionToAlignment(AutoScrollPosition position) {
    return position == AutoScrollPosition.begin
        ? 0
        : position == AutoScrollPosition.end
        ? 1
        : 0.5;
  }

  AutoScrollPosition _alignmentToPosition(double alignment) => alignment == 0
      ? AutoScrollPosition.begin
      : alignment == 1
      ? AutoScrollPosition.end
      : AutoScrollPosition.middle;

  /// return offset, which is a absolute offset to bring the target index object into the location(depends on [direction]) of viewport
  /// see also: _offsetYToRevealInViewport()
  double _directionalOffsetToRevealInViewport(int index, double alignment) {
    assert(alignment == 0 || alignment == 0.5 || alignment == 1);
    // 1.0 bottom, 0.5 center, 0.0 begin if list is vertically from begin to end
    final tagOffsetInViewport = _offsetToRevealInViewport(index, alignment);

    if (tagOffsetInViewport == null) {
      return -1;
    } else {
      double absoluteOffsetToViewport = tagOffsetInViewport.offset;
      if (alignment == 0.5) {
        return absoluteOffsetToViewport;
      } else if (alignment == 0) {
        return absoluteOffsetToViewport - beginGetter(viewportBoundaryGetter());
      } else {
        return absoluteOffsetToViewport + endGetter(viewportBoundaryGetter());
      }
    }
  }

  /// return offset, which is a absolute offset to bring the target index object into the center of the viewport
  /// see also: _directionalOffsetToRevealInViewport()
  RevealedOffset? _offsetToRevealInViewport(int index, double alignment) {
    final ctx = tagMap[index]?.context;
    if (ctx == null) return null;

    final renderBox = ctx.findRenderObject()!;
    assert(Scrollable.of(ctx) != null);
    final RenderAbstractViewport viewport =
    RenderAbstractViewport.of(renderBox)!;
    final revealedOffset = viewport.getOffsetToReveal(renderBox, alignment);

    return revealedOffset;
  }
}

// 新增:为FlutterListView实现的mixin
mixin FlutterListAutoScrollControllerMixin on FlutterListViewController
implements FlutterListAutoScrollController {
  @override
  final Map<int, AutoScrollTagState> tagMap = <int, AutoScrollTagState>{};

  bool __isAutoScrolling = false;
  set _isAutoScrolling(bool isAutoScrolling) {
    __isAutoScrolling = isAutoScrolling;
  }

  @override
  bool get isAutoScrolling => __isAutoScrolling;

  static const maxBound = 30; // 0.5 second if 60fps
  @override
  Future scrollToIndex(int index,
      {Duration duration: scrollAnimationDuration,
        AutoScrollPosition? preferPosition}) async {
    return co(
        this,
            () => _scrollToIndex(index,
            duration: duration, preferPosition: preferPosition));
  }

  Future _scrollToIndex(int index,
      {Duration duration: scrollAnimationDuration,
        AutoScrollPosition? preferPosition}) async {
    assert(duration > Duration.zero);

    // In listView init or reload case, widget state of list item may not be ready for query.
    // this prevent from over scrolling becoming empty screen or unnecessary scroll bounce.
    Future makeSureStateIsReady() async {
      for (var count = 0; count < maxBound; count++) {
        if (_isEmptyStates) {
          await _waitForWidgetStateBuild();
        } else
          return null;
      }

      return null;
    }

    await makeSureStateIsReady();

    // For FlutterListView, we directly use the controller's animateToIndex method
    _isAutoScrolling = true;
    // try {
    //   // 使用FlutterListView的animateToIndex方法
    //   await sliverController.animateToIndex(
    //     index,
    //     duration: duration,
    //     curve: Curves.ease,
    //     // offset: 0.0, // 添加默认偏移量
    //     offset: 0.0, // 添加默认偏移量
    //     // basedOnBottom: false, // 添加默认参数
    //   );
    // } finally {
    //   _isAutoScrolling = false;
    // }
    try {
      // 使用FlutterListView的animateToIndex方法
      // 根据preferPosition参数设置offsetBasedOnBottom参数
      bool offsetBasedOnBottom = false;
      double offset = 0.0;

      if (preferPosition == AutoScrollPosition.end) {
        offsetBasedOnBottom = true;
        offset = 0.0;
      } else if (preferPosition == AutoScrollPosition.middle) {
        // 对于middle位置,我们可以设置一个适中的偏移量
        // 这里需要获取视口高度来计算中间位置,暂时设置为0
        offset = 350.0;
      } else {
        // 默认或begin位置
        offsetBasedOnBottom = false;
        offset = 0.0;
      }

      await sliverController.animateToIndex(
        index,
        duration: duration,
        curve: Curves.ease,
        offset: offset,
      );
    } finally {
      _isAutoScrolling = false;
    }

    return null;
  }

  @override
  Future highlight(int index,
      {bool cancelExistHighlights: true,
        Duration highlightDuration: _highlightDuration,
        bool animated: true}) async {
    final tag = tagMap[index];
    return tag == null
        ? null
        : await tag.highlight(
        cancelExisting: cancelExistHighlights,
        highlightDuration: highlightDuration,
        animated: animated);
  }

  @override
  void cancelAllHighlights() {
    _cancelAllHighlights();
  }

  @override
  bool isIndexStateInLayoutRange(int index) => tagMap[index] != null;

  /// this means there is no widget state existing, usually happened before build.
  /// we should wait for next frame.
  bool get _isEmptyStates => tagMap.isEmpty;

  /// wait until the [SchedulerPhase] in [SchedulerPhase.persistentCallbacks].
  /// it means if we do animation scrolling to a position, the Future call back will in [SchedulerPhase.midFrameMicrotasks].
  /// if we want to search viewport element depending on Widget State, we must delay it to [SchedulerPhase.persistentCallbacks].
  /// which is the phase widget build/layout/draw
  Future _waitForWidgetStateBuild() => SchedulerBinding.instance.endOfFrame;
}

void _cancelAllHighlights([AutoScrollTagState? state]) {
  for (final tag in _highlights.keys)
    tag._cancelController(reset: tag != state);

  _highlights.clear();
}

typedef Widget TagHighlightBuilder(BuildContext context, Animation<double> highlight);
class AutoScrollTag extends StatefulWidget {
  final dynamic controller; // 修改为dynamic类型以接受两种控制器类型
  final int index;
  final Widget? child;
  final TagHighlightBuilder? builder;
  final Color? color;
  final Color? highlightColor;
  final bool disabled;

  AutoScrollTag(
      {required Key key,
        required this.controller,
        required this.index,
        this.child,
        this.builder,
        this.color,
        this.highlightColor,
        this.disabled: false})
      : assert(child != null || builder != null), super(key: key);

  @override
  AutoScrollTagState createState() {
    return new AutoScrollTagState<AutoScrollTag>();
  }
}

Map<AutoScrollTagState, AnimationController?> _highlights =
<AutoScrollTagState, AnimationController?>{};

class AutoScrollTagState<W extends AutoScrollTag> extends State<W>
    with TickerProviderStateMixin {
  AnimationController? _controller;

  @override
  void initState() {
    super.initState();
    if (!widget.disabled) {
      register(widget.index);
    }
  }

  @override
  void dispose() {
    _cancelController();
    if (!widget.disabled) {
      unregister(widget.index);
    }
    _controller = null;
    _highlights.remove(this);
    super.dispose();
  }

  @override
  void didUpdateWidget(W oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.index != widget.index ||
        oldWidget.key != widget.key ||
        oldWidget.disabled != widget.disabled) {
      if (!oldWidget.disabled) unregister(oldWidget.index);

      if (!widget.disabled) register(widget.index);
    }
  }

  void register(int index) {
    // the caller in initState() or dispose() is not in the order of first dispose and init
    // so we can't assert there isn't a existing key
    // assert(!widget.controller.tagMap.keys.contains(index));
    widget.controller.tagMap[index] = this;
  }

  void unregister(int index) {
    _cancelController();
    _highlights.remove(this);
    // the caller in initState() or dispose() is not in the order of first dispose and init
    // so we can't assert there isn't a existing key
    // assert(widget.controller.tagMap.keys.contains(index));
    if (widget.controller.tagMap[index] == this)
      widget.controller.tagMap.remove(index);
  }

  @override
  Widget build(BuildContext context) {
    final animation = _controller ?? kAlwaysDismissedAnimation;
    return widget.builder?.call(context, animation)
        ?? buildHighlightTransition(context: context, highlight: animation, child: widget.child!,
            background: widget.color, highlightColor: widget.highlightColor);
  }

  //used to make sure we will drop the old highlight
  //it's rare that we call it more than once in same millisecond, so we just make the time stamp as the unique key
  DateTime? _startKey;

  /// this function can be called multiple times. every call will reset the highlight style.
  Future highlight(
      {bool cancelExisting: true,
        Duration highlightDuration: _highlightDuration,
        bool animated: true}) async {
    if (!mounted) return null;

    if (cancelExisting) {
      _cancelAllHighlights(this);
    }

    if (_highlights.containsKey(this)) {
      assert(_controller != null);
      _controller!.stop();
    }

    if (_controller == null) {
      _controller = new AnimationController(vsync: this);
      _highlights[this] = _controller;
    }

    final startKey0 = _startKey = DateTime.now();
    const animationShow = 1.0;
    setState(() {});
    if (animated)
      await catchAnimationCancel(_controller!
          .animateTo(animationShow, duration: scrollAnimationDuration));
    else
      _controller!.value = animationShow;
    await Future.delayed(highlightDuration);

    if (startKey0 == _startKey) {
      if (mounted) {
        setState(() {});
        const animationHide = 0.0;
        if (animated)
          await catchAnimationCancel(_controller!
              .animateTo(animationHide, duration: scrollAnimationDuration));
        else
          _controller!.value = animationHide;
      }

      if (startKey0 == _startKey) {
        _controller = null;
        _highlights.remove(this);
      }
    }
    return null;
  }

  void _cancelController({bool reset: true}) {
    if (_controller != null) {
      if (_controller!.isAnimating) _controller!.stop();

      if (reset && _controller!.value != 0.0) _controller!.value = 0.0;
    }
  }
}

// Widget buildHighlightTransition({required BuildContext context, required Animation<double> highlight,
//   required Widget child, Color? background, Color? highlightColor}) {
//   return DecoratedBoxTransition(
//       decoration: DecorationTween(
//           begin: background != null ?
//           BoxDecoration(color: background) :
//           BoxDecoration(),
//           end: background != null ?
//           BoxDecoration(color: background) :
//           BoxDecoration(color: highlightColor)
//       ).animate(highlight),
//       child: child
//   );
// }

Widget buildHighlightTransition({required BuildContext context, required Animation<double> highlight,
  required Widget child, Color? background, Color? highlightColor}) {
  return Stack(
    children: [
      child,
      Positioned.fill(
        child: IgnorePointer(
          ignoring: true,
          child: FadeTransition(
            opacity: highlight,
            child: DecoratedBox(
              decoration: BoxDecoration(
                color: highlightColor,
                // borderRadius: BorderRadius.circular(10.0)
                borderRadius: const BorderRadius.all(Radius.circular(10)),
              ),
            ),
          ),
        ),
      )
    ],
  );

}

代码中还重新写了buildHighlightTransition,使源码中的高亮显示在控件的最底层改为最上层,读者可以自行更改

在使用中,Controller可以做如下定义:

Dart 复制代码
  /// 消息列表滚动控制器
  SimpleFlutterListAutoScrollController? _listViewController;

  /// 获取滚动控制器
  SimpleFlutterListAutoScrollController get listViewController {
    _listViewController ??= SimpleFlutterListAutoScrollController();
    return _listViewController!;
  }

对于其他按照scroll_to_index正常使用就好

相关推荐
deli0071 小时前
手机拍灯为什么有一条条黑纹?PWM 240Hz 实测
前端
wangruofeng6 小时前
AI Models 1.1.0 发布,查大模型价格、对比模型和找 API 地址
前端·aigc·ai编程
wangruofeng6 小时前
给前端开发者的 100 个设计参考网站
前端·设计
LinXunFeng9 小时前
拿竞品 App 一扫,技术栈全暴露了
android·flutter·开源
星栈9 小时前
视频即代码,Remotion 能做什么、不能做什么
前端·音视频开发
广州华水科技9 小时前
单北斗GNSS位移监测系统的应用与技术解析
前端
用户47949283569159 小时前
从一句话或代码仓到可交互 HTML:开源 Agent 画图技能 Archify v3.2 实测与选型边界
前端·后端·面试
风骏时光牛马12 小时前
生成一个AI多模态标题,仅输出一条标题,无额外文字、问句、备选、配图、文案等任何附加内容,不需要其他的任务服务 只要标题。
前端
wangruofeng12 小时前
从 GIF 到 AVIF:网页图片格式演进与 2026 年选型指南
前端·架构
清晨Coding13 小时前
为什么用 React Developer Tools 查看组件树这么乱?
前端·react.js