From 1521a10ec2cbf60ae4bc0cf31a75fcd0ae820b56 Mon Sep 17 00:00:00 2001 From: endless7 Date: Fri, 16 Sep 2022 16:04:33 +0800 Subject: [PATCH] feat: using existedChildren instead of autoWrap. --- example/lib/main.dart | 6 +- .../{position_page.dart => jump_page.dart} | 18 +- example/lib/pages/scroll_page.dart | 189 +++++ lib/src/adaptor/ro.dart | 4 + lib/src/adaptor/widget.dart | 6 +- lib/src/refresh/smart_refresher.dart | 3 +- lib/src/sk_positioned_list.dart | 3 +- lib/src/sk_sliver_list.dart | 6 +- lib/src/utils/auto_scroll.dart | 721 ++++++++++++++++++ pubspec.yaml | 1 - 10 files changed, 935 insertions(+), 22 deletions(-) rename example/lib/pages/{position_page.dart => jump_page.dart} (92%) create mode 100644 example/lib/pages/scroll_page.dart create mode 100644 lib/src/utils/auto_scroll.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 50d2ced..a146342 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -2,7 +2,8 @@ import 'package:example/pages/life_cycle_page.dart'; import 'package:example/pages/loadMore_page.dart'; -import 'package:example/pages/position_page.dart'; +import 'package:example/pages/jump_page.dart'; +import 'package:example/pages/scroll_page.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -31,8 +32,9 @@ class MyApp extends StatelessWidget { class HomePage extends StatelessWidget { final Map m = { - "Position": const PositionedListPage(), + "JumpTo": const JumpToPage(), "loadMore": const LoadMorePage(), + "ScrollTo": const ScrollToPage(), "LifeCycle": const LifeCyclePage() }; diff --git a/example/lib/pages/position_page.dart b/example/lib/pages/jump_page.dart similarity index 92% rename from example/lib/pages/position_page.dart rename to example/lib/pages/jump_page.dart index 3fda9d3..25d8ba9 100644 --- a/example/lib/pages/position_page.dart +++ b/example/lib/pages/jump_page.dart @@ -17,8 +17,8 @@ const scrollDuration = Duration(seconds: 2); const randomMax = 1 << 32; -class PositionedListPage extends StatefulWidget { - const PositionedListPage({Key? key}) : super(key: key); +class JumpToPage extends StatefulWidget { + const JumpToPage({Key? key}) : super(key: key); @override _ScrollablePositionedListPageState createState() => @@ -26,7 +26,7 @@ class PositionedListPage extends StatefulWidget { } class _ScrollablePositionedListPageState - extends State { + extends State { late List itemHeights; late List itemColors; @@ -64,7 +64,7 @@ class _ScrollablePositionedListPageState children: [ Column( children: [ - scrollControlButtons, + // scrollControlButtons, jumpControlButtons, // alignmentControl, ], @@ -133,16 +133,6 @@ class _ScrollablePositionedListPageState ); } - Widget get scrollControlButtons => Row( - children: [ - const Text('scroll to'), - scrollButton(0), - scrollButton(5), - scrollButton(10), - scrollButton(30), - ], - ); - Widget get jumpControlButtons => Row( children: [ const Text('jump to'), diff --git a/example/lib/pages/scroll_page.dart b/example/lib/pages/scroll_page.dart new file mode 100644 index 0000000..55c49bc --- /dev/null +++ b/example/lib/pages/scroll_page.dart @@ -0,0 +1,189 @@ +// Copyright 2019 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// This file may have been modified by Bytedance Inc.(“Bytedance Inc.'s +// Modifications”). All Bytedance Inc.'s Modifications are Copyright (2022) +// Bytedance Inc.. + +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:scroll_kit/scroll_kit.dart'; + +const numberOfItems = 5001; +const minItemHeight = 20.0; +const maxItemHeight = 150.0; +const scrollDuration = Duration(seconds: 2); + +const randomMax = 1 << 32; + +class ScrollToPage extends StatefulWidget { + const ScrollToPage({Key? key}) : super(key: key); + + @override + _ScrollablePositionedListPageState createState() => + _ScrollablePositionedListPageState(); +} + +class _ScrollablePositionedListPageState + extends State { + + late List itemHeights; + late List itemColors; + bool reversed = false; + + /// The alignment to be used next time the user scrolls or jumps to an item. + double alignment = 0; + + @override + void initState() { + super.initState(); + final heightGenerator = Random(328902348); + final colorGenerator = Random(42490823); + itemHeights = List.generate( + numberOfItems, + (int _) => + heightGenerator.nextDouble() * (maxItemHeight - minItemHeight) + + minItemHeight); + itemColors = List.generate(numberOfItems, + (int _) => Color(colorGenerator.nextInt(randomMax)).withOpacity(1)); + } + + @override + Widget build(BuildContext context) => Material( + child: OrientationBuilder( + builder: (context, orientation) => Column( + children: [ + Expanded( + child: list1(), + // child: list(orientation), + ), + Container( + height: 100, + child: Row( + children: [ + Column( + children: [ + scrollControlButtons, + ], + ), + ], + ) + ) + ], + ), + ), + ); + + Widget get alignmentControl => Row( + mainAxisSize: MainAxisSize.max, + children: [ + const Text('Alignment: '), + SizedBox( + width: 100, + child: SliderTheme( + data: SliderThemeData( + showValueIndicator: ShowValueIndicator.always, + ), + child: Slider( + value: alignment, + label: alignment.toStringAsFixed(2), + onChanged: (double value) => setState(() => alignment = value), + ), + ), + ), + ], + ); + + late SKPositionController controller; + + List data = () { + List data = []; + for (var i = 0; i < 50; i++) { + data.add(i); + } + return data; + }(); + + Widget list1() { + + controller = SKPositionController( + viewportBoundaryGetter: () => + Rect.fromLTRB(0, 0, 0, MediaQuery.of(context).padding.bottom), + axis: Axis.vertical + ); + + return SKPositionedList( + controller: controller, + delegate: SKSliverChildBuilderDelegate( + (context, index) { + return Container( + height: 80, + color: Colors.grey, + child: Center( + child: Text(data[index].toString()), + ), + ); + }, + childCount: data.length, + reuseIdentifier: (i)=>"" + ), + ); + } + + Widget get scrollControlButtons => Row( + children: [ + const Text('scroll to'), + scrollButton(0), + scrollButton(5), + scrollButton(10), + scrollButton(30), + ], + ); + + final _scrollButtonStyle = ButtonStyle( + padding: MaterialStateProperty.all( + const EdgeInsets.symmetric(horizontal: 20, vertical: 0), + ), + minimumSize: MaterialStateProperty.all(Size.zero), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + + Widget scrollButton(int value) => TextButton( + key: ValueKey('Scroll$value'), + onPressed: () => scrollTo(value), + child: Text('$value'), + style: _scrollButtonStyle, + ); + + Widget jumpButton(int value) => TextButton( + key: ValueKey('Jump$value'), + onPressed: () async { + await jumpTo(value); + }, + child: Text('$value'), + style: _scrollButtonStyle, + ); + + Future scrollTo(int index) { + return controller.scrollTo(index); + } + + Future jumpTo(int index) { + return controller.jumpTo(index); + } + + /// Generate item number [i]. + Widget item(int i, Orientation orientation) { + return SizedBox( + height: orientation == Orientation.portrait ? itemHeights[i] : null, + width: orientation == Orientation.landscape ? itemHeights[i] : null, + child: Container( + color: itemColors[i], + child: Center( + child: Text('Item $i'), + ), + ), + ); + } +} diff --git a/lib/src/adaptor/ro.dart b/lib/src/adaptor/ro.dart index ae25a83..21463c0 100644 --- a/lib/src/adaptor/ro.dart +++ b/lib/src/adaptor/ro.dart @@ -241,6 +241,7 @@ abstract class SKRenderSliverMultiBoxAdaptor extends RenderSliver void _createOrObtainChild(int index, { required RenderBox? after }) { invokeLayoutCallback((SliverConstraints constraints) { assert(constraints == this.constraints); + existChildren.add(index); if (_keepAliveBucket.containsKey(index)) { final RenderBox child = _keepAliveBucket.remove(index)!; final SKSliverMultiBoxAdaptorParentData childParentData = child.parentData! as SKSliverMultiBoxAdaptorParentData; @@ -257,6 +258,7 @@ abstract class SKRenderSliverMultiBoxAdaptor extends RenderSliver void _destroyOrCacheChild(RenderBox child) { final SKSliverMultiBoxAdaptorParentData childParentData = child.parentData! as SKSliverMultiBoxAdaptorParentData; + existChildren.remove(indexOf(child)); if (childParentData.keepAlive) { assert(!childParentData._keptAlive); remove(child); @@ -274,6 +276,8 @@ abstract class SKRenderSliverMultiBoxAdaptor extends RenderSliver final List _exposedChildren = []; + final List existChildren = []; + final SKLifeCycleManager _lifeCycleManager; void handleLifeCycle({required SliverConstraints constraints, double? extent}) { diff --git a/lib/src/adaptor/widget.dart b/lib/src/adaptor/widget.dart index 53fd054..4ef3f20 100644 --- a/lib/src/adaptor/widget.dart +++ b/lib/src/adaptor/widget.dart @@ -9,6 +9,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/rendering.dart'; import 'package:scroll_kit/scroll_kit.dart'; +import 'package:scroll_kit/src/utils/auto_scroll.dart'; import 'element.dart'; import 'ro.dart'; @@ -18,11 +19,14 @@ abstract class SKSliverMultiBoxAdaptorWidget extends SliverWithKeepAliveWidget { const SKSliverMultiBoxAdaptorWidget({ super.key, required this.delegate, - this.forwardRefreshCount + this.forwardRefreshCount, + this.scrollController }) : assert(delegate != null); final int? forwardRefreshCount; + final AutoScrollController? scrollController; + /// {@template flutter.widgets.SliverMultiBoxAdaptorWidget.delegate} /// The delegate that provides the children for this widget. /// diff --git a/lib/src/refresh/smart_refresher.dart b/lib/src/refresh/smart_refresher.dart index 1b6cb45..4e2f758 100644 --- a/lib/src/refresh/smart_refresher.dart +++ b/lib/src/refresh/smart_refresher.dart @@ -10,10 +10,9 @@ import 'dart:async'; -import 'package:scroll_to_index/scroll_to_index.dart'; +import '../utils/auto_scroll.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' hide RefreshIndicator, RefreshIndicatorState; -import 'package:flutter/physics.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/foundation.dart'; import 'package:scroll_kit/src/refresh/internals/slivers.dart'; diff --git a/lib/src/sk_positioned_list.dart b/lib/src/sk_positioned_list.dart index ce8789f..fb65b24 100644 --- a/lib/src/sk_positioned_list.dart +++ b/lib/src/sk_positioned_list.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:scroll_kit/src/sk_sliver_list.dart'; -import 'package:scroll_to_index/scroll_to_index.dart'; +import 'utils/auto_scroll.dart'; import 'sk_child_delegate.dart'; // ignore: must_be_immutable @@ -98,6 +98,7 @@ class _SKPositionedListState extends State SKSliverList( delegate: widget.delegate, forwardRefreshCount: forwardRefreshCount, + scrollController: widget.controller.scrollController, ) ], ); diff --git a/lib/src/sk_sliver_list.dart b/lib/src/sk_sliver_list.dart index 12733c3..56ce45d 100644 --- a/lib/src/sk_sliver_list.dart +++ b/lib/src/sk_sliver_list.dart @@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; +import 'package:scroll_kit/src/utils/auto_scroll.dart'; import 'sk_child_delegate.dart'; import 'adaptor/widget.dart'; import 'adaptor/element.dart'; @@ -20,6 +21,7 @@ class SKSliverList extends SKSliverMultiBoxAdaptorWidget { super.key, required super.delegate, super.forwardRefreshCount, + super.scrollController }); @override @@ -30,8 +32,10 @@ class SKSliverList extends SKSliverMultiBoxAdaptorWidget { SKRenderSliverList createRenderObject(BuildContext context) { final SKSliverMultiBoxAdaptorElement element = context as SKSliverMultiBoxAdaptorElement; - return SKRenderSliverList( + final renderList = SKRenderSliverList( childManager: element, lifeCycleManager: delegate); + scrollController?.adaptor = renderList; + return renderList; } } diff --git a/lib/src/utils/auto_scroll.dart b/lib/src/utils/auto_scroll.dart new file mode 100644 index 0000000..08f8e94 --- /dev/null +++ b/lib/src/utils/auto_scroll.dart @@ -0,0 +1,721 @@ +//Copyright (C) 2019 Potix Corporation. All Rights Reserved. +//History: Tue Apr 24 09:17 CST 2019 +// Author: Jerry Chen + +import 'dart:async'; +import 'dart:collection'; +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:scroll_kit/src/adaptor/ro.dart'; +import 'package:scroll_kit/src/sk_sliver_list.dart'; + + +const defaultScrollDistanceOffset = 100.0; +const defaultDurationUnit = 40; + +const _millisecond = Duration(milliseconds: 1); +const _highlightDuration = const Duration(seconds: 3); +const scrollAnimationDuration = Duration(milliseconds: 250); + +/// used to invoke async functions in order +Future co(Object key, FutureOr action()) async { + for (;;) { + final c = _locks[key]; + if (c == null) break; + try { + await c.future; + } catch (_) {} //ignore error (so it will continue) + } + + final c = _locks[key] = new Completer(); + void then(T result) { + final c2 = _locks.remove(key); + c.complete(result); + + assert(identical(c, c2)); + } + + void catchError(Object ex, StackTrace st) { + final c2 = _locks.remove(key); + c.completeError(ex, st); + + assert(identical(c, c2)); + } + + try { + final result = action(); + if (result is Future) { + result.then(then).catchError(catchError); + } else { + then(result); + } + } catch (ex, st) { + catchError(ex, st); + } + + return c.future; +} + +final _locks = new HashMap(); + +/// skip the TickerCanceled exception +Future catchAnimationCancel(TickerFuture future) async { + return future.orCancel.catchError((Object x) async { + // do nothing, skip TickerCanceled exception + return null; + }, test: (ex) => ex is TickerCanceled); +} + + +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; + + // ADD + late SKRenderSliverMultiBoxAdaptor? adaptor; + + /// 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 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); +} + +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); + } + + @override + SKRenderSliverMultiBoxAdaptor? adaptor; +} + +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); + } + + @override + SKRenderSliverMultiBoxAdaptor? adaptor; +} + +enum AutoScrollPosition { begin, middle, end } +mixin AutoScrollControllerMixin on ScrollController +implements AutoScrollController { + @override + final Map tagMap = {}; + 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}) 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); + final moveTarget = + _forecastMoveUnit(index, nearest, usedSuggestedRowHeightIfAny)!; + if (moveTarget < 0) //can't forecast the move range + return null; + // 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) => adaptor!.existChildren.contains(index); + + /// 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 (tagMap[currentNearestIndex] == null) return -1; + + 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 = adaptor!.existChildren; + 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; + } +} + +void _cancelAllHighlights([AutoScrollTagState? state]) { + for (final tag in _highlights.keys) + tag._cancelController(reset: tag != state); + + _highlights.clear(); +} + +typedef Widget TagHighlightBuilder(BuildContext context, Animation highlight); +class AutoScrollTag extends StatefulWidget { + final AutoScrollController controller; + 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(); + } +} + +Map _highlights = +{}; + +class AutoScrollTagState extends State + 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 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 + ); +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index f01336d..ea1720d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,6 @@ environment: dependencies: flutter: sdk: flutter - scroll_to_index: ^3.0.1 dev_dependencies: flutter_test: