Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: remove need for an extra pull #42

Merged
merged 2 commits into from
Oct 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions lib/src/defaults.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,5 @@ Widget defaultInfiniteListErrorBuilder(BuildContext buildContext) {
);
}

/// Default value to [InfiniteList.scrollExtentThreshold].
const defaultScrollExtentThreshold = 400.0;

/// Default value to [InfiniteList.debounceDuration].
const defaultDebounceDuration = Duration(milliseconds: 100);
33 changes: 9 additions & 24 deletions lib/src/infinite_list.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:very_good_infinite_list/src/defaults.dart';
import 'package:very_good_infinite_list/src/sliver_infinite_list.dart';

Expand Down Expand Up @@ -28,7 +29,7 @@ class InfiniteList extends StatelessWidget {
this.scrollController,
this.scrollDirection = Axis.vertical,
this.physics,
this.scrollExtentThreshold = defaultScrollExtentThreshold,
this.cacheExtent,
this.debounceDuration = defaultDebounceDuration,
this.reverse = false,
this.isLoading = false,
Expand All @@ -39,10 +40,7 @@ class InfiniteList extends StatelessWidget {
this.loadingBuilder,
this.errorBuilder,
this.separatorBuilder,
}) : assert(
scrollExtentThreshold >= 0.0,
'scrollExtentThreshold must be greater than or equal to 0.0',
);
});

