├── less ├── _base.less └── ui-table-view.less ├── .travis.yml ├── CHANGELOG.md ├── .gitignore ├── dist ├── ui-table-view.css ├── ui-table-view.min.js └── ui-table-view.js ├── bower.json ├── package.json ├── LICENSE ├── examples └── table-view │ ├── table-view.html │ └── TableViewCtrl.js ├── app.js ├── index.html ├── karma.conf.js ├── Gruntfile.js ├── ui-table-view.min.js ├── README.md ├── test └── ui-table-view.js └── src └── ui-table-view.js /less/_base.less: -------------------------------------------------------------------------------- 1 | @import url('ui-table-view.less'); -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.10 4 | before_script: 5 | - npm install -g bower 6 | - bower install -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Version numbers correspond to `bower.json` version 2 | 3 | # 1.0.0 4 | 5 | ## Features 6 | 7 | ## Bug Fixes 8 | 9 | ## Breaking Changes -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # generic (system) files/extensions we don't want 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | .idea/* 10 | *.DS_Store 11 | lib-cov 12 | pids 13 | logs 14 | results 15 | 16 | node_modules 17 | 18 | bower_components -------------------------------------------------------------------------------- /dist/ui-table-view.css: -------------------------------------------------------------------------------- 1 | /* mlz-ui-table-view */ 2 | mlz-ui-table-view { 3 | display: block; 4 | overflow: auto; 5 | -webkit-overflow-scrolling: touch; 6 | } 7 | mlz-ui-table-view .mlz-ui-table-view-wrapper { 8 | position: relative; 9 | } 10 | /* Enable scrollbars on Android (optional) */ 11 | ::-webkit-scrollbar { 12 | border-left: 1px solid #e5e5e5; 13 | height: 0; 14 | overflow: visible; 15 | width: 5px; 16 | } 17 | ::-webkit-scrollbar-thumb { 18 | background-color: rgba(0, 0, 0, 0.2); 19 | background-clip: padding-box; 20 | min-height: 28px; 21 | border-width: 1px 1px 1px 6px; 22 | width: 5px; 23 | } 24 | ::-webkit-scrollbar-corner { 25 | background: transparent; 26 | overflow: visible; 27 | } 28 | -------------------------------------------------------------------------------- /less/ui-table-view.less: -------------------------------------------------------------------------------- 1 | /* mlz-ui-table-view */ 2 | 3 | mlz-ui-table-view { 4 | display: block; 5 | overflow: auto; 6 | -webkit-overflow-scrolling: touch; 7 | 8 | .mlz-ui-table-view-wrapper { 9 | position: relative; 10 | } 11 | } 12 | 13 | /* Enable scrollbars on Android (optional) */ 14 | ::-webkit-scrollbar { 15 | border-left: 1px solid rgb(229, 229, 229); 16 | height: 0; 17 | overflow: visible; 18 | width: 5px; 19 | } 20 | ::-webkit-scrollbar-thumb { 21 | background-color: rgba(0, 0, 0, .2); 22 | background-clip: padding-box; 23 | min-height: 28px; 24 | border-width: 1px 1px 1px 6px; 25 | width: 5px; 26 | } 27 | ::-webkit-scrollbar-corner { 28 | background: transparent; 29 | overflow: visible; 30 | } 31 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-ui-table-view", 3 | "version": "0.0.0", 4 | "authors": [ 5 | "Jamie Sutherland " 6 | ], 7 | "description": "Angular UITableView component", 8 | "keywords": [], 9 | "license": "MIT", 10 | "ignore": [ 11 | "**/.*", 12 | "node_modules", 13 | "bower_components", 14 | "test", 15 | "tests" 16 | ], 17 | "main": [ 18 | "dist/ui-table-view.js", 19 | "dist/ui-table-view.css" 20 | ], 21 | "dependencies": { 22 | "angular": "~1.2.0", 23 | "angular-animate": "~1.2.0", 24 | "angular-sanitize": "~1.2.0", 25 | "angular-touch": "~1.2.0", 26 | "angular-route": "~1.2.0", 27 | "angular-mocks": "~1.2.0" 28 | }, 29 | "devDependencies": { 30 | "lodash": "~2.4.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "", 3 | "name": "angular-ui-table-view-demo", 4 | "version": "0.0.0", 5 | "description": "", 6 | "homepage": "", 7 | "dependencies": { 8 | "grunt": "~0.4.1", 9 | "grunt-contrib-concat": "~0.3.0", 10 | "grunt-contrib-less": "~0.8.1", 11 | "grunt-contrib-uglify": "~0.2.5", 12 | "grunt-contrib-cssmin": "~0.7.0", 13 | "grunt-contrib-jshint": "~0.7.0", 14 | "grunt-karma": "~0.6.2" 15 | }, 16 | "devDependencies": { 17 | "grunt-contrib-connect": "~0.6.0", 18 | "grunt-contrib-watch": "~0.5.3", 19 | "mocha": "~1.16.2", 20 | "karma": "~0.10", 21 | "karma-mocha": "~0.1.1", 22 | "karma-osx-reporter": "*", 23 | "karma-sinon": "~1.0.0", 24 | "karma-chai": "0.0.2", 25 | "karma-sinon-chai": "~0.1.4" 26 | }, 27 | "scripts": { 28 | "test": "./node_modules/.bin/karma start --single-run --browsers PhantomJS" 29 | }, 30 | "repository": "", 31 | "engines": { 32 | "node": "0.10.10" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 - Mallzee 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 NON INFRINGEMENT. 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. -------------------------------------------------------------------------------- /examples/table-view/table-view.html: -------------------------------------------------------------------------------- 1 |
2 |

UI Table View Demo

3 |

Items length: {{list.length}}

