Flutter: how to test the scroll - testing

I have a ListView.builder with a ScrollController as controller:
_buildListView(state) => ListView.builder(
itemBuilder: (_, int index) => _draw(index, state),
itemCount: state.count
controller: _scrollController,
);
I add a listener to the controller:
View() {
_scrollController.addListener(_onScroll);
}
I would like to test the _onScroll function:
void _onScroll() {
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels;
if (maxScroll - currentScroll <= _scrollThreshold) {
_bloc.dispatch(Fetch());
}
}
But I don't know how can I test it. This is what I tried so far:
testWidgets('Should test the scroll',
(WidgetTester tester) async {
await tester.pumpWidget(generateApp());
await tester.pump();
await tester.drag(find.byType(ListView), const Offset(0.0, -300));
await tester.pump();
...
)}
but it doesn't call at all that function.

for those using the new flutter_test lib, we also have the dragUntilVisible method:
await tester.dragUntilVisible(
find.text('Earn mana!'), // what you want to find
find.byKey(ValueKey('OnboardingCarousel')), // widget you want to scroll
const Offset(-250, 0), // delta to move
);

You can create a TestGesture in your tests and perform a scroll that way.
final gesture = await tester.startGesture(Offset(0, 300)); //Position of the scrollview
await gesture.moveBy(Offset(0, -300)); //How much to scroll by
await tester.pump();

If you pass the key parameter to the builder:
ListView.builder(key: Key('ListViewKey'),...);
Then finding by key:
await tester.drag(find.byKey(Key('ListViewKey')), const Offset(0.0, -300));
await tester.pump();
Will work.

Scrolling can be tested using dragUntilVisible, which scrolls the widget till it gets visible on screen, just make sure the right delta is added for moving it vertically or horizontally.
final expectedWidget = find.byText("Find me!");
await tester.dragUntilVisible(
expectedWidget, // what you want to find
find.byType(ListView),
// widget you want to scroll
const Offset(0, 500) // delta to move
);

I highly recommend you to pay attention in the "Cartesian plane" of your screen/dragging movement.
Let me explain:
You should use:
await tester.drag(keyCartItemProduct1, Offset(-500.0, 0.0));
However, your "Offset" Command, must obey the same "Cartesian direction" than your Dragging.
2.1) Therefore: (The command Offset uses Cartesian 'directions') - lets see:
a) Left Dragging: Offset(-500.0, 0.0)
b) Right Dragging: Offset(+500.0, 0.0)
c) Up Dragging: Offset(0.0, +500.0)
d) Down Dragging: Offset(0.0, -500.0)

As an alternative it's also possible to search for the ListView widget itself and retrieve its scroll controller to manipulate it directly:
final listView = tester.widget<ListView>(find.byType(ListView));
final ctrl = listView.controller;
ctrl.jumpTo(ctrl.offset + 300);
await tester.pumpAndSettle(duration);

Related

How to work with EventListeners for draggable scrolling?

I'm trying to build a calendar in which you can scroll. I would like to achieve the scrolling also by dragging. So I use EventListeners.
mousemove continuously triggers, after the first mousedown, even if I already released the mouse. So the removeEventListeners don't really work. I don't quite understand what's wrong or how to get the interactions between all Listeners to work correctly.
Here is my CodeSandBox
mounted() {
this.initScrollCalendar()
},
methods: {
initScrollCalendar() {
const calendar = this.$refs.calendar
calendar.scrollLeft = this.position.left;
calendar.addEventListener('mousedown', (e) => this.mouseDownHandler())
},
mouseDownHandler() {
const calendar = this.$refs.calendar
calendar.addEventListener('mousemove', (e) => this.mouseMoveHandler(e) )
calendar.addEventListener('mouseup', this.mouseUpHandler());
},
mouseMoveHandler(e) {
console.log("Move")
const calendar = this.$refs.calendar
const rect = calendar.getBoundingClientRect()
const clientX = e.clientX - rect.left
const dx = clientX - this.position.x
calendar.scrollLeft = this.position.left + dx
},
mouseUpHandler() {
console.log("Up")
const calendar = this.$refs.calendar
calendar.removeEventListener('mousemove', (e) => this.mouseMoveHandler(e));
calendar.removeEventListener('mouseup', this.mouseUpHandler());
}
}
I ended up using vue-dragscroll. Works like a charm and the setup is totally simple. If needed, I can also set a starting position of my scroll container.
Don't forget overflow: auto in css
<div ref="scrollContainer" v-dragscroll>
...
</div>
// Setting starting point
const scrollContainer = this.$refs.scrollContainer
scrollContainer.scrollLeft = 500;