/// {@template scroll_controller}
/// An optional [ScrollController] to be used by the internal [ScrollView].
Expand All @@ -60,22 +58,6 @@ class InfiniteList extends StatelessWidget {
/// {@endtemplate}
final ScrollPhysics? physics;

/// {@template scroll_extent_threshold}
/// The offset, in pixels, that the [scrollController] must be scrolled over
/// to trigger [onFetchData].
///
/// This is useful for fetching data _before_ the user has scrolled all the
/// way to the end of the list, so the fetching mechanism is more well hidden.
///
/// For example, if this is set to `400.0` (the default), [onFetchData] will
/// be called when the list is scrolled `400.0` pixels away from the bottom
/// (or the top if [reverse] is `true`).
///
/// This value must be `0.0` or greater, is set to
/// [defaultScrollExtentThreshold] by default and cannot be `null`.
/// {@endtemplate}
final double scrollExtentThreshold;

/// {@template debounce_duration}
/// The duration with which calls to [onFetchData] will be debounced.
///
Expand Down Expand Up @@ -135,14 +117,17 @@ class InfiniteList extends StatelessWidget {
/// In normal operation, this method should trigger new data to be fetched and
/// [isLoading] to be set to `true`.
///
/// Exactly when this is called depends on the [scrollExtentThreshold].
/// Exactly when this is called depends on the [cacheExtent].
/// Additionally, every call to this will be debounced by the provided
/// [debounceDuration].
///
/// Is required and cannot be `null`.
/// {@endtemplate}
final VoidCallback onFetchData;

/// See [RenderViewportBase.cacheExtent]
final double? cacheExtent;

/// {@template padding}
/// The amount of space by which to inset the list of items.
///
Expand Down Expand Up @@ -193,9 +178,10 @@ class InfiniteList extends StatelessWidget {
Widget build(BuildContext context) {
return CustomScrollView(
scrollDirection: scrollDirection,
reverse: reverse,
controller: scrollController,
physics: physics,
reverse: reverse,
cacheExtent: cacheExtent,
slivers: [
_ContextualSliverPadding(
padding: padding,
Expand All @@ -204,7 +190,6 @@ class InfiniteList extends StatelessWidget {
itemCount: itemCount,
onFetchData: onFetchData,
itemBuilder: itemBuilder,
scrollExtentThreshold: scrollExtentThreshold,
debounceDuration: debounceDuration,
isLoading: isLoading,
hasError: hasError,
Expand Down
151 changes: 20 additions & 131 deletions lib/src/sliver_infinite_list.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:very_good_infinite_list/src/callback_debouncer.dart';
import 'package:very_good_infinite_list/src/defaults.dart';
Expand All @@ -10,14 +9,13 @@ import 'package:very_good_infinite_list/src/infinite_list.dart';
///
/// As a infinite list, it is supposed to be the last sliver in the current
/// [ScrollView]. Otherwise, re-fetching data will have an unintuitive behavior.
class SliverInfiniteList extends StatelessWidget {
class SliverInfiniteList extends StatefulWidget {
/// Constructs a [SliverInfiniteList].
const SliverInfiniteList({
super.key,
required this.itemCount,
required this.onFetchData,
required this.itemBuilder,
this.scrollExtentThreshold = defaultScrollExtentThreshold,
this.debounceDuration = defaultDebounceDuration,
this.isLoading = false,
this.hasError = false,
Expand All @@ -26,13 +24,7 @@ class SliverInfiniteList extends StatelessWidget {
this.errorBuilder,
this.separatorBuilder,
this.emptyBuilder,
}) : assert(
scrollExtentThreshold >= 0.0,
'scrollExtentThreshold must be greater than or equal to 0.0',
);

/// {@macro scroll_extent_threshold}
final double scrollExtentThreshold;
});

/// {@macro debounce_duration}
final Duration debounceDuration;
Expand Down Expand Up @@ -68,154 +60,48 @@ class SliverInfiniteList extends StatelessWidget {
final ItemBuilder itemBuilder;

@override
Widget build(BuildContext context) {
return SliverLayoutBuilder(
builder: (context, constraints) {
return _SliverInfiniteListInternal(
itemCount: itemCount,
onFetchData: onFetchData,
itemBuilder: itemBuilder,
scrollExtentThreshold: scrollExtentThreshold,
debounceDuration: debounceDuration,
isLoading: isLoading,
hasError: hasError,
hasReachedMax: hasReachedMax,
precedingScrollExtent: constraints.precedingScrollExtent,
loadingBuilder: loadingBuilder,
errorBuilder: errorBuilder,
separatorBuilder: separatorBuilder,
emptyBuilder: emptyBuilder,
);
},
);
}
State<SliverInfiniteList> createState() => _SliverInfiniteListState();
}

class _SliverInfiniteListInternal extends StatefulWidget {
const _SliverInfiniteListInternal({
required this.itemCount,
required this.onFetchData,
required this.itemBuilder,
required this.scrollExtentThreshold,
required this.debounceDuration,
required this.isLoading,
required this.hasError,
required this.hasReachedMax,
required this.precedingScrollExtent,
this.loadingBuilder,
this.errorBuilder,
this.separatorBuilder,
this.emptyBuilder,
});

final double scrollExtentThreshold;

final Duration debounceDuration;

final int itemCount;

final bool isLoading;

final bool hasError;

final bool hasReachedMax;

final VoidCallback onFetchData;

/// See [SliverConstraints.precedingScrollExtent]
final double precedingScrollExtent;

final WidgetBuilder? loadingBuilder;

final WidgetBuilder? errorBuilder;

final WidgetBuilder? separatorBuilder;

final ItemBuilder itemBuilder;

final WidgetBuilder? emptyBuilder;

@override
State<_SliverInfiniteListInternal> createState() =>
_SliverInfiniteListInternalState();
}

class _SliverInfiniteListInternalState
extends State<_SliverInfiniteListInternal> {
class _SliverInfiniteListState extends State<SliverInfiniteList> {
late final CallbackDebouncer debounce;

ScrollPosition? scrollPosition;
int? _lastFetchedIndex;

@override
void initState() {
super.initState();
debounce = CallbackDebouncer(widget.debounceDuration);
WidgetsBinding.instance.addPostFrameCallback((_) {
attemptFetch();
});
}

@override
void didChangeDependencies() {
super.didChangeDependencies();

detachFromPosition();
scrollPosition = Scrollable.of(context)?.position;
attachToPosition();
attemptFetch();
}

@override
void didUpdateWidget(_SliverInfiniteListInternal oldWidget) {
void didUpdateWidget(SliverInfiniteList oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.itemCount != oldWidget.itemCount ||
widget.hasReachedMax != oldWidget.hasReachedMax) {
WidgetsBinding.instance.addPostFrameCallback((_) {
attemptFetch();
});
if (!widget.hasReachedMax && oldWidget.hasReachedMax) {
attemptFetch();
}
}

@override
void dispose() {
super.dispose();
debounce.dispose();
detachFromPosition();
}

void attachToPosition() {
scrollPosition?.addListener(attemptFetch);
}

void detachFromPosition() {
scrollPosition?.removeListener(attemptFetch);
}

void attemptFetch() {
if (isAtEnd &&
!widget.hasReachedMax &&
!widget.isLoading &&
!widget.hasError) {
debounce(widget.onFetchData);
if (!widget.hasReachedMax && !widget.isLoading && !widget.hasError) {
WidgetsBinding.instance.addPostFrameCallback((_) {
debounce(widget.onFetchData);
});
}
}

bool get isAtEnd {
if (widget.itemCount == 0) {
return true;
}

final scrollPosition = this.scrollPosition;
if (scrollPosition == null) {
return false;
void onBuiltLast(int lastItemIndex) {
if (_lastFetchedIndex != lastItemIndex) {
_lastFetchedIndex = lastItemIndex;
attemptFetch();
}

// This considers the end of the scrollable content as the
// position to trigger a data fetch. It may cause unintuitive behaviors
// when there is any sliver after this one.
final maxScroll = scrollPosition.maxScrollExtent;

final currentScroll = scrollPosition.pixels - widget.precedingScrollExtent;
return currentScroll >= (maxScroll - widget.scrollExtentThreshold);
}

WidgetBuilder get loadingBuilder =>
Expand Down Expand Up @@ -244,6 +130,9 @@ class _SliverInfiniteListInternalState
delegate: SliverChildBuilderDelegate(
childCount: effectiveItemCount,
(context, index) {
if (index == lastItemIndex) {
onBuiltLast(lastItemIndex);
}
if (index == lastItemIndex && showBottomWidget) {
if (widget.hasError) {
return errorBuilder(context);
Expand Down
17 changes: 10 additions & 7 deletions test/sliver_infinite_list_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:very_good_infinite_list/src/sliver_infinite_list.dart';

extension on WidgetTester {
Future<void> pumpSlivers(List<Widget> slivers) async {
Future<void> pumpSlivers(
List<Widget> slivers, {
double? cacheExtent,
}) async {
await pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
height: 500,
child: CustomScrollView(
cacheExtent: cacheExtent,
slivers: slivers,
),
),
Expand All @@ -28,16 +32,16 @@ void main() {
var onFetchDataCalls = 0;

await tester.pumpSlivers(
cacheExtent: 0,
[
StatefulBuilder(
builder: (context, setState) {
return SliverInfiniteList(
itemCount: itemCount,
debounceDuration: Duration.zero,
hasReachedMax: itemCount == 12,
onFetchData: () {
setState(() {
itemCount += 3;
itemCount += 8;
onFetchDataCalls++;
});
},
Expand All @@ -51,15 +55,15 @@ void main() {
);
await tester.pumpAndSettle();

expect(onFetchDataCalls, equals(4));
expect(onFetchDataCalls, equals(5));
});
});
group('consider preceding slivers', () {
testWidgets('on mount', (tester) async {
var itemCount = 0;
var onFetchDataCalls = 0;

await tester.pumpSlivers(
cacheExtent: 0,
[
const SliverAppBar(
expandedHeight: 500,
Expand All @@ -73,10 +77,9 @@ void main() {
return SliverInfiniteList(
itemCount: itemCount,
debounceDuration: Duration.zero,
hasReachedMax: itemCount == 12,
onFetchData: () {
setState(() {
itemCount += 3;
itemCount += 8;
onFetchDataCalls++;
});
},
Expand Down