Flutter detect triple tap - flutter

I would like to be able to detect a triple tap (or even more) in a Flutter widget, although GestureDetector only has detection for double-tap built in.
What is the easiest way for me to detect a triple tap on a widget?
(I want continually clicking on a part of the screen to unlock some developer options)

Was a bit lazy with this one, in reality it's not that hard
// init
int lastTap = DateTime.now().millisecondsSinceEpoch;
int consecutiveTaps = 0;
GestureDetector(
onTap: () {
int now = DateTime.now().millisecondsSinceEpoch;
if (now - lastTap < 1000) {
print("Consecutive tap");
consecutiveTaps ++;
print("taps = " + consecutiveTaps.toString());
if (consecutiveTaps > 4){
// Do something
}
} else {
consecutiveTaps = 0;
}
lastTap = now;
},
child: ...
)

I tried the method mentioned here, but it didn't work for me. GestureDetector onTap is called only once,
regardless of the number of taps. Probably something has changed in flutter (I'm on the beta channel).
However, I dug into the source code of flutter and come to the solution (https://api.flutter.dev/flutter/gestures/SerialTapGestureRecognizer-class.html):
import "package:flutter/gestures.dart";
RawGestureDetector(gestures: {
SerialTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<SerialTapGestureRecognizer>(
() =>SerialTapGestureRecognizer(), (SerialTapGestureRecognizer instance) {
instance.onSerialTapDown = (SerialTapDownDetails details) {
if (details.count == 3) print("Consecutive tap 3");
};
})

I took a little different approach. Instead of having to compare timestamps, I set a Timer, which will reset the tapped state. But each time there is a tap, the old timer is canceled.
Timer? devPageClickTimer;
num devPageTapped = 0;
final devPageTapGoal = 5;
GestureDetector(
onTap: () {
devPageTapped++;
if (devPageTapped >= devPageTapGoal) {
router.push(const DeveloperRoute());
}
if (devPageClickTimer != null) {
devPageClickTimer!.cancel();
}
devPageClickTimer = Timer(const Duration(milliseconds: 200), () => devPageTapped = 0);
},

I have tried this method with reduced timeout and with both double and triple tap
int lastTap = DateTime.now().millisecondsSinceEpoch;
int consecutiveTaps = 1;
GestureDetector(
onTap: () {
int now = DateTime.now().millisecondsSinceEpoch;
if (consecutiveTaps == 1) {
print("taps = " + consecutiveTaps.toString());
lastTap = now;
}
if (now - lastTap < 300) {
print("Consecutive tap");
consecutiveTaps++;
print("taps = " + consecutiveTaps.toString());
if (consecutiveTaps == 3) {
print("Consecutive tap 3");
} else if (consecutiveTaps == 2) {
print("Consecutive tap 2");
}
} else {
consecutiveTaps = 1;
}
lastTap = now;
},
child: \\child);

Relevant solution.
Here is flexible reusable multiple tap widget based on Listener widget that reports raw pointer events:
class AppMultipleTap extends StatefulWidget {
final Widget child;
final VoidCallback onMultipleTap;
final int taps;
final Duration duration;
const AppMultipleTap({
super.key,
required this.child,
required this.onMultipleTap,
/// feel free to override these values
this.taps = 3,
this.duration = const Duration(milliseconds: 600),
});
#override
State<AppMultipleTap> createState() => _AppMultipleTapState();
}
class _AppMultipleTapState extends State<AppMultipleTap> {
/// in _count we store current number of taps
int _count = 0;
Timer? _timer;
#override
Widget build(BuildContext context) {
return Listener(
onPointerDown: (_) {
if (_timer == null) _startTimer();
_count++;
},
child: widget.child,
);
}
void _startTimer() {
_timer = Timer(widget.duration, () {
/// you can change this condition to ==, if you need 100% match
if (_count >= widget.taps) {
widget.onMultipleTap.call();
}
_timer = null;
_count = 0;
});
}
}
Then you can use it like that:
#override
Widget build(BuildContext context) {
return AppMultipleTap(
onMultipleTap: /// Do some action

I like this simple approach, without so many nested if blocks.
// Variables in the state class
var startTap = timeNow;
var consecutiveTaps = 0;
static const int serialTaps = 4;
static const int tapDurationInMs = 1000;
static int get timeNow => DateTime.now().millisecondsSinceEpoch;
// Build method
GestureDetector(
onTap: () {
final now = timeNow;
final userExceededTapDuration = now - startTap > tapDurationInMs;
if (userExceededTapDuration) {
consecutiveTaps = 0;
startTap = now;
}
consecutiveTaps++;
if (consecutiveTaps == serialTaps) {
// widget.onTap();
}
},
);

Related

How to play list of video URLs forever or until stopped in flutter? I am stuck :(

I want to create a video player that continuously plays videos until stopped. Thanks to "easeccy" currently I am able to play videos one after another with a buffer, but it stops after playing the last video. I have been trying to loop all these videos...please help :(
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
class VideoPlayerDemo extends StatefulWidget {
const VideoPlayerDemo({Key? key}) : super(key: key);
#override
State<VideoPlayerDemo> createState() => _VideoPlayerDemoState();
}
class _VideoPlayerDemoState extends State<VideoPlayerDemo> {
int index = 0;
double _position = 0;
double _buffer = 0;
bool lock = true;
final Map<String, VideoPlayerController> _controllers = {};
final Map<int, VoidCallback> _listeners = {};
final Set<String> _urls = {
'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4#1',
'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4',
'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4#2'
};
#override
void initState() {
super.initState();
_playListLoop();
if (_urls.isNotEmpty) {
_initController(0).then((_) {
_playController(0);
});
}
if (_urls.length > 1) {
_initController(1).whenComplete(() => lock = false);
}
}
VoidCallback _listenerSpawner(index) {
return () {
int dur = _controller(index).value.duration.inMilliseconds;
int pos = _controller(index).value.position.inMilliseconds;
int buf = _controller(index).value.buffered.last.end.inMilliseconds;
setState(() {
if (dur <= pos) {
_position = 0;
return;
}
_position = pos / dur;
_buffer = buf / dur;
});
if (dur - pos < 1) {
if (index < _urls.length - 1) {
_nextVideo();
}
}
};
}
VideoPlayerController _controller(int index) {
return _controllers[_urls.elementAt(index)]!;
}
Future<void> _initController(int index) async {
var controller = VideoPlayerController.network(_urls.elementAt(index));
_controllers[_urls.elementAt(index)] = controller;
await controller.initialize();
}
void _removeController(int index) {
_controller(index).dispose();
_controllers.remove(_urls.elementAt(index));
_listeners.remove(index);
}
void _stopController(int index) {
_controller(index).removeListener(_listeners[index]!);
_controller(index).pause();
_controller(index).seekTo(const Duration(milliseconds: 0));
}
void _playController(int index) async {
if (!_listeners.keys.contains(index)) {
_listeners[index] = _listenerSpawner(index);
}
_controller(index).addListener(_listeners[index]!);
await _controller(index).play();
setState(() {});
}
void _previousVideo() {
if (lock || index == 0) {
return;
}
lock = true;
_stopController(index);
if (index + 1 < _urls.length) {
_removeController(index + 1);
}
_playController(--index);
if (index == 0) {
lock = false;
} else {
_initController(index - 1).whenComplete(() => lock = false);
}
}
void _nextVideo() async {
if (lock || index == _urls.length - 1) {
return;
}
lock = true;
_stopController(index);
if (index - 1 >= 0) {
_removeController(index - 1);
}
_playController(++index);
if (index == _urls.length - 1) {
lock = false;
} else {
_initController(index + 1).whenComplete(() => lock = false);
}
}
_playListLoop() async {
if (index + 1 == _urls.length) {
if (_controller(index).value.position ==
_controller(index).value.position) {
setState(() {
_controller(index).initialize();
});
}
}
}
#override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.topCenter,
children: <Widget>[
SizedBox.expand(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: _controller(index).value.size.width,
height: _controller(index).value.size.height,
child: VideoPlayer(
_controller(index)..setVolume(0.0),
),
),
),
),
//Container ends here.
],
);
}
}
Tried while loop and for loop but its repeating the last loaded video only...

Making changes in Cloned Object instance reflects everywhere the object is being passed as parameter Flutter

I am passing a cloned object instance as parameter to anther widget. But issue I am facing is that if any changes occur in the object the cloned object which was passed previously also reflects changes.
This is model class:
class Food {
String id;
String name;
double price;
double singleItemPrice;
String image;
Food clone() => Food(
id,
name,
price,
singleItemPrice,
image,
);
Food(
this.id,
this.name,
this.price,
this.singleItemPrice,
this.image,
);
}
This is code where object is passed as param to another widget, where it is added to cart. HotKeysWidget.of(context)! .updateCartList(dealItem, 1, false, [], items); function is called to add item to cart. I am calling this function on clicking of Done button.
Future specialDealDialog() {
return showGeneralDialog(
context: context,
barrierDismissible: false,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: Colors.black45,
transitionDuration: const Duration(milliseconds: 200),
pageBuilder: (BuildContext buildContext, Animation animation,
Animation secondaryAnimation) {
return Dialog(
child: SpecialDealsWidget(
deal: item.clone(),
setSelection: (dealItem, items, changesMade) {
print('DEAL TOTAL PRICE: ${dealItem.totalPrice}');
HotKeysWidget.of(context)!
.updateCartList(dealItem, 1, false, [], items);
},
editDeal: false,
closeDialog: (deal) {
//resetDealData(deal.clone());
//resetDealData(deal);
},
),
);
},
);
}
class SpecialDealsWidget extends StatefulWidget {
Food deal;
Function(Food, List<Map<String, dynamic>>, bool) setSelection;
bool editDeal;
Function(Food) closeDialog;
SpecialDealsWidget({
required this.deal,
required this.setSelection,
required this.editDeal,
required this.closeDialog,
});
#override
State<StatefulWidget> createState() {
// TODO: implement createState
return SpecialDealsWidgetState();
}
}
class SpecialDealsWidgetState extends State<SpecialDealsWidget> {
late Future<Food> data;
Food? menuItem;
final GlobalKey<DealItemWidgetState> modKey = GlobalKey();
double totalPrice = 0.0;
#override
void initState() {
menuItem = widget.deal.clone();
if (!widget.editDeal) {
data = fetchData();
} else {
totalPrice = menuItem!.totalPrice;
}
super.initState();
}
Future<Food> fetchData() async {
menuItem!.totalPrice = menuItem!.price;
totalPrice = menuItem!.totalPrice;
menuItem!.dealItem!.selectedItems = [];
for (var i = 0; i < menuItem!.dealItem!.items.length; i++) {
menuItem!.dealItem!.items[i].singleMenuPrice = 0.0;
if (menuItem!.dealItem!.items[i].selected) {
menuItem!.dealItem!.items[i].selected = false;
}
for (int j = 0; j < menuItem!.dealItem!.items[i].itemMenu.length; j++) {
menuItem!.dealItem!.items[i].itemMenu[j].selectedItem = null;
}
/////////////LOOP THROUGH DROPDOWNS FOR SINGLE DEAL ITEM
for (int f = 0; f < menuItem!.dealItem!.items[i].itemMenu.length; f++) {
for (int j = 0;
j < widget.deal.dealItem!.items[i].itemMenu[f].menuItemList.length;
j++) {
menuItem!.dealItem!.items[i].itemMenu[f].menuItemList[j]
.selectedModifiers = [];
menuItem!.dealItem!.items[i].itemMenu[f].menuItemList[j].extrasIDS =
[];
if (menuItem!
.dealItem!.items[i].itemMenu[f].menuItemList[j].modifierExist) {
for (int h = 0;
h <
menuItem!.dealItem!.items[i].itemMenu[f].menuItemList[j]
.modifiers.length;
h++) {
menuItem!.dealItem!.items[i].itemMenu[f].menuItemList[j]
.modifiers[h].allFilled = false;
menuItem!.dealItem!.items[i].itemMenu[f].menuItemList[j]
.modifiers[h].currentSelections = 0;
}
}
}
}
////////////RESET DEAL ITEM SELECTED ITEM
if (menuItem!.dealItem!.items[i].selectedItem != null) {
menuItem!.dealItem!.items[i].selectedItem = null;
menuItem!.dealItem!.items[i].selectedCatIndex = null;
menuItem!.dealItem!.items[i].selectionMade = false;
}
}
return menuItem!;
}
}
In above code snippet the issue occurs, When I open this dialog window and click Done button, item add to cart successfully. But when I reopen this window and close it without clicking Done button it alters the cart item.
The changes which are defined in the above fetchData method. I don't get it how it is changing the cart item. I am just opening and closing the window and doing nothing, no method on closing nothing
Anyone kindly help me with this issue. It's been quite long time I am facing this issue but has not yet found any solution. Although I am cloning the object instance which is apparently the solution in my search so far
Food clone(){
final<Food> clone = new Food(id,
name,
price,
singleItemPrice,
image,);
return clone;
}
...untested and written while driving...sory for any error.

Where to prevent re-animation of ListView.builder items when scrolling back to previously animated items?

I have a listview in which the text of items are animated when they first appear - and when they reappear after enough scrolling. When the list grows to certain size and the user scrolls back far enough items are animated again - presumably they've been removed from the widget tree and are now being re-inserted and thus get re-initiated etc. I want to prevent this from happening so that they only animate the first time they appear.
I think this means I need to have state stored somewhere per item that keeps track and tells the individual items whether they should animate on them being built or not. I am not sure where to put and how to connect that though, partly because it seems to overlap between presentation and business logic layers. I think perhaps it should be a variable in the list items contained in the list object that the listview builder is constructing from - or should it somehow be in the actual widgets in the listview?
class _StockListViewBuilderState extends State<StockListViewBuilder> with asc_alertBar {
final ScrollController _scrollController = ScrollController();
late double _scrollPosition;
late double _maxScrollExtent;
late bool isThisTheEnd = false;
_scrollListener() async {
setState(() {
_scrollPosition = _scrollController.position.pixels;
_maxScrollExtent = _scrollController.position.maxScrollExtent;
});
if (!isThisTheEnd && _scrollPosition / _maxScrollExtent > 0.90) {
isThisTheEnd = true;
if (widget.stockListicle.getIsRemoteEmpty()) {
alertBar('No more items available', /* null,*/ context: context);
} else {
await widget.stockListicle.fetch(numberToFetch: 5);
}
}
if (isThisTheEnd && _scrollPosition / _maxScrollExtent <= 0.90) {
isThisTheEnd = false;
}
}
#override
void initState() {
super.initState();
late String? userFullName = GetIt.I.get<Authenticate>().user?.fullName;
developer.log('Authenticated user $userFullName', name: '_StockListViewBuilderState');
developer.log("init ", name: "_StockListViewBuilderState ");
int listCount;
_scrollController.addListener(_scrollListener);
WidgetsBinding.instance.addPostFrameCallback((_) async {
//developer.log("stckLtcl init pf con ");
listCount = widget.stockListicle.items.length;
if (listCount < 10 && !widget.stockListicle.getIsRemoteEmpty()) {
try {
await widget.stockListicle.fetch(numberToFetch: 10);
} catch (e) {
super.setState(() {
//developer.log("Can't load stock:$e");
alertBar(
"Couldn't load from the internet.",
context: context,
backgroundColor: Colors.purple,
);
});
}
}
});
WidgetsBinding.instance.addPostFrameCallback((_) async {
final ConnectionNotifier connectionNotifier = context.read<ConnectionNotifier>();
if (connectionNotifier.isConnected() != true) {
await connectionNotifier.check();
if (connectionNotifier.isConnected() != true) {
alertBar("Please check the internet connection.", context: context);
}
}
});
}
#override
Widget build(BuildContext context) {
return ListView.builder(
scrollDirection: Axis.vertical,
controller: _scrollController,
shrinkWrap: true,
key: widget.theKey,
itemCount: widget.stockListicle.items.length + 1,
itemBuilder: (context, index) {
if (index <= widget.stockListicle.items.length - 1) {
return InkWell(
onTap: (() => Navigator.pushNamed(
context,
'/stocks/stock',
arguments: ScreenArguments(widget.stockListicle.items[index] as Stock),
)),
child: StockListItem(
stock: widget.stockListicle.items[index] as Stock,
));
} else {
return LoadingItemNotifier(
isLoading: widget.stockListicle.getIsBusyLoading(),
);
}
},
);
}
}
//...
Currently StockListItem extends StatelessWidget and returns a 'ListTile' which as its title parameter has ...title: AnimatedText(textContent: stock.title),...
I was trying to keep track of first-time-animation inside AnimatedText widget until I realized from an OOP & Flutter perspective, it's probably wrong place...
class AnimatedText extends StatefulWidget {
final bool doShowMe;
final String textContent;
final Duration hideDuration;
final double durationFactor;
const AnimatedText({
Key? key,
this.doShowMe = true,
this.textContent = '',
this.hideDuration = const Duration(milliseconds: 500),
this.durationFactor = 1,
}) : super(key: key);
#override
State<AnimatedText> createState() => _AnimatedTextState();
}
class _AnimatedTextState extends State<AnimatedText> with SingleTickerProviderStateMixin {
late AnimationController _appearanceController;
late String displayText;
late String previousText;
late double durationFactor;
late Duration buildDuration = Duration(
milliseconds: (widget.textContent.length / 15 * widget.durationFactor * 1000).round());
#override
void initState() {
super.initState();
developer.log('init ${widget.textContent}', name: '_AnimatedTextState');
displayText = '';
previousText = widget.textContent;
_appearanceController = AnimationController(
vsync: this,
duration: buildDuration,
)..addListener(
() => updateText(),
);
if (widget.doShowMe) {
_doShowMe();
}
}
void updateText() {
String payload = widget.textContent;
int numCharsToShow = (_appearanceController.value * widget.textContent.length).ceil();
if (widget.doShowMe) {
// make it grow
displayText = payload.substring(0, numCharsToShow);
// developer.log('$numCharsToShow / ${widget.textContent.length} ${widget.textContent}');
} else {
// make it shrink
displayText = payload.substring(payload.length - numCharsToShow, payload.length);
}
}
#override
void didUpdateWidget(AnimatedText oldWidget) {
super.didUpdateWidget(oldWidget);
if ((widget.doShowMe != oldWidget.doShowMe) || (widget.textContent != oldWidget.textContent)) {
if (widget.doShowMe) {
_doShowMe();
} else {
_doHideMe();
}
}
if (widget.doShowMe && widget.textContent != previousText) {
previousText = widget.textContent;
developer.log('reset');
_appearanceController
..reset()
..forward();
}
}
#override
void dispose() {
_appearanceController.dispose();
displayText = '';
super.dispose();
}
#override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _appearanceController,
builder: (context, child) {
return Text(displayText);
});
}
void _doShowMe() {
_appearanceController
..duration = buildDuration
..forward();
}
void _doHideMe() {
_appearanceController
..duration = widget.hideDuration
..reverse();
}
}

Flutter- detect memory leak

I'm little bit confused because I was thinking there are no memory leak in flutter since there is no concept of weak (if I'm correct).
I'm running this on iOS device.
I'm trying to play videos and initialize some videos beforehand so that user can see it without delay.
To do that I prepared six VideoPlayerController and make those always being initialized while current video is playing.
There are three more initialized VideoPlayerController next to current one and two more initialized ones before current one like image below.
With this logic I play video very smoothly back and forth. But after play about ten videos, app crush because of memory issue.
I tried every function Future, async, await but still eats lots of memories.
I'm not sure but it might be NotificationListener?
Since onNotification returns bool not Future or
is this something to do with main thread or something?
Does anyone know how to fix this memory issue?
Code:
class _SwiperScreenState extends State<SwiperScreen> {
VideoPlayerController _firstController;
VideoPlayerController _secondController;
VideoPlayerController _thirdController;
VideoPlayerController _fourthController;
VideoPlayerController _fifthController;
VideoPlayerController _sixthController;
List<VideoPlayerController> _controllers;
List<String> urls = [
'https://firebasestorage.googleapis.com/v0/b/waitingboy-34497.appspot.com/o/video%2F8-21%2F1534825377992OsfJfKsdf90K8sf?alt=media&token=12245ee4-1598-4f7e-ba28-a9eb72ca474e',
'http://www.sample-videos.com/video/mp4/720/big_buck_bunny_720p_20mb.mp4',
'https://firebasestorage.googleapis.com/v0/b/waitingboy-34497.appspot.com/o/video%2F8-21%2F1534825377992OsfJfKsdf90K8sf?alt=media&token=12245ee4-1598-4f7e-ba28-a9eb72ca474e',
'http://www.sample-videos.com/video/mp4/720/big_buck_bunny_720p_20mb.mp4',
];
int currentIndex = 0; //refer to current playing controller index
int videosIndex = 0; //refer to current playing urls index
bool _didGetNotification(ScrollNotification notification) {
if (notification is UserScrollNotification) {
if (notification.direction.toString() == 'ScrollDirection.reverse') {
//swipe to left so add one more video
videosIndex++;
//modify index so that always in the range of 0 ~ 5.
if (currentIndex <= 2) {
final int prepareIndex = currentIndex + 3;
urls.add(
'https://firebasestorage.googleapis.com/v0/b/waitingboy-34497.appspot.com/o/video%2F8-21%2F1534825377992OsfJfKsdf90K8sf?alt=media&token=12245ee4-1598-4f7e-ba28-a9eb72ca474e');
_initVideo(urls[videosIndex], prepareIndex);
} else {
final int prepareIndex = (currentIndex + 3) - 6;
urls.add(
'http://www.sample-videos.com/video/mp4/720/big_buck_bunny_720p_20mb.mp4');
_initVideo(urls[videosIndex], prepareIndex);
}
}
if (notification.direction.toString() == 'ScrollDirection.forward') {
//swipe to right so back one more video
videosIndex--;
//modify index so that always in the range of 0 ~ 5 .
if (currentIndex >= 2) {
final int videoIndex = videosIndex - 2;
final int prepareIndex = currentIndex - 2;
_initVideo(urls[videoIndex], prepareIndex);
} else {
final int videoIndex = videosIndex - 2;
final int prepareIndex = 4 + currentIndex;
_initVideo(urls[videoIndex], prepareIndex);
}
}
}
return true;
}
Future _initVideo(String url, int initIndex) async {
if (_controllers[initIndex] != null) {
await _controllers[initIndex].dispose();
}
_controllers[initIndex] = new VideoPlayerController.network(url);
await _controllers[initIndex].initialize().then((_) async => await _controllers[initIndex].setLooping(true));
setState(() {});
}
Future _initFirstThree() async {
for (int i = 1; i < urls.length; i++) {
await _initVideo(urls[i], i);
}
}
#override
void initState() {
_controllers = [
_firstController,
_secondController,
_thirdController,
_fourthController,
_fifthController,
_sixthController
];
_initVideo(urls[0], 0).then((_) => _controllers[0].play());
_initFirstThree();
super.initState();
}
#override
void deactivate() {
_controllers[currentIndex].setVolume(0.0);
_controllers[currentIndex].pause();
super.deactivate();
}
#override
void dispose() {
_controllers.forEach((con) {
con.dispose();
});
super.dispose();
}
#override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text('Swiper'),
actions: <Widget>[
new IconButton(
icon: new Icon(Icons.disc_full),
onPressed: () {
Navigator
.of(context)
.push(MaterialPageRoute(builder: (context) => Dissmiss()));
},
)
],
),
body: new NotificationListener(
onNotification: _didGetNotification,
child: new Swiper(
itemCount: 6,
itemBuilder: (BuildContext context, int index) {
return _controllers[index].value.initialized
? new AspectRatio(
aspectRatio: _controllers[index].value.aspectRatio,
child: new VideoPlayer(_controllers[index]),
)
: new Center(child: new CircularProgressIndicator());
},
loop: urls.length > 6 ? true : false,
onIndexChanged: (i) async {
currentIndex = i;
final int pauseIndex = i == 0 ? 5 : i - 1;
await _controllers[pauseIndex].pause().then((_) async {
await _controllers[i].play();
});
},
),
),
);
}
}