Vuejs v-for so laggy when infinite scrolling

I have this weird vuejs effect where when I am adding a new object data, the v-for re-renders all the object even if its already rendered.
I am implementing an infinite scroll like facebook.
The Code
To explain this code, I am fetching a new data from firebase and then push the data into the data object when it reaches the bottom of the screen
var vueApp = new Vue({
el: '#root',
data: {
postList: [],
isOkaytoLoad: false,
isRoomPostEmpty: false,
},
mounted: function() {
// Everytime user scroll, call handleScroll() method
window.addEventListener('scroll', this.handleScroll);
},
methods: {
handleScroll: function()
{
var d = document.documentElement;
var offset = d.scrollTop + window.innerHeight;
var height = d.offsetHeight - 200;
// If the user is near the bottom and it's okay to load new data, get new data from firebase
if (this.isOkaytoLoad && offset >= height)
{
this.isOkaytoLoad = false;
(async()=>{
const lastPost = this.postList[this.postList.length - 1];
const room_id = PARAMETERS.get('room');
const q = query(collection(db, 'posts'), where('room', '==', room_id), orderBy("time", "desc"), limit(5), startAfter(lastPost));
const roomSnapshot = await getDocs(q);
roomSnapshot.forEach((doc) => {
const postID = doc.id;
(async()=>{
// Put the new data at the postList object
this.postList = [...this.postList, doc];
const q = query(collection(db, 'comments'), where('post_id', '==', postID));
const commentSnapshot = await getDocs(q);
doc['commentCount'] = commentSnapshot.size;
//this.postList.push(doc);
console.log(this.postList);
setTimeout(()=>{ this.isOkaytoLoad = true }, 1000);
})();
});
})();
}
}
}
})
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<div v-if="postList.length > 0" class="card-containers">
<!-- I have a component `Postcard` in my js file and it works well -->
<Postcard
v-for="post in postList"
:key="post.id"
:post-id="post.id"
:owner-name="post.data().owner_displayName"
:owner-uid="post.data().owner_id"
:post-type="post.data().post_type"
:image-url="post.data().image_url"
:post-content="truncateString(linkify(post.data().post_content))"
:room="post.data().room"
:time="post.data().time.toDate()"
:likers="post.data().likers"
:comment-count="post.commentCount"
:file-url="post.data().file_url"
:file-name="post.data().file_name"
:downloads="post.data().downloads">
</Postcard>
</div>
Now, the problem is here ...
Look at this screen record, FOCUS AT THE MOUSE, it's lagging and I can't even click on those buttons when vuejs is adding and loading the new data
Here is the code That I used
What I suspect
I am suspecting that everytime I add a new data, the VueJS re-renders it all, which does that effect. How can I force vueJS to not re-render those data that is already rendered in the screen?
You've got two unnecessary async IIFE; the second one inside the forEach is particularly problematic because the async code inside it will be executed concurrently for each loop iteration, which has implications:
getDocs() will be fired all at once for each loop ieration, potentially spamming the server (assuming this is performing a network request). Was this your intention? It looks like you're only fetching at most 5 new posts, so this is probably OK.
The async function updates some state which will trigger Vue to re-render for each doc. This should be batched together at the end so Vue does as minimal updates as possible.
Also don't use var; use const or let instead. There's almost no good reason to use var anymore, let it die.
I can't say this will improve your performance substantially, but I recommend making the following changes (untested):
async handleScroll() {
const d = document.documentElement;
const offset = d.scrollTop + window.innerHeight;
const height = d.offsetHeight - 200;
// If the user is near the bottom and it's okay to load new data, get new data from firebase
if (this.isOkaytoLoad && offset >= height) {
// Prevent loading while we load more posts
this.isOkaytoLoad = false;
try {
// Get new posts
const lastPost = this.postList[this.postList.length - 1];
const room_id = PARAMETERS.get('room');
const q = query(collection(db, 'posts'), where('room', '==', room_id), orderBy("time", "desc"), limit(5), startAfter(lastPost));
const roomSnapshot = await getDocs(q);
// Fetch comments of each post. Do this all at once for each post.
// TODO: This can probably be optimized into a single query
// for all the posts, instead of one query per post.
await Promise.all(roomSnapshot.docs.map(async doc => {
const postID = doc.id;
const q = query(collection(db, 'comments'), where('post_id', '==', postID));
const commentSnapshot = await getDocs(q);
doc.commentCount = commentSnapshot.size;
}));
// Append the new posts to the list
this.postList.push(...roomSnapshot.docs);
} catch (ex) {
// TODO: Handle error
} finally {
// Wait a bit to re-enable loading
setTimeout(() => { this.isOkaytoLoad = true }, 1000);
}
}
}
Doing :post-content="truncateString(linkify(post.data().post_content))" in the template means linkify will be executed during each re-render. I suspect linkify may be potentially slow for long lists? Can this be pre-calculated for each post ahead of time?
You're registering a window scroll event listener when the component is mounted. If the component is ever destroyed, you need to unregister the event listener otherwise it'll still fire whenever the window is scrolled. This may not be an issue in your case, but for reusable components it must be done.

