Remember Scroll Position in very long ListView - list

I have a very long list of items being shown in my Flutter app, the list is being populated by an API and the user can opt to refresh the list to get the latest information.
Is there any way to know where in the list the user has scrolled to at any particular time?
Then when the user presses refresh I can then scroll back to the latest place in the list they were looking at?
I trued using this "scrolled position list" but when I scrolled the frame rate was dropping a LOT...down to 20 fps, I want a nice smooth 60fps if possible.
https://pub.flutter-io.cn/packages/scrollable_positioned_list

Have you tried running your app with --release ? Release mode has a better performance than debug mode. And I think you can just use a normal ListView.builder() with a ScrollController https://api.flutter.dev/flutter/widgets/ScrollController-class.html Maybe that has better performance than the scrollable List on pub.dev

I don't know the scrollable_positioned_list package itself, but I don't think you need it for your implementation.
First of all, for long lists it is important to use ListView.builder() for performance reasons. This will only load the items that are currently visible on your screen. This saves a lot of resources (this is the equivalent of the RecyclerView in Android).
To get the position of a list, you can save the scroll offset value of the list. You can get this via a ScrollController.
Here is my example code:
import 'package:flutter/material.dart';
import 'package:preferences/preference_service.dart';
class MyListView extends StatefulWidget {
final String _prefKey = 'listViewOffset';
#override
_MyListViewState createState() => _MyListViewState();
}
class _MyListViewState extends State<MyListView> {
ScrollController controller;
#override
void initState() {
controller = ScrollController(initialScrollOffset: PrefService.getDouble(widget.prefKey);
controller.addListener(() {
PrefService.setDouble(widget.prefKey, controller.offset);
});
super.initState();
}
#override
Widget build(BuildContext context) {
throw ListView.builder(
controller: controller,
itemBuilder: (BuildContext context, int position) {
return ListTile(title: Text("Position $position"));
}
);
}
#override
void dispose() {
controller.dispose(); // remove the listeners of our controller
super.dispose();
}
}
To save the offset I use the package preferences but you can use the shared_preferences package as well.
Unfortunately I didn't get the chance to test my code in a finished app, but I hope it does what it should.

Related

SwiftUI: delete animation in List?

I have a SwiftUI View that shows a list of items. The view is pretty large so I won't paste the whole thing here. However, the List uses a ForEach:
ForEach(model.items, id: \.id) { item in
myCell(item: item)
}
Everything works fine. What I am trying to do is have a simple slide animation when an item is deleted in the model.
I looked at this answer on stack:
Insert, update and delete animations with ForEach in SwiftUI
However, my setup is different and I am not sure how to try to apply that to my case. In that link the items are stored as a #State property in the view.
My setup is the view uses as #StateObject:
#StateObject var model = MyViewModel()
The model has this:
#Published var items = [MyStruct]()
The List's cells allow the swipe action, the user taps on delete, and the view tells the model to delete that specific item.
The model then has async logic that kicks in and eventually (pretty much immediately) removes the related item from its items. When that happens the List updates, the cell goes away and all works.
I would like to add a cell delete animation. How can I do that given my setup?
EDIT
Trying to add more code to help with context.
The deletion is triggered from an alert like so:
return Alert(
title: Text(title),
message: Text("You cannot undo this action"),
primaryButton: .destructive(Text("Delete")) {
withAnimation {
model.deleteItem(id)
}
},
secondaryButton: .cancel()
)
I added that withAnimation but nothing has changed.
Ok,
so I found what was making things not work even withAnimation.
My model has a call:
model.deleteItem(id)
Internally, as part of the work needed to delete that item, deleteItem(id) uses a Task.init { ... } task. I was then jumping back on main to then update items #Published var items = [MyStruct]() in the model.
The async nature of it broke the withAnimation.
I pulled out the removal of the item from items and left it sync within the withAnimation call and now the default delete animation works.
So, as long as the removal is synchronous within the withAnimation call, the animation kicks in.

Binding 'style' to a computed property

I have a component which is inserted into the DOM as a '' tag (e.g., default behaviour). The component's job is to wrap a 3rd party jQuery tool and I'm trying to ensure it is responsive to "resize" events so I would like to explicitly set width and height style attributes.
In the component, it is easy enough to being to the style attribute:
attributeBindings: ['style'],
style: function() {
return "width: auto";
}.property('widthCalc'),
In this case, this works but doesn't do anything useful because style just returns a static string (width: auto).
Instead what I want to do is -- based on any change to the computed property widthCalc -- set the width based on the new value. So here's the next logical step:
style: function() {
var width = $('body')[0].offsetWidth;
return 'width: ' + width + 'px';
}.property('widthCalc'),
This too works, dynamically setting the DIV to the width of the body's width (note: this isn't really what I want but it does prove that this simple binding works). Now what I really want is to get the value of width from a computed property on the component but I don't even have to go that far to run into trouble; notice that instead of a global jQuery selector I switch to a localised component-scoped selector:
style: function() {
var width = this.$().offsetWidth;
return 'width: ' + width + 'px';
}.property('widthCalc'),
Unfortunately this causes the page NOT to load and gives the following error:
Uncaught Error: Something you did caused a view to re-render after it rendered but before it was inserted into the DOM.
I imagine this is Ember run-loop juju but I'm not sure how to proceed. Any help would be appreciated.
Since it is not possible to call this.$() in the component before it has been added to the dom, provide an initial value until the component is ready.
For example,
Setting a default value to the property style and on didInsertElement event reopen the class and define style as a calculated property using this.$()
http://emberjs.jsbin.com/delexoqize/1/edit?html,js,output
js
App.MyCompComponent = Em.Component.extend({
attributeBindings:["style"],
style:"visibility:hidden",
prop1:null,
initializeThisStyle:function(){
this.set("style","visibility:visible");
this.reopen({
style:function(){
// var thisOffsetWidth = this.$().get(0).offsetWidth;
return "visibility:visible;color:red;background-color:lightgrey;width:"+this.get("prop1")+"px";
}.property("prop1")
});
}.on("didInsertElement")
});
Alternatively handle the error raised by this.$() and provide a default value. Afterwards when the component will be added the property will be calculated as planned.
http://emberjs.jsbin.com/hilalapoce/1/edit?html,js,output
js
App.MyCompComponent = Em.Component.extend({
attributeBindings:["style"],
style:function(){
try{
this.$();//this will throw an erro initialy
return "visibility:visible;color:red;background-color:lightgrey;width:"+this.get("prop1")+"px";
}catch(e){
return "color:blue";
}
}.property("prop1"),
prop1:null
});
With the component I was trying to solve for I ended coming up with an solution that seems effective to me which I will share below. For an understanding of the why I was getting the error and how one might more directly address that error please see the comment from #melc above.
My Solution
What I'm solving for is resizing a jQuery component wrapped in an Ember component. In many cases, resizing is handled gracefully by CSS alone but some jQuery components -- including the very nice knob component from aterrien -- has JS which gets directly involved and therefore needs the containers width and height properties to be set explicitly by the Ember component so that it reacts appropriately.
When solving for this I realised my use-case had two problems:
Solving for a page resize event
Adjusting to the fact that my knob component was -- at times -- in the DOM but in a part of the DOM which was not visible (more explicitly it was in Bootstrap tab which wasn't visible).
The Resize Listener
The first part of the solution is to listen for a page-level resize of the page. I do this with the following:
resizeListener: function() {
var self = this;
self.$(window).on('resize', Ember.run.bind(self, self.resizeDidHappen));
}.on('didInsertElement'),
Page Resize Handler
When a resize is done at the "page" level I now want my component to inspect what the resize impact has been on the component:
resizeDidHappen: function() {
Ember.run.debounce(this, function() {
// get dimensions
var newWidth = Number(this.$().parent().get(0).offsetWidth);
var newHeight = Number(this.$().parent().get(0).offsetHeight);
// set instance variables
this.set('width', newWidth);
this.set('height', newWidth);
// reconfigure knob
this.$('.knob').trigger(
'configure',
{
width: newWidth,
height: newWidth
}
);
}, 300);
}
This solves the page resize problem if it exists in isolation but to make the component it is probably a good idea to solve for the visibility use case as well (certainly in my case it was critical).
Visibility Handler
Why? Well, for two reasons that I can think of:
Many jQuery components refuse to load or perform badly if they aren't loaded
The ember component appears to not be able to establish a "resize" event when it is not visible in the DOM
The one problem is that there is no DOM-level event for visibility changes, so how do we react to a change in visibility without polling on an interval? Well in most cases there will be a UI element which is controlling the state of visibility. In my case it's Bootstrap's tab bar and in this case they have events that fire on the tabs when they become visible. Great. Here's a selector for Bootstrap's selector (assuming you're inside the content area of the newly visible tab):
visibilityEventEmitter: function(context) {
// since there is no specific DOM event for a change in visibility we must rely on
// whatever component is creating this change to notify us via a bespoke event
// this function is setup for a Bootstrap tab pane; for other event emmitters you will have to build your own
try {
var thisTabPane = context.$().closest('.tab-pane').attr('id');
var $emitter = context.$().closest('.tab-content').siblings('[role=tabpanel]').find('li a[aria-controls=' + thisTabPane + ']');
return $emitter;
} catch(e) {
console.log('Problem getting event emitter: %o', e);
}
return false;
},
visibilityEventName: 'shown.bs.tab',
then we just need to add the following code:
_init: function() {
var isVisible = this.$().get(0).offsetWidth > 0;
if (isVisible) {
this.visibilityDidHappen();
}
}.on('didInsertElement'),
visibilityListener: function() {
// Listen for visibility event and signal a resize when it happens
// note: this listener is placed on a DOM element which is assumed
// to always be visibile so no need to wait on placing this listener
var self = this;
Ember.run.schedule('afterRender', function() {
var $selector = self.get('visibilityEventEmitter')(self);
$selector.on(self.get('visibilityEventName'), Ember.run.bind(self, self.visibilityDidHappen ));
});
}.on('didInsertElement'),
visibilityDidHappen: function() {
// On the first visibility event, the component must be initialised
if(!this.get('isInitialised')) {
this.initiateKnob();
} else {
// force a resize assessment as window sizing may have changed
// since last time component was visible
this.resizeDidHappen();
}
},
Note that this also results in a tiny refactor of our resize listener, removing it's trigger from the didInsertElement event and instead being triggered by initiateKnob which will happen not when the Ember component loads but instead lazy load at the first point of visibility in the DOM.
initiateKnob: function() {
var self = this;
this.set('isInitialised', true);
var options = this.buildOptions();
this.$('.knob').knob(options);
this.syncValue();
this.resizeDidHappen(); // get dimensions initialised on load
console.log('setting resize listener for %s', self.elementId);
self.resizeListener(); // add a listener for future resize events
},
resizeListener: function() {
this.$(window).on('resize', Ember.run.bind(this, this.resizeDidHappen));
},
Does it work?
To a large degree but not completely. Here's what works:
the first 'tab' which is visible at load resizes on demand
all tabs resize when they are switched to (aka, when they gain visibility)
what doesn't work is:
tabs other than the first tab do not resize (aka, the onresize callback appears broken)
The error I get is:
vendor.js:13693 Uncaught TypeError: undefined is not a function
Backburner.run vendor.js:13716
Backburner.join vendor.js:34296
run.join vendor.js:34349
run.bind vendor.js:4759
jQuery.event.dispatch vendor.js:4427
jQuery.event.add.elemData.handle
Not sure what to make of this ... any help would be appreciated. Full code can be found here:
https://gist.github.com/295e7e05c3f2ec92fb45.git

How to live update jqPlot graph with ember.js?

I know how to update and redraw a jqPlot object without using ember...
I created the following fiddle to show the "problem": http://jsfiddle.net/QNGWU/
Here, the function load() of App.graphStateController is called every second and updates the series data in the controller's content.
First problem: The updates of the series seem not to propagate to the view.
Second problem: Even if they would, where can i place a call to update the plot (i.e. plotObj.drawSeries())?
I already tried to register an observer in the view's didInsertElement function:
didInsertElement : function() {
var me = this;
me._super();
me.plotObj = $.jqplot('theegraph', this.series, this.options);
me.plotObj.draw();
me.addObserver('series', me.seriesChanged);
},
seriesChanged: function() {
var me = this;
if (me.plotObj != null) {
me.plotObj.drawSeries({});
}
}
But that didn't work...
Well, figured it out, see updated fiddle.
The secret sauce was to update the whole graphState object (not just it's properties) in App.graphStateController:
var newState = App.GraphState.create();
newState.set('series', series);
me.set('content', newState);
And then attach an observer to it in the App.graphStateView:
updateGraph : function() {
[...]
}.observes('graphState')
The updateGraph function then isn't pretty, since jqPlot's data series are stored as [x,y] pairs.
The whole problem, i guess, was that the properties series and options in the App.graphState object itself are not derived from Ember.object and therefore no events are fired for them. Another solution may be to change that to Ember.objects, too.

Ember - Clearing an ArrayProxy

On the Ember MVC TodoApp there is an option "Clear all Completed".
I've been trying to do a simple "Clear All".
I've tried multiple things, none of them work as I expected (clearing the data, the local storage and refreshing the UI).
The ones that comes with the sample is this code below:
clearCompleted: function () {
this.filterProperty(
'completed', true
).forEach(this.removeObject, this);
},
My basic test, that I expected to work was this one:
clearAll: function () {
this.forEach(this.removeObject, this);
},
Though, it's leaving some items behind.
If I click the button that calls this function in the Entries controller a couple times the list ends up being empty. I have no clue what's going on! And don't want to do a 'workaround'.
The clearCompleted works perfectly by the way.
The answer depends on what you really want to know-- if you want to clear an ArrayProxy, as per the question title, you just call clear() on the ArrayProxy instance e.g.:
var stuff = ['apple', 'orange', 'banana'];
var ap = Ember.ArrayProxy.create({ content: Ember.A(stuff) });
ap.get('length'); // => 3
ap.clear();
ap.get('length'); // => 0
This way you're not touching the content property directly and any observers are notified (you'll notice on the TodoMVC example that the screen updates if you type Todos.router.entriesController.clear() in the console).
If you're specifically asking about the TodoMVC Ember example you're at the mercy of the quick and dirty "Store" implementation... if you did as above you'll see when you refresh the page the item's return since there is no binding or observing being done between the entry "controller" and the Store (kinda dumb since it's one of Ember's strengths but meh whatev)
Anywho... a "clearAll" method on the entriesController like you were looking for can be done like this:
clearAll: function() {
this.clear();
this.store.findAll().forEach(this.removeObject, this);
}
Well, this worked:
clearAll: function () {
for (var i = this.content.length - 1; i >= 0; i--) {
this.removeObject(this.content[i]);
}
},
If someone can confirm if it's the right way to do it that would be great!

Stuck scrolling of a list, using Sencha Touch

What I am trying to do is have a "load more" button at the bottom of a ajax populated list. I have got all the code working with a docked button, but I would now like to have it at the bottom.
What is happening is when the listView card is show I see my list but the list won't scroll. It pulls up and down a little but just won't have it. I have tried adding different configurations and layouts to listView with no different.
What I have done is the following
var moreButton = new Ext.Button({
text: 'Load more...',
ui: 'round',
handler: function() {//Do the loading - this works}
});
//In my list config I have a docked top bar for going "back" other than that pretty standard
var list = new Ext.List(Ext.apply(listConfig, {
fullscreen: false
}));
//This is my view for what I am trying to do
var listView = new Ext.Container({
items:[list, moreButton]
});
listView is then added to an other container as it is populated from a search box, it is show with setCard when I get a valid response from the server.
[sencha person] are you on 0.98? I think we had a regression in our scroller. Might want to downgrade back to 0.97