How to check if scroll position is at top or bottom in ListView?

I'm trying to implement a infinite scroll functionality.
I tried using a ListView inside on a NotificationListener to detect scroll events, but I can't see an event that says if the scroll has reached the bottom of the view.
Which would be the best way to achieve this?
There are generally two ways of doing it.
1. Using ScrollController
// Create a variable
final _controller = ScrollController();
#override
void initState() {
super.initState();
// Setup the listener.
_controller.addListener(() {
if (_controller.position.atEdge) {
bool isTop = _controller.position.pixels == 0;
if (isTop) {
print('At the top');
} else {
print('At the bottom');
}
}
});
}
Usage:
ListView(controller: _controller) // Assign the controller.
2. Using NotificationListener
NotificationListener<ScrollEndNotification>(
onNotification: (scrollEnd) {
final metrics = scrollEnd.metrics;
if (metrics.atEdge) {
bool isTop = metrics.pixels == 0;
if (isTop) {
print('At the top');
} else {
print('At the bottom');
}
}
return true;
},
child: ListView.builder(
physics: ClampingScrollPhysics(),
itemBuilder: (_, i) => ListTile(title: Text('Item $i')),
itemCount: 20,
),
)
You can use a ListView.builder to create a scrolling list with unlimited items. Your itemBuilder will be called as needed when new cells are revealed.
If you want to be notified about scroll events so you can load more data off the network, you can pass a controller argument and use addListener to attach a listener to the ScrollController. The position of the ScrollController can be used to determine whether the scrolling is close to the bottom.
_scrollController = new ScrollController();
_scrollController.addListener(
() {
double maxScroll = _scrollController.position.maxScrollExtent;
double currentScroll = _scrollController.position.pixels;
double delta = 200.0; // or something else..
if ( maxScroll - currentScroll <= delta) { // whatever you determine here
//.. load more
}
}
);
Collin's should be accepted answer....
I would like to add example for answer provided by collin jackson. Refer following snippet
var _scrollController = ScrollController();
_scrollController.addListener(() {
if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
// Perform your task
}
});
This will be only triggered when last item is visible in the list.
A more simpler aproach is like this:
NotificationListener<ScrollEndNotification>(
onNotification: onNotification,
child: <a ListView or Wrap or whatever widget you need>
)
and create a method to detect the position:
bool onNotification(ScrollEndNotification t) {
if (t.metrics.pixels >0 && t.metrics.atEdge) {
log('I am at the end');
} else {
log('I am at the start')
}
return true;
}
t.metrics.pixel is 0 when the user is with the scrol at the top, as is more then 0 when the sure scrools.
t.metrics.atEdge is true when the user is either at the top with the scrol or at the end with the scrol
the log method is from package import 'dart:developer';
I feel like this answer is a complement to Esteban's one (with extension methods and a throttle), but it's a valid answer too, so here it is:
Dart recently (not sure) got a nice feature, method extensions, which allow us to write the onBottomReach method like a part of the ScrollController:
import 'dart:async';
import 'package:flutter/material.dart';
extension BottomReachExtension on ScrollController {
void onBottomReach(VoidCallback callback,
{double sensitivity = 200.0, Duration throttleDuration}) {
final duration = throttleDuration ?? Duration(milliseconds: 200);
Timer timer;
addListener(() {
if (timer != null) {
return;
}
// I used the timer to destroy the timer
timer = Timer(duration, () => timer = null);
// see Esteban Díaz answer
final maxScroll = position.maxScrollExtent;
final currentScroll = position.pixels;
if (maxScroll - currentScroll <= sensitivity) {
callback();
}
});
}
}
Here's a usage example:
// if you're declaring the extension in another file, don't forget to import it here.
class Screen extends StatefulWidget {
Screen({Key key}) : super(key: key);
#override
_ScreenState createState() => _ScreenState();
}
class _ScreenState extends State<Screen> {
ScrollController_scrollController;
#override
void initState() {
super.initState();
_scrollController = ScrollController()
..onBottomReach(() {
// your code goes here
}, sensitivity: 200.0, throttleDuration: Duration(milliseconds: 500));
}
#override
void dispose() {
_scrollController.dispose();
super.dispose();
}
}
Note: if you're using method extensions, you need to configure some things, see "How to enable Dart Extension Methods"
final ScrollController controller = ScrollController();
void _listener() {
double maxPosition = controller.position.maxScrollExtent;
double currentPosition = controller.position.pixels;
/// You can change this value . It's a default value for the
/// test if the difference between the great value and the current value is smaller
/// or equal
double difference = 10.0;
/// bottom position
if ( maxPosition - currentPosition <= difference )
/// top position
else
if(mounted)
setState(() {});
}
#override
void initState() {
super.initState();
controller.addListener(_listener);
}
I used different approach for infinite scrolling. I used ChangeNotifier class for variable change listener.
If there is change in variable It triggers the event and eventually hit the API.
class DashboardAPINotifier extends ChangeNotifier {
bool _isLoading = false;
get getIsLoading => _isLoading;
set setLoading(bool isLoading) => _isLoading = isLoading;
}
Initialize DashboardAPINotifier class.
#override
void initState() {
super.initState();
_dashboardAPINotifier = DashboardAPINotifier();
_hitDashboardAPI(); // init state
_dashboardAPINotifier.addListener(() {
if (_dashboardAPINotifier.getIsLoading) {
print("loading is true");
widget._page++; // For API page
_hitDashboardAPI(); //Hit API
} else {
print("loading is false");
}
});
}
Now the best part is when you have to hit the API.
If you are using SliverList, Then at what point you have to hit the API.
SliverList(delegate: new SliverChildBuilderDelegate(
(BuildContext context, int index) {
Widget listTile = Container();
if (index == widget._propertyList.length - 1 &&
widget._propertyList.length <widget._totalItemCount) {
listTile = _reachedEnd();
} else {
listTile = getItem(widget._propertyList[index]);
}
return listTile;
},
childCount: (widget._propertyList != null)? widget._propertyList.length: 0,
addRepaintBoundaries: true,
addAutomaticKeepAlives: true,
),
)
_reachEnd() method take care to hit the api. It trigger the `_dashboardAPINotifier._loading`
// Function that initiates a refresh and returns a CircularProgressIndicator - Call when list reaches its end
Widget _reachedEnd() {
if (widget._propertyList.length < widget._totalItemCount) {
_dashboardAPINotifier.setLoading = true;
_dashboardAPINotifier.notifyListeners();
return const Padding(
padding: const EdgeInsets.all(20.0),
child: const Center(
child: const CircularProgressIndicator(),
),
);
} else {
_dashboardAPINotifier.setLoading = false;
_dashboardAPINotifier.notifyListeners();
print("No more data found");
Utils.getInstance().showSnackBar(_globalKey, "No more data found");
}
}
Note: After your API response you need to notify the listener,
setState(() {
_dashboardAPINotifier.setLoading = false;
_dashboardAPINotifier.notifyListeners();
}
You can use the package scroll_edge_listener.
It comes with an offset and debounce time configuration which is quite useful. Wrap your scroll view with a ScrollEdgeListener and attach a listener. That's it.
ScrollEdgeListener(
edge: ScrollEdge.end,
edgeOffset: 400,
continuous: false,
debounce: const Duration(milliseconds: 500),
dispatch: true,
listener: () {
debugPrint('listener called');
},
child: ListView(
children: const [
Placeholder(),
Placeholder(),
Placeholder(),
Placeholder(),
],
),
),
You can use any one of below conditions :
NotificationListener<ScrollNotification>(
onNotification: (notification) {
final metrices = notification.metrics;
if (metrices.atEdge && metrices.pixels == 0) {
//you are at top of list
}
if (metrices.pixels == metrices.minScrollExtent) {
//you are at top of list
}
if (metrices.atEdge && metrices.pixels > 0) {
//you are at end of list
}
if (metrices.pixels >= metrices.maxScrollExtent) {
//you are at end of list
}
return false;
},
child: ListView.builder());