├── .gitignore ├── .gitmodules ├── .jshintrc ├── Gruntfile.js ├── LICENCE ├── README.md ├── demo ├── data │ └── tale-of-two-cities.txt ├── index.html ├── scripts │ ├── app.js │ └── controllers │ │ ├── autoscroll.js │ │ ├── biglist.js │ │ ├── comparison.js │ │ ├── expose.js │ │ ├── nested.js │ │ ├── rotating.js │ │ ├── small.js │ │ └── table.js ├── styles │ └── main.css └── views │ ├── autoscroll.html │ ├── biglist.html │ ├── comparison.html │ ├── expose.html │ ├── nested.html │ ├── rotating.html │ ├── small.html │ └── table.html ├── package.json └── src ├── module.js ├── scroller.js ├── sublist.js └── virtual-repeat.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .DS_Store 3 | components/ 4 | node_modules/ 5 | docs/ 6 | site 7 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "dist"] 2 | path = dist 3 | url = git@github.com:stackfull/angular-virtual-scroll-bower.git 4 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "browser": true, 3 | "bitwise": true, 4 | "camelcase": true, 5 | "curly": true, 6 | "eqeqeq": true, 7 | "eqnull": true, 8 | "immed": true, 9 | "indent": 2, 10 | "newcap": true, 11 | "noarg": true, 12 | "regexp": true, 13 | "undef": true, 14 | "unused": true, 15 | "strict": true, 16 | "trailing": true, 17 | "smarttabs": true, 18 | "white": false, 19 | "globals": { 20 | "require": false, 21 | "define": false, 22 | "angular": false, 23 | 24 | "inject": false, 25 | "jasmine": false, 26 | "spyOn": false, 27 | "it": false, 28 | "console": false, 29 | "describe": false, 30 | "expect": false, 31 | "beforeEach": false, 32 | "waits": false, 33 | "waitsFor": false, 34 | "runs": false, 35 | 36 | "setFixtures": false, 37 | "sandbox": false 38 | 39 | 40 | } 41 | } 42 | 43 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /*global module:false */ 2 | module.exports = function( grunt ) { 3 | 'use strict'; 4 | var shell = require('shelljs'); 5 | var semver = require('semver'); 6 | 7 | var SOURCES = [ 'src/**/*.js' ]; 8 | var DEMOSOURCES = [ 'demo/scripts/**/*.js' ]; 9 | var DISTSOURCES = [ 10 | 'src/module.js', 11 | 'src/sublist.js', 12 | 'src/scroller.js', 13 | 'src/virtual-repeat.js' 14 | ]; 15 | var DISTDIR = 'dist'; 16 | var TASK_IMPORTS = [ 17 | 'grunt-contrib-concat', 18 | 'grunt-contrib-uglify', 19 | 'grunt-contrib-jshint', 20 | 'grunt-contrib-watch', 21 | 'grunt-contrib-connect', 22 | 'grunt-contrib-copy', 23 | 'grunt-contrib-clean', 24 | 'grunt-bowerful' 25 | ]; 26 | 27 | var describe = shell.exec('git describe'); 28 | 29 | grunt.initConfig({ 30 | 31 | pkg: grunt.file.readJSON('package.json'), 32 | git: { 33 | description: describe.output.trim() 34 | }, 35 | 36 | clean: { 37 | dist: [DISTDIR], 38 | site: ['site'] 39 | }, 40 | 41 | copy: { 42 | demo: { 43 | expand: true, 44 | cwd: 'demo/', 45 | src: ['**/*'], 46 | dest: 'site/' 47 | }, 48 | concat: { 49 | expand: true, 50 | flatten: true, 51 | src: [ '<%= concat.dist.dest %>' ], 52 | dest: 'site/components/angular-virtual-scroll/' 53 | } 54 | }, 55 | 56 | concat: { 57 | options: { 58 | stripBanners: { 59 | line: true 60 | }, 61 | banner: '// <%= pkg.name %> - v<%= pkg.version %>\n\n', 62 | process: { 63 | version: true 64 | } 65 | }, 66 | dist: { 67 | src: DISTSOURCES, 68 | dest: DISTDIR + '/<%= pkg.name %>.js' 69 | } 70 | }, 71 | 72 | uglify: { 73 | options: { 74 | banner: '/* <%= pkg.name %> - v<%= pkg.version %> - <%= grunt.template.today("yyyy-mm-dd") %> */ ', 75 | mangle: { 76 | topleve: true, 77 | defines: { 78 | NDEBUG: true 79 | } 80 | }, 81 | squeeze: {}, 82 | codegen: {} 83 | }, 84 | dist: { 85 | src: [ '<%= concat.dist.dest %>' ], 86 | dest: DISTDIR + '/<%= pkg.name %>.min.js' 87 | 88 | } 89 | }, 90 | 91 | jshint: { 92 | options: { 93 | curly: true, 94 | eqeqeq: true, 95 | immed: true, 96 | newcap: true, 97 | noarg: true, 98 | sub: true, 99 | undef: true, 100 | boss: true, 101 | eqnull: true, 102 | browser: true 103 | }, 104 | grunt: { 105 | src: [ 'Gruntfile.js' ], 106 | options: {node:true} 107 | }, 108 | source: { 109 | src: SOURCES.concat(DEMOSOURCES), 110 | options: { 111 | globals: { 112 | angular: true 113 | } 114 | } 115 | } 116 | }, 117 | 118 | docco: { 119 | virtualScroll: { 120 | src: SOURCES, 121 | dest: 'docs/', 122 | options: { 123 | layout: "parallel" 124 | } 125 | } 126 | }, 127 | 128 | bowerful: { 129 | site: { 130 | store: 'site/components', 131 | 132 | packages: { 133 | 'angular': "1.2.x", 134 | 'angular-route': "1.2.x", 135 | jquery: "1.9.x", 136 | json3: "3.2.x", 137 | "es5-shim": "2.0.x", 138 | "bootstrap": "3.1.1", 139 | "jasmine-jquery": "1.x" 140 | } 141 | } 142 | }, 143 | 144 | connect: { 145 | site: { 146 | options: { 147 | port: 8000, 148 | host: '*', 149 | base: 'site' 150 | } 151 | }, 152 | docs: { 153 | options: { 154 | port: 9001, 155 | base: 'docs' 156 | } 157 | } 158 | }, 159 | 160 | watch: { 161 | sources: { 162 | options: { 163 | livereload: true 164 | }, 165 | files: SOURCES, 166 | tasks: ['jshint:source', 'concat', 'copy:concat'] 167 | }, 168 | demo: { 169 | options: { 170 | livereload: true 171 | }, 172 | files: ['demo/**/*'], 173 | tasks: ['jshint:source', 'copy:demo'] 174 | }, 175 | gruntFile: { 176 | options: { 177 | reload: true 178 | }, 179 | files: ['Gruntfile.js'], 180 | tasks: ['jshint:grunt'] 181 | } 182 | } 183 | 184 | }); 185 | 186 | TASK_IMPORTS.forEach(grunt.loadNpmTasks); 187 | 188 | grunt.registerTask('default', ['jshint', 'doc']); 189 | 190 | grunt.registerTask('dist', ['jshint', 'concat', 'min']); 191 | 192 | grunt.registerTask('doc', ['docco']); 193 | grunt.registerTask('min', ['uglify']); 194 | 195 | // 'demo' task - stage demo system into 'site' and watch for source changes 196 | grunt.registerTask('build-site', ['bowerful', 'copy:demo', 'concat', 'copy:concat']); 197 | grunt.registerTask('demo', ['clean:site', 'build-site', 'connect:site', 'watch']); 198 | 199 | function run(cmd, msg){ 200 | shell.exec(cmd, {silent:true}); 201 | if( msg ){ 202 | grunt.log.ok(msg); 203 | } 204 | } 205 | 206 | grunt.registerTask('release-prepare', 'Set up submodule to receive a new release', function(){ 207 | // Make sure we have the submodule in dist 208 | run("git submodule init"); 209 | run("git submodule update"); 210 | run("cd dist; git checkout master"); 211 | // Bump version 212 | var newVer = grunt.config('pkg').version; 213 | var comp = grunt.file.readJSON(DISTDIR+"/bower.json"); 214 | grunt.log.writeln("Package version: " + newVer); 215 | grunt.log.writeln("Component version: " + comp.version); 216 | if( !semver.gt( newVer, comp.version ) ){ 217 | grunt.warn("Need to up-version package.json first!"); 218 | } 219 | }); 220 | 221 | 222 | grunt.registerTask('release-commit', 'push new build to bower component repo', function(){ 223 | // Stamp version 224 | var newVer = grunt.config('pkg').version; 225 | var comp = grunt.file.readJSON(DISTDIR+"/bower.json"); 226 | grunt.log.writeln("Package version: " + newVer); 227 | grunt.log.writeln("Component version: " + comp.version); 228 | if( !semver.gt( newVer, comp.version ) ){ 229 | grunt.warn("Need to up-version package.json first!"); 230 | } 231 | comp.version = newVer; 232 | grunt.file.write(DISTDIR+"/bower.json", JSON.stringify(comp, null, ' ')+'\n'); 233 | // Commit submodule 234 | // Tag submodule 235 | run('cd dist; git commit -a -m"Build version '+ newVer +'"', "Commited to bower repo"); 236 | run('cd dist; git tag ' + newVer + ' -m"Release version '+ newVer +'"', "Tagged bower repo"); 237 | // Commit and tag this. 238 | run('git commit -a -m"Build version '+ newVer +'"', "Commited to source repo"); 239 | run('git tag ' + newVer + ' -m"Release version '+ newVer +'"', "Tagged source repo"); 240 | run("git submodule update"); 241 | // push? 242 | grunt.log.ok("DON'T FORGET TO PUSH BOTH!"); 243 | }); 244 | 245 | grunt.registerTask('release', 'build and push to the bower component repo', 246 | ['release-prepare', 'dist', 'release-commit']); 247 | 248 | var docco = require('docco'); 249 | 250 | grunt.registerMultiTask('docco', 'Docco processor.', function() { 251 | var task = this, 252 | fdone = 0, 253 | flength = this.files.length, 254 | done = this.async(); 255 | 256 | this.files.forEach(function(file) { 257 | docco.document(task.options({ output: file.dest, args: file.src }), function(){ 258 | if(++fdone === flength){ 259 | done(); 260 | } 261 | }); 262 | }); 263 | }); 264 | }; 265 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2013 Paul Thomas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | angular-virtual-scroll 2 | ====================== 3 | 4 | Source for the sf.virtualScroll module for AngularJS 5 | 6 | Intended as a replacement for `ng-repeat` for large collections of data and 7 | contains different solutions to the problem. 8 | 9 | About 10 | ----- 11 | 12 | The module was originally developed as a proof of concept but has matured into 13 | a useable component. It isn't the ideal solution to the performance issues of 14 | large `ng-repeat` instances, but it does work as a drop-in replacement (with 15 | some caveats). 16 | 17 | It started because I needed to display log messages and I didn't want to use 18 | paging. There were some excellent alternatives including some wrappers of 19 | jQuery grids, but nothing was using the `ng-repeat` pattern. I wrote a couple 20 | of articles explaining myself as I went along: 21 | 22 | * https://stackfull.github.io/blog/2013/02/16/angularjs-virtual-scrolling-part-1.html 23 | * https://stackfull.github.io/blog/2013/03/24/angularjs-virtual-scrolling-part-2.html 24 | 25 | There should be an online demo here: http://demo.stackfull.com/virtual-scroll/ 26 | 27 | **2016:** I don't get much time to add to this component unless there are serious 28 | bugs to fix. If you need more features, there is a similar component 29 | https://github.com/kamilkp/angular-vs-repeat 30 | that uses a nicer directive structure in that the custom directive surrounds a 31 | vanilla `ng-repeat` to define the viewport. Although this relies on internals 32 | of ng-repeat, it is a better user experience. Also, more features are added and 33 | updates are frequent. 34 | 35 | Usage 36 | ----- 37 | 38 | Whether you build the component, copy the raw source or use bower (see below), 39 | the end result should be included in your page and the module `sf.virtualScroll` 40 | included as a dependency: 41 | 42 | ```js 43 | angular.module('myModule', ['sf.virtualScroll']); 44 | ``` 45 | 46 | Then use the directive `sf-virtual-repeat` just as you would use `ng-repeat`. 47 | 48 | ```html 49 |
50 |
51 | 52 | 53 |
{{$index}}: {{line}} 54 |
55 |
56 |
57 | 58 |
59 | 62 |
63 | ``` 64 | 65 | If you want to expose the scroll postion (to simulate an `atEnd` event for 66 | example), use `ng-model` and you have access to the scroll properties. 67 | 68 | Check out the examples in the demo folder for all the details. 69 | 70 | Limitations 71 | ----------- 72 | 73 | First up, the obligatory warning: **virtual scrolling is usually the wrong 74 | approach**. If you want to display really large lists, your users will probably 75 | not thank you for it: filtering can be a more friendly way to tame the data. Or 76 | if you have performance problems with angular bindings, one of the "bind-once" 77 | implementation may make more sense. 78 | 79 | Tables are problematic. It is possible to use `sf-virtual-repeat` in a `` 80 | to create table rows, but you have to be very careful about your CSS. 81 | 82 | The element having the `sf-virtual-repeat` needs to be contained within an 83 | element suitable for use as a viewport. This suitability is the main difficulty 84 | as the viewport must contain a single element (and no text) and this contained 85 | element must be explicitly sizable. So a `table` will need 2 parent `div`s for 86 | example. 87 | 88 | The collection must be an array (not an object) and the array must not change 89 | identity - that is, the value on the scope must remain the same and you should 90 | push, pop, splice etc. rather than re-assigning to the scope variable. This is 91 | a limitiation that might be removed in future versions, but for now it's a 92 | consequence of watching the collection lightly. 93 | 94 | Developing 95 | ---------- 96 | 97 | [Grunt](http://gruntjs.com/) is used as the build tool, so you will need 98 | [node](http://nodejs.org/) and [npm](https://npmjs.org/) installed. Since v0.4, 99 | grunt has 2 parts: the heavy lifting package `grunt` and the shell command 100 | `grunt-cli`. If you haven't already installed `grunt-cli` globally, do so now 101 | with: 102 | 103 | ```shell 104 | sudo npm install -g grunt-cli 105 | ``` 106 | 107 | To run the simple demo, install the npm dependencies for the build tools and go: 108 | 109 | ```shell 110 | npm install 111 | grunt demo 112 | ``` 113 | 114 | You can now view the demo at http://localhost:8000/ 115 | 116 | Build with `grunt dist` and choose a file from the `dist` directory. 117 | 118 | Using the component 119 | ------------------- 120 | 121 | For use with [bower](http://twitter.github.com/bower/), there is a separate 122 | repo containing just the built artifacts here: 123 | [angular-virtual-scroll-bower](https://github.com/stackfull/angular-virtual-scroll-bower). 124 | You can add the component to your project with: 125 | 126 | ```shell 127 | bower install angular-virtual-scroll 128 | ``` 129 | 130 | Or by adding a line to your `component.json` file. 131 | 132 | If you are using `grunt` for your build, consider using a plugin like 133 | [bowerful](https://npmjs.org/package/grunt-bowerful). 134 | 135 | All comments to 136 | 137 | ChangeLog 138 | --------- 139 | 140 | ### 0.6.2 (28 Jul 2014) 141 | 142 | - added sfVirtualScroll constant for version info 143 | - [FIX \#25] Guard against empty collection 144 | 145 | ### 0.6.1 (30 Apr 2014) 146 | 147 | - [ENHANCEMENT \#13] reduce debug noise 148 | - upgrade dependencies 149 | 150 | ### 0.6.0 (19 Jan 2014) 151 | 152 | - [ENHANCEMENT \#9] allow filters in the collection expression 153 | - [FIX \#12] improved stability in the face of collection changes 154 | 155 | ### 0.5.0 (28 Jul 2013) 156 | 157 | - [FIX \#6] be more careful searching for a viewport (tables again) 158 | - [ENHANCEMENT \#2]configurable watermark levels 159 | - more demos 160 | 161 | ### 0.4.0 (11 May 2013) 162 | 163 | - [ENHANCEMENT \#4] prevent tables messing up the viewport 164 | - expose state variables as models 165 | 166 | ### 0.3.1 (14 Apr 2013) 167 | 168 | - added "auto-scroll" feature to the virtual repeater 169 | - fleshed out demos in place of tests 170 | 171 | ### 0.3.0 (17 Mar 2013) 172 | First "dagnamit" fix. 173 | 174 | ### 0.2.0 (16 Mar 2013) 175 | First sight of daylight. 176 | 177 | 178 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Virtual Scrolling Demos 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 21 | 22 | 26 | 27 | 52 | 53 | 54 |
55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /demo/scripts/app.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 'use strict'; 3 | 4 | var DEMOS = []; 5 | angular.forEach(['comparison', 'nested', 'big list', 'auto scroll', 6 | 'rotating', 'small', 'table', 'expose'], function(demo){ 7 | var title = demo.replace(/\w\S*/g, function(txt){ 8 | return txt.charAt(0).toUpperCase() + txt.substr(1); 9 | }); 10 | var file = demo.replace(/\s+/g, ''); 11 | 12 | DEMOS.push({ 13 | name: demo, 14 | id: title.replace(/\s+/g, ''), 15 | title: title, 16 | file: file, 17 | path: '/'+file 18 | }); 19 | }); 20 | var mod = angular.module('virtualScrollingApp', 21 | ['sf.virtualScroll', 'ngRoute']); 22 | 23 | mod.config(['$routeProvider', '$logProvider', function($routeProvider, $logProvider) { 24 | angular.forEach(DEMOS, function(demo){ 25 | $routeProvider.when(demo.path, { 26 | templateUrl: 'views/'+demo.file+'.html', 27 | controller: demo.id+'Ctrl' 28 | }); 29 | }); 30 | $routeProvider.otherwise({ 31 | redirectTo: DEMOS[0].path 32 | }); 33 | $logProvider.debugEnabled(false); 34 | }]); 35 | 36 | mod.controller('NavCtrl', function($scope, $location, sfVirtualScroll){ 37 | $scope.loc = $location; 38 | $scope.demos = DEMOS; 39 | $scope.version = sfVirtualScroll.version; 40 | }); 41 | 42 | }()); 43 | -------------------------------------------------------------------------------- /demo/scripts/controllers/autoscroll.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 'use strict'; 3 | 4 | angular.module('virtualScrollingApp').controller('AutoScrollCtrl', function AutoScrollCtrl($scope, $timeout) { 5 | $scope.log = { 6 | title: "None", 7 | msgs: [] 8 | }; 9 | (function poll () { 10 | var now = new Date(); 11 | $scope.log.msgs.push( { message: "Another log at " + now, time: now.getTime() } ); 12 | $timeout(poll, 1000); 13 | })(); 14 | }); 15 | }()); 16 | -------------------------------------------------------------------------------- /demo/scripts/controllers/biglist.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 'use strict'; 3 | angular.module('virtualScrollingApp').controller('BigListCtrl', function BigListCtrl($scope, $http, $filter) { 4 | $scope.filtered_lines = []; 5 | $scope.query = ""; 6 | $scope.book = { 7 | title: "None", 8 | lines: [] 9 | }; 10 | 11 | $scope.filter_lines = function(){ 12 | if( $scope.query.length ){ 13 | $scope.filtered_lines = $filter('filter')($scope.book.lines, $scope.query); 14 | }else{ 15 | $scope.filtered_lines = $scope.book.lines; 16 | } 17 | }; 18 | 19 | $http({ 20 | method: 'GET', 21 | url: '/data/tale-of-two-cities.txt' 22 | }).success(function(data){ 23 | $scope.book.title = "Tale of Two Cities"; 24 | $scope.book.lines = data.split('\n'); 25 | $scope.filter_lines(); 26 | }); 27 | }); 28 | }()); 29 | -------------------------------------------------------------------------------- /demo/scripts/controllers/comparison.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 'use strict'; 3 | angular.module('virtualScrollingApp').controller('ComparisonCtrl', function ComparisonCtrl($scope) { 4 | $scope.slicePosition = 0; 5 | $scope.simpleList = [ 'FIRST', 'Second']; 6 | for( var ii = 3; ii < 500; ii++ ){ 7 | $scope.simpleList.push(''+ii); 8 | } 9 | $scope.simpleList.push('LAST'); 10 | }); 11 | }()); 12 | -------------------------------------------------------------------------------- /demo/scripts/controllers/expose.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 'use strict'; 3 | angular.module('virtualScrollingApp').controller('ExposeCtrl', function ExposeCtrl($scope, $http, $log) { 4 | $scope.book = { 5 | title: "", 6 | lines: [] 7 | }; 8 | $scope.load = function(){ 9 | $log.log("Loading..."); 10 | $http({ 11 | method: 'GET', 12 | url: '/data/tale-of-two-cities.txt' 13 | }).success(function(data){ 14 | $log.log("...loaded."); 15 | $scope.book.title = "Tale of Two Cities"; 16 | $scope.book.lines = data.split('\n'); 17 | }); 18 | }; 19 | $scope.scrollModel = {}; 20 | }); 21 | }()); 22 | -------------------------------------------------------------------------------- /demo/scripts/controllers/nested.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 'use strict'; 3 | angular.module('virtualScrollingApp').controller('NestedCtrl', function NestedCtrl($scope) { 4 | $scope.things = [ 5 | 'FIRST', 'Second', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', 6 | '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23', '24', '25', 7 | '26', '27', '28', '29', '30', '31', '32', '33', '34', '35', '36', '37', '38', 8 | '39', '40', '41', '42', '43', '44', '45', '46', '47', '48', '49', '50', '51', 9 | '52', '53', '54', '55', '56', '57', '58', '59', '60', '61', '62', '63', '64', 10 | '65', '66', '67', '68', '69', '70', '71', '72', '73', '74', '75', '76', '77', 11 | '78', '79', '80', '81', '82', '83', '84', '85', '86', '87', '88', '89', '90', 12 | '91', '92', '93', '94', '95', '96', '97', '98', '99', 'LAST' 13 | ]; 14 | }); 15 | }()); 16 | -------------------------------------------------------------------------------- /demo/scripts/controllers/rotating.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 'use strict'; 3 | 4 | angular.module('virtualScrollingApp').controller('RotatingCtrl', function RotatingCtrl($scope, $timeout) { 5 | var now = (new Date()).getTime(); 6 | $scope.log = { 7 | title: "None", 8 | msgs: [ 9 | { time: now, message: "Initial message" } 10 | ] 11 | }; 12 | for (var i=0; i < 500; i++) { 13 | $scope.log.msgs.push( { time: now, message: "Old Message " + i } ); 14 | } 15 | (function poll () { 16 | var now = new Date(); 17 | $scope.log.msgs.push( { message: "Another log at " + now, time: now.getTime() } ); 18 | $scope.log.msgs.shift(); 19 | $timeout(poll, 1000); 20 | })(); 21 | }); 22 | }()); 23 | -------------------------------------------------------------------------------- /demo/scripts/controllers/small.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 'use strict'; 3 | 4 | angular.module('virtualScrollingApp').controller('SmallCtrl', function SmallCtrl($scope) { 5 | $scope.rows = ["one", "two"]; 6 | }); 7 | }()); 8 | 9 | 10 | -------------------------------------------------------------------------------- /demo/scripts/controllers/table.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 'use strict'; 3 | 4 | angular.module('virtualScrollingApp').controller('TableCtrl', function TableCtrl($scope, $http) { 5 | $scope.log = { 6 | title: "None", 7 | msgs: [{ 8 | time: new Date(), 9 | message: "First thing that happened" 10 | },{ 11 | time: new Date(), 12 | message: "Second thing that happened" 13 | },{ 14 | time: new Date(), 15 | message: "Third thing that happened" 16 | },{ 17 | time: new Date(), 18 | message: "Fourth thing that happened - now add your own" 19 | }] 20 | }; 21 | $scope.message = ''; 22 | $scope.logit = function(){ 23 | $scope.log.msgs.push({ time: new Date(), message: $scope.message }); 24 | }; 25 | $scope.book = { 26 | title: "None", 27 | lines: [] 28 | }; 29 | $http({ 30 | method: 'GET', 31 | url: '/data/tale-of-two-cities.txt' 32 | }).success(function(data){ 33 | $scope.book.title = "Tale of Two Cities"; 34 | $scope.book.lines = data.split('\n'); 35 | }); 36 | }); 37 | }()); 38 | 39 | -------------------------------------------------------------------------------- /demo/styles/main.css: -------------------------------------------------------------------------------- 1 | .viewport { 2 | white-space: nowrap; 3 | max-height: 300px; 4 | border: 1px solid blue; 5 | } 6 | .viewport.real,.viewport.slice,.viewport.virtual{ 7 | max-height: 100px; 8 | } 9 | .viewport li{ 10 | background: #eee; 11 | } 12 | .viewport li:nth-child(even){ 13 | background: #ccc; 14 | } 15 | 16 | .real{ 17 | overflow: auto; 18 | } 19 | .real ul, .slice ul{ 20 | margin: 0; 21 | padding: 0; 22 | list-style: none; 23 | } 24 | 25 | tr { 26 | height: 25px; 27 | } 28 | td { 29 | font: 12px monospace; 30 | line-height: 1em !important; 31 | } 32 | td.time { 33 | width: 7em; 34 | } 35 | .line-count { 36 | display: inline-block; 37 | width: 7em; 38 | } 39 | -------------------------------------------------------------------------------- /demo/views/autoscroll.html: -------------------------------------------------------------------------------- 1 |
2 |

A constantly updating list. 3 |

Log Messages

4 |
5 |
6 |

{{msg.time|date:'mediumTime'}}: {{msg.message}} 7 |

8 |
9 |
10 | -------------------------------------------------------------------------------- /demo/views/biglist.html: -------------------------------------------------------------------------------- 1 |
2 |

That's right, a big list 3 |

4 |
5 | 6 | {{filtered_lines.length}} lines. 7 |
8 |

{{book.title}}

9 |
10 |
11 |
12 |

{{line}} 13 |

14 |
15 |
16 | -------------------------------------------------------------------------------- /demo/views/comparison.html: -------------------------------------------------------------------------------- 1 |
2 |

A simple list presented in 3 ways: A regular ng-repeat, a filtered ng-repeat and a sf-virtual-repeat. 3 |

Real list

4 |
5 |
    6 |
  • 7 | +{{thing}} 8 |
  • 9 |
10 |
11 |

Sublist filter

12 |
13 |
    14 |
  • 15 | +{{thing}} 16 |
  • 17 |
18 |
19 |
20 |

Virtual list

21 |
22 |
    23 |
  • 24 | +{{thing}} 25 |
  • 26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /demo/views/expose.html: -------------------------------------------------------------------------------- 1 |
2 |

State variables exposed via ng-model 3 | Load Book 4 |

no book loaded{{book.title}}

5 |
6 |
7 |
8 |
9 |

{{line}} 10 |

11 |
12 |
{{book.lines.length}} lines.
13 |
14 |
15 | 16 | 17 | 19 | 21 | 23 | 25 | 27 | 29 | 31 | 33 |
First Active Row: {{scrollModel.firstActive}} 18 |
First Visible Row: {{scrollModel.firstVisible}} 20 |
Last Visible Row: {{scrollModel.firstVisible + scrollModel.visible}} 22 |
Last Active Row: {{scrollModel.firstActive + scrollModel.active}} 24 |
Visible Rows: {{scrollModel.visible}} 26 |
Active Rows: {{scrollModel.active}} 28 |
Total Rows: {{scrollModel.total}} 30 |
At Start?: {{scrollModel.firstActive == 0}} 32 |
At End?: {{scrollModel.firstActive + scrollModel.active == scrollModel.total}} 34 |
35 |
36 |
37 |
38 | -------------------------------------------------------------------------------- /demo/views/nested.html: -------------------------------------------------------------------------------- 1 |
2 |

A sf-virtual-repeat inside a ng-repeat. 3 |

4 |

Virtual {{example}}

5 |
6 |
    7 |
  • 8 | +{{thing}} 9 |
  • 10 |
11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /demo/views/rotating.html: -------------------------------------------------------------------------------- 1 |
2 |

A constantly updating list of fixed size. 3 |

Log Messages

4 |
5 |
6 |

{{msg.time|date:'mediumTime'}}: {{msg.message}} 7 |

8 |
9 |
10 | -------------------------------------------------------------------------------- /demo/views/small.html: -------------------------------------------------------------------------------- 1 |
2 |

Small lists should behave like ng-repeat. 3 |

Two Lines

4 |
5 |
6 |

{{r}} 7 |

8 |
9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /demo/views/table.html: -------------------------------------------------------------------------------- 1 |
2 |

Constructing Tables. 3 |

Log Messages

4 |
5 |
6 | 7 | 8 | 11 |
{{msg.time|date:'mediumTime'}} 9 | {{msg.message}} 10 |
12 |
13 |
14 |
15 |
16 |
17 | 18 | 19 |
20 |
21 |
22 | 23 | 24 |
25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 38 | 39 |
TimeMessage
{{msg.time|date:'mediumTime'}} 36 | {{msg.message}} 37 |
40 |
41 |
42 | 43 |
44 | 45 |
46 |

Big list, meet big table 47 |

{{book.title}}

48 |
49 |
50 | 51 | 52 | 54 |
#Line 53 |
{{$index}}: {{line}} 55 |
56 |
57 |
58 |
{{book.lines.length}} lines.
59 |
60 | 61 |
62 | 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Paul Thomas ", 3 | "name": "angular-virtual-scroll", 4 | "version": "0.6.2", 5 | "dependencies": {}, 6 | "devDependencies": { 7 | "grunt": "~0.4.0", 8 | "docco": "0.6.3", 9 | "bower": "~1.3.5", 10 | "grunt-bowerful": "0.3.0", 11 | "grunt-contrib-jshint": "~0.10.0", 12 | "grunt-contrib-concat": "~0.4.0", 13 | "grunt-contrib-uglify": "~0.4.0", 14 | "grunt-contrib-watch": "~0.6.1", 15 | "grunt-contrib-connect": "~0.7.0", 16 | "grunt-contrib-clean": "~0.5.0", 17 | "shelljs": "~0.3.0", 18 | "semver": "~2.3.0", 19 | "grunt-contrib-copy": "~0.5.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/module.js: -------------------------------------------------------------------------------- 1 | // © Copyright 2013 Paul Thomas . All Rights Reserved. 2 | 3 | // Include this first to define the module that the directives etc. hang off. 4 | // 5 | (function(){ 6 | 'use strict'; 7 | angular.module('sf.virtualScroll', []).constant('sfVirtualScroll', { 8 | release: "<%= pkg.version %>", 9 | version: "<%= git.description %>" 10 | }); 11 | }()); 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/scroller.js: -------------------------------------------------------------------------------- 1 | // © Copyright 2013 Paul Thomas . All Rights Reserved. 2 | 3 | // sf-scroller directive 4 | // ===================== 5 | // Makes a simple scrollbar widget using the native overflow: scroll mechanism. 6 | // 7 | /*jshint jquery:true */ 8 | (function(){ 9 | 'use strict'; 10 | // (part of the sf.virtualScroll module). 11 | var mod = angular.module('sf.virtualScroll'); 12 | 13 | mod.directive("sfScroller", function(){ 14 | 15 | // Should be roughly a "row" height but it doesn't matter too much, it 16 | // determines how responsive the scroller will feel. 17 | var HEIGHT_MULTIPLIER = 18; 18 | 19 | // The range expression appears in the directive and must have the form: 20 | // 21 | // x in a to b 22 | // 23 | // This helper will return `{ axis: "x", lower: "a", upper: "b" }` 24 | function parseRangeExpression (expression) { 25 | /*jshint regexp:false */ 26 | var match = expression.match(/^(x|y)\s*(=|in)\s*(.+) to (.+)$/); 27 | if( !match ){ 28 | throw new Error("Expected sfScroller in form of '_axis_ in _lower_ to _upper_' but got '" + expression + "'."); 29 | } 30 | return { 31 | axis: match[1], 32 | lower: match[3], 33 | upper: match[4] 34 | }; 35 | } 36 | 37 | // just a post-link function 38 | return function(scope, element, attrs){ 39 | var range = parseRangeExpression(attrs.sfScroller), 40 | lower = scope.$eval(range.lower), 41 | upper = scope.$eval(range.upper), 42 | horizontal = range.axis === 'x'; 43 | element.css({ 44 | // The element must expand to fit the parent 45 | // and `1em` seems to work most often for the scrollbar width 46 | // (can tweak with css if needed). 47 | height: horizontal ? '1em' : '100%', 48 | width: horizontal ? '100%' : '1em', 49 | "overflow-x": horizontal ? 'scroll' : 'hidden', 50 | "overflow-y": horizontal ? 'hidden' : 'scroll', 51 | // Want the scroller placed at the right edge of the parent 52 | position: 'absolute', 53 | top: horizontal ? '100%' : 0, 54 | right: 0 55 | }).parent().css({ 56 | // so parent must create a new context for positioning. 57 | position: 'relative' 58 | }); 59 | var dummy = angular.element('
'); 60 | element.append(dummy); 61 | element.bind('scroll', function(){ 62 | // When the user scrolls, push the new position into the ng world via 63 | // the `ng-model`. 64 | var newTop = element.prop('scrollTop'); 65 | if( attrs.ngModel ){ 66 | scope.$apply(attrs.ngModel + ' = ' + newTop/HEIGHT_MULTIPLIER); 67 | } 68 | }); 69 | // Watch the values in the range expression 70 | scope.$watch(range.lower, function(newVal){ 71 | lower = newVal; 72 | dummy.css('height', (upper-lower)*HEIGHT_MULTIPLIER+'px'); 73 | }); 74 | scope.$watch(range.upper, function(newVal){ 75 | upper = newVal; 76 | dummy.css('height', (upper-lower)*HEIGHT_MULTIPLIER+'px'); 77 | }); 78 | // and make the position a 2-way binding 79 | scope.$watch(attrs.ngModel, function(newVal){ 80 | var scrollTop = newVal * HEIGHT_MULTIPLIER; 81 | if( element.prop('scrollTop') !== scrollTop ){ 82 | element.prop('scrollTop'. scrollTop); 83 | } 84 | }); 85 | }; 86 | }); 87 | 88 | }()); 89 | -------------------------------------------------------------------------------- /src/sublist.js: -------------------------------------------------------------------------------- 1 | // © Copyright 2013 Paul Thomas . All Rights Reserved. 2 | 3 | // sublist filter 4 | // ============== 5 | // Narrows a collection expression to a sub-collection. 6 | // 7 | (function(){ 8 | 'use strict'; 9 | // (part of the sf.virtualScroll module). 10 | var mod = angular.module('sf.virtualScroll'); 11 | 12 | mod.filter('sublist', function(){ 13 | return function(input, range, start){ 14 | return input.slice(start, start+range); 15 | }; 16 | }); 17 | 18 | }()); 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/virtual-repeat.js: -------------------------------------------------------------------------------- 1 | // © Copyright 2013 Paul Thomas . All Rights Reserved. 2 | 3 | // sf-virtual-repeat directive 4 | // =========================== 5 | // Like `ng-repeat` with reduced rendering and binding 6 | // 7 | (function(){ 8 | 'use strict'; 9 | // (part of the sf.virtualScroll module). 10 | var mod = angular.module('sf.virtualScroll'); 11 | var DONT_WORK_AS_VIEWPORTS = ['TABLE', 'TBODY', 'THEAD', 'TR', 'TFOOT']; 12 | var DONT_WORK_AS_CONTENT = ['TABLE', 'TBODY', 'THEAD', 'TR', 'TFOOT']; 13 | var DONT_SET_DISPLAY_BLOCK = ['TABLE', 'TBODY', 'THEAD', 'TR', 'TFOOT']; 14 | 15 | // Utility to clip to range 16 | function clip(value, min, max){ 17 | if( angular.isArray(value) ){ 18 | return angular.forEach(value, function(v){ 19 | return clip(v, min, max); 20 | }); 21 | } 22 | return Math.max(min, Math.min(value, max)); 23 | } 24 | 25 | mod.directive('sfVirtualRepeat', ['$log', '$rootElement', function($log, $rootElement){ 26 | 27 | return { 28 | require: '?ngModel', 29 | transclude: 'element', 30 | priority: 1000, 31 | terminal: true, 32 | compile: sfVirtualRepeatCompile 33 | }; 34 | 35 | // Turn the expression supplied to the directive: 36 | // 37 | // a in b 38 | // 39 | // into `{ value: "a", collection: "b" }` 40 | function parseRepeatExpression(expression){ 41 | var match = expression.match(/^\s*([\$\w]+)\s+in\s+([\S\s]*)$/); 42 | if (! match) { 43 | throw new Error("Expected sfVirtualRepeat in form of '_item_ in _collection_' but got '" + 44 | expression + "'."); 45 | } 46 | return { 47 | value: match[1], 48 | collection: match[2] 49 | }; 50 | } 51 | 52 | // Utility to filter out elements by tag name 53 | function isTagNameInList(element, list){ 54 | var t, tag = element.tagName.toUpperCase(); 55 | for( t = 0; t < list.length; t++ ){ 56 | if( list[t] === tag ){ 57 | return true; 58 | } 59 | } 60 | return false; 61 | } 62 | 63 | 64 | // Utility to find the viewport/content elements given the start element: 65 | function findViewportAndContent(startElement){ 66 | /*jshint eqeqeq:false, curly:false */ 67 | var root = $rootElement[0]; 68 | var e, n; 69 | // Somewhere between the grandparent and the root node 70 | for( e = startElement.parent().parent()[0]; e !== root; e = e.parentNode ){ 71 | // is an element 72 | if( e.nodeType != 1 ) break; 73 | // that isn't in the blacklist (tables etc.), 74 | if( isTagNameInList(e, DONT_WORK_AS_VIEWPORTS) ) continue; 75 | // has a single child element (the content), 76 | if( e.childElementCount != 1 ) continue; 77 | // which is not in the blacklist 78 | if( isTagNameInList(e.firstElementChild, DONT_WORK_AS_CONTENT) ) continue; 79 | // and no text. 80 | for( n = e.firstChild; n; n = n.nextSibling ){ 81 | if( n.nodeType == 3 && /\S/g.test(n.textContent) ){ 82 | break; 83 | } 84 | } 85 | if( n == null ){ 86 | // That element should work as a viewport. 87 | return { 88 | viewport: angular.element(e), 89 | content: angular.element(e.firstElementChild) 90 | }; 91 | } 92 | } 93 | throw new Error("No suitable viewport element"); 94 | } 95 | 96 | // Apply explicit height and overflow styles to the viewport element. 97 | // 98 | // If the viewport has a max-height (inherited or otherwise), set max-height. 99 | // Otherwise, set height from the current computed value or use 100 | // window.innerHeight as a fallback 101 | // 102 | function setViewportCss(viewport){ 103 | var viewportCss = {'overflow': 'auto'}, 104 | style = window.getComputedStyle ? 105 | window.getComputedStyle(viewport[0]) : 106 | viewport[0].currentStyle, 107 | maxHeight = style && style.getPropertyValue('max-height'), 108 | height = style && style.getPropertyValue('height'); 109 | 110 | if( maxHeight && maxHeight !== '0px' ){ 111 | viewportCss.maxHeight = maxHeight; 112 | }else if( height && height !== '0px' ){ 113 | viewportCss.height = height; 114 | }else{ 115 | viewportCss.height = window.innerHeight; 116 | } 117 | viewport.css(viewportCss); 118 | } 119 | 120 | // Apply explicit styles to the content element to prevent pesky padding 121 | // or borders messing with our calculations: 122 | function setContentCss(content){ 123 | var contentCss = { 124 | margin: 0, 125 | padding: 0, 126 | border: 0, 127 | 'box-sizing': 'border-box' 128 | }; 129 | content.css(contentCss); 130 | } 131 | 132 | // TODO: compute outerHeight (padding + border unless box-sizing is border) 133 | function computeRowHeight(element){ 134 | var style = window.getComputedStyle ? window.getComputedStyle(element) 135 | : element.currentStyle, 136 | maxHeight = style && style.getPropertyValue('max-height'), 137 | height = style && style.getPropertyValue('height'); 138 | 139 | if( height && height !== '0px' && height !== 'auto' ){ 140 | $log.debug('Row height is "%s" from css height', height); 141 | }else if( maxHeight && maxHeight !== '0px' && maxHeight !== 'none' ){ 142 | height = maxHeight; 143 | $log.debug('Row height is "%s" from css max-height', height); 144 | }else if( element.clientHeight ){ 145 | height = element.clientHeight+'px'; 146 | $log.debug('Row height is "%s" from client height', height); 147 | }else{ 148 | throw new Error("Unable to compute height of row"); 149 | } 150 | angular.element(element).css('height', height); 151 | return parseInt(height, 10); 152 | } 153 | 154 | // The compile gathers information about the declaration. There's not much 155 | // else we could do in the compile step as we need a viewport parent that 156 | // is exculsively ours - this is only available at link time. 157 | function sfVirtualRepeatCompile(element, attr, linker) { 158 | var ident = parseRepeatExpression(attr.sfVirtualRepeat); 159 | 160 | return { 161 | post: sfVirtualRepeatPostLink 162 | }; 163 | // ---- 164 | 165 | // Set up the initial value for our watch expression (which is just the 166 | // start and length of the active rows and the collection length) and 167 | // adds a listener to handle child scopes based on the active rows. 168 | function sfVirtualRepeatPostLink(scope, iterStartElement, attrs){ 169 | 170 | var rendered = []; 171 | var rowHeight = 0; 172 | var scrolledToBottom = false; 173 | var stickyEnabled = "sticky" in attrs; 174 | var dom = findViewportAndContent(iterStartElement); 175 | // The list structure is controlled by a few simple (visible) variables: 176 | var state = 'ngModel' in attrs ? scope.$eval(attrs.ngModel) : {}; 177 | // - The index of the first active element 178 | state.firstActive = 0; 179 | // - The index of the first visible element 180 | state.firstVisible = 0; 181 | // - The number of elements visible in the viewport. 182 | state.visible = 0; 183 | // - The number of active elements 184 | state.active = 0; 185 | // - The total number of elements 186 | state.total = 0; 187 | // - The point at which we add new elements 188 | state.lowWater = state.lowWater || 100; 189 | // - The point at which we remove old elements 190 | state.highWater = state.highWater || 300; 191 | // TODO: now watch the water marks 192 | 193 | setContentCss(dom.content); 194 | setViewportCss(dom.viewport); 195 | // When the user scrolls, we move the `state.firstActive` 196 | dom.viewport.bind('scroll', sfVirtualRepeatOnScroll); 197 | 198 | // The watch on the collection is just a watch on the length of the 199 | // collection. We don't care if the content changes. 200 | scope.$watch(sfVirtualRepeatWatchExpression, sfVirtualRepeatListener, true); 201 | 202 | // and that's the link done! All the action is in the handlers... 203 | return; 204 | // ---- 205 | 206 | // Apply explicit styles to the item element 207 | function setElementCss (element) { 208 | var elementCss = { 209 | // no margin or it'll screw up the height calculations. 210 | margin: '0' 211 | }; 212 | if( !isTagNameInList(element[0], DONT_SET_DISPLAY_BLOCK) ){ 213 | // display: block if it's safe to do so 214 | elementCss.display = 'block'; 215 | } 216 | if( rowHeight ){ 217 | elementCss.height = rowHeight+'px'; 218 | } 219 | element.css(elementCss); 220 | } 221 | 222 | function makeNewScope (idx, colExpr, containerScope) { 223 | var childScope = containerScope.$new(), 224 | collection = containerScope.$eval(colExpr); 225 | childScope[ident.value] = collection[idx]; 226 | childScope.$index = idx; 227 | childScope.$first = (idx === 0); 228 | childScope.$last = (idx === (collection.length - 1)); 229 | childScope.$middle = !(childScope.$first || childScope.$last); 230 | childScope.$watch(function updateChildScopeItem(){ 231 | collection = containerScope.$eval(colExpr); 232 | childScope[ident.value] = collection[idx]; 233 | }); 234 | return childScope; 235 | } 236 | 237 | function addElements (start, end, colExpr, containerScope, insPoint) { 238 | var frag = document.createDocumentFragment(); 239 | var newElements = [], element, idx, childScope; 240 | for( idx = start; idx !== end; idx ++ ){ 241 | childScope = makeNewScope(idx, colExpr, containerScope); 242 | element = linker(childScope, angular.noop); 243 | setElementCss(element); 244 | newElements.push(element); 245 | frag.appendChild(element[0]); 246 | } 247 | insPoint.after(frag); 248 | return newElements; 249 | } 250 | 251 | function recomputeActive() { 252 | // We want to set the start to the low water mark unless the current 253 | // start is already between the low and high water marks. 254 | var start = clip(state.firstActive, state.firstVisible - state.lowWater, state.firstVisible - state.highWater); 255 | // Similarly for the end 256 | var end = clip(state.firstActive + state.active, 257 | state.firstVisible + state.visible + state.lowWater, 258 | state.firstVisible + state.visible + state.highWater ); 259 | state.firstActive = clip(start, 0, state.total - state.visible - state.lowWater); 260 | state.active = Math.min(end, state.total) - state.firstActive; 261 | } 262 | 263 | function sfVirtualRepeatOnScroll(evt){ 264 | if( !rowHeight ){ 265 | return; 266 | } 267 | // Enter the angular world for the state change to take effect. 268 | scope.$apply(function(){ 269 | state.firstVisible = Math.floor(evt.target.scrollTop / rowHeight); 270 | state.visible = Math.ceil(dom.viewport[0].clientHeight / rowHeight); 271 | $log.debug('scroll to row %o', state.firstVisible); 272 | scrolledToBottom = evt.target.scrollTop + evt.target.clientHeight >= evt.target.scrollHeight; 273 | recomputeActive(); 274 | $log.debug(' state is now %o', state); 275 | $log.debug(' scrolledToBottom = %o', scrolledToBottom); 276 | }); 277 | } 278 | 279 | function sfVirtualRepeatWatchExpression(scope){ 280 | var coll = scope.$eval(ident.collection); 281 | if( coll && coll.length !== state.total ){ 282 | state.total = coll.length; 283 | recomputeActive(); 284 | } 285 | return { 286 | start: state.firstActive, 287 | active: state.active, 288 | len: coll ? coll.length : 0 289 | }; 290 | } 291 | 292 | function destroyActiveElements (action, count) { 293 | var dead, ii, remover = Array.prototype[action]; 294 | for( ii = 0; ii < count; ii++ ){ 295 | dead = remover.call(rendered); 296 | dead.scope().$destroy(); 297 | dead.remove(); 298 | } 299 | } 300 | 301 | // When the watch expression for the repeat changes, we may need to add 302 | // and remove scopes and elements 303 | function sfVirtualRepeatListener(newValue, oldValue, scope){ 304 | var oldEnd = oldValue.start + oldValue.active, 305 | newElements; 306 | if( newValue === oldValue ){ 307 | $log.debug('initial listen'); 308 | newElements = addElements(newValue.start, oldEnd, ident.collection, scope, iterStartElement); 309 | rendered = newElements; 310 | if( rendered.length ){ 311 | rowHeight = computeRowHeight(newElements[0][0]); 312 | } 313 | }else{ 314 | var newEnd = newValue.start + newValue.active; 315 | var forward = newValue.start >= oldValue.start; 316 | var delta = forward ? newValue.start - oldValue.start 317 | : oldValue.start - newValue.start; 318 | var endDelta = newEnd >= oldEnd ? newEnd - oldEnd : oldEnd - newEnd; 319 | var contiguous = delta < (forward ? oldValue.active : newValue.active); 320 | $log.debug('change by %o,%o rows %s', delta, endDelta, forward ? 'forward' : 'backward'); 321 | if( !contiguous ){ 322 | $log.debug('non-contiguous change'); 323 | destroyActiveElements('pop', rendered.length); 324 | rendered = addElements(newValue.start, newEnd, ident.collection, scope, iterStartElement); 325 | }else{ 326 | if( forward ){ 327 | $log.debug('need to remove from the top'); 328 | destroyActiveElements('shift', delta); 329 | }else if( delta ){ 330 | $log.debug('need to add at the top'); 331 | newElements = addElements( 332 | newValue.start, 333 | oldValue.start, 334 | ident.collection, scope, iterStartElement); 335 | rendered = newElements.concat(rendered); 336 | } 337 | if( newEnd < oldEnd ){ 338 | $log.debug('need to remove from the bottom'); 339 | destroyActiveElements('pop', oldEnd - newEnd); 340 | }else if( endDelta ){ 341 | var lastElement = rendered[rendered.length-1]; 342 | $log.debug('need to add to the bottom'); 343 | newElements = addElements( 344 | oldEnd, 345 | newEnd, 346 | ident.collection, scope, lastElement); 347 | rendered = rendered.concat(newElements); 348 | } 349 | } 350 | if( !rowHeight && rendered.length ){ 351 | rowHeight = computeRowHeight(rendered[0][0]); 352 | } 353 | dom.content.css({'padding-top': newValue.start * rowHeight + 'px'}); 354 | } 355 | dom.content.css({'height': newValue.len * rowHeight + 'px'}); 356 | if( scrolledToBottom && stickyEnabled ){ 357 | dom.viewport[0].scrollTop = dom.viewport[0].clientHeight + dom.viewport[0].scrollHeight; 358 | } 359 | } 360 | } 361 | } 362 | }]); 363 | 364 | }()); 365 | 366 | --------------------------------------------------------------------------------