4 |
5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 |
13 | 14 |
15 | 16 | 17 |
18 |
19 |
20 | 21 | 22 |
23 |
24 |
25 |
26 |
-------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | /** 2 | @toc 3 | 1. setup - whitelist, appPath, html5Mode 4 | */ 5 | 6 | 'use strict'; 7 | 8 | angular.module('AngularUiTableView', [ 9 | 'ngRoute', 10 | 'ngSanitize', 11 | 'ngTouch', 12 | 'ngAnimate', 13 | 'restangular', 14 | //additional angular modules 15 | 'mallzee.ui-table-view' 16 | ]). 17 | config(['$routeProvider', '$locationProvider', '$compileProvider', 'RestangularProvider', function($routeProvider, $locationProvider, $compileProvider, RestangularProvider) { 18 | /** 19 | setup - whitelist, appPath, html5Mode 20 | @toc 1. 21 | */ 22 | $locationProvider.html5Mode(false); //can't use this with github pages / if don't have access to the server 23 | 24 | var staticPath = '/'; 25 | //var staticPath; 26 | //staticPath ='/angular-directives/angular-ui-table-view/'; //local 27 | // staticPath ='/angular-ui-table-view/'; //gh-pages 28 | var appPathRoute = '/'; 29 | var pagesPath = staticPath+'examples/'; 30 | 31 | 32 | $routeProvider.when(appPathRoute+'home', {templateUrl: 'examples/table-view/table-view.html'}); 33 | 34 | $routeProvider.otherwise({redirectTo: appPathRoute+'home'}); 35 | 36 | RestangularProvider.setBaseUrl('http://staging.api.mallzee.com'); 37 | RestangularProvider.setRestangularFields({ 38 | id: "_id" 39 | }); 40 | 41 | // Now let's configure the response extractor for each request 42 | RestangularProvider.setResponseExtractor(function(response) { 43 | if (response.records) { 44 | var newResponse = response.records; 45 | newResponse.originalElement = angular.copy(response); 46 | return newResponse; 47 | } 48 | return response; 49 | }); 50 | 51 | }]); -------------------------------------------------------------------------------- /examples/table-view/TableViewCtrl.js: -------------------------------------------------------------------------------- 1 | /** 2 | */ 3 | 4 | 'use strict'; 5 | 6 | angular.module('AngularUiTableView').controller('TableViewCtrl', ['$scope', 'Restangular', function($scope, Restangular) { 7 | 8 | $scope.list = []; 9 | $scope.view = { 10 | rows: 100, 11 | rowHeight: 100, 12 | columns: 1 13 | }; 14 | 15 | $scope.generateArray = function () { 16 | console.log('Generate array'); 17 | $scope.list.length = 0; 18 | for (var i = 0; i < 1000; i++) { 19 | $scope.list.push({ 20 | id: i, 21 | name: 'Name ' + i, 22 | detail: 'Detail ' + i 23 | }); 24 | } 25 | }; 26 | 27 | var products = Restangular.all('products'); 28 | 29 | var page = 0, loading = false; 30 | $scope.changeList = function () { 31 | console.log('Changing list'); 32 | if (loading) { 33 | return; 34 | } 35 | loading = true; 36 | products.getList({limit:50, page: page++}).then(function(data) { 37 | angular.forEach(data, function (item) { 38 | $scope.list.push(item); 39 | }); 40 | loading = false; 41 | }); 42 | }; 43 | 44 | $scope.updateOrder = function(id) { 45 | angular.forEach($scope.list, function (item) { 46 | if (item._id === id) { 47 | item.created_at = new Date().toISOString(); 48 | } 49 | }); 50 | }; 51 | 52 | $scope.deleteMe = function(index) { 53 | console.log('Delete Me', index, $scope.list.length); 54 | $scope.list.splice(index, 1); 55 | }; 56 | 57 | $scope.iCanHazDelete = function(item) { 58 | console.log('I HAZ DELETE', item); 59 | }; 60 | 61 | console.log('Items now has ' + $scope.list.length + ' element'); 62 | 63 | }]); -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | angular-ui-table-view 7 | 8 | 31 | 32 | 33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Karma configuration 4 | // Generated on Tue Jan 07 2014 16:18:07 GMT+0000 (GMT) 5 | 6 | module.exports = function(config) { 7 | config.set({ 8 | 9 | // base path, that will be used to resolve files and exclude 10 | basePath: '', 11 | 12 | 13 | // frameworks to use 14 | frameworks: ['mocha', 'chai', 'sinon'], 15 | 16 | 17 | // list of files / patterns to load in the browser 18 | files: [ 19 | 'bower_components/iscroll/build/iscroll-probe.js', 20 | 'bower_components/angular/angular.js', 21 | 'bower_components/angular-animate/angular-animate.js', 22 | 'bower_components/angular-mocks/angular-mocks.js', 23 | 'bower_components/lodash/dist/lodash.js', 24 | 'dist/ui-table-view.css', 25 | 'src/*.js', 26 | 'test/*.js' 27 | ], 28 | 29 | 30 | // list of files to exclude 31 | exclude: [ 32 | 33 | ], 34 | 35 | // test results reporter to use 36 | // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' 37 | reporters: ['dots', 'osx'], 38 | 39 | 40 | // web server port 41 | port: 9876, 42 | 43 | 44 | // enable / disable colors in the output (reporters and logs) 45 | colors: true, 46 | 47 | 48 | // level of logging 49 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 50 | logLevel: config.LOG_INFO, 51 | 52 | 53 | // enable / disable watching file and executing tests whenever any file changes 54 | autoWatch: true, 55 | 56 | 57 | // Start these browsers, currently available: 58 | // - Chrome 59 | // - ChromeCanary 60 | // - Firefox 61 | // - Opera (has to be installed with `npm install karma-opera-launcher`) 62 | // - Safari (only Mac; has to be installed with `npm install karma-safari-launcher`) 63 | // - PhantomJS 64 | // - IE (only Windows; has to be installed with `npm install karma-ie-launcher`) 65 | browsers: ['PhantomJS'], 66 | 67 | 68 | // If browser does not capture in given timeout [ms], kill it 69 | captureTimeout: 60000, 70 | 71 | 72 | // Continuous Integration mode 73 | // if true, it capture browsers, run tests and exit 74 | singleRun: false 75 | }); 76 | }; 77 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(grunt) { 4 | 5 | // configurable paths 6 | var yeomanConfig = { 7 | base: '.' 8 | }; 9 | 10 | grunt.loadNpmTasks('grunt-contrib-connect'); 11 | grunt.loadNpmTasks('grunt-contrib-watch'); 12 | grunt.loadNpmTasks('grunt-contrib-concat'); 13 | grunt.loadNpmTasks('grunt-contrib-less'); 14 | grunt.loadNpmTasks('grunt-contrib-uglify'); 15 | grunt.loadNpmTasks('grunt-contrib-cssmin'); 16 | grunt.loadNpmTasks('grunt-contrib-jshint'); 17 | grunt.loadNpmTasks('grunt-karma'); 18 | 19 | function init(params) { 20 | grunt.initConfig({ 21 | watch: { 22 | html: { 23 | files: ['pages/**', 'index.html'], 24 | options: { 25 | livereload: true 26 | } 27 | }, 28 | less: { 29 | files: ['*.less'], 30 | tasks: ['less:development'], 31 | options: { 32 | livereload: true 33 | } 34 | }, 35 | karma: { 36 | files: ['src/ui-table-view.js', 'test/ui-table-view.js'], 37 | tasks: ['karma:unit:run'] 38 | } 39 | }, 40 | connect: { 41 | server: { 42 | options: { 43 | port: 9001, 44 | base: yeomanConfig.base, 45 | // Change this to '0.0.0.0' to access the server from outside. 46 | hostname: '0.0.0.0' 47 | } 48 | } 49 | }, 50 | concat: { 51 | devCss: { 52 | src: [], 53 | dest: [] 54 | }, 55 | build: { 56 | src: ['src/ui-table-view.js'], 57 | dest: 'dist/ui-table-view.js' 58 | } 59 | }, 60 | jshint: { 61 | options: { 62 | //force: true, 63 | globalstrict: true, 64 | //sub: true, 65 | node: true, 66 | loopfunc: true, 67 | browser: true, 68 | devel: true, 69 | globals: { 70 | angular: false, 71 | $: false, 72 | moment: false, 73 | Pikaday: false, 74 | module: false, 75 | forge: false, 76 | _: false 77 | } 78 | }, 79 | beforeconcat: { 80 | options: { 81 | force: false, 82 | ignores: ['**.min.js'] 83 | }, 84 | files: { 85 | src: [] 86 | } 87 | }, 88 | //quick version - will not fail entire grunt process if there are lint errors 89 | beforeconcatQ: { 90 | options: { 91 | force: true, 92 | ignores: ['**.min.js'] 93 | }, 94 | files: { 95 | src: ['**.js'] 96 | } 97 | } 98 | }, 99 | uglify: { 100 | options: { 101 | mangle: false 102 | }, 103 | build: { 104 | files: {}, 105 | src: 'dist/ui-table-view.js', 106 | dest: 'dist/ui-table-view.min.js' 107 | } 108 | }, 109 | less: { 110 | development: { 111 | options: { 112 | }, 113 | files: { 114 | "dist/ui-table-view.css": "less/_base.less" 115 | } 116 | } 117 | }, 118 | karma: { 119 | unit: { 120 | configFile: 'karma.conf.js', 121 | background: true 122 | //browsers: ['Chrome'] 123 | }, 124 | continuous: { 125 | configFile: 'karma.conf.js', 126 | singleRun: true, 127 | browsers: ['PhantomJS'] 128 | } 129 | } 130 | }); 131 | 132 | // Default task(s). 133 | grunt.registerTask('default', ['concat:build', 'jshint:beforeconcatQ', 'less:development', 'uglify:build']); 134 | 135 | grunt.registerTask('continuous', ['default', 'karma:continuous']); 136 | 137 | grunt.registerTask('server', [ 138 | 'default', 139 | 'karma:unit:start', 140 | 'connect:server:livereload', 141 | 'watch' 142 | ]); 143 | 144 | } 145 | init({}); //initialize here for defaults (init may be called again later within a task) 146 | 147 | }; -------------------------------------------------------------------------------- /ui-table-view.min.js: -------------------------------------------------------------------------------- 1 | "use strict";function UITableView(scope,element,attr,$timeout){function render(){bufferedItems=element.children().children();for(var i=0;i=0;i--)if(void 0!==newItems[i]){decreaseBufferPointer(),newItems[i].top=tv.window.startPx-tv.row.height*j++,angular.extend(tv.buffer.items[tv.buffer.pointer],newItems[i]);var bufferedItem=angular.element(bufferedItems[tv.buffer.pointer]);bufferedItem&&bufferedItem.css("-webkit-transform","translateY("+newItems[i].top+"px)")}tv.window.startIndex-=tv.scroll.deltaIndex,tv.window.endIndex-=tv.scroll.deltaIndex,updateViewWindow()}function moveViewWindowDown(){for(var newItems=tv.allItems.slice(tv.window.endIndex+1,tv.window.endIndex+1+tv.scroll.deltaIndex),i=0;i=tv.buffer.size&&(tv.buffer.pointer=0)}function decreaseBufferPointer(){tv.buffer.pointer--,tv.buffer.pointer<0&&(tv.buffer.pointer=tv.buffer.size-1)}var BUFFER_SIZE=20,ROW_HEIGHT=40,ROW_WIDTH="100%",tv={},container=element,wrapper=angular.element(container.children()),bufferedItems=element.children().children();return container.css("overflow","auto"),container.addClass("mlz-ui-table-view"),wrapper.css("position","relative"),tv.container={height:container.attr("height")||container.prop("clientHeight"),width:container.attr("width")||container.prop("clientWidth")},tv.wrapper={height:0,width:tv.container.width||0},tv.row={height:+attr.mlzUiTableViewRowHeight||ROW_HEIGHT,width:+attr.mlzUiTableViewColumnWidth||ROW_WIDTH},tv.allItems=scope.$eval(attr.mlzUiTableView)||[],tv.scroll={_y:0,y:0,deltaY:0,index:0,_index:0,deltaIndex:0,direction:"down",height:0,width:0},tv.buffer={size:+attr.mlzUiTableViewBufferSize||BUFFER_SIZE,visible:0,items:angular.copy(bufferedItems),pointer:0},tv.items=tv.buffer.items,tv.window={startIndex:0,startPx:0,endIndex:tv.buffer.size-1||BUFFER_SIZE,endPx:tv.buffer.size*tv.row.height||BUFFER_SIZE*ROW_HEIGHT},tv.initialise=function(){tv.buffer.visible=Math.ceil(tv.container.height/tv.row.height)+1,render()},tv.setScrollPosition=function(y){tv.scroll.y=y,tv.scroll.deltaY=y-tv.scroll._y,tv.scroll._y=y,tv.scroll.index=Math.abs(Math.floor(y/tv.row.height)),tv.scroll.deltaIndex=Math.abs(tv.scroll.index-tv.scroll._index),tv.scroll._index=tv.scroll.index,tv.scroll.direction=tv.scroll.deltaY>=0?"down":"up",0!==tv.scroll.deltaIndex&&("down"===tv.scroll.direction?moveViewWindowDown():moveViewWindowUp())},tv.scrollToTop=function(){element.animate({scrollTop:0},"slow")},tv.updatePositions=function(items){tv.allItems=items,tv.wrapper.height=tv.allItems.length*tv.row.height,wrapper.css("height",tv.wrapper.height+"px"),angular.copy(tv.allItems.slice(tv.window.startIndex,tv.window.endIndex+1),tv.buffer.items);for(var position=tv.buffer.pointer,i=0;i=tv.buffer.items.length&&(position=0);$timeout(function(){render()})},tv}angular.module("mallzee.ui-table-view",[]).filter("slice",function(){return function(arr,start,end){return arr.slice(start,end)}}).directive("mlzUiTableView",["$window","$timeout",function($window,$timeout){return{restrict:"A",transclude:!1,scope:!0,link:function(scope,element,attributes){scope.tableView=new UITableView(scope,element,attributes,$timeout),scope.tableView.initialise(),scope.$watchCollection("tableView.allItems",function(items){scope.tableView.updatePositions(items)}),$window.addEventListener("statusTap",function(){scope.tableView.scrollToTop()}),element.on("scroll",function(){scope.$apply(function(){scope.tableView.setScrollPosition(element[0].scrollTop)})})}}}]); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [ ![Codeship Status for mallzee/angular-ui-table-view](https://www.codeship.io/projects/6e488550-7091-0131-b629-6a793a0a9a66/status?branch=master)](https://www.codeship.io/projects/13414) 3 | 4 | # AngularJS UITableView - WIP 5 | 6 | ### An AngularJS Directive to mimic iOS UITableView to give a fast unlimited length list if items on mobile using ng-repeat 7 | 8 | Scrolling on mobile is a pain. Infinite scrolling and large lists are a massive pain! Which is why there is no perfect solution out there, especially in the Angular world. So we developed our own. The core value of the project is to be as simple to utilise as possible while turning long lists of data into seamless, jank free, scrolling lists on mobile. Which in turn means they run shit hot on the desktop too! 9 | 10 | ## Demo 11 | http://angular-ui-table-view.mallzee.com 12 | 13 | ## How do we get this beast running? 14 | 15 | If you are using bower, which we highly recommend, Just run the following. 16 | 17 | bower install angular-ui-table-view --save-dev 18 | 19 | Add the required files to your projects `index.html` file 20 | 21 | ```HTML 22 | 23 | 24 | ``` 25 | 26 | Add the `mallzee.ui-table-view` module to your application 27 | 28 | angular.module('myApp', ['mallzee.ui-table-view']); 29 | 30 | Use the following directive to turn your regular joe lists into super performant lists. 31 | 32 | Here's some sample markup to turn your list into a super list 33 | ```HTML 34 | 35 |
36 |
37 |
38 |
39 |
40 | ``` 41 | 42 | # How does it work? 43 | 44 | The mlz-ui-table-view directive watches over your big list of items. It creates a subset of the items based the viewport size. You can override the calculated values with a view object. It then injects the correct data from your full list into the correct DOM elements and moves them into position to create the illusion of a stream of items. This is required when displaying large lists to avoid killing the performance of your app, or crashing it all together. 45 | 46 | # Why is this different 47 | 48 | A lot of solutions out there rely on keeping DOM elements to a minimum, but create and destroy them as is necessary, which is expensive. Here, DOM elements are limited to the buffer size and are only ever destroyed or created after initialisation when the list becomes smaller than the buffer size, or grows towards the buffer limit. This is what makes the list highly performant. They are moved into the correct place in the list using 3d transforms based on the item index and elements scope is injected with the correct information from the larger array. 49 | 50 | # Attributes 51 | 52 | The following attributes are supported by mlz-ui-table-view 53 | 54 | ###list 55 | The array given to the list to enhance. This will be monitored for changes so that the heights of the wrapper can be adjusted if items are added or removed from this array. 56 | 57 | If you are removing items from the current items in view. You should make use of the provided directive functions deleteItem($index). This allows the table to quickly workout how to redraw the table. 58 | 59 | ###item-name 60 | By default, the list will copy the given view and inject in a scope with the correct item from the big list. This is given the scope property name of `item` by default. You can rename this to anything you want if item doesn't suit. Think of this as the equivalent to item in this ng-repeat expression `item in items` 61 | 62 | ###view-params 63 | Eventually the view will do it's best to calculate the view parameters. Until then you have to specify these parameters in a view object. This object is watched, so the view can be updated by manipulating this object. 64 | 65 | * **rows** - Default: 10 - This is the number of elements you are going to keep in the buffer. Generally this is the containers height / row height + 1 66 | * **columns** - Default: 1 - This table view will handle columns of items. 67 | * **rowHeight** - Default: 100 - Specify the row height so the items can be positioned correctly. 68 | * **triggerDistance** - Default: 0 - If you want to trigger the edge before hitting it, set the number of items before the edge you want to concider the trigger zone. A value of zero means when the edge is touched. (i.e. a value of three means when the third item from the edge comes into view, the trigger zone for that edge will be triggered) 69 | 70 | *Example object* 71 | 72 | ``` 73 | var view = { 74 | rows: 10, 75 | rowHeight: 100, 76 | columns: 1, 77 | triggerDistance: 0 78 | } 79 | ``` 80 | 81 | ###trigger-top 82 | ###trigger-bottom 83 | Functions that will be called when the trigger zone is entered at the top or bottom of the list based on the views `triggerDistance` parameter 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /dist/ui-table-view.min.js: -------------------------------------------------------------------------------- 1 | !function(window,angular,undefined){"use strict";function getBlockElements(nodes){var startNode=nodes[0],endNode=nodes[nodes.length-1];if(startNode===endNode)return angular.element(startNode);var element=startNode,elements=[element];do{if(element=element.nextSibling,!element)break;elements.push(element)}while(element!==endNode);return angular.element(elements)}function getItemElement(nodes){var startNode=nodes[0],endNode=nodes[nodes.length-1];if(startNode===endNode)return angular.element(startNode);var element=startNode;do{if(element.classList&&element.classList.contains("mlz-ui-table-view-item"))return angular.element(element);element=element.nextSibling}while(element!==endNode);return!1}var move=function(arr,old_index,new_index){if(new_index>=arr.length)for(var k=new_index-arr.length;k--+1;)arr.push(undefined);return arr.splice(new_index,0,arr.splice(old_index,1)[0]),arr};window.requestAnimFrame=function(){return window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||function(callback){window.setTimeout(callback,1e3/60)}}(),angular.module("mallzee.ui-table-view",["ngAnimate"]).directive("mlzUiTableView",["$window","$timeout","$log","$animate",function($window,$timeout,$log,$animate){return{restrict:"E",transclude:!0,terminal:!0,priority:1e4,$$tlb:!0,replace:!1,template:'
',link:function(scope,element,attributes,ctrl,$transclude){function update(){setScrollPosition(y),updating=!1}function initialise(scope,attributes){element.css({display:"block",overflow:"auto"}),wrapper.el.css({position:"relative"}),_scroll=angular.copy(scroll),_view=angular.copy(view),_buffer=angular.copy(buffer),attributes.itemName&&(itemName=attributes.itemName),attributes.viewParams&&scope.$watch(attributes.viewParams,function(view){row.height=view.rowHeight||ROW_HEIGHT,columns=view.columns||COLUMNS,buffer.rows=view.rows||BUFFER_ROWS,buffer.size=buffer.rows*columns,refresh()},!0),attributes.triggerTop&&(triggerTop=function(){scope.$eval(attributes.triggerTop)}),attributes.triggerBottom&&(triggerBottom=function(){scope.$eval(attributes.triggerBottom)}),$window.addEventListener("statusTap",function(){scrollToTop()}),calculateContainer()}function refresh(){updateBufferModel(),generateBufferedItems(),calculateDimensions(),updateViewModel(),triggerEdge()}function cloneElement(clone){clone.addClass("mlz-ui-table-view-item"),clone[clone.length++]=document.createComment(" end mlzTableViewItem: "+attributes.list+" "),$animate.enter(clone,wrapper.el)}function updateItem(elIndex,item,coords,index){buffer.elements[elIndex].scope[itemName]=item,buffer.elements[elIndex].scope.$coords=coords,buffer.elements[elIndex].scope.$index=index}function destroyItem(index){var elementsToRemove=getBlockElements(buffer.elements[index].clone);$animate.leave(elementsToRemove),buffer.elements[index].scope.$destroy(),buffer.elements.splice(index,1)}function generateBufferedItems(){if(angular.copy(list.slice(itemIndexFromRow(buffer.top),itemIndexFromRow(buffer.bottom)),items),buffer.elements.length>buffer.size)for(var elementsLength=buffer.elements.length,i=elementsLength-1;i>=buffer.size;i--)destroyItem(i);for(var p,x,y,e,found,r=buffer.top-1,i=0;i=items.length)buffer.elements[i]&&destroyItem(i);else{if(found=!1,e=itemIndexFromRow(buffer.top)+i,p=getRelativeBufferPosition(e),p%columns===0?r++:null,x=p%columns*(container.width/columns),y=r*row.height,buffer.elements[p]&&angular.equals(list[e],buffer.elements[p].scope[itemName])){buffer.elements[p].scope.$coords={x:x,y:y},repositionElement(buffer.elements[p]);continue}if(buffer.elements[p]){for(var k=p;k=list.length/columns&&(buffer.bottom=list.length/columns,buffer.top=buffer.bottom-buffer.size>0?buffer.bottom-buffer.size:0),buffer.yTop=buffer.top*row.height,buffer.yBottom=buffer.bottom*row.height,buffer.atEdge=buffer.top<=0?EDGE_TOP:buffer.bottom>=wrapper.rows?EDGE_BOTTOM:!1}function isRenderRequired(){return scroll.direction===SCROLL_UP&&buffer.atEdge!==EDGE_TOP&&view.deadZone===!1&&(scroll.directionChange||view.ytChange)||scroll.direction===SCROLL_DOWN&&buffer.atEdge!==EDGE_BOTTOM&&view.deadZone===!1&&(scroll.directionChange||view.ytChange)||!(view.deadZone!==!1&&view.deadZoneChange===!1)}function isTriggerRequired(){return view.triggerZone!==!1&&view.triggerZoneChange}function updateScrollModel(y){scroll.y=y,scroll.yDelta=y-_scroll.y,scroll.row=Math.floor(y/row.height),scroll.row<0&&(scroll.row=0),scroll.row>=wrapper.rows&&(scroll.row=wrapper.rows-1),scroll.yDistance=Math.abs(scroll.row-_scroll.row),scroll.yChange=scroll.yDistance>0,scroll.direction=scroll.yDelta>=0?SCROLL_DOWN:SCROLL_UP,scroll.directionChange=scroll.direction!==_scroll.direction,buffer.reset=scroll.yDistance>buffer.rows}function updateViewModel(){view.yTop=scroll.y,view.yBottom=scroll.y+container.height,view.top=scroll.row,view.bottom=Math.floor(view.yBottom/row.height),view.atEdge=!(view.top>0&&view.bottom(list.length/columns-trigger.distance-1)*row.height?EDGE_BOTTOM:view.yTop(list.length-1)*row.height?EDGE_BOTTOM:!1,view.deadZoneChange=view.deadZone!==_view.deadZone,view.ytChange=view.top!==_view.top,view.ybChange=view.bottom!==_view.bottom}function setBufferToIndex(index){buffer.top=index,buffer.bottom=buffer.top+buffer.rows,validateBuffer()}function updateBufferModel(){var index=scroll.row,direction=scroll.direction,distance=buffer.distance;switch(direction){case SCROLL_UP:buffer.top=index-distance,buffer.bottom=index-distance+buffer.rows;break;case SCROLL_DOWN:buffer.top=index,buffer.bottom=index+buffer.rows;break;default:$log.warn("We only know how to deal with scrolling on the y axis for now")}validateBuffer()}function scrollingUp(start,distance){for(var p,x,y,itemsToMerge=list.slice(start*columns,start*columns+distance),r=start+distance/columns-1,updates=[],i=itemsToMerge.length-1;i>=0;i--){p=getRelativeBufferPosition(start*columns+i),x=p%columns*(container.width/columns),y=r*row.height;var coords={x:x,y:y},index=start*columns+i;updates.push(p),updateItem(p,itemsToMerge[i],coords,index),p%columns===0?r--:null}requestAnimFrame(function(){scope.$apply(function(){for(var i=0;i0?buffer.rows-view.rows:0}function clearElements(){for(var i=0;i ' 22 | + '
' 23 | + '
' 24 | + '
' 25 | + '
' 26 | + ''; 27 | 28 | beforeEach(module("mallzee.ui-table-view")); 29 | 30 | function generateList(array, length) { 31 | for (var i = 0; i < length; i++) { 32 | array.push({ 33 | id: i, 34 | name: 'Name ' + i, 35 | detail: 'Detail ' + i 36 | }); 37 | } 38 | } 39 | function initialiseWithListSet () { 40 | 41 | inject(function ($compile, $rootScope, $document, $timeout) { 42 | var $scope = $rootScope.$new(); 43 | 44 | $scope.view = { 45 | rows: bufferSize, 46 | rowHeight: rowHeight, 47 | columns: 1 48 | }; 49 | 50 | $scope.list = []; 51 | 52 | generateList($scope.list, numberOfItems); 53 | 54 | $scope.bottomTrigger = function () { }; 55 | $scope.topTrigger = function () { }; 56 | 57 | $scope.deleteItem = function(index) { 58 | $scope.list.splice(index, 1); 59 | }; 60 | 61 | document = $document; 62 | 63 | element = angular.element(html); 64 | element = $compile(element)($scope); 65 | 66 | scope = element.scope(); 67 | timeout = $timeout; 68 | 69 | scope.$digest(); 70 | 71 | $document.find('body').append(element); 72 | 73 | container = element; 74 | wrapper = element.children(); 75 | 76 | elements = element.children().children(); 77 | clock = sinon.useFakeTimers(); 78 | 79 | }); 80 | } 81 | 82 | function scrollTo(y) { 83 | container.prop('scrollTop', y).triggerHandler('scroll'); 84 | clock.tick(17); 85 | } 86 | 87 | 88 | function scrollToIndex(index) { 89 | scrollTo(index * rowHeight); 90 | } 91 | 92 | function getElementIndexFromListIndex(index) { 93 | return index % elements.length; 94 | } 95 | 96 | /** 97 | * Helper function to check all the elements are inline 98 | * @param index 99 | */ 100 | function checkElementsStartingFrom(index) { 101 | for (var i = 0; i < elements.length; i++) { 102 | var pos = getElementIndexFromListIndex(index); 103 | var el = angular.element(elements[pos]); 104 | expect(el.css('-webkit-transform'), 'transform element' + pos).to.equal('translate3d(0px, ' + index * rowHeight + 'px, 0px)'); 105 | index++; 106 | } 107 | } 108 | 109 | function cleanUp() { 110 | document.find('mlz-ui-table-view').empty(); 111 | clock.restore(); 112 | } 113 | 114 | describe('initialisation with array', function () { 115 | 116 | beforeEach(function () { 117 | initialiseWithListSet(); 118 | }); 119 | 120 | it('should have ' + numberOfItems + ' items', function () { 121 | expect(scope.list.length).to.equal(numberOfItems); 122 | }); 123 | 124 | it('should have buffered ' + bufferSize + ' elements', function () { 125 | expect(elements.length, 'Elements length').to.equal(10); 126 | }); 127 | 128 | it('should have the correct element the top level', function () { 129 | expect(container.prop('tagName')).to.equal('MLZ-UI-TABLE-VIEW'); 130 | }); 131 | 132 | describe('dimensions', function () { 133 | it('should have the correct container height and width', function () { 134 | expect(container.prop('clientHeight')).to.equal(480); 135 | // TODO: Workout why PhantomJS gets this wrong 136 | //expect(container.prop('clientWidth')).to.equal(320); 137 | }); 138 | 139 | it('should have the correct wrapper classes and properties', function () { 140 | expect(wrapper.hasClass('mlz-ui-table-view-wrapper')).to.be.true; 141 | //expect(wrapper.css('position')).to.equal('relative'); 142 | }); 143 | 144 | it('should have the correct calculated wrapper height', function () { 145 | expect(wrapper.prop('clientHeight')).to.equal(rowHeight * scope.list.length); 146 | }); 147 | 148 | it('should have an elements of the correct size', function () { 149 | expect(elements.prop('clientHeight')).to.equal(100); 150 | // TODO: Workout why PhantomJS gets this wrong 151 | //expect(elements.prop('clientWidth')).to.equal(320); 152 | }); 153 | }); 154 | }); 155 | 156 | 157 | describe('scrolling', function () { 158 | 159 | describe('down by one item', function () { 160 | 161 | beforeEach(function () { 162 | initialiseWithListSet(); 163 | scrollTo(100); 164 | }); 165 | 166 | afterEach(function () { 167 | cleanUp(); 168 | }); 169 | 170 | it('should update the scroll model', function () { 171 | expect(container.prop('scrollTop'), 'scroll top').to.equal(100); 172 | }); 173 | 174 | it('should have elements in the correct positions', function () { 175 | checkElementsStartingFrom(1); 176 | }); 177 | 178 | }); 179 | 180 | describe('down by ten items', function () { 181 | 182 | beforeEach(function () { 183 | initialiseWithListSet(); 184 | scrollToIndex(10); 185 | }); 186 | 187 | afterEach(function () { 188 | cleanUp(); 189 | }); 190 | 191 | it('should update the scroll model', function () { 192 | expect(container.prop('scrollTop'), 'scroll top').to.equal(1000); 193 | }); 194 | 195 | it('should have elements in the correct positions', function () { 196 | checkElementsStartingFrom(10); 197 | }); 198 | }); 199 | 200 | describe('down by a hundred items', function () { 201 | 202 | beforeEach(function () { 203 | initialiseWithListSet(); 204 | scrollToIndex(100); 205 | }); 206 | 207 | afterEach(function () { 208 | cleanUp(); 209 | }); 210 | 211 | it('should update the scroll model', function () { 212 | expect(container.prop('scrollTop'), 'scroll top').to.equal(10000); 213 | }); 214 | 215 | it('should have elements in the correct positions', function () { 216 | checkElementsStartingFrom(100); 217 | }); 218 | }); 219 | 220 | describe('down by a lot of items to trigger the bottom edge', function () { 221 | 222 | beforeEach(function () { 223 | initialiseWithListSet(); 224 | scrollToIndex(994); 225 | }); 226 | 227 | afterEach(function () { 228 | cleanUp(); 229 | }); 230 | 231 | it('should update the scroll model', function () { 232 | expect(container.prop('scrollTop'), 'scroll top').to.equal(99400); 233 | }); 234 | 235 | it('should have elements in the correct positions', function () { 236 | checkElementsStartingFrom(990); 237 | }); 238 | 239 | }); 240 | 241 | describe('up by one item', function () { 242 | 243 | beforeEach(function () { 244 | initialiseWithListSet(); 245 | scrollToIndex(2); 246 | scrollToIndex(0); 247 | }); 248 | 249 | afterEach(function () { 250 | cleanUp(); 251 | }); 252 | 253 | it('should update the scroll model', function () { 254 | expect(container.prop('scrollTop'), 'scroll top').to.equal(0); 255 | }); 256 | 257 | it('should have elements in the correct positions', function () { 258 | checkElementsStartingFrom(0); 259 | }); 260 | 261 | }); 262 | 263 | describe('down three items, up two', function () { 264 | 265 | beforeEach(function () { 266 | initialiseWithListSet(); 267 | scrollTo(320); 268 | scrollToIndex(3); 269 | scrollToIndex(1); 270 | }); 271 | 272 | afterEach(function () { 273 | cleanUp(); 274 | }); 275 | 276 | it('should update the scroll model', function () { 277 | expect(container.prop('scrollTop'), 'scroll top').to.equal(100); 278 | }); 279 | 280 | it('should have elements in the correct positions', function () { 281 | checkElementsStartingFrom(0); 282 | }); 283 | }); 284 | 285 | xdescribe('down three items, up two, direction change down', function () { 286 | 287 | beforeEach(function () { 288 | initialiseWithListSet(); 289 | scrollTo(320); 290 | scrollTo(300); 291 | scrollTo(100); 292 | scrollTo(110); 293 | }); 294 | 295 | afterEach(function () { 296 | cleanUp(); 297 | }); 298 | 299 | it('should update the scroll model', function () { 300 | expect(container.prop('scrollTop'), 'scroll top').to.equal(0); 301 | }); 302 | 303 | it('should have elements in the correct positions', function () { 304 | checkElementsStartingFrom(1); 305 | }); 306 | 307 | }); 308 | 309 | describe('up by ten items', function () { 310 | 311 | beforeEach(function () { 312 | initialiseWithListSet(); 313 | scrollToIndex(10); 314 | scrollToIndex(0); 315 | }); 316 | 317 | afterEach(function () { 318 | cleanUp(); 319 | }); 320 | 321 | it('should update the scroll model', function () { 322 | expect(container.prop('scrollTop'), 'scroll top').to.equal(0); 323 | }); 324 | 325 | it('should have elements in the correct positions', function () { 326 | checkElementsStartingFrom(0); 327 | }); 328 | 329 | }); 330 | 331 | describe('down eleven items, up one', function () { 332 | 333 | beforeEach(function () { 334 | initialiseWithListSet(); 335 | scrollToIndex(11); 336 | scrollToIndex(10); 337 | }); 338 | 339 | afterEach(function () { 340 | cleanUp(); 341 | }); 342 | 343 | it('should update the scroll model', function () { 344 | expect(container.prop('scrollTop'), 'scroll top').to.equal(1000); 345 | }); 346 | 347 | it('should have elements in the correct positions', function () { 348 | // TODO: Fix this check 349 | //checkElementsStartingFrom(6); 350 | }); 351 | 352 | 353 | 354 | /*it('should have updated buffered items', function () { 355 | expect(tv.buffer.items[6].id).to.equal(6); 356 | expect(tv.buffer.items[6].$$top).to.equal(600); 357 | expect(tv.buffer.items[7].id).to.equal(7); 358 | expect(tv.buffer.items[7].$$top).to.equal(700); 359 | expect(tv.buffer.items[8].id).to.equal(8); 360 | expect(tv.buffer.items[8].$$top).to.equal(800); 361 | expect(tv.buffer.items[9].id).to.equal(9); 362 | expect(tv.buffer.items[9].$$top).to.equal(900); 363 | expect(tv.buffer.items[0].id).to.equal(10); 364 | expect(tv.buffer.items[0].$$top).to.equal(1000); 365 | expect(tv.buffer.items[1].id).to.equal(11); 366 | expect(tv.buffer.items[1].$$top).to.equal(1100); 367 | expect(tv.buffer.items[2].id).to.equal(12); 368 | expect(tv.buffer.items[2].$$top).to.equal(1200); 369 | expect(tv.buffer.items[3].id).to.equal(13); 370 | expect(tv.buffer.items[3].$$top).to.equal(1300); 371 | expect(tv.buffer.items[4].id).to.equal(14); 372 | expect(tv.buffer.items[4].$$top).to.equal(1400); 373 | expect(tv.buffer.items[5].id).to.equal(15); 374 | expect(tv.buffer.items[5].$$top).to.equal(1500); 375 | });*/ 376 | 377 | }); 378 | 379 | describe('down up down no index change', function () { 380 | beforeEach(function () { 381 | initialiseWithListSet(); 382 | scrollTo(10); 383 | scrollTo(0); 384 | scrollTo(10); 385 | }); 386 | 387 | it('should update the scroll model', function () { 388 | expect(container.prop('scrollTop'), 'scroll top').to.equal(10); 389 | }); 390 | 391 | it('should have elements in the correct positions', function () { 392 | checkElementsStartingFrom(0); 393 | }); 394 | 395 | }); 396 | 397 | describe('down up down', function () { 398 | beforeEach(function () { 399 | initialiseWithListSet(); 400 | scrollToIndex(1); 401 | scrollToIndex(0); 402 | scrollToIndex(1); 403 | scrollToIndex(0); 404 | scrollToIndex(1); 405 | }); 406 | 407 | 408 | it('should update the scroll model', function () { 409 | expect(container.prop('scrollTop'), 'scroll top').to.equal(100); 410 | }); 411 | 412 | it('should have elements in the correct positions', function () { 413 | checkElementsStartingFrom(1); 414 | }); 415 | 416 | }); 417 | 418 | describe('down 10 up 1 down 1', function () { 419 | beforeEach(function () { 420 | initialiseWithListSet(); 421 | scrollToIndex(10); 422 | scrollToIndex(9); 423 | scrollToIndex(10); 424 | scrollToIndex(7); 425 | scrollToIndex(10); 426 | scrollToIndex(9); 427 | scrollToIndex(10); 428 | }); 429 | 430 | it('should update the scroll model', function () { 431 | expect(container.prop('scrollTop'), 'scroll top').to.equal(1000); 432 | }); 433 | 434 | it('should have elements in the correct positions', function () { 435 | checkElementsStartingFrom(10); 436 | }); 437 | 438 | }); 439 | 440 | describe('overscroll up down', function () { 441 | beforeEach(function () { 442 | initialiseWithListSet(); 443 | scrollTo(-90); 444 | scrollTo(-50); 445 | }); 446 | 447 | it('should update the scroll model', function () { 448 | expect(container.prop('scrollTop'), 'scroll top').to.equal(0); 449 | }); 450 | 451 | it('should have elements in the correct positions', function () { 452 | checkElementsStartingFrom(0); 453 | }); 454 | 455 | }); 456 | 457 | // TODO: Workout why these spys aren't working 458 | // Something to do with the funky way these are triggered I think 459 | xdescribe('into trigger zones', function () { 460 | 461 | it('should trigger the bottom zone', function () { 462 | var spy = sinon.spy(scope, 'bottomTrigger'); 463 | initialiseWithListSet(); 464 | 465 | scrollToIndex(996); 466 | expect(spy.calledOnce, 'bottomTrigger').to.be.true; 467 | }); 468 | 469 | it('should trigger the upper zone', function () { 470 | 471 | var spy = sinon.spy(scope, 'topTrigger'); 472 | initialiseWithListSet(); 473 | 474 | scrollToIndex(996); 475 | scrollToIndex(0); 476 | expect(spy.calledOnce, 'topTrigger').to.be.true; 477 | }); 478 | }); 479 | 480 | }); 481 | 482 | describe('changing list', function () { 483 | 484 | beforeEach(function () { 485 | initialiseWithListSet(); 486 | }); 487 | 488 | it('should have a new list with 500 list items', function () { 489 | var newList = []; 490 | generateList(newList, 500); 491 | 492 | scope.list = newList; 493 | scope.$digest(); 494 | 495 | expect(wrapper.prop('clientHeight')).to.equal(50000); 496 | }); 497 | }); 498 | 499 | describe('deleting items', function () { 500 | 501 | beforeEach(function () { 502 | initialiseWithListSet(); 503 | }); 504 | 505 | it('should remove an item', function () { 506 | scope.deleteItem(0); 507 | scope.$digest(); 508 | 509 | expect(scope.list.length).to.equal(999); 510 | expect(wrapper.prop('clientHeight')).to.equal(99900); 511 | expect(scope.list[0].id, 'List item id 0').to.equal(1); 512 | /*expect(scope.items[0].id, 'Buffered Items 0').to.equal(1); 513 | expect(scope.items[0].$$top).to.equal(0); 514 | expect(scope.items[1].id).to.equal(2); 515 | expect(scope.items[1].$$top).to.equal(100); 516 | expect(scope.items[2].id).to.equal(3); 517 | expect(scope.items[2].$$top).to.equal(200); 518 | expect(scope.items[3].id).to.equal(4); 519 | expect(scope.items[3].$$top).to.equal(300); 520 | expect(scope.items[4].id).to.equal(5); 521 | expect(scope.items[4].$$top).to.equal(400); 522 | expect(scope.items[5].id).to.equal(6); 523 | expect(scope.items[5].$$top).to.equal(500); 524 | expect(scope.items[6].id).to.equal(7); 525 | expect(scope.items[6].$$top).to.equal(600); 526 | expect(scope.items[7].id).to.equal(8); 527 | expect(scope.items[7].$$top).to.equal(700); 528 | expect(scope.items[8].id).to.equal(9); 529 | expect(scope.items[8].$$top).to.equal(800); 530 | expect(scope.items[9].id).to.equal(10); 531 | expect(scope.items[9].$$top).to.equal(900);*/ 532 | 533 | }); 534 | 535 | 536 | it('should remove an item from the bottom', function () { 537 | scrollToIndex(990); 538 | scope.deleteItem(999); 539 | scope.$digest(); 540 | 541 | expect(scope.list.length).to.equal(999); 542 | expect(wrapper.prop('clientHeight')).to.equal(99900); 543 | /*expect(scope.list[0].id).to.equal(900); 544 | expect(scope.items[0].id).to.equal(990); 545 | expect(scope.items[0].$$top).to.equal(99000); 546 | expect(scope.items[1].id).to.equal(991); 547 | expect(scope.items[1].$$top).to.equal(99100); 548 | expect(scope.items[2].id).to.equal(992); 549 | expect(scope.items[2].$$top).to.equal(99200); 550 | expect(scope.items[3].id).to.equal(993); 551 | expect(scope.items[3].$$top).to.equal(99300); 552 | expect(scope.items[4].id).to.equal(994); 553 | expect(scope.items[4].$$top).to.equal(99400); 554 | expect(scope.items[5].id).to.equal(995); 555 | expect(scope.items[5].$$top).to.equal(99500); 556 | expect(scope.items[6].id).to.equal(996); 557 | expect(scope.items[6].$$top).to.equal(99600); 558 | expect(scope.items[7].id).to.equal(997); 559 | expect(scope.items[7].$$top).to.equal(99700); 560 | expect(scope.items[8].id).to.equal(998); 561 | expect(scope.items[8].$$top).to.equal(99800); 562 | expect(scope.items[9].id).to.equal(989); 563 | expect(scope.items[9].$$top).to.equal(98900);*/ 564 | 565 | }); 566 | }); 567 | 568 | }); -------------------------------------------------------------------------------- /dist/ui-table-view.js: -------------------------------------------------------------------------------- 1 | /** 2 | * UITableView 3 | */ 4 | 5 | (function (window, angular, undefined) { 6 | 'use strict'; 7 | 8 | var move = function (arr, old_index, new_index) { 9 | if (new_index >= arr.length) { 10 | var k = new_index - arr.length; 11 | while ((k--) + 1) { 12 | arr.push(undefined); 13 | } 14 | } 15 | arr.splice(new_index, 0, arr.splice(old_index, 1)[0]); 16 | return arr; // for testing purposes 17 | }; 18 | 19 | 20 | /** 21 | * Return the DOM siblings between the first and last node in the given array. 22 | * @param {Array} array like object 23 | * @returns {DOMElement} object containing the elements 24 | */ 25 | function getBlockElements(nodes) { 26 | var startNode = nodes[0], 27 | endNode = nodes[nodes.length - 1]; 28 | if (startNode === endNode) { 29 | return angular.element(startNode); 30 | } 31 | 32 | var element = startNode; 33 | var elements = [element]; 34 | 35 | do { 36 | element = element.nextSibling; 37 | if (!element) break; 38 | elements.push(element); 39 | } while (element !== endNode); 40 | 41 | return angular.element(elements); 42 | } 43 | 44 | function getItemElement(nodes) { 45 | var startNode = nodes[0], 46 | endNode = nodes[nodes.length - 1]; 47 | 48 | if (startNode === endNode) { 49 | return angular.element(startNode); 50 | } 51 | 52 | var element = startNode; 53 | 54 | do { 55 | if (element.classList && element.classList.contains('mlz-ui-table-view-item')) { 56 | return angular.element(element); 57 | } 58 | element = element.nextSibling; 59 | } while (element !== endNode); 60 | 61 | return false; 62 | } 63 | 64 | window.requestAnimFrame = (function(){ 65 | return window.requestAnimationFrame || 66 | window.webkitRequestAnimationFrame || 67 | window.mozRequestAnimationFrame || 68 | function( callback ){ 69 | window.setTimeout(callback, 1000 / 60); 70 | }; 71 | })(); 72 | 73 | /** 74 | * TODO: Add in some docs on how to use this angular module and publish them on GitHub pages. 75 | */ 76 | angular.module('mallzee.ui-table-view', ['ngAnimate']) 77 | .directive('mlzUiTableView', ['$window', '$timeout', '$log', '$animate', function ($window, $timeout, $log, $animate) { 78 | return { 79 | restrict: 'E', 80 | transclude: true, 81 | terminal: true, 82 | priority: 10000, 83 | $$tlb: true, 84 | replace: false, 85 | template: '
', 86 | link: function (scope, element, attributes, ctrl, $transclude) { 87 | 88 | var BUFFER_ROWS = 20, 89 | COLUMNS = 1, 90 | ROW_HEIGHT = 40, 91 | ROW_WIDTH = '100%', 92 | EDGE_TOP = 'top', 93 | EDGE_BOTTOM = 'bottom', 94 | SCROLL_UP = 'up', 95 | SCROLL_DOWN = 'down', 96 | TRIGGER_DISTANCE = 1; 97 | 98 | var list = [], items = [], itemName = 'item', 99 | 100 | model = { 101 | 102 | }, 103 | // Model the main container for the view table 104 | container = { 105 | height: 0, 106 | width: 0, 107 | el: undefined 108 | }, 109 | 110 | // Model the wrapper that will hold the buffer and be scrolled by the container 111 | wrapper = { 112 | height: 0, 113 | width: 0, 114 | rows: 0, 115 | el: undefined 116 | }, 117 | 118 | elements, 119 | 120 | // Model a row 121 | row = { 122 | height: ROW_HEIGHT, 123 | width: ROW_WIDTH 124 | }, 125 | 126 | columns = COLUMNS, 127 | 128 | trigger = { 129 | distance: TRIGGER_DISTANCE 130 | }, 131 | 132 | // Information about the scroll status 133 | // _ indicates the value of that item on the previous tick 134 | scroll = { 135 | // X-Axis 136 | x: 0, //TODO: Support x axis 137 | xDelta: 0, //TODO: Support x axis 138 | xIndex: 0, //TODO: Support x axis 139 | xDistance: 0, 140 | xChange: false, 141 | 142 | //Only dealing with Y for just now 143 | // Y-Axis 144 | y: 0, 145 | yDelta: 0, 146 | yDistance: 0, 147 | yChange: false, // Marks when we have scrolled to a new index 148 | row: 0, // Which row we are currently on 149 | 150 | // Track which item we are on 151 | // topIndex: 0, // Mark the topIndex we are heading for 152 | // _topIndex: 0, // Last ticks topIndex so we can get the delta 153 | 154 | // topIndexDelta: 0, // How many indexes have we moved this tick? 155 | 156 | // bottomIndex: 0, 157 | // _bottomIndex: 0, 158 | // bottomIndexDelta: 0, 159 | 160 | pointer: 0, // Mark where we are in the big list while adjusting the window. 161 | 162 | // Which direction was the scroll 163 | direction: SCROLL_DOWN, 164 | directionChange: false 165 | }, 166 | _scroll, // Previous tick data 167 | 168 | metadata = { 169 | $$position: 0, 170 | $$visible: true, 171 | $$coords: { 172 | x: 0, 173 | y: 0 174 | }, 175 | $$height: 0 176 | }, 177 | 178 | view = { 179 | top: 0, 180 | bottom: 0, 181 | left: 0, //TODO: Support x axis 182 | right: 0, //TODO: Support x axis 183 | items: [], 184 | size: 0, 185 | rows: 0, 186 | yTop: 0, 187 | yBottom: 0, 188 | atEdge: EDGE_TOP, 189 | deadZone: EDGE_TOP, 190 | deadZoneChange: false 191 | }, 192 | _view, // Previous tick data 193 | 194 | // Information about the buffer status 195 | buffer = { 196 | rows: BUFFER_ROWS, 197 | size: BUFFER_ROWS * COLUMNS, // The buffer size, i.e. how many DOM elements we'll track 198 | items: [], // The items data that are in the current buffer 199 | elements: [], // Reference to that actual DOM elements that make up the buffer //TODO: Remove this from the scope some how 200 | top: 0, // Index position of the top of the buffer. 201 | bottom: 0, // Index position of the bottom of the buffer. 202 | left: 0, //TODO: Support x axis 203 | right: 0, //TODO: Support x axis 204 | 205 | yTop: 0, // Pixel position of the top of buffer. 206 | yBottom: 0, // Pixel position of the bottom of the buffer. 207 | 208 | atEdge: EDGE_TOP, // Marks if the buffer is at an edge, and if so, which one. 209 | pointer: 0, // Marks the current element at the top of the table 210 | distance: 0, // How many elements of the buffer are out of view. Used to calculate the distance moved on a direction change 211 | reset: false, 212 | refresh: false 213 | }, 214 | _buffer; // Previous tick data 215 | 216 | 217 | // Save references to the elements we need access to 218 | container.el = element; 219 | wrapper.el = element.children(); 220 | 221 | // Setup the table view 222 | initialise(scope, attributes); 223 | 224 | // The master list of items has changed. Recalculate the virtual list 225 | scope.$watchCollection(attributes.list, function (newList) { 226 | list = newList || []; 227 | refresh(); 228 | }); 229 | 230 | /** 231 | * Lets get our scroll oawn! 232 | * 233 | * We're making use of rAF so we get silky smooth 234 | * motion out of our scrolls. 235 | */ 236 | // When we scroll. Lets work some magic. 237 | var y, updating = false; 238 | element.on('scroll', function () { 239 | y = element.prop('scrollTop'); 240 | update(); 241 | }); 242 | 243 | function update() { 244 | setScrollPosition(y); 245 | updating = false; 246 | } 247 | 248 | 249 | /* * * * * * * * * * * * * * * */ 250 | /* Helper functions */ 251 | /* * * * * * * * * * * * * * * */ 252 | 253 | /** 254 | * Initialised the directive. Setups watchers and calcaultes what we can without data 255 | * Copy the current models so we can create a delta from them 256 | */ 257 | function initialise (scope, attributes) { 258 | 259 | element.css({ 260 | display: 'block', 261 | overflow: 'auto' 262 | }); 263 | 264 | wrapper.el.css({ 265 | position: 'relative' 266 | }); 267 | 268 | _scroll = angular.copy(scroll); 269 | _view = angular.copy(view); 270 | _buffer = angular.copy(buffer); 271 | 272 | /** 273 | * Handle attributes 274 | */ 275 | if (attributes.itemName) { 276 | itemName = attributes.itemName; 277 | } 278 | 279 | if (attributes.viewParams) { 280 | scope.$watch(attributes.viewParams, function (view) { 281 | row.height = view.rowHeight || ROW_HEIGHT; 282 | columns = view.columns || COLUMNS; 283 | buffer.rows = view.rows || BUFFER_ROWS; 284 | buffer.size = buffer.rows * columns; 285 | refresh(); 286 | }, true); 287 | } 288 | 289 | // Setup trigger functions for the directive 290 | if (attributes.triggerTop) { 291 | triggerTop = function () { 292 | scope.$eval(attributes.triggerTop); 293 | }; 294 | } 295 | 296 | // Setup trigger functions for the directive 297 | if (attributes.triggerBottom) { 298 | triggerBottom = function () { 299 | scope.$eval(attributes.triggerBottom); 300 | }; 301 | } 302 | 303 | /** 304 | * Add event listeners 305 | */ 306 | // The status bar has been tapped. To the top with ye! 307 | $window.addEventListener('statusTap', function () { 308 | scrollToTop(); 309 | }); 310 | 311 | calculateContainer(); 312 | } 313 | 314 | function refresh () { 315 | updateBufferModel(); 316 | generateBufferedItems(); 317 | calculateDimensions(); 318 | 319 | updateViewModel(); 320 | triggerEdge(); 321 | } 322 | 323 | /** 324 | * Function used when transcluding the code into the wrapper 325 | * Creates the comment marker and animates the entry to the DOM 326 | * @param clone 327 | */ 328 | function cloneElement(clone) { 329 | clone.addClass('mlz-ui-table-view-item'); 330 | clone[clone.length++] = document.createComment(' end mlzTableViewItem: ' + attributes.list + ' '); 331 | $animate.enter(clone, wrapper.el); 332 | } 333 | 334 | 335 | function updateItem(elIndex, item, coords, index) { 336 | buffer.elements[elIndex].scope[itemName] = item; 337 | buffer.elements[elIndex].scope.$coords = coords; 338 | buffer.elements[elIndex].scope.$index = index; 339 | } 340 | 341 | /** 342 | * Remove an element from the view at the specified index 343 | * @param index 344 | */ 345 | function destroyItem(index) { 346 | var elementsToRemove = getBlockElements(buffer.elements[index].clone); 347 | $animate.leave(elementsToRemove); 348 | buffer.elements[index].scope.$destroy(); 349 | buffer.elements.splice(index, 1); 350 | } 351 | 352 | /** 353 | * Create the buffered items required to display the data. 354 | * Add/Removes items if the large data set changes in size 355 | * 356 | */ 357 | function generateBufferedItems() { 358 | 359 | // TODO: Handle the case where the list could be smaller than the buffer. 360 | angular.copy(list.slice(itemIndexFromRow(buffer.top), itemIndexFromRow(buffer.bottom)), items); 361 | 362 | // We have more elements than specified by our buffer parameters. 363 | // Lets get rid of any un needed elements 364 | if (buffer.elements.length > buffer.size) { 365 | // Keep a copy of the original elements length as we'll be adjusting this as we delete 366 | var elementsLength = buffer.elements.length; 367 | for(var i = elementsLength - 1; i >= buffer.size; i--) { 368 | destroyItem(i); 369 | } 370 | } 371 | 372 | // OK Now we can look at updating the current buffer. 373 | // including adding any missing elements that may be required 374 | var p, x, y, e, r = buffer.top - 1, found; 375 | 376 | for (var i = 0; i < buffer.size; i++) { 377 | 378 | if (items.length < buffer.size) {} 379 | // If we're changing the item list. Remove any buffered items that are not required 380 | // because the list is smaller than the buffer. 381 | if (items && i >= items.length) { 382 | // TODO: Refactor this as it's a bit pish 383 | if (buffer.elements[i]) { 384 | destroyItem(i); 385 | } 386 | } else { 387 | 388 | found = false; 389 | e = itemIndexFromRow(buffer.top) + i; 390 | p = getRelativeBufferPosition(e); 391 | 392 | (p % columns === 0) ? r++ : null; 393 | 394 | // Workout the x and y coords of this element 395 | x = (p % columns) * (container.width / columns); 396 | y = r * row.height; 397 | 398 | // If we have an element cached and it contains the same info, leave it as it is. 399 | if (buffer.elements[p] && angular.equals(list[e], buffer.elements[p].scope[itemName])) { 400 | buffer.elements[p].scope.$coords = { x:x, y:y } 401 | //$animate.move(buffer.elements[p].clone, wrapper.el); 402 | repositionElement(buffer.elements[p]); 403 | 404 | continue; 405 | } 406 | 407 | if (buffer.elements[p]) { 408 | // Scan the buffer for this item. If it exists we should move that item into this 409 | // position and send this block to the bottom to be reused. 410 | for(var k = p; k < buffer.size; k++) { 411 | if (buffer.elements[k] && found) { 412 | // Update positions of everything else in the buffer 413 | buffer.elements[k].scope.$coords = { x:x, y:y } 414 | } 415 | if (buffer.elements[k] && angular.equals(list[e], buffer.elements[k].scope[itemName])) { 416 | buffer.elements[k].scope.$index = e; 417 | //buffer.elements[p].scope.$coords = buffer.elements[k].scope.$coords; 418 | buffer.elements[k].scope.$coords = { x:x, y:y }; 419 | // Cut out the elements in between the invalid item and this found one 420 | // and move them to the end. 421 | //buffer.elements.join(buffer.elements.slice(p, k - p)); 422 | // Move the found element into the correct place in the buffer elements array 423 | move(buffer.elements, k, p); 424 | //$animate.move(buffer.elements[p].clone, wrapper.el); 425 | //repositionElement(buffer.elements[p]); 426 | //repositionElement(buffer.elements[k]); 427 | found = true; 428 | //break; 429 | } 430 | 431 | } 432 | 433 | if (!found) { 434 | buffer.elements[p].scope[itemName] = list[e]; 435 | buffer.elements[p].scope.$index = e; 436 | buffer.elements[p].scope.$height = row.height; 437 | buffer.elements[p].scope.$coords = { x:x, y:y }; 438 | //$animate.move(buffer.elements[p].clone, wrapper.el); 439 | //repositionElement(buffer.elements[p]); 440 | } 441 | repositionElement(buffer.elements[p]); 442 | 443 | } else { 444 | 445 | var newItem = {}; 446 | 447 | newItem.scope = scope.$new(); 448 | newItem.scope[itemName] = list[e]; 449 | newItem.scope.$index = e; 450 | newItem.scope.$height = row.height; 451 | newItem.scope.$coords = { x:x, y:y }; 452 | newItem.scope.$visible = false; 453 | newItem.clone = $transclude(newItem.scope, cloneElement); 454 | buffer.elements[i] = newItem; 455 | setupElement(buffer.elements[i]); 456 | } 457 | } 458 | } 459 | 460 | calculateWrapper(); 461 | setupNextTick(); 462 | } 463 | 464 | /** 465 | * Move the scroller back to the top. 466 | */ 467 | function scrollToTop () { 468 | // TODO: Stop momentum scroll before adjusting scrollTop 469 | } 470 | 471 | /** 472 | * Update the scroll model with a scroll offset. 473 | */ 474 | function setScrollPosition (x, y) { 475 | 476 | //TODO: Support x axis 477 | if (y === undefined) { 478 | y = x; 479 | } 480 | 481 | // Alright lets update the scroll model to where we're at now 482 | updateScrollModel(y); 483 | 484 | // Lets move the view as well 485 | updateViewModel(); 486 | 487 | // We're going to scroll right passed the limits of our buffer. 488 | // We may as well just redraw from the new index 489 | if (buffer.reset || buffer.refresh) { 490 | switch (scroll.direction) { 491 | case SCROLL_UP: 492 | setBufferToIndex(view.bottom - buffer.size); 493 | break; 494 | case SCROLL_DOWN: 495 | setBufferToIndex(view.top); 496 | break; 497 | } 498 | } 499 | 500 | // Update the buffer model 501 | updateBufferModel(); 502 | 503 | //$window.performance.mark('before_render'); 504 | // Render the current buffer 505 | render(); 506 | 507 | // Edge trigger because we might be in the zone? 508 | triggerEdge(); 509 | 510 | // Prep for the next pass 511 | setupNextTick(); 512 | } 513 | 514 | /** 515 | * Returns which element position should be used for a given index 516 | * from the main array 517 | * @param index 518 | */ 519 | function getRelativeBufferPosition (index) { 520 | return index % buffer.size; 521 | } 522 | 523 | /** 524 | * Calculate the starting index of an item in a row 525 | * @param row 526 | * @returns {number} 527 | */ 528 | function itemIndexFromRow (row) { 529 | return row * columns; 530 | } 531 | 532 | 533 | /** 534 | * Update model variables for delta tracking 535 | */ 536 | function setupNextTick () { 537 | angular.extend(_scroll, scroll); 538 | angular.extend(_view, view); 539 | angular.extend(_buffer, buffer); 540 | } 541 | 542 | /** 543 | * Validate our buffer numbers, and fix them if we've gone out of bounds 544 | */ 545 | function validateBuffer () { 546 | // If we're breaking the boundaries 547 | // we need to adjust the buffer accordingly 548 | if (buffer.top < 0) { 549 | buffer.top = 0; 550 | buffer.bottom = buffer.rows; 551 | } else if (buffer.bottom >= list.length / columns) { 552 | buffer.bottom = list.length / columns; 553 | buffer.top = (buffer.bottom - buffer.size > 0) ? buffer.bottom - buffer.size : 0 ; 554 | } 555 | 556 | // Update the extra properties of the buffer 557 | buffer.yTop = buffer.top * row.height; 558 | buffer.yBottom = buffer.bottom * row.height; 559 | buffer.atEdge = (buffer.top <= 0) ? EDGE_TOP : (buffer.bottom >= wrapper.rows) ? EDGE_BOTTOM : false; 560 | } 561 | 562 | /** 563 | * Calculates if a render is required. 564 | */ 565 | function isRenderRequired () { 566 | return( 567 | ((scroll.direction === SCROLL_UP && buffer.atEdge !== EDGE_TOP && view.deadZone === false) && (scroll.directionChange || view.ytChange)) || 568 | ((scroll.direction === SCROLL_DOWN && buffer.atEdge !== EDGE_BOTTOM && view.deadZone === false) && (scroll.directionChange || view.ytChange)) || !(view.deadZone !== false && view.deadZoneChange === false) 569 | ); 570 | } 571 | 572 | /** 573 | * Calculates if we've hit a trigger zone 574 | * @returns {boolean|*} 575 | */ 576 | function isTriggerRequired () { 577 | return (view.triggerZone !== false && view.triggerZoneChange); 578 | } 579 | 580 | /** 581 | * Update the scroll model base on the current scroll position 582 | * @param y 583 | */ 584 | function updateScrollModel (y) { 585 | // Update the coordinates 586 | scroll.y = y; 587 | scroll.yDelta = y - _scroll.y; 588 | 589 | // Update indexes 590 | scroll.row = Math.floor(y / row.height); 591 | 592 | if (scroll.row < 0) { 593 | scroll.row = 0; 594 | } 595 | 596 | if (scroll.row >= wrapper.rows) { 597 | scroll.row = wrapper.rows - 1; 598 | } 599 | 600 | scroll.yDistance = Math.abs(scroll.row - _scroll.row); 601 | scroll.yChange = (scroll.yDistance > 0); 602 | 603 | // Update direction 604 | scroll.direction = (scroll.yDelta >= 0) ? SCROLL_DOWN : SCROLL_UP; 605 | scroll.directionChange = (scroll.direction !== _scroll.direction); 606 | 607 | // Check if we should reset the buffer this tick 608 | buffer.reset = scroll.yDistance > buffer.rows; 609 | } 610 | 611 | /** 612 | * Update the view model based on the current scroll model 613 | * TODO: Experiment with watchers to handle the delta changes 614 | */ 615 | function updateViewModel () { 616 | 617 | //view.bottom = scroll.row + view.rows - 1; 618 | view.yTop = scroll.y; 619 | view.yBottom = scroll.y + container.height; 620 | view.top = scroll.row; 621 | view.bottom = Math.floor(view.yBottom / row.height); 622 | view.atEdge = !(view.top > 0 && view.bottom < list.length - 1); 623 | 624 | // Calculate if we're in a trigger zone and if there's been a change. 625 | view.triggerZone = view.yBottom > (((list.length / columns) - trigger.distance - 1) * row.height) ? EDGE_BOTTOM : (view.yTop < trigger.distance * row.height) ? EDGE_TOP : false; 626 | view.triggerZoneChange = (view.triggerZone !== _view.triggerZone) || (list.length / columns) < (trigger.distance * 2) ; 627 | 628 | // Calculate if we're in a dead zone and if there's been a change. 629 | view.deadZone = (view.yTop < row.height) ? EDGE_TOP : view.yBottom > ((list.length - 1) * row.height) ? EDGE_BOTTOM : false; 630 | view.deadZoneChange = (view.deadZone !== _view.deadZone); 631 | 632 | // Calculate if there have been index changes on either side of the view 633 | view.ytChange = (view.top !== _view.top); 634 | view.ybChange = (view.bottom !== _view.bottom); 635 | } 636 | 637 | /** 638 | * Set the buffer to an index. 639 | * 640 | * @param edge 641 | */ 642 | function setBufferToIndex (index) { 643 | buffer.top = index; 644 | buffer.bottom = buffer.top + buffer.rows; 645 | validateBuffer(); 646 | } 647 | 648 | /** 649 | * Update the buffer model based on the current scroll model 650 | * 651 | * @param index 652 | * @param direction 653 | * @param distance 654 | */ 655 | function updateBufferModel () { 656 | 657 | var index = scroll.row, 658 | direction = scroll.direction, 659 | distance = buffer.distance; 660 | 661 | // Based on the scroll direction, update the buffer model 662 | switch (direction) { 663 | case SCROLL_UP: 664 | buffer.top = index - distance; 665 | buffer.bottom = (index - distance) + buffer.rows; 666 | break; 667 | case SCROLL_DOWN: 668 | buffer.top = index; 669 | buffer.bottom = index + buffer.rows; 670 | break; 671 | default: 672 | $log.warn('We only know how to deal with scrolling on the y axis for now'); 673 | break; 674 | } 675 | validateBuffer(); 676 | } 677 | 678 | /** 679 | * Perform the scrolling up action by updating the required elements 680 | * @param start 681 | * @param end 682 | */ 683 | function scrollingUp (start, distance) { 684 | 685 | var itemsToMerge = list.slice((start * columns), (start * columns) + distance); 686 | 687 | var p, x, y, r = start + (distance / columns) - 1, updates = []; 688 | 689 | for (var i = itemsToMerge.length - 1; i >= 0; i--) { 690 | p = getRelativeBufferPosition((start * columns) + i); 691 | x = (p % columns) * (container.width / columns); 692 | y = r * row.height; 693 | 694 | var coords = { 695 | x: x, 696 | y: y 697 | }; 698 | var index = start * columns + i; 699 | updates.push(p); 700 | updateItem(p, itemsToMerge[i], coords, index); 701 | 702 | (p % columns === 0) ? r-- : null; 703 | 704 | } 705 | 706 | requestAnimFrame(function () { 707 | scope.$apply(function () { 708 | for (var i = 0; i < updates.length; i++) { 709 | repositionElement(buffer.elements[updates[i]]); 710 | } 711 | }); 712 | }); 713 | } 714 | 715 | /** 716 | * Perform the scrolling down action by updating the required elements 717 | * @param start 718 | * @param end 719 | */ 720 | function scrollingDown (start, distance) { 721 | 722 | var itemsToMerge = list.slice((start * columns), (start * columns) + distance); 723 | var p, x, y, r = start - 1, updates = []; 724 | 725 | for (var i = 0; i < itemsToMerge.length; i++) { 726 | 727 | p = getRelativeBufferPosition((start * columns) + i); 728 | 729 | (p % columns === 0) ? r++ : null; 730 | 731 | x = (p % columns) * (container.width / columns); 732 | y = r * row.height; 733 | 734 | var coords = { 735 | x: x, 736 | y: y 737 | }; 738 | var index = start * columns + i; 739 | updates.push(p); 740 | updateItem(p, itemsToMerge[i], coords, index); 741 | } 742 | 743 | requestAnimFrame(function () { 744 | scope.$apply(function () { 745 | for (var i = 0; i < updates.length; i++) { 746 | repositionElement(buffer.elements[updates[i]]); 747 | } 748 | }); 749 | }); 750 | } 751 | 752 | /** 753 | * Update the DOM and based on the current state of the models 754 | */ 755 | function render () { 756 | 757 | var start, end, distance; 758 | 759 | 760 | // Update the DOM based on the models 761 | if (!isRenderRequired()) { 762 | return; 763 | } 764 | 765 | if (buffer.reset || buffer.refresh) { 766 | // Reset the buffer to this ticks window 767 | start = buffer.top; 768 | end = buffer.bottom; 769 | distance = (end - start) * columns; 770 | 771 | // If we've changed the scroll direction. 772 | // Update the buffer to reflect the direction. 773 | switch (scroll.direction) { 774 | case SCROLL_UP: 775 | scrollingUp(start, distance); 776 | break; 777 | case SCROLL_DOWN: 778 | scrollingDown(start, distance); 779 | break; 780 | } 781 | } else { 782 | // If we've changed the scroll direction. 783 | // Update the buffer to reflect the direction. 784 | switch (scroll.direction) { 785 | case SCROLL_UP: 786 | start = buffer.top; 787 | end = _buffer.top; 788 | distance = Math.abs((end - start) * columns); 789 | scrollingUp(start, distance); 790 | break; 791 | case SCROLL_DOWN: 792 | start = _buffer.bottom; 793 | end = buffer.bottom; 794 | distance = Math.abs((end - start) * columns); 795 | scrollingDown(start, distance); 796 | break; 797 | } 798 | } 799 | } 800 | 801 | 802 | function setupElement (element) { 803 | var el = getItemElement(element.clone); 804 | el.css({ 805 | '-webkit-transform': 'translate3d(' + element.scope.$coords.x + 'px, ' + element.scope.$coords.y + 'px, 0px)', 806 | '-moz-transform': 'translate3d(' + element.scope.$coords.x + 'px, ' + element.scope.$coords.y + 'px, 0px)', 807 | '-ms-transform': 'translate3d(' + element.scope.$coords.x + 'px, ' + element.scope.$coords.y + 'px, 0px)', 808 | transform: 'translate3d(' + element.scope.$coords.x + 'px, ' + element.scope.$coords.y + 'px, 0px)', 809 | position: 'absolute', 810 | height: element.scope.$height + 'px' 811 | }); 812 | } 813 | 814 | /** 815 | * Set a given buffered element to the given y coordinate 816 | * @param index 817 | * @param y 818 | */ 819 | function repositionElement (element) { 820 | var el = getItemElement(element.clone); 821 | el.css({ 822 | '-webkit-transform': 'translate3d(' + element.scope.$coords.x + 'px, ' + element.scope.$coords.y + 'px, 0px)', 823 | '-moz-transform': 'translate3d(' + element.scope.$coords.x + 'px, ' + element.scope.$coords.y + 'px, 0px)', 824 | '-ms-transform': 'translate3d(' + element.scope.$coords.x + 'px, ' + element.scope.$coords.y + 'px, 0px)', 825 | 'transform': 'translate3d(' + element.scope.$coords.x + 'px, ' + element.scope.$coords.y + 'px, 0px)' 826 | }); 827 | } 828 | 829 | /** 830 | * Trigger a function supplied to the directive 831 | */ 832 | function triggerEdge () { 833 | if (!isTriggerRequired()) { 834 | return false; 835 | } 836 | 837 | switch (view.triggerZone) { 838 | case EDGE_TOP: 839 | triggerTop(); 840 | break; 841 | case EDGE_BOTTOM: 842 | triggerBottom(); 843 | break; 844 | default: 845 | $log.warn('Zone ' + view.triggerZone + ' is not supported'); 846 | } 847 | } 848 | 849 | /** 850 | * Empty placeholder trigger functions 851 | */ 852 | function triggerTop () { 853 | } 854 | 855 | function triggerBottom () { 856 | } 857 | 858 | /** 859 | * Helper function to calculate all the dimensions required to base 860 | * the buffer calculations from 861 | */ 862 | function calculateDimensions () { 863 | calculateWrapper(); 864 | calculateBuffer(); 865 | } 866 | 867 | /** 868 | * Calculate the size of the container used by the scroller 869 | */ 870 | function calculateContainer () { 871 | // Get the containing dimensions to base our calculations from. 872 | container.height = container.el.prop('clientHeight'); 873 | container.width = container.el.prop('clientWidth'); 874 | } 875 | 876 | /** 877 | * Calculate the wrapper size based on the current items list 878 | */ 879 | function calculateWrapper () { 880 | // Recalculate the virtual wrapper height 881 | if (list) { 882 | wrapper.rows = ((list.length + (list.length % columns)) / columns); 883 | wrapper.height = wrapper.rows * row.height; 884 | wrapper.el.css('height', wrapper.height + 'px'); 885 | } 886 | } 887 | 888 | /** 889 | * Calculate the buffer size and positions 890 | */ 891 | function calculateBuffer () { 892 | // Calculate the number of items that can be visible at a given time 893 | view.rows = Math.ceil(container.height / row.height) + 1; 894 | buffer.bottom = buffer.top + buffer.rows || buffer.top + BUFFER_ROWS * COLUMNS; 895 | buffer.yBottom = buffer.bottom * row.height || (buffer.top + BUFFER_ROWS * COLUMNS) * ROW_HEIGHT; 896 | buffer.distance = (buffer.rows - view.rows) > 0 ? buffer.rows - view.rows : 0; 897 | } 898 | 899 | function clearElements() { 900 | for (var i = 0; i < buffer.elements; i++) { 901 | destroyItem(i); 902 | } 903 | } 904 | 905 | function cleanup () { 906 | $window.removeEventListener('statusTap'); 907 | container.el.off('scroll'); 908 | clearElements(); 909 | wrapper.el.remove(); 910 | container.el.remove(); 911 | delete container.el; 912 | delete wrapper.el; 913 | delete buffer.elements; 914 | } 915 | 916 | scope.$on('$destroy', function () { 917 | cleanup(); 918 | }); 919 | } 920 | }; 921 | 922 | }]); 923 | })(window, window.angular); 924 | -------------------------------------------------------------------------------- /src/ui-table-view.js: -------------------------------------------------------------------------------- 1 | /** 2 | * UITableView 3 | */ 4 | 5 | (function (window, angular, undefined) { 6 | 'use strict'; 7 | 8 | var move = function (arr, old_index, new_index) { 9 | if (new_index >= arr.length) { 10 | var k = new_index - arr.length; 11 | while ((k--) + 1) { 12 | arr.push(undefined); 13 | } 14 | } 15 | arr.splice(new_index, 0, arr.splice(old_index, 1)[0]); 16 | return arr; // for testing purposes 17 | }; 18 | 19 | 20 | /** 21 | * Return the DOM siblings between the first and last node in the given array. 22 | * @param {Array} array like object 23 | * @returns {DOMElement} object containing the elements 24 | */ 25 | function getBlockElements(nodes) { 26 | var startNode = nodes[0], 27 | endNode = nodes[nodes.length - 1]; 28 | if (startNode === endNode) { 29 | return angular.element(startNode); 30 | } 31 | 32 | var element = startNode; 33 | var elements = [element]; 34 | 35 | do { 36 | element = element.nextSibling; 37 | if (!element) break; 38 | elements.push(element); 39 | } while (element !== endNode); 40 | 41 | return angular.element(elements); 42 | } 43 | 44 | function getItemElement(nodes) { 45 | var startNode = nodes[0], 46 | endNode = nodes[nodes.length - 1]; 47 | 48 | if (startNode === endNode) { 49 | return angular.element(startNode); 50 | } 51 | 52 | var element = startNode; 53 | 54 | do { 55 | if (element.classList && element.classList.contains('mlz-ui-table-view-item')) { 56 | return angular.element(element); 57 | } 58 | element = element.nextSibling; 59 | } while (element !== endNode); 60 | 61 | return false; 62 | } 63 | 64 | window.requestAnimFrame = (function(){ 65 | return window.requestAnimationFrame || 66 | window.webkitRequestAnimationFrame || 67 | window.mozRequestAnimationFrame || 68 | function( callback ){ 69 | window.setTimeout(callback, 1000 / 60); 70 | }; 71 | })(); 72 | 73 | /** 74 | * TODO: Add in some docs on how to use this angular module and publish them on GitHub pages. 75 | */ 76 | angular.module('mallzee.ui-table-view', ['ngAnimate']) 77 | .directive('mlzUiTableView', ['$window', '$timeout', '$log', '$animate', function ($window, $timeout, $log, $animate) { 78 | return { 79 | restrict: 'E', 80 | transclude: true, 81 | terminal: true, 82 | priority: 10000, 83 | $$tlb: true, 84 | replace: false, 85 | template: '
', 86 | link: function (scope, element, attributes, ctrl, $transclude) { 87 | 88 | var BUFFER_ROWS = 20, 89 | COLUMNS = 1, 90 | ROW_HEIGHT = 40, 91 | ROW_WIDTH = '100%', 92 | EDGE_TOP = 'top', 93 | EDGE_BOTTOM = 'bottom', 94 | SCROLL_UP = 'up', 95 | SCROLL_DOWN = 'down', 96 | TRIGGER_DISTANCE = 1; 97 | 98 | var list = [], items = [], itemName = 'item', 99 | 100 | model = { 101 | 102 | }, 103 | // Model the main container for the view table 104 | container = { 105 | height: 0, 106 | width: 0, 107 | el: undefined 108 | }, 109 | 110 | // Model the wrapper that will hold the buffer and be scrolled by the container 111 | wrapper = { 112 | height: 0, 113 | width: 0, 114 | rows: 0, 115 | el: undefined 116 | }, 117 | 118 | elements, 119 | 120 | // Model a row 121 | row = { 122 | height: ROW_HEIGHT, 123 | width: ROW_WIDTH 124 | }, 125 | 126 | columns = COLUMNS, 127 | 128 | trigger = { 129 | distance: TRIGGER_DISTANCE 130 | }, 131 | 132 | // Information about the scroll status 133 | // _ indicates the value of that item on the previous tick 134 | scroll = { 135 | // X-Axis 136 | x: 0, //TODO: Support x axis 137 | xDelta: 0, //TODO: Support x axis 138 | xIndex: 0, //TODO: Support x axis 139 | xDistance: 0, 140 | xChange: false, 141 | 142 | //Only dealing with Y for just now 143 | // Y-Axis 144 | y: 0, 145 | yDelta: 0, 146 | yDistance: 0, 147 | yChange: false, // Marks when we have scrolled to a new index 148 | row: 0, // Which row we are currently on 149 | 150 | // Track which item we are on 151 | // topIndex: 0, // Mark the topIndex we are heading for 152 | // _topIndex: 0, // Last ticks topIndex so we can get the delta 153 | 154 | // topIndexDelta: 0, // How many indexes have we moved this tick? 155 | 156 | // bottomIndex: 0, 157 | // _bottomIndex: 0, 158 | // bottomIndexDelta: 0, 159 | 160 | pointer: 0, // Mark where we are in the big list while adjusting the window. 161 | 162 | // Which direction was the scroll 163 | direction: SCROLL_DOWN, 164 | directionChange: false 165 | }, 166 | _scroll, // Previous tick data 167 | 168 | metadata = { 169 | $$position: 0, 170 | $$visible: true, 171 | $$coords: { 172 | x: 0, 173 | y: 0 174 | }, 175 | $$height: 0 176 | }, 177 | 178 | view = { 179 | top: 0, 180 | bottom: 0, 181 | left: 0, //TODO: Support x axis 182 | right: 0, //TODO: Support x axis 183 | items: [], 184 | size: 0, 185 | rows: 0, 186 | yTop: 0, 187 | yBottom: 0, 188 | atEdge: EDGE_TOP, 189 | deadZone: EDGE_TOP, 190 | deadZoneChange: false 191 | }, 192 | _view, // Previous tick data 193 | 194 | // Information about the buffer status 195 | buffer = { 196 | rows: BUFFER_ROWS, 197 | size: BUFFER_ROWS * COLUMNS, // The buffer size, i.e. how many DOM elements we'll track 198 | items: [], // The items data that are in the current buffer 199 | elements: [], // Reference to that actual DOM elements that make up the buffer //TODO: Remove this from the scope some how 200 | top: 0, // Index position of the top of the buffer. 201 | bottom: 0, // Index position of the bottom of the buffer. 202 | left: 0, //TODO: Support x axis 203 | right: 0, //TODO: Support x axis 204 | 205 | yTop: 0, // Pixel position of the top of buffer. 206 | yBottom: 0, // Pixel position of the bottom of the buffer. 207 | 208 | atEdge: EDGE_TOP, // Marks if the buffer is at an edge, and if so, which one. 209 | pointer: 0, // Marks the current element at the top of the table 210 | distance: 0, // How many elements of the buffer are out of view. Used to calculate the distance moved on a direction change 211 | reset: false, 212 | refresh: false 213 | }, 214 | _buffer; // Previous tick data 215 | 216 | 217 | // Save references to the elements we need access to 218 | container.el = element; 219 | wrapper.el = element.children(); 220 | 221 | // Setup the table view 222 | initialise(scope, attributes); 223 | 224 | // The master list of items has changed. Recalculate the virtual list 225 | scope.$watchCollection(attributes.list, function (newList) { 226 | list = newList || []; 227 | refresh(); 228 | }); 229 | 230 | /** 231 | * Lets get our scroll oawn! 232 | * 233 | * We're making use of rAF so we get silky smooth 234 | * motion out of our scrolls. 235 | */ 236 | // When we scroll. Lets work some magic. 237 | var y, updating = false; 238 | element.on('scroll', function () { 239 | y = element.prop('scrollTop'); 240 | update(); 241 | }); 242 | 243 | function update() { 244 | setScrollPosition(y); 245 | updating = false; 246 | } 247 | 248 | 249 | /* * * * * * * * * * * * * * * */ 250 | /* Helper functions */ 251 | /* * * * * * * * * * * * * * * */ 252 | 253 | /** 254 | * Initialised the directive. Setups watchers and calcaultes what we can without data 255 | * Copy the current models so we can create a delta from them 256 | */ 257 | function initialise (scope, attributes) { 258 | 259 | element.css({ 260 | display: 'block', 261 | overflow: 'auto' 262 | }); 263 | 264 | wrapper.el.css({ 265 | position: 'relative' 266 | }); 267 | 268 | _scroll = angular.copy(scroll); 269 | _view = angular.copy(view); 270 | _buffer = angular.copy(buffer); 271 | 272 | /** 273 | * Handle attributes 274 | */ 275 | if (attributes.itemName) { 276 | itemName = attributes.itemName; 277 | } 278 | 279 | if (attributes.viewParams) { 280 | scope.$watch(attributes.viewParams, function (view) { 281 | row.height = view.rowHeight || ROW_HEIGHT; 282 | columns = view.columns || COLUMNS; 283 | buffer.rows = view.rows || BUFFER_ROWS; 284 | buffer.size = buffer.rows * columns; 285 | refresh(); 286 | }, true); 287 | } 288 | 289 | // Setup trigger functions for the directive 290 | if (attributes.triggerTop) { 291 | triggerTop = function () { 292 | scope.$eval(attributes.triggerTop); 293 | }; 294 | } 295 | 296 | // Setup trigger functions for the directive 297 | if (attributes.triggerBottom) { 298 | triggerBottom = function () { 299 | scope.$eval(attributes.triggerBottom); 300 | }; 301 | } 302 | 303 | /** 304 | * Add event listeners 305 | */ 306 | // The status bar has been tapped. To the top with ye! 307 | $window.addEventListener('statusTap', function () { 308 | scrollToTop(); 309 | }); 310 | 311 | calculateContainer(); 312 | } 313 | 314 | function refresh () { 315 | updateBufferModel(); 316 | generateBufferedItems(); 317 | calculateDimensions(); 318 | 319 | updateViewModel(); 320 | triggerEdge(); 321 | } 322 | 323 | /** 324 | * Function used when transcluding the code into the wrapper 325 | * Creates the comment marker and animates the entry to the DOM 326 | * @param clone 327 | */ 328 | function cloneElement(clone) { 329 | clone.addClass('mlz-ui-table-view-item'); 330 | clone[clone.length++] = document.createComment(' end mlzTableViewItem: ' + attributes.list + ' '); 331 | $animate.enter(clone, wrapper.el); 332 | } 333 | 334 | 335 | function updateItem(elIndex, item, coords, index) { 336 | buffer.elements[elIndex].scope[itemName] = item; 337 | buffer.elements[elIndex].scope.$coords = coords; 338 | buffer.elements[elIndex].scope.$index = index; 339 | } 340 | 341 | /** 342 | * Remove an element from the view at the specified index 343 | * @param index 344 | */ 345 | function destroyItem(index) { 346 | var elementsToRemove = getBlockElements(buffer.elements[index].clone); 347 | $animate.leave(elementsToRemove); 348 | buffer.elements[index].scope.$destroy(); 349 | buffer.elements.splice(index, 1); 350 | } 351 | 352 | /** 353 | * Create the buffered items required to display the data. 354 | * Add/Removes items if the large data set changes in size 355 | * 356 | */ 357 | function generateBufferedItems() { 358 | 359 | // TODO: Handle the case where the list could be smaller than the buffer. 360 | angular.copy(list.slice(itemIndexFromRow(buffer.top), itemIndexFromRow(buffer.bottom)), items); 361 | 362 | // We have more elements than specified by our buffer parameters. 363 | // Lets get rid of any un needed elements 364 | if (buffer.elements.length > buffer.size) { 365 | // Keep a copy of the original elements length as we'll be adjusting this as we delete 366 | var elementsLength = buffer.elements.length; 367 | for(var i = elementsLength - 1; i >= buffer.size; i--) { 368 | destroyItem(i); 369 | } 370 | } 371 | 372 | // OK Now we can look at updating the current buffer. 373 | // including adding any missing elements that may be required 374 | var p, x, y, e, r = buffer.top - 1, found; 375 | 376 | for (var i = 0; i < buffer.size; i++) { 377 | 378 | if (items.length < buffer.size) {} 379 | // If we're changing the item list. Remove any buffered items that are not required 380 | // because the list is smaller than the buffer. 381 | if (items && i >= items.length) { 382 | // TODO: Refactor this as it's a bit pish 383 | if (buffer.elements[i]) { 384 | destroyItem(i); 385 | } 386 | } else { 387 | 388 | found = false; 389 | e = itemIndexFromRow(buffer.top) + i; 390 | p = getRelativeBufferPosition(e); 391 | 392 | (p % columns === 0) ? r++ : null; 393 | 394 | // Workout the x and y coords of this element 395 | x = (p % columns) * (container.width / columns); 396 | y = r * row.height; 397 | 398 | // If we have an element cached and it contains the same info, leave it as it is. 399 | if (buffer.elements[p] && angular.equals(list[e], buffer.elements[p].scope[itemName])) { 400 | buffer.elements[p].scope.$coords = { x:x, y:y } 401 | //$animate.move(buffer.elements[p].clone, wrapper.el); 402 | repositionElement(buffer.elements[p]); 403 | 404 | continue; 405 | } 406 | 407 | if (buffer.elements[p]) { 408 | // Scan the buffer for this item. If it exists we should move that item into this 409 | // position and send this block to the bottom to be reused. 410 | for(var k = p; k < buffer.size; k++) { 411 | if (buffer.elements[k] && found) { 412 | // Update positions of everything else in the buffer 413 | buffer.elements[k].scope.$coords = { x:x, y:y } 414 | } 415 | if (buffer.elements[k] && angular.equals(list[e], buffer.elements[k].scope[itemName])) { 416 | buffer.elements[k].scope.$index = e; 417 | //buffer.elements[p].scope.$coords = buffer.elements[k].scope.$coords; 418 | buffer.elements[k].scope.$coords = { x:x, y:y }; 419 | // Cut out the elements in between the invalid item and this found one 420 | // and move them to the end. 421 | //buffer.elements.join(buffer.elements.slice(p, k - p)); 422 | // Move the found element into the correct place in the buffer elements array 423 | move(buffer.elements, k, p); 424 | //$animate.move(buffer.elements[p].clone, wrapper.el); 425 | //repositionElement(buffer.elements[p]); 426 | //repositionElement(buffer.elements[k]); 427 | found = true; 428 | //break; 429 | } 430 | 431 | } 432 | 433 | if (!found) { 434 | buffer.elements[p].scope[itemName] = list[e]; 435 | buffer.elements[p].scope.$index = e; 436 | buffer.elements[p].scope.$height = row.height; 437 | buffer.elements[p].scope.$coords = { x:x, y:y }; 438 | //$animate.move(buffer.elements[p].clone, wrapper.el); 439 | //repositionElement(buffer.elements[p]); 440 | } 441 | repositionElement(buffer.elements[p]); 442 | 443 | } else { 444 | 445 | var newItem = {}; 446 | 447 | newItem.scope = scope.$new(); 448 | newItem.scope[itemName] = list[e]; 449 | newItem.scope.$index = e; 450 | newItem.scope.$height = row.height; 451 | newItem.scope.$coords = { x:x, y:y }; 452 | newItem.scope.$visible = false; 453 | newItem.clone = $transclude(newItem.scope, cloneElement); 454 | buffer.elements[i] = newItem; 455 | setupElement(buffer.elements[i]); 456 | } 457 | } 458 | } 459 | 460 | calculateWrapper(); 461 | setupNextTick(); 462 | } 463 | 464 | /** 465 | * Move the scroller back to the top. 466 | */ 467 | function scrollToTop () { 468 | // TODO: Stop momentum scroll before adjusting scrollTop 469 | } 470 | 471 | /** 472 | * Update the scroll model with a scroll offset. 473 | */ 474 | function setScrollPosition (x, y) { 475 | 476 | //TODO: Support x axis 477 | if (y === undefined) { 478 | y = x; 479 | } 480 | 481 | // Alright lets update the scroll model to where we're at now 482 | updateScrollModel(y); 483 | 484 | // Lets move the view as well 485 | updateViewModel(); 486 | 487 | // We're going to scroll right passed the limits of our buffer. 488 | // We may as well just redraw from the new index 489 | if (buffer.reset || buffer.refresh) { 490 | switch (scroll.direction) { 491 | case SCROLL_UP: 492 | setBufferToIndex(view.bottom - buffer.size); 493 | break; 494 | case SCROLL_DOWN: 495 | setBufferToIndex(view.top); 496 | break; 497 | } 498 | } 499 | 500 | // Update the buffer model 501 | updateBufferModel(); 502 | 503 | //$window.performance.mark('before_render'); 504 | // Render the current buffer 505 | render(); 506 | 507 | // Edge trigger because we might be in the zone? 508 | triggerEdge(); 509 | 510 | // Prep for the next pass 511 | setupNextTick(); 512 | } 513 | 514 | /** 515 | * Returns which element position should be used for a given index 516 | * from the main array 517 | * @param index 518 | */ 519 | function getRelativeBufferPosition (index) { 520 | return index % buffer.size; 521 | } 522 | 523 | /** 524 | * Calculate the starting index of an item in a row 525 | * @param row 526 | * @returns {number} 527 | */ 528 | function itemIndexFromRow (row) { 529 | return row * columns; 530 | } 531 | 532 | 533 | /** 534 | * Update model variables for delta tracking 535 | */ 536 | function setupNextTick () { 537 | angular.extend(_scroll, scroll); 538 | angular.extend(_view, view); 539 | angular.extend(_buffer, buffer); 540 | } 541 | 542 | /** 543 | * Validate our buffer numbers, and fix them if we've gone out of bounds 544 | */ 545 | function validateBuffer () { 546 | // If we're breaking the boundaries 547 | // we need to adjust the buffer accordingly 548 | if (buffer.top < 0) { 549 | buffer.top = 0; 550 | buffer.bottom = buffer.rows; 551 | } else if (buffer.bottom >= list.length / columns) { 552 | buffer.bottom = list.length / columns; 553 | buffer.top = (buffer.bottom - buffer.size > 0) ? buffer.bottom - buffer.size : 0 ; 554 | } 555 | 556 | // Update the extra properties of the buffer 557 | buffer.yTop = buffer.top * row.height; 558 | buffer.yBottom = buffer.bottom * row.height; 559 | buffer.atEdge = (buffer.top <= 0) ? EDGE_TOP : (buffer.bottom >= wrapper.rows) ? EDGE_BOTTOM : false; 560 | } 561 | 562 | /** 563 | * Calculates if a render is required. 564 | */ 565 | function isRenderRequired () { 566 | return( 567 | ((scroll.direction === SCROLL_UP && buffer.atEdge !== EDGE_TOP && view.deadZone === false) && (scroll.directionChange || view.ytChange)) || 568 | ((scroll.direction === SCROLL_DOWN && buffer.atEdge !== EDGE_BOTTOM && view.deadZone === false) && (scroll.directionChange || view.ytChange)) || !(view.deadZone !== false && view.deadZoneChange === false) 569 | ); 570 | } 571 | 572 | /** 573 | * Calculates if we've hit a trigger zone 574 | * @returns {boolean|*} 575 | */ 576 | function isTriggerRequired () { 577 | return (view.triggerZone !== false && view.triggerZoneChange); 578 | } 579 | 580 | /** 581 | * Update the scroll model base on the current scroll position 582 | * @param y 583 | */ 584 | function updateScrollModel (y) { 585 | // Update the coordinates 586 | scroll.y = y; 587 | scroll.yDelta = y - _scroll.y; 588 | 589 | // Update indexes 590 | scroll.row = Math.floor(y / row.height); 591 | 592 | if (scroll.row < 0) { 593 | scroll.row = 0; 594 | } 595 | 596 | if (scroll.row >= wrapper.rows) { 597 | scroll.row = wrapper.rows - 1; 598 | } 599 | 600 | scroll.yDistance = Math.abs(scroll.row - _scroll.row); 601 | scroll.yChange = (scroll.yDistance > 0); 602 | 603 | // Update direction 604 | scroll.direction = (scroll.yDelta >= 0) ? SCROLL_DOWN : SCROLL_UP; 605 | scroll.directionChange = (scroll.direction !== _scroll.direction); 606 | 607 | // Check if we should reset the buffer this tick 608 | buffer.reset = scroll.yDistance > buffer.rows; 609 | } 610 | 611 | /** 612 | * Update the view model based on the current scroll model 613 | * TODO: Experiment with watchers to handle the delta changes 614 | */ 615 | function updateViewModel () { 616 | 617 | //view.bottom = scroll.row + view.rows - 1; 618 | view.yTop = scroll.y; 619 | view.yBottom = scroll.y + container.height; 620 | view.top = scroll.row; 621 | view.bottom = Math.floor(view.yBottom / row.height); 622 | view.atEdge = !(view.top > 0 && view.bottom < list.length - 1); 623 | 624 | // Calculate if we're in a trigger zone and if there's been a change. 625 | view.triggerZone = view.yBottom > (((list.length / columns) - trigger.distance - 1) * row.height) ? EDGE_BOTTOM : (view.yTop < trigger.distance * row.height) ? EDGE_TOP : false; 626 | view.triggerZoneChange = (view.triggerZone !== _view.triggerZone) || (list.length / columns) < (trigger.distance * 2) ; 627 | 628 | // Calculate if we're in a dead zone and if there's been a change. 629 | view.deadZone = (view.yTop < row.height) ? EDGE_TOP : view.yBottom > ((list.length - 1) * row.height) ? EDGE_BOTTOM : false; 630 | view.deadZoneChange = (view.deadZone !== _view.deadZone); 631 | 632 | // Calculate if there have been index changes on either side of the view 633 | view.ytChange = (view.top !== _view.top); 634 | view.ybChange = (view.bottom !== _view.bottom); 635 | } 636 | 637 | /** 638 | * Set the buffer to an index. 639 | * 640 | * @param edge 641 | */ 642 | function setBufferToIndex (index) { 643 | buffer.top = index; 644 | buffer.bottom = buffer.top + buffer.rows; 645 | validateBuffer(); 646 | } 647 | 648 | /** 649 | * Update the buffer model based on the current scroll model 650 | * 651 | * @param index 652 | * @param direction 653 | * @param distance 654 | */ 655 | function updateBufferModel () { 656 | 657 | var index = scroll.row, 658 | direction = scroll.direction, 659 | distance = buffer.distance; 660 | 661 | // Based on the scroll direction, update the buffer model 662 | switch (direction) { 663 | case SCROLL_UP: 664 | buffer.top = index - distance; 665 | buffer.bottom = (index - distance) + buffer.rows; 666 | break; 667 | case SCROLL_DOWN: 668 | buffer.top = index; 669 | buffer.bottom = index + buffer.rows; 670 | break; 671 | default: 672 | $log.warn('We only know how to deal with scrolling on the y axis for now'); 673 | break; 674 | } 675 | validateBuffer(); 676 | } 677 | 678 | /** 679 | * Perform the scrolling up action by updating the required elements 680 | * @param start 681 | * @param end 682 | */ 683 | function scrollingUp (start, distance) { 684 | 685 | var itemsToMerge = list.slice((start * columns), (start * columns) + distance); 686 | 687 | var p, x, y, r = start + (distance / columns) - 1, updates = []; 688 | 689 | for (var i = itemsToMerge.length - 1; i >= 0; i--) { 690 | p = getRelativeBufferPosition((start * columns) + i); 691 | x = (p % columns) * (container.width / columns); 692 | y = r * row.height; 693 | 694 | var coords = { 695 | x: x, 696 | y: y 697 | }; 698 | var index = start * columns + i; 699 | updates.push(p); 700 | updateItem(p, itemsToMerge[i], coords, index); 701 | 702 | (p % columns === 0) ? r-- : null; 703 | 704 | } 705 | 706 | requestAnimFrame(function () { 707 | scope.$apply(function () { 708 | for (var i = 0; i < updates.length; i++) { 709 | repositionElement(buffer.elements[updates[i]]); 710 | } 711 | }); 712 | }); 713 | } 714 | 715 | /** 716 | * Perform the scrolling down action by updating the required elements 717 | * @param start 718 | * @param end 719 | */ 720 | function scrollingDown (start, distance) { 721 | 722 | var itemsToMerge = list.slice((start * columns), (start * columns) + distance); 723 | var p, x, y, r = start - 1, updates = []; 724 | 725 | for (var i = 0; i < itemsToMerge.length; i++) { 726 | 727 | p = getRelativeBufferPosition((start * columns) + i); 728 | 729 | (p % columns === 0) ? r++ : null; 730 | 731 | x = (p % columns) * (container.width / columns); 732 | y = r * row.height; 733 | 734 | var coords = { 735 | x: x, 736 | y: y 737 | }; 738 | var index = start * columns + i; 739 | updates.push(p); 740 | updateItem(p, itemsToMerge[i], coords, index); 741 | } 742 | 743 | requestAnimFrame(function () { 744 | scope.$apply(function () { 745 | for (var i = 0; i < updates.length; i++) { 746 | repositionElement(buffer.elements[updates[i]]); 747 | } 748 | }); 749 | }); 750 | } 751 | 752 | /** 753 | * Update the DOM and based on the current state of the models 754 | */ 755 | function render () { 756 | 757 | var start, end, distance; 758 | 759 | 760 | // Update the DOM based on the models 761 | if (!isRenderRequired()) { 762 | return; 763 | } 764 | 765 | if (buffer.reset || buffer.refresh) { 766 | // Reset the buffer to this ticks window 767 | start = buffer.top; 768 | end = buffer.bottom; 769 | distance = (end - start) * columns; 770 | 771 | // If we've changed the scroll direction. 772 | // Update the buffer to reflect the direction. 773 | switch (scroll.direction) { 774 | case SCROLL_UP: 775 | scrollingUp(start, distance); 776 | break; 777 | case SCROLL_DOWN: 778 | scrollingDown(start, distance); 779 | break; 780 | } 781 | } else { 782 | // If we've changed the scroll direction. 783 | // Update the buffer to reflect the direction. 784 | switch (scroll.direction) { 785 | case SCROLL_UP: 786 | start = buffer.top; 787 | end = _buffer.top; 788 | distance = Math.abs((end - start) * columns); 789 | scrollingUp(start, distance); 790 | break; 791 | case SCROLL_DOWN: 792 | start = _buffer.bottom; 793 | end = buffer.bottom; 794 | distance = Math.abs((end - start) * columns); 795 | scrollingDown(start, distance); 796 | break; 797 | } 798 | } 799 | } 800 | 801 | 802 | function setupElement (element) { 803 | var el = getItemElement(element.clone); 804 | el.css({ 805 | '-webkit-transform': 'translate3d(' + element.scope.$coords.x + 'px, ' + element.scope.$coords.y + 'px, 0px)', 806 | '-moz-transform': 'translate3d(' + element.scope.$coords.x + 'px, ' + element.scope.$coords.y + 'px, 0px)', 807 | '-ms-transform': 'translate3d(' + element.scope.$coords.x + 'px, ' + element.scope.$coords.y + 'px, 0px)', 808 | transform: 'translate3d(' + element.scope.$coords.x + 'px, ' + element.scope.$coords.y + 'px, 0px)', 809 | position: 'absolute', 810 | height: element.scope.$height + 'px' 811 | }); 812 | } 813 | 814 | /** 815 | * Set a given buffered element to the given y coordinate 816 | * @param index 817 | * @param y 818 | */ 819 | function repositionElement (element) { 820 | var el = getItemElement(element.clone); 821 | el.css({ 822 | '-webkit-transform': 'translate3d(' + element.scope.$coords.x + 'px, ' + element.scope.$coords.y + 'px, 0px)', 823 | '-moz-transform': 'translate3d(' + element.scope.$coords.x + 'px, ' + element.scope.$coords.y + 'px, 0px)', 824 | '-ms-transform': 'translate3d(' + element.scope.$coords.x + 'px, ' + element.scope.$coords.y + 'px, 0px)', 825 | 'transform': 'translate3d(' + element.scope.$coords.x + 'px, ' + element.scope.$coords.y + 'px, 0px)' 826 | }); 827 | } 828 | 829 | /** 830 | * Trigger a function supplied to the directive 831 | */ 832 | function triggerEdge () { 833 | if (!isTriggerRequired()) { 834 | return false; 835 | } 836 | 837 | switch (view.triggerZone) { 838 | case EDGE_TOP: 839 | triggerTop(); 840 | break; 841 | case EDGE_BOTTOM: 842 | triggerBottom(); 843 | break; 844 | default: 845 | $log.warn('Zone ' + view.triggerZone + ' is not supported'); 846 | } 847 | } 848 | 849 | /** 850 | * Empty placeholder trigger functions 851 | */ 852 | function triggerTop () { 853 | } 854 | 855 | function triggerBottom () { 856 | } 857 | 858 | /** 859 | * Helper function to calculate all the dimensions required to base 860 | * the buffer calculations from 861 | */ 862 | function calculateDimensions () { 863 | calculateWrapper(); 864 | calculateBuffer(); 865 | } 866 | 867 | /** 868 | * Calculate the size of the container used by the scroller 869 | */ 870 | function calculateContainer () { 871 | // Get the containing dimensions to base our calculations from. 872 | container.height = container.el.prop('clientHeight'); 873 | container.width = container.el.prop('clientWidth'); 874 | } 875 | 876 | /** 877 | * Calculate the wrapper size based on the current items list 878 | */ 879 | function calculateWrapper () { 880 | // Recalculate the virtual wrapper height 881 | if (list) { 882 | wrapper.rows = ((list.length + (list.length % columns)) / columns); 883 | wrapper.height = wrapper.rows * row.height; 884 | wrapper.el.css('height', wrapper.height + 'px'); 885 | } 886 | } 887 | 888 | /** 889 | * Calculate the buffer size and positions 890 | */ 891 | function calculateBuffer () { 892 | // Calculate the number of items that can be visible at a given time 893 | view.rows = Math.ceil(container.height / row.height) + 1; 894 | buffer.bottom = buffer.top + buffer.rows || buffer.top + BUFFER_ROWS * COLUMNS; 895 | buffer.yBottom = buffer.bottom * row.height || (buffer.top + BUFFER_ROWS * COLUMNS) * ROW_HEIGHT; 896 | buffer.distance = (buffer.rows - view.rows) > 0 ? buffer.rows - view.rows : 0; 897 | } 898 | 899 | function clearElements() { 900 | for (var i = 0; i < buffer.elements; i++) { 901 | destroyItem(i); 902 | } 903 | } 904 | 905 | function cleanup () { 906 | $window.removeEventListener('statusTap', scrollToTop); 907 | container.el.off('scroll'); 908 | clearElements(); 909 | wrapper.el.remove(); 910 | container.el.remove(); 911 | delete container.el; 912 | delete wrapper.el; 913 | delete buffer.elements; 914 | } 915 | 916 | scope.$on('$destroy', function () { 917 | cleanup(); 918 | }); 919 | } 920 | }; 921 | 922 | }]); 923 | })(window, window.angular); 924 | --------------------------------------------------------------------------------