├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── bower.json ├── demo ├── demo.css ├── index.html └── indexSlow.html ├── dist ├── tileview.css └── tileview.js ├── gulpfile.js ├── package.json └── src ├── tileview.less ├── tileview.tpl.html └── tileview.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | 8 | [*.md] 9 | trim_trailing_whitespace = false 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bower_components 3 | gh-pages -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Tiny Desk Development 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 all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # angular-tileview 2 | 3 | A tile view that uses virtualisation to efficiently display large numbers of cells. 4 | 5 | ## Get Started 6 | 7 | Install via bower: 8 | 9 | ``` 10 | bower install angular-tileview --save 11 | ``` 12 | 13 | Add dependency: 14 | 15 | ```javascript 16 | angular.module('myApp', ['td.tileview']); 17 | ``` 18 | 19 | Add component to template: 20 | 21 | ```html 22 | 23 | ``` 24 | 25 | Make sure that both `dist/tileview.js` and `dist/tileview.css` are included in your html file. The most common source of errors in relation to this component is that the css file was not included correctly. 26 | 27 | ## Demo 28 | 29 | [http://tinydesk.github.io/angular-tileview/demo/](http://tinydesk.github.io/angular-tileview/demo/) 30 | 31 | ## Parameters 32 | 33 | ### items 34 | 35 | Type: `Array` (required) 36 | 37 | The data that should be displayed. Each item is associated with one cell in the tileview. The cell will be bound to the data of the corresponding item. There is no restriction on the shape of the items in the array. Any data can be used. Note that though it seems that each item has it's own cell, the component only creates enough dom elements to view all visible items at once and reuses those elements when the user scrolls. 38 | 39 | ### options 40 | 41 | Type: `Object` 42 | 43 | The component supports the following options: 44 | 45 | #### templateUrl 46 | 47 | Type: `String` (required) 48 | 49 | Path to a template that is used to render a cell. The template might reference the `item` property which will always points to the item that is displayed in that cell. Note that it is possible to change this property later on, but it must be always the same for all cells. 50 | 51 | #### tileSize 52 | 53 | Type: `Object` (required) 54 | 55 | An object that has two numeric properties `width` and `height` which define the exact size of each cell. Note that it is possible to change this property later on, but it must be always the same for all cells. 56 | 57 | #### alignHorizontal 58 | 59 | Type: `boolean`. Default: `false` 60 | 61 | If set to true, line breaks will be disabled and the items will be aligned in one large row with a horizontal scroll-bar if necessary. 62 | 63 | #### onScrollEnd 64 | 65 | Type: `function` 66 | 67 | A callback that is invoked when the user scrolls to the end of the data. The expression can be optionally triggered in advance by setting the option `scrollEndOffset`. 68 | 69 | #### scrollEndOffset 70 | 71 | Type: `number`. Default: `0` 72 | 73 | The row, counted from the end, that triggers the `scrollEnd` expression. 74 | 75 | #### overflow 76 | 77 | Type: `number`. Default: `2` 78 | 79 | The number of excess rows that are added to the DOM. 80 | 81 | #### debounce 82 | 83 | Type: `number`. Default: `0` 84 | 85 | Debounce while scrolling in milliseconds. A value of `0` is interpreted as no debounce. 86 | 87 | #### disablePointerEvents 88 | 89 | Type: `number` 90 | 91 | Sets `pointer-events` to `none` during scrolling to improve performance. The value of this property is a number indicating the number of milliseconds that the component waits to determine if scrolling has ended. 92 | 93 | ## Events 94 | 95 | ### Manual Resize 96 | 97 | Event: `td.tileview.resize` 98 | Type: Input 99 | 100 | A manual resize will trigger a layout of the tiles if the new size changes the tile configuration. 101 | 102 | ### Manual Update 103 | 104 | Event: `td.tileview.update` 105 | Type: Input 106 | 107 | A manual update will always perform a layout, even if the number of tiles stays the same. This can be, for example, used to force an update after the element's direction value has changed. 108 | 109 | ### Layout 110 | 111 | Event: `td.tileview.layout` 112 | Type: Output 113 | 114 | This event is emitted by the tileview whenever it performs a layout. -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-tileview", 3 | "description": "A tileview for angular", 4 | "main": [ 5 | "dist/tileview.js", 6 | "dist/tileview.css" 7 | ], 8 | "authors": [ 9 | "Hannes Widmoser " 10 | ], 11 | "license": "MIT", 12 | "homepage": "https://github.com/tinydesk/angular-tileview", 13 | "ignore": [ 14 | "**/.*", 15 | "node_modules", 16 | "bower_components", 17 | "test", 18 | "tests" 19 | ], 20 | "dependencies": { 21 | "angular-scroll-rtl": "^0.1.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /demo/demo.css: -------------------------------------------------------------------------------- 1 | .demo { 2 | height: 700px; 3 | border: 1px solid; 4 | } 5 | 6 | .item-cell-padding { 7 | padding: 10px; 8 | } 9 | 10 | .item-cell-padding.small { 11 | padding: 2px; 12 | } 13 | 14 | .item-cell { 15 | border: 1px solid; 16 | height: 100%; 17 | padding: 10px; 18 | } 19 | 20 | .item-cell img { 21 | margin-bottom: 5px; 22 | } 23 | 24 | .controls { 25 | padding: 20px; 26 | } -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | TileView: Demo 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 |
25 |
26 | 27 | 28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 | 39 | 43 |
44 |
45 | 46 | 47 |
48 |
49 | 50 | 51 |
52 |
53 | 54 | 55 |
56 |
57 | 58 | 59 |
60 |
61 | 62 | 63 |
64 | 65 | 66 | 67 |
68 |
69 | 70 | 71 | 72 | 73 | 74 | 149 | 159 | 164 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /demo/indexSlow.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | TileView: Demo 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 |
25 |
26 | 27 | 28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 | 39 | 43 |
44 |
45 | 46 | 47 |
48 |
49 | 50 | 51 |
52 |
53 | 54 | 55 |
56 |
57 | 58 | 59 |
60 | 61 | 62 | 63 |
64 |
65 | 66 | 67 | 68 | 69 | 153 | 160 | 165 | 166 | 167 | 168 | -------------------------------------------------------------------------------- /dist/tileview.css: -------------------------------------------------------------------------------- 1 | .tile-view { 2 | height: 100%; 3 | overflow-y: auto; 4 | overflow-x: hidden; 5 | } 6 | .tile-view .item-container { 7 | position: relative; 8 | } 9 | .tile-view.horizontal { 10 | white-space: nowrap; 11 | overflow-x: auto; 12 | } 13 | .tile-view.horizontal > * { 14 | vertical-align: top; 15 | display: inline-block; 16 | height: 100%; 17 | } 18 | .tile-view.horizontal .item-container > div > * { 19 | white-space: normal; 20 | } 21 | -------------------------------------------------------------------------------- /dist/tileview.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | var mod = angular.module('td.tileview', ['td.scroll']); 4 | /** 5 | * @ngdoc directive 6 | * @name td.tileview.directive:tdTileview 7 | * @restrict E 8 | * 9 | * @description 10 | * 11 | * The tile directive provides a tile-based view on a list of data. The tiles can be arranged in a grid or they can be 12 | * horizontally stacked. 13 | * 14 | * The tile directive will automatically resize when the window is resized. If the size changed for some other reasons, a manual resize 15 | * can be triggered, by broadcasting the `td.tileview.resize` event. There are two other events, that indicate the beginning and ending 16 | * of a scrolling movement. These events can be used to implement custom performance optimisations, because not every DOM change needs to 17 | * be done while scrolling. The events are: `td.tileview.scrollStart` and `td.tileview.scrollEnd`. In order to detect when scrolling ends 18 | * a debounce delay is used. It can be configured with the `afterScrollDelay` options property. 19 | * 20 | * @param {Array=} items The items that are to be displayed in the tile view 21 | * @param {object=} options An options object defining options that are relevant specifically for the tile ui such as 22 | * tile sizes for example. It consists of the following properties: 23 | * 24 | * - **templateUrl** - {string} - Path to the template that should be used to render tiles. The template will implicitly have 25 | * access to the tile directive's scope plus an `item` object. Note that the template is responsible for maintaining the 26 | * selection state by calling the appropriate methods on the selection object. 27 | * - **tileSize** - {object} - The current tile size represented as an object with the following properties: 28 | * - **width** - {int} - The width of the tile. 29 | * - **height** - {int} - The height of the tile. 30 | * Can be dynamically adjusted. 31 | * - **alignHorizontal** - {boolean} - Whether to show the tiles in a grid with a vertical scrollbar or horizontally 32 | * stacked. 33 | * - **onScrollEnd** - {function} - A callback that is invoked when the user scrolls to the end of the data. 34 | * - **scrollEndOffset** - {number} - Some features that rely on the `scrollEnd` callback need to be informed in advance. 35 | * This property specifies an offset in rows to trigger the scroll end event before actually hitting the bottom of the data. **Default**: 0 36 | * - **overflow** - {number} - Number of rows that are rendered additionally to the visible rows to make the scrolling experience more fluent. **Default**: 2 37 | * - **debounce** - {number} - Debounce for the scroll event. A value of `0` is interpreted as no debounce. **Default**: 0. 38 | * - **afterScrollDelay** - {number} - Time to wait in order to decide whether a scroll movement has finished. **Default**: 100. 39 | */ 40 | mod.directive('tdTileview', ['$compile', '$templateCache', '$timeout', '$window', function ($compile, $templateCache, $timeout, $window) { 41 | return { 42 | restrict: 'E', 43 | scope: { 44 | items: '=', 45 | options: '=' 46 | }, 47 | template: $templateCache.get('tileview.tpl.html'), 48 | link: function (scope, elem, attrs) { 49 | scope.elem = elem; 50 | scope.tileStyle = {}; 51 | scope.tileStyle.marginRight = "4px"; 52 | scope.tileStyle.marginBottom = "4px"; 53 | scope.tileStyle.float = "left"; 54 | var container = elem.children(); 55 | var itemContainer = container.children().eq(0); 56 | var linkFunction; 57 | var heightStart = 0; 58 | var heightEnd = 0; 59 | var startRow = 0, endRow; 60 | var renderedStartRow = -1, renderedEndRow = -1; 61 | var itemsPerRow; 62 | var rowCount; 63 | var cachedRowCount; 64 | var virtualRows = []; 65 | var scopes = {}; 66 | var scopeCounter = 0; 67 | function nextScopeId() { 68 | scopeCounter = scopeCounter + 1; 69 | return 'scope-' + scopeCounter; 70 | } 71 | function handleTileSizeChange() { 72 | forEachElement(function (el) { 73 | el.css('width', scope.options.tileSize.width + 'px'); 74 | el.css('height', scope.options.tileSize.height + 'px'); 75 | }); 76 | } 77 | function handleTemplateUrlChange() { 78 | var template = $templateCache.get(scope.options.templateUrl); 79 | if (template !== undefined) { 80 | linkFunction = $compile(template); 81 | removeAll(); 82 | } 83 | else { 84 | console.error('Template url not found: ' + scope.options.templateUrl); 85 | } 86 | } 87 | function handleAlignHorizontalChange() { 88 | if (scope.options.alignHorizontal) { 89 | sizeDimension = 'width'; 90 | minSizeDimension = 'min-width'; 91 | orthogonalDimension = 'min-height'; 92 | elem.children().addClass('horizontal'); 93 | } 94 | else { 95 | sizeDimension = 'height'; 96 | minSizeDimension = 'min-height'; 97 | orthogonalDimension = 'min-width'; 98 | elem.children().removeClass('horizontal'); 99 | } 100 | } 101 | scope.$watch('options', function (options, currentOptions) { 102 | // set defaults: 103 | options.scrollEndOffset = def(options.scrollEndOffset, 0); 104 | options.overflow = def(options.overflow, 2); 105 | options.debounce = def(options.debounce, 0); 106 | options.afterScrollDelay = def(options.afterScrollDelay, 100); 107 | if (options === currentOptions || options.templateUrl !== currentOptions.templateUrl) { 108 | handleTemplateUrlChange(); 109 | } 110 | if (options === currentOptions || options.alignHorizontal !== currentOptions.alignHorizontal) { 111 | handleAlignHorizontalChange(); 112 | } 113 | layout(true); 114 | if (options === currentOptions || options.tileSize.width !== currentOptions.tileSize.width || options.tileSize.height !== currentOptions.tileSize.height) { 115 | handleTileSizeChange(); 116 | } 117 | }, true); 118 | var sizeDimension, minSizeDimension, orthogonalDimension; 119 | scope.$watchCollection('items', function () { 120 | lastScrollPosition = Number.NEGATIVE_INFINITY; 121 | layout(true); 122 | }); 123 | var resizeTimeout; 124 | scope.$on('td.tileview.resize', function () { 125 | // this might be called within a $digest 126 | if (resizeTimeout) { 127 | $timeout.cancel(resizeTimeout); 128 | } 129 | resizeTimeout = $timeout(resize, 50, false); 130 | }); 131 | scope.$on('td.tileview.update', function () { 132 | layout(true); 133 | }); 134 | angular.element($window).on('resize', onResize); 135 | scope.$on('$destroy', function () { 136 | angular.element($window).off('resize', onResize); 137 | // unregister all timers: 138 | if (resizeTimeout !== undefined) { 139 | $timeout.cancel(resizeTimeout); 140 | } 141 | if (scrollEndTimeout !== undefined) { 142 | $timeout.cancel(scrollEndTimeout); 143 | } 144 | if (debounceTimeout !== undefined) { 145 | $timeout.cancel(debounceTimeout); 146 | } 147 | removeAll(); 148 | }); 149 | function removeElement(el) { 150 | var id = el.attr('id'); 151 | if (scopes[id] !== undefined) { 152 | scopes[id].$destroy(); 153 | delete scopes[id]; 154 | } 155 | el.remove(); 156 | } 157 | function removeAll() { 158 | forEachRow(removeRow); 159 | } 160 | function forEachElement(fn) { 161 | forEachRow(function (row, rowIndex) { 162 | for (var i = 0; i < row.children().length; ++i) { 163 | fn(row.children().eq(i), rowIndex * itemsPerRow + i); 164 | } 165 | }); 166 | } 167 | function forEachRow(fn) { 168 | var numOfRows = visibleRowCount(); 169 | for (var i = 0; i < numOfRows; ++i) { 170 | fn(itemContainer.children().eq(i), startRow + i); 171 | } 172 | } 173 | function visibleRowCount() { 174 | return itemContainer.children().length; 175 | } 176 | function itemElementCount() { 177 | return visibleRowCount() * itemsPerRow; 178 | } 179 | var lastScrollPosition = Number.NEGATIVE_INFINITY; 180 | function updateVisibleRows() { 181 | function clamp(value, min, max) { 182 | return Math.max(Math.min(value, max), min); 183 | } 184 | var rect = container[0].getBoundingClientRect(); 185 | var itemSize = scope.options.tileSize[sizeDimension]; 186 | var maxScrollPosition = rowCount * itemSize - rect[sizeDimension]; 187 | var scrollPosition = scope.options.alignHorizontal ? 188 | container.scrollLeft() : 189 | container[0].scrollTop; 190 | var scrollEndThreshold = maxScrollPosition - scope.options.scrollEndOffset * itemSize; 191 | if (scrollPosition >= scrollEndThreshold && !(lastScrollPosition >= scrollEndThreshold) && scope.options.onScrollEnd !== undefined) { 192 | scope.options.onScrollEnd(); 193 | } 194 | startRow = clamp(Math.floor(scrollPosition / itemSize) - scope.options.overflow, 0, rowCount - cachedRowCount); 195 | endRow = startRow + cachedRowCount; 196 | lastScrollPosition = scrollPosition; 197 | } 198 | function updateItem(elem, index, digest) { 199 | var item = scope.items[index]; 200 | if (item !== undefined) { 201 | if (elem.css('display') === 'none') { 202 | elem.css('display', 'inline-block'); 203 | } 204 | var itemScope = scopes[elem.attr('id')]; 205 | itemScope.item = item; 206 | itemScope.$index = index; 207 | if (digest === true) { 208 | itemScope.$digest(); 209 | } 210 | } 211 | else { 212 | elem.css('display', 'none'); 213 | } 214 | } 215 | function updateRow(el, rowIndex, digest) { 216 | var ch = el.children(); 217 | for (var i = 0; i < ch.length; ++i) { 218 | updateItem(ch.eq(i), rowIndex * itemsPerRow + i, digest); 219 | } 220 | var translate = Math.max(rowIndex * scope.options.tileSize[sizeDimension], 0); 221 | //el.css('transform', `${translate}(${Math.max(rowIndex * scope.options.tileSize[sizeDimension], 0)}px), translateZ(${rowIndex})`); 222 | if (scope.options.alignHorizontal) { 223 | if (itemContainer.direction() === 'rtl') { 224 | translate = -translate; 225 | } 226 | el.css('transform', "translate3d(" + translate + "px, 0px, 0)"); 227 | } 228 | else { 229 | el.css('transform', "translate3d(0px, " + translate + "px, 0)"); 230 | } 231 | } 232 | function addRow() { 233 | var row = angular.element('
'); 234 | row.css('position', 'absolute'); 235 | itemContainer.append(row); 236 | return row; 237 | } 238 | function clearRow(row) { 239 | while (row.children().length > 0) { 240 | removeElementFromRow(row); 241 | } 242 | } 243 | function removeRow() { 244 | var row = itemContainer.children().eq(-1); 245 | clearRow(row); 246 | row.remove(); 247 | } 248 | function addElementToRow(row) { 249 | var newScope = scope.$parent.$new(); 250 | linkFunction(newScope, function (clonedElement) { 251 | clonedElement.css({ 252 | width: scope.options.tileSize.width + 'px', 253 | height: scope.options.tileSize.height + 'px', 254 | display: 'inline-block', 255 | 'vertical-align': 'top' 256 | }); 257 | var scopeId = nextScopeId(); 258 | clonedElement.attr('id', scopeId); 259 | scopes[scopeId] = newScope; 260 | row.append(clonedElement); 261 | }); 262 | } 263 | function fillRow(row) { 264 | var currentRowLength = row.children().length; 265 | if (currentRowLength < itemsPerRow) { 266 | for (var i = currentRowLength; i < itemsPerRow; ++i) { 267 | addElementToRow(row); 268 | } 269 | } 270 | else if (currentRowLength > itemsPerRow) { 271 | for (var i = currentRowLength; i > itemsPerRow; --i) { 272 | removeElementFromRow(row); 273 | } 274 | } 275 | } 276 | function removeElementFromRow(row) { 277 | removeElement(row.children().eq(-1)); 278 | } 279 | function createElements(numRows) { 280 | updateVisibleRows(); 281 | var currentRowCount = itemContainer.children().length; 282 | if (currentRowCount < numRows) { 283 | for (var i = currentRowCount; i < numRows; ++i) { 284 | addRow(); 285 | } 286 | } 287 | else if (currentRowCount > numRows) { 288 | for (var i = currentRowCount; i > numRows; --i) { 289 | removeRow(); 290 | } 291 | } 292 | forEachRow(fillRow); 293 | virtualRows = []; 294 | var startIndex = startRow * itemsPerRow; 295 | forEachRow(function (el, i) { 296 | virtualRows.push(el); 297 | updateRow(el, i, false); 298 | }); 299 | renderedStartRow = startRow; 300 | renderedEndRow = endRow; 301 | } 302 | function resize() { 303 | var newComponentSize = container[0].getBoundingClientRect(); 304 | if (newComponentSize.width !== componentWidth || newComponentSize.height !== componentHeight) { 305 | if (layout(false)) { 306 | forEachElement(function (el) { return scopes[el.attr('id')].$digest(); }); 307 | } 308 | } 309 | } 310 | function onResize() { 311 | resize(); 312 | } 313 | function measure() { 314 | var rect = container[0].getBoundingClientRect(); 315 | componentWidth = rect.width; 316 | componentHeight = rect.height; 317 | var itemWidth = scope.options.tileSize.width; 318 | var width = rect.width; 319 | var size = rect[sizeDimension]; 320 | var newItemsPerRow = (scope.options.alignHorizontal) ? 1 : Math.floor(width / itemWidth); 321 | var newCachedRowCount = Math.ceil(size / scope.options.tileSize[sizeDimension]) + scope.options.overflow * 2; 322 | var changes = newItemsPerRow !== itemsPerRow || newCachedRowCount !== cachedRowCount; 323 | itemsPerRow = Math.max(newItemsPerRow, 1); 324 | cachedRowCount = newCachedRowCount; 325 | rowCount = Math.ceil(scope.items.length / itemsPerRow); 326 | return changes; 327 | } 328 | var componentWidth = 0, componentHeight = 0; 329 | function layout(alwaysLayout) { 330 | if (linkFunction !== undefined && scope.items !== undefined && sizeDimension !== undefined) { 331 | if (measure() || alwaysLayout) { 332 | createElements(cachedRowCount); 333 | itemContainer.css(minSizeDimension, rowCount * scope.options.tileSize[sizeDimension] + 'px'); 334 | itemContainer.css(orthogonalDimension, '100%'); 335 | //setPlaceholder(); 336 | scope.$parent.$broadcast('td.tileview.layout'); 337 | return true; 338 | } 339 | } 340 | return false; 341 | } 342 | function update() { 343 | updateVisibleRows(); 344 | animationFrameRequested = false; 345 | if (startRow !== renderedStartRow || endRow !== renderedEndRow) { 346 | if (startRow > renderedEndRow || endRow < renderedStartRow) { 347 | virtualRows.forEach(function (el, i) { return updateRow(el, startRow + i, true); }); 348 | } 349 | else { 350 | var intersectionStart = Math.max(startRow, renderedStartRow); 351 | var intersectionEnd = Math.min(endRow, renderedEndRow); 352 | if (endRow > intersectionEnd) { 353 | // scrolling downwards 354 | for (var i = intersectionEnd; i < endRow; ++i) { 355 | var e = virtualRows.shift(); 356 | updateRow(e, i, true); 357 | virtualRows.push(e); 358 | } 359 | } 360 | else if (startRow < intersectionStart) { 361 | // scrolling upwards 362 | for (var i = intersectionStart - 1; i >= startRow; --i) { 363 | var e = virtualRows.pop(); 364 | updateRow(e, i, true); 365 | virtualRows.unshift(e); 366 | } 367 | } 368 | } 369 | renderedStartRow = startRow; 370 | renderedEndRow = endRow; 371 | } 372 | } 373 | function detectScrollStartEnd() { 374 | if (scope.options.afterScrollDelay !== undefined) { 375 | if (scrollEndTimeout !== undefined) { 376 | $timeout.cancel(scrollEndTimeout); 377 | } 378 | else { 379 | scope.$parent.$broadcast('td.tileview.scrollStart'); 380 | } 381 | scrollEndTimeout = $timeout(function () { 382 | // scrolling ends: 383 | scrollEndTimeout = undefined; 384 | scope.$parent.$broadcast('td.tileview.scrollEnd'); 385 | }, scope.options.afterScrollDelay, false); 386 | } 387 | } 388 | var debounceTimeout, scrollEndTimeout; 389 | var animationFrameRequested = false; 390 | function onScroll() { 391 | detectScrollStartEnd(); 392 | if (scope.options.debounce !== undefined && scope.options.debounce > 0) { 393 | if (debounceTimeout === undefined) { 394 | debounceTimeout = $timeout(function () { 395 | debounceTimeout = undefined; 396 | update(); 397 | }, scope.options.debounce, false); 398 | } 399 | } 400 | else { 401 | if (!animationFrameRequested) { 402 | animationFrameRequested = true; 403 | requestAnimationFrame(update); 404 | } 405 | } 406 | } 407 | container.on('scroll', onScroll); 408 | } 409 | }; 410 | }]); 411 | // Helper functions: 412 | function def(value, defaultValue) { 413 | return (value !== undefined) ? value : defaultValue; 414 | } 415 | })(); 416 | 417 | angular.module("td.tileview").run(["$templateCache", function($templateCache) {$templateCache.put("tileview.tpl.html","
\n
\n\n
\n
");}]); -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var ts = require('gulp-typescript'); 3 | var createTemplateCache = require('gulp-angular-templatecache'); 4 | var concat = require('gulp-concat'); 5 | var less = require('gulp-less'); 6 | var streamqueue = require('streamqueue'); 7 | 8 | gulp.task('default', ['build']); 9 | 10 | gulp.task('watch', function() { 11 | return gulp.watch('src/**', ['build']) 12 | }); 13 | 14 | gulp.task('build', ['compile', 'less']); 15 | 16 | gulp.task('compile', function () { 17 | streamqueue({ objectMode: true }, compile(), templateCache()) 18 | .pipe(concat('tileview.js')) 19 | .pipe(gulp.dest('dist')); 20 | }); 21 | 22 | gulp.task('less', function() { 23 | return gulp.src('src/**/*.less') 24 | .pipe(less()) 25 | .pipe(gulp.dest('dist')); 26 | }); 27 | 28 | function compile() { 29 | return gulp.src('src/**/*.ts') 30 | .pipe(ts({ 31 | //noImplicitAny: true 32 | })); 33 | } 34 | 35 | function templateCache() { 36 | return gulp.src('src/**/*.tpl.html') 37 | .pipe(createTemplateCache({ 38 | module: 'td.tileview' 39 | })); 40 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-tileview", 3 | "version": "0.6.1", 4 | "description": "A tileview for angular", 5 | "main": "gulpfile.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/tinydesk/angular-tileview.git" 12 | }, 13 | "author": "", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/tinydesk/angular-tileview/issues" 17 | }, 18 | "homepage": "https://github.com/tinydesk/angular-tileview#readme", 19 | "devDependencies": { 20 | "gulp": "^3.9.1", 21 | "gulp-angular-templatecache": "^1.8.0", 22 | "gulp-concat": "^2.6.0", 23 | "gulp-less": "^3.0.5", 24 | "gulp-typescript": "^2.13.0", 25 | "streamqueue": "^1.1.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/tileview.less: -------------------------------------------------------------------------------- 1 | .tile-view { 2 | height: 100%; 3 | overflow-y: auto; 4 | overflow-x: hidden; 5 | 6 | .item-container { 7 | position: relative; 8 | } 9 | 10 | &.horizontal { 11 | white-space: nowrap; 12 | overflow-x: auto; 13 | 14 | > * { 15 | vertical-align: top; 16 | display: inline-block; 17 | height: 100%; 18 | } 19 | 20 | .item-container { 21 | > div > * { 22 | white-space: normal; 23 | } 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /src/tileview.tpl.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
-------------------------------------------------------------------------------- /src/tileview.ts: -------------------------------------------------------------------------------- 1 | // service of module 2 | declare const angular: any; 3 | 4 | (() => { 5 | 'use strict'; 6 | 7 | const mod = angular.module('td.tileview', ['td.scroll']); 8 | 9 | /** 10 | * @ngdoc directive 11 | * @name td.tileview.directive:tdTileview 12 | * @restrict E 13 | * 14 | * @description 15 | * 16 | * The tile directive provides a tile-based view on a list of data. The tiles can be arranged in a grid or they can be 17 | * horizontally stacked. 18 | * 19 | * The tile directive will automatically resize when the window is resized. If the size changed for some other reasons, a manual resize 20 | * can be triggered, by broadcasting the `td.tileview.resize` event. There are two other events, that indicate the beginning and ending 21 | * of a scrolling movement. These events can be used to implement custom performance optimisations, because not every DOM change needs to 22 | * be done while scrolling. The events are: `td.tileview.scrollStart` and `td.tileview.scrollEnd`. In order to detect when scrolling ends 23 | * a debounce delay is used. It can be configured with the `afterScrollDelay` options property. 24 | * 25 | * @param {Array=} items The items that are to be displayed in the tile view 26 | * @param {object=} options An options object defining options that are relevant specifically for the tile ui such as 27 | * tile sizes for example. It consists of the following properties: 28 | * 29 | * - **templateUrl** - {string} - Path to the template that should be used to render tiles. The template will implicitly have 30 | * access to the tile directive's scope plus an `item` object. Note that the template is responsible for maintaining the 31 | * selection state by calling the appropriate methods on the selection object. 32 | * - **tileSize** - {object} - The current tile size represented as an object with the following properties: 33 | * - **width** - {int} - The width of the tile. 34 | * - **height** - {int} - The height of the tile. 35 | * Can be dynamically adjusted. 36 | * - **alignHorizontal** - {boolean} - Whether to show the tiles in a grid with a vertical scrollbar or horizontally 37 | * stacked. 38 | * - **onScrollEnd** - {function} - A callback that is invoked when the user scrolls to the end of the data. 39 | * - **scrollEndOffset** - {number} - Some features that rely on the `scrollEnd` callback need to be informed in advance. 40 | * This property specifies an offset in rows to trigger the scroll end event before actually hitting the bottom of the data. **Default**: 0 41 | * - **overflow** - {number} - Number of rows that are rendered additionally to the visible rows to make the scrolling experience more fluent. **Default**: 2 42 | * - **debounce** - {number} - Debounce for the scroll event. A value of `0` is interpreted as no debounce. **Default**: 0. 43 | * - **afterScrollDelay** - {number} - Time to wait in order to decide whether a scroll movement has finished. **Default**: 100. 44 | */ 45 | mod.directive('tdTileview', ['$compile', '$templateCache', '$timeout', '$window', ($compile, $templateCache, $timeout, $window) => { 46 | return { 47 | restrict: 'E', 48 | scope: { 49 | items: '=', 50 | options: '=' 51 | }, 52 | template: $templateCache.get('tileview.tpl.html'), 53 | link: (scope, elem, attrs) => { 54 | scope.elem = elem; 55 | scope.tileStyle = {}; 56 | scope.tileStyle.marginRight = "4px"; 57 | scope.tileStyle.marginBottom = "4px"; 58 | scope.tileStyle.float = "left"; 59 | 60 | const container = elem.children(); 61 | const itemContainer = container.children().eq(0); 62 | 63 | let linkFunction; 64 | 65 | let heightStart = 0; 66 | let heightEnd = 0; 67 | 68 | let startRow = 0, endRow; 69 | let renderedStartRow = -1, renderedEndRow = -1; 70 | 71 | let itemsPerRow; 72 | let rowCount; 73 | let cachedRowCount; 74 | 75 | let virtualRows = []; 76 | const scopes = {}; 77 | let scopeCounter = 0; 78 | 79 | function nextScopeId() { 80 | scopeCounter = scopeCounter + 1; 81 | return 'scope-' + scopeCounter; 82 | } 83 | 84 | function handleTileSizeChange() { 85 | forEachElement(el => { 86 | el.css('width', scope.options.tileSize.width + 'px'); 87 | el.css('height', scope.options.tileSize.height + 'px'); 88 | }); 89 | } 90 | 91 | function handleTemplateUrlChange() { 92 | const template = $templateCache.get(scope.options.templateUrl); 93 | if (template !== undefined) { 94 | linkFunction = $compile(template); 95 | removeAll(); 96 | } else { 97 | console.error('Template url not found: ' + scope.options.templateUrl); 98 | } 99 | } 100 | 101 | function handleAlignHorizontalChange() { 102 | if (scope.options.alignHorizontal) { 103 | sizeDimension = 'width'; 104 | minSizeDimension = 'min-width'; 105 | orthogonalDimension = 'min-height'; 106 | elem.children().addClass('horizontal'); 107 | } else { 108 | sizeDimension = 'height'; 109 | minSizeDimension = 'min-height'; 110 | orthogonalDimension = 'min-width'; 111 | elem.children().removeClass('horizontal'); 112 | } 113 | } 114 | 115 | scope.$watch('options', (options, currentOptions) => { 116 | // set defaults: 117 | options.scrollEndOffset = def(options.scrollEndOffset, 0); 118 | options.overflow = def(options.overflow, 2); 119 | options.debounce = def(options.debounce, 0); 120 | options.afterScrollDelay = def(options.afterScrollDelay, 100); 121 | 122 | if (options === currentOptions || options.templateUrl !== currentOptions.templateUrl) { 123 | handleTemplateUrlChange(); 124 | } 125 | if (options === currentOptions || options.alignHorizontal !== currentOptions.alignHorizontal) { 126 | handleAlignHorizontalChange(); 127 | } 128 | layout(true); 129 | if (options === currentOptions || options.tileSize.width !== currentOptions.tileSize.width || options.tileSize.height !== currentOptions.tileSize.height) { 130 | handleTileSizeChange(); 131 | } 132 | }, true); 133 | 134 | var sizeDimension, minSizeDimension, orthogonalDimension; 135 | scope.$watchCollection('items', () => { 136 | lastScrollPosition = Number.NEGATIVE_INFINITY; 137 | layout(true); 138 | }); 139 | 140 | let resizeTimeout; 141 | scope.$on('td.tileview.resize', () => { 142 | // this might be called within a $digest 143 | if (resizeTimeout) { 144 | $timeout.cancel(resizeTimeout); 145 | } 146 | resizeTimeout = $timeout(resize, 50, false); 147 | }); 148 | scope.$on('td.tileview.update', () => { 149 | layout(true); 150 | }); 151 | 152 | angular.element($window).on('resize', onResize); 153 | 154 | scope.$on('$destroy', function () { 155 | angular.element($window).off('resize', onResize); 156 | 157 | // unregister all timers: 158 | if (resizeTimeout !== undefined) { 159 | $timeout.cancel(resizeTimeout); 160 | } 161 | if (scrollEndTimeout !== undefined) { 162 | $timeout.cancel(scrollEndTimeout); 163 | } 164 | if (debounceTimeout !== undefined) { 165 | $timeout.cancel(debounceTimeout); 166 | } 167 | 168 | removeAll(); 169 | }); 170 | 171 | function removeElement(el) { 172 | const id = el.attr('id'); 173 | if (scopes[id] !== undefined) { 174 | scopes[id].$destroy(); 175 | delete scopes[id]; 176 | } 177 | el.remove(); 178 | } 179 | 180 | function removeAll() { 181 | forEachRow(removeRow); 182 | } 183 | 184 | function forEachElement(fn) { 185 | forEachRow((row, rowIndex) => { 186 | for (let i = 0; i < row.children().length; ++i) { 187 | fn(row.children().eq(i), rowIndex*itemsPerRow + i); 188 | } 189 | }); 190 | } 191 | 192 | function forEachRow(fn) { 193 | const numOfRows = visibleRowCount(); 194 | for (let i = 0; i < numOfRows; ++i) { 195 | fn(itemContainer.children().eq(i), startRow + i); 196 | } 197 | } 198 | 199 | function visibleRowCount() { 200 | return itemContainer.children().length; 201 | } 202 | 203 | function itemElementCount() { 204 | return visibleRowCount() * itemsPerRow; 205 | } 206 | 207 | let lastScrollPosition = Number.NEGATIVE_INFINITY; 208 | function updateVisibleRows() { 209 | function clamp(value, min, max) { 210 | return Math.max(Math.min(value, max), min); 211 | } 212 | 213 | const rect = container[0].getBoundingClientRect(); 214 | const itemSize = scope.options.tileSize[sizeDimension]; 215 | 216 | const maxScrollPosition = rowCount * itemSize - rect[sizeDimension]; 217 | 218 | let scrollPosition = scope.options.alignHorizontal ? 219 | container.scrollLeft() : 220 | container[0].scrollTop; 221 | 222 | const scrollEndThreshold = maxScrollPosition - scope.options.scrollEndOffset * itemSize; 223 | if (scrollPosition >= scrollEndThreshold && !(lastScrollPosition >= scrollEndThreshold) && scope.options.onScrollEnd !== undefined) { 224 | scope.options.onScrollEnd(); 225 | } 226 | 227 | startRow = clamp(Math.floor(scrollPosition / itemSize) - scope.options.overflow, 0, rowCount - cachedRowCount); 228 | endRow = startRow + cachedRowCount; 229 | lastScrollPosition = scrollPosition; 230 | } 231 | 232 | function updateItem(elem, index, digest) { 233 | const item = scope.items[index]; 234 | if (item !== undefined) { 235 | if (elem.css('display') === 'none') { 236 | elem.css('display', 'inline-block'); 237 | } 238 | const itemScope = scopes[elem.attr('id')]; 239 | itemScope.item = item; 240 | itemScope.$index = index; 241 | if (digest === true) { 242 | itemScope.$digest(); 243 | } 244 | } else { 245 | elem.css('display', 'none'); 246 | } 247 | } 248 | 249 | function updateRow(el, rowIndex, digest) { 250 | const ch = el.children(); 251 | for (let i = 0; i < ch.length; ++i) { 252 | updateItem(ch.eq(i), rowIndex * itemsPerRow + i, digest); 253 | } 254 | let translate = Math.max(rowIndex * scope.options.tileSize[sizeDimension], 0); 255 | //el.css('transform', `${translate}(${Math.max(rowIndex * scope.options.tileSize[sizeDimension], 0)}px), translateZ(${rowIndex})`); 256 | if (scope.options.alignHorizontal) { 257 | if (itemContainer.direction() === 'rtl') { 258 | translate = -translate; 259 | } 260 | el.css('transform', `translate3d(${translate}px, 0px, 0)`); 261 | } else { 262 | el.css('transform', `translate3d(0px, ${translate}px, 0)`); 263 | } 264 | } 265 | 266 | function addRow() { 267 | const row = angular.element('
'); 268 | row.css('position', 'absolute'); 269 | itemContainer.append(row); 270 | return row; 271 | } 272 | 273 | function clearRow(row) { 274 | while (row.children().length > 0) { 275 | removeElementFromRow(row); 276 | } 277 | } 278 | 279 | function removeRow() { 280 | const row = itemContainer.children().eq(-1); 281 | clearRow(row); 282 | row.remove(); 283 | } 284 | 285 | function addElementToRow(row) { 286 | const newScope = scope.$parent.$new(); 287 | linkFunction(newScope, function (clonedElement) { 288 | clonedElement.css({ 289 | width: scope.options.tileSize.width + 'px', 290 | height: scope.options.tileSize.height + 'px', 291 | display: 'inline-block', 292 | 'vertical-align': 'top' 293 | }); 294 | const scopeId = nextScopeId(); 295 | clonedElement.attr('id', scopeId); 296 | scopes[scopeId] = newScope; 297 | row.append(clonedElement); 298 | }); 299 | } 300 | 301 | function fillRow(row) { 302 | const currentRowLength = row.children().length; 303 | if (currentRowLength < itemsPerRow) { 304 | for (let i = currentRowLength; i < itemsPerRow; ++i) { 305 | addElementToRow(row); 306 | } 307 | } else if (currentRowLength > itemsPerRow) { 308 | for (let i = currentRowLength; i > itemsPerRow; --i) { 309 | removeElementFromRow(row); 310 | } 311 | } 312 | } 313 | 314 | function removeElementFromRow(row) { 315 | removeElement(row.children().eq(-1)); 316 | } 317 | 318 | function createElements(numRows) { 319 | updateVisibleRows(); 320 | const currentRowCount = itemContainer.children().length; 321 | 322 | if (currentRowCount < numRows) { 323 | for (let i = currentRowCount; i < numRows; ++i) { 324 | addRow(); 325 | } 326 | } else if (currentRowCount > numRows) { 327 | for (let i = currentRowCount; i > numRows; --i) { 328 | removeRow(); 329 | } 330 | } 331 | 332 | forEachRow(fillRow); 333 | 334 | virtualRows = []; 335 | const startIndex = startRow * itemsPerRow; 336 | forEachRow((el, i) => { 337 | virtualRows.push(el); 338 | updateRow(el, i, false); 339 | }); 340 | renderedStartRow = startRow; 341 | renderedEndRow = endRow; 342 | } 343 | 344 | function resize() { 345 | const newComponentSize = container[0].getBoundingClientRect(); 346 | if (newComponentSize.width !== componentWidth || newComponentSize.height !== componentHeight) { 347 | if (layout(false)) { 348 | forEachElement(el => scopes[el.attr('id')].$digest()); 349 | } 350 | } 351 | } 352 | 353 | function onResize() { 354 | resize(); 355 | } 356 | 357 | function measure() { 358 | const rect = container[0].getBoundingClientRect(); 359 | componentWidth = rect.width; 360 | componentHeight = rect.height; 361 | const itemWidth = scope.options.tileSize.width; 362 | const width = rect.width; 363 | const size = rect[sizeDimension]; 364 | 365 | const newItemsPerRow = (scope.options.alignHorizontal) ? 1 : Math.floor(width / itemWidth); 366 | const newCachedRowCount = Math.ceil(size / scope.options.tileSize[sizeDimension]) + scope.options.overflow * 2; 367 | 368 | const changes = newItemsPerRow !== itemsPerRow || newCachedRowCount !== cachedRowCount; 369 | itemsPerRow = Math.max(newItemsPerRow, 1); 370 | cachedRowCount = newCachedRowCount; 371 | rowCount = Math.ceil(scope.items.length / itemsPerRow); 372 | return changes; 373 | } 374 | 375 | let componentWidth = 0, componentHeight = 0; 376 | function layout(alwaysLayout) { 377 | if (linkFunction !== undefined && scope.items !== undefined && sizeDimension !== undefined) { 378 | if (measure() || alwaysLayout) { 379 | createElements(cachedRowCount); 380 | 381 | itemContainer.css(minSizeDimension, rowCount * scope.options.tileSize[sizeDimension] + 'px'); 382 | itemContainer.css(orthogonalDimension, '100%'); 383 | //setPlaceholder(); 384 | scope.$parent.$broadcast('td.tileview.layout'); 385 | return true; 386 | } 387 | } 388 | return false; 389 | } 390 | 391 | function update() { 392 | updateVisibleRows(); 393 | animationFrameRequested = false; 394 | 395 | if (startRow !== renderedStartRow || endRow !== renderedEndRow) { 396 | if (startRow > renderedEndRow || endRow < renderedStartRow) { 397 | virtualRows.forEach((el, i) => updateRow(el, startRow + i, true)); 398 | //forEachRow((el, i) => updateRow(el, startRow + i, true)); 399 | } else { 400 | const intersectionStart = Math.max(startRow, renderedStartRow); 401 | const intersectionEnd = Math.min(endRow, renderedEndRow); 402 | if (endRow > intersectionEnd) { 403 | // scrolling downwards 404 | for (let i = intersectionEnd; i < endRow; ++i) { 405 | const e = virtualRows.shift(); 406 | updateRow(e, i, true); 407 | virtualRows.push(e); 408 | } 409 | } else if (startRow < intersectionStart) { 410 | // scrolling upwards 411 | for (let i = intersectionStart - 1; i >= startRow; --i) { 412 | const e = virtualRows.pop(); 413 | updateRow(e, i, true); 414 | virtualRows.unshift(e); 415 | } 416 | } 417 | } 418 | 419 | renderedStartRow = startRow; 420 | renderedEndRow = endRow; 421 | } 422 | } 423 | 424 | function detectScrollStartEnd() { 425 | if (scope.options.afterScrollDelay !== undefined) { 426 | if (scrollEndTimeout !== undefined) { 427 | $timeout.cancel(scrollEndTimeout); 428 | } else { 429 | scope.$parent.$broadcast('td.tileview.scrollStart'); 430 | } 431 | scrollEndTimeout = $timeout(() => { 432 | // scrolling ends: 433 | scrollEndTimeout = undefined; 434 | scope.$parent.$broadcast('td.tileview.scrollEnd'); 435 | }, scope.options.afterScrollDelay, false); 436 | } 437 | } 438 | 439 | let debounceTimeout, scrollEndTimeout; 440 | let animationFrameRequested = false; 441 | function onScroll() { 442 | detectScrollStartEnd(); 443 | if (scope.options.debounce !== undefined && scope.options.debounce > 0) { 444 | if (debounceTimeout === undefined) { 445 | debounceTimeout = $timeout(function () { 446 | debounceTimeout = undefined; 447 | update(); 448 | }, scope.options.debounce, false); 449 | } 450 | } else { 451 | if (!animationFrameRequested) { 452 | animationFrameRequested = true; 453 | requestAnimationFrame(update); 454 | } 455 | } 456 | } 457 | 458 | container.on('scroll', onScroll); 459 | } 460 | }; 461 | }]); 462 | 463 | // Helper functions: 464 | function def(value, defaultValue) { 465 | return (value !== undefined) ? value : defaultValue; 466 | } 467 | 468 | })(); --------------------------------------------------------------------------------