How to correctly use setInterval for multiple functions being called repeatedly in React Native

I am building a simple app in React Native that aims to flash different colors on the screen at certain time intervals. My implementation is as follows:
useEffect(() => {
var blinkOnValue;
var blinkOffValue;
function blinkOn() {
const colorAndWord = getRandomColor(colorArray);
setBackground(colorAndWord.color);
}
function blinkOff() {
setBackground('#F3F3F3');
}
if (strobeStart) {
if (on) {
blinkOnValue = setInterval(() => {
blinkOn();
setOn(false);
}, info.length * 1000);
} else {
blinkOffValue = setInterval(() => {
blinkOff();
setOn(true);
}, info.delay * 1000);
}
}
return () => {
on ? clearInterval(blinkOnValue) : clearInterval(blinkOffValue);
};
}, [colorArray, info.delay, info.length, on, strobeStart]);
The blinkOn function sets the background a certain color and the blinkOff function sets the background a default light gray-ish color. These functions should alternate back and forth, blinking on and off at different intervals. For example, if info.length is 2 and info.delay is 0.5, then the color should flash on for 2 seconds and then the screen should be light gray for 0.5 seconds and repeat. However, the duration of both of the blinkOn and blinkOff are happening for the same amount of time, no matter what the two values are. Sometimes it uses the value from info.length, and sometimes it uses the value from info.delay which is also quite strange.
I think it has something to do with components mounting and unmounting correctly but honestly I am quite lost. If anyone has any advice on how to make this code consistently work where it flashes appropriately I would really appreciate it.
Instead of trying to time your events just right, I suggest using a single timer and computing the blink state from the current system time.
var oldState = true;
function blink() {
var ms = new Date().getTime();
var t = ms % (info.delay + info.length);
var state = (t < info.length ? true : false);
if (state == oldState) return;
if (state) {
blinkOn();
}
else
{
blinkOff();
}
oldState = state;
}
Now set a short timer to check the time and update the blink state as needed:
setInterval( () => blink(), 100 );

DataTables: How to set page in preInit?

The docs say that I should be able to set the page via DataTablesApiInstance.page(pageNumber), but I can't get it to work.
All the other API methods like search and order seem to work fine.
Here's my code:
$(document)
.on('preInit.dt', (ev, settings) => {
let tableId = ev.target.id;
let tableState = _.get(['datatables', tableId], history.state) || {};
let api = new $.fn.dataTable.Api(settings);
if(tableState.hasOwnProperty('page')) {
api.page(tableState.page); // <-- problem is here; page doesn't get set
}
if(tableState.hasOwnProperty('search')) {
api.search(tableState.search);
}
if(tableState.hasOwnProperty('order')) {
api.order(tableState.order);
}
const setState = (key, value) => {
history.replaceState(_.set(['datatables', tableId, key], value, history.state), '');
};
api.on('page', ev => {
let info = api.page.info();
// console.log('page', tableId, info.page);
setState('page', info.page);
});
api.on('order', ev => {
let order = api.order();
// console.log('order', tableId, order);
setState('order', order);
});
api.on('search', ev => {
setState('search', api.search());
});
});
The method is hit, but the page isn't set. Am I using the wrong API method? Is there another way to set the page before the data loads?
I'm using datatables.net#1.10.12.
If I defer the call to init instead of preInit then the correct page number is highlighted, but the data is still from the first page. If I add a 0ms delay on top of that (as below), it does work, but causes a 2nd data fetch + draw.
if(tableState.page) {
api.on('init', ev => {
setTimeout(() => {
api.page(tableState.page).draw('page');
}, 0);
});
}
How can I set the page without incurring a 2nd ajax request?
You can use displayStart option to define the starting point for data display when using DataTables with pagination as recommended by the author of jQuery DataTables.
It works correctly with table in server-side processing mode and only 1 Ajax request is performed.
var table = $("#example").DataTable({
"processing": true,
"serverSide": true,
"ajax": "/test",
"displayStart": 200
});
From the documentation:
Note that this parameter is the number of records (counting from 0), rather than the page number, so if you have 10 records per page and want to start on the third page, it should be 20 rather than 2 or 3.
This doesn't (directly) answer the question of how to set the page in the preInit, but it solves my problem.
We can use the stateLoadCallback to load the state (including page) from the history API instead of using localStorage as the default implementation does (which will remember the state even when navigating away and then back again).
$.extend(true, $.fn.dataTable.defaults, {
stateSave: true,
stateSaveCallback: (settings, data) => {
let tableId = settings.nTable.id;
if(!tableId) {
// console.warn(`DataTable is missing an ID; cannot save its state`);
return;
}
history.replaceState(_.set(['datatables', tableId], data, history.state), '');
},
stateLoadCallback: settings => {
let tableId = settings.nTable.id;
if(!tableId) {
console.warn(`DataTable is missing an ID; cannot load its state`);
return;
}
return _.get(['datatables', tableId], history.state) || null;
}
});

React Native Animated singleValue.stopTracking is not a function

I have the following code to animate in React Native
Animated.timing(
this.state.absoluteChangeX,
{toValue: 0},
).start(function() {
this.lastX = 0;
this.lastY = 0;
});
Pretty simple, but whenever it's triggered, I receive the error:
singleValue.stopTracking is not a function
Here's where the error originates:
/react-native/Libraries/Animates/src/AnimtaedImplementation.js
var timing = function(
value: AnimatedValue | AnimatedValueXY,
config: TimingAnimationConfig,
): CompositeAnimation {
return maybeVectorAnim(value, config, timing) || {
start: function(callback?: ?EndCallback): void {
var singleValue: any = value;
var singleConfig: any = config;
singleValue.stopTracking(); // <--------------- HERE!!!
if (config.toValue instanceof Animated) {
singleValue.track(new AnimatedTracking(
singleValue,
config.toValue,
TimingAnimation,
singleConfig,
callback
));
} else {
singleValue.animate(new TimingAnimation(singleConfig), callback);
}
},
stop: function(): void {
value.stopAnimation();
},
};
};
I'm not extremely versed in typeScript, but var singleValue: any means that "singleValue" could be any type. In my case, it's a number. Since numbers don't have methods, it would make sense that this would error.
Am I doing something wrong?
The value you wish to animate must be an instance of Animated.Value, or one of its subtypes. When you initialize your state, it should look something like this:
getInitialState() {
return { absoluteChangeX: new Animated.Value(0) };
}
The fact that the type declaration in the framework method is any is just a lack of constraint, not an explicit invitation to pass any value into it.
See the Animated docs for more examples.
I run into this issue sometimes (React hooks instead) when I forget to set my variable to the .current of the ref:
function MyComponent() {
const animatedValue = useRef(new Animated.Value(0.0)).current; // Notice the .current
}
This may not necessarily answer the original question, but developers who encounter this error while using React hooks may end up here so maybe it will help someone.
I ran into this issue because I used the animated value (2) instead of the object (1):
const animatedValue = useRef(new Animated.Value(0.0)).current; // (1)
const transform = animatedValue.interpolate({
inputRange: [0.0, 1.0],
outputRange: [0, 100]
}); // (2)
Animated.timing(animatedValue, { // USE animatedValue, NOT transform HERE!
toValue: 1.0,
duration: 3000,
});
Hope this can help anyone that was new to React Native Animation (like me :) )...