2 * Angular Material Design
3 * https://github.com/angular/material
7 goog.provide('ngmaterial.components.gridList');
8 goog.require('ngmaterial.core');
11 * @name material.components.gridList
13 GridListController['$inject'] = ["$mdUtil"];
14 GridLayoutFactory['$inject'] = ["$mdUtil"];
15 GridListDirective['$inject'] = ["$interpolate", "$mdConstant", "$mdGridLayout", "$mdMedia"];
16 GridTileDirective['$inject'] = ["$mdMedia"];
17 angular.module('material.components.gridList', ['material.core'])
18 .directive('mdGridList', GridListDirective)
19 .directive('mdGridTile', GridTileDirective)
20 .directive('mdGridTileFooter', GridTileCaptionDirective)
21 .directive('mdGridTileHeader', GridTileCaptionDirective)
22 .factory('$mdGridLayout', GridLayoutFactory);
27 * @module material.components.gridList
30 * Grid lists are an alternative to standard list views. Grid lists are distinct
31 * from grids used for layouts and other visual presentations.
33 * A grid list is best suited to presenting a homogenous data type, typically
34 * images, and is optimized for visual comprehension and differentiating between
37 * A grid list is a continuous element consisting of tessellated, regular
38 * subdivisions called cells that contain tiles (`md-grid-tile`).
40 * <img src="//material-design.storage.googleapis.com/publish/v_2/material_ext_publish/0Bx4BSt6jniD7OVlEaXZ5YmU1Xzg/components_grids_usage2.png"
41 * style="width: 300px; height: auto; margin-right: 16px;" alt="Concept of grid explained visually">
42 * <img src="//material-design.storage.googleapis.com/publish/v_2/material_ext_publish/0Bx4BSt6jniD7VGhsOE5idWlJWXM/components_grids_usage3.png"
43 * style="width: 300px; height: auto;" alt="Grid concepts legend">
45 * Cells are arrayed vertically and horizontally within the grid.
47 * Tiles hold content and can span one or more cells vertically or horizontally.
49 * ### Responsive Attributes
51 * The `md-grid-list` directive supports "responsive" attributes, which allow
52 * different `md-cols`, `md-gutter` and `md-row-height` values depending on the
53 * currently matching media query.
55 * In order to set a responsive attribute, first define the fallback value with
56 * the standard attribute name, then add additional attributes with the
57 * following convention: `{base-attribute-name}-{media-query-name}="{value}"`
58 * (ie. `md-cols-lg="8"`)
60 * @param {number} md-cols Number of columns in the grid.
61 * @param {string} md-row-height One of
63 * <li>CSS length - Fixed height rows (eg. `8px` or `1rem`)</li>
64 * <li>`{width}:{height}` - Ratio of width to height (eg.
65 * `md-row-height="16:9"`)</li>
66 * <li>`"fit"` - Height will be determined by subdividing the available
67 * height by the number of rows</li>
69 * @param {string=} md-gutter The amount of space between tiles in CSS units
71 * @param {expression=} md-on-layout Expression to evaluate after layout. Event
72 * object is available as `$event`, and contains performance information.
77 * <md-grid-list md-cols="5" md-gutter="1em" md-row-height="4:3">
78 * <md-grid-tile></md-grid-tile>
84 * <md-grid-list md-cols="4" md-row-height="200px" ...>
85 * <md-grid-tile></md-grid-tile>
91 * <md-grid-list md-cols="4" md-row-height="fit" style="height: 400px;" ...>
92 * <md-grid-tile></md-grid-tile>
96 * Using responsive attributes:
104 * <md-grid-tile></md-grid-tile>
108 function GridListDirective($interpolate, $mdConstant, $mdGridLayout, $mdMedia) {
111 controller: GridListController,
118 function postLink(scope, element, attrs, ctrl) {
119 element.addClass('_md'); // private md component indicator for styling
122 element.attr('role', 'list');
124 // Provide the controller with a way to trigger layouts.
125 ctrl.layoutDelegate = layoutDelegate;
127 var invalidateLayout = angular.bind(ctrl, ctrl.invalidateLayout),
128 unwatchAttrs = watchMedia();
129 scope.$on('$destroy', unwatchMedia);
132 * Watches for changes in media, invalidating layout as necessary.
134 function watchMedia() {
135 for (var mediaName in $mdConstant.MEDIA) {
136 $mdMedia(mediaName); // initialize
137 $mdMedia.getQuery($mdConstant.MEDIA[mediaName])
138 .addListener(invalidateLayout);
140 return $mdMedia.watchResponsiveAttributes(
141 ['md-cols', 'md-row-height', 'md-gutter'], attrs, layoutIfMediaMatch);
144 function unwatchMedia() {
145 ctrl.layoutDelegate = angular.noop;
148 for (var mediaName in $mdConstant.MEDIA) {
149 $mdMedia.getQuery($mdConstant.MEDIA[mediaName])
150 .removeListener(invalidateLayout);
155 * Performs grid layout if the provided mediaName matches the currently
158 function layoutIfMediaMatch(mediaName) {
159 if (mediaName == null) {
160 // TODO(shyndman): It would be nice to only layout if we have
161 // instances of attributes using this media type
162 ctrl.invalidateLayout();
163 } else if ($mdMedia(mediaName)) {
164 ctrl.invalidateLayout();
171 * Invokes the layout engine, and uses its results to lay out our
174 * @param {boolean} tilesInvalidated Whether tiles have been
175 * added/removed/moved since the last layout. This is to avoid situations
176 * where tiles are replaced with properties identical to their removed
179 function layoutDelegate(tilesInvalidated) {
180 var tiles = getTileElements();
182 tileSpans: getTileSpans(tiles),
183 colCount: getColumnCount(),
184 rowMode: getRowMode(),
185 rowHeight: getRowHeight(),
189 if (!tilesInvalidated && angular.equals(props, lastLayoutProps)) {
194 $mdGridLayout(props.colCount, props.tileSpans, tiles)
195 .map(function(tilePositions, rowCount) {
199 style: getGridStyle(props.colCount, rowCount,
200 props.gutter, props.rowMode, props.rowHeight)
202 tiles: tilePositions.map(function(ps, i) {
204 element: angular.element(tiles[i]),
205 style: getTileStyle(ps.position, ps.spans,
206 props.colCount, rowCount,
207 props.gutter, props.rowMode, props.rowHeight)
218 performance: performance
222 lastLayoutProps = props;
225 // Use $interpolate to do some simple string interpolation as a convenience.
227 var startSymbol = $interpolate.startSymbol();
228 var endSymbol = $interpolate.endSymbol();
230 // Returns an expression wrapped in the interpolator's start and end symbols.
231 function expr(exprStr) {
232 return startSymbol + exprStr + endSymbol;
235 // The amount of space a single 1x1 tile would take up (either width or height), used as
236 // a basis for other calculations. This consists of taking the base size percent (as would be
237 // if evenly dividing the size between cells), and then subtracting the size of one gutter.
238 // However, since there are no gutters on the edges, each tile only uses a fration
239 // (gutterShare = numGutters / numCells) of the gutter size. (Imagine having one gutter per
240 // tile, and then breaking up the extra gutter on the edge evenly among the cells).
241 var UNIT = $interpolate(expr('share') + '% - (' + expr('gutter') + ' * ' + expr('gutterShare') + ')');
243 // The horizontal or vertical position of a tile, e.g., the 'top' or 'left' property value.
244 // The position comes the size of a 1x1 tile plus gutter for each previous tile in the
245 // row/column (offset).
246 var POSITION = $interpolate('calc((' + expr('unit') + ' + ' + expr('gutter') + ') * ' + expr('offset') + ')');
248 // The actual size of a tile, e.g., width or height, taking rowSpan or colSpan into account.
249 // This is computed by multiplying the base unit by the rowSpan/colSpan, and then adding back
250 // in the space that the gutter would normally have used (which was already accounted for in
251 // the base unit calculation).
252 var DIMENSION = $interpolate('calc((' + expr('unit') + ') * ' + expr('span') + ' + (' + expr('span') + ' - 1) * ' + expr('gutter') + ')');
255 * Gets the styles applied to a tile element described by the given parameters.
256 * @param {{row: number, col: number}} position The row and column indices of the tile.
257 * @param {{row: number, col: number}} spans The rowSpan and colSpan of the tile.
258 * @param {number} colCount The number of columns.
259 * @param {number} rowCount The number of rows.
260 * @param {string} gutter The amount of space between tiles. This will be something like
262 * @param {string} rowMode The row height mode. Can be one of:
263 * 'fixed': all rows have a fixed size, given by rowHeight,
264 * 'ratio': row height defined as a ratio to width, or
265 * 'fit': fit to the grid-list element height, divinding evenly among rows.
266 * @param {string|number} rowHeight The height of a row. This is only used for 'fixed' mode and
267 * for 'ratio' mode. For 'ratio' mode, this is the *ratio* of width-to-height (e.g., 0.75).
268 * @returns {Object} Map of CSS properties to be applied to the style element. Will define
269 * values for top, left, width, height, marginTop, and paddingTop.
271 function getTileStyle(position, spans, colCount, rowCount, gutter, rowMode, rowHeight) {
272 // TODO(shyndman): There are style caching opportunities here.
274 // Percent of the available horizontal space that one column takes up.
275 var hShare = (1 / colCount) * 100;
277 // Fraction of the gutter size that each column takes up.
278 var hGutterShare = (colCount - 1) / colCount;
280 // Base horizontal size of a column.
281 var hUnit = UNIT({share: hShare, gutterShare: hGutterShare, gutter: gutter});
283 // The width and horizontal position of each tile is always calculated the same way, but the
284 // height and vertical position depends on the rowMode.
286 left: POSITION({ unit: hUnit, offset: position.col, gutter: gutter }),
287 width: DIMENSION({ unit: hUnit, span: spans.col, gutter: gutter }),
297 // In fixed mode, simply use the given rowHeight.
298 style.top = POSITION({ unit: rowHeight, offset: position.row, gutter: gutter });
299 style.height = DIMENSION({ unit: rowHeight, span: spans.row, gutter: gutter });
303 // Percent of the available vertical space that one row takes up. Here, rowHeight holds
304 // the ratio value. For example, if the width:height ratio is 4:3, rowHeight = 1.333.
305 var vShare = hShare / rowHeight;
307 // Base veritcal size of a row.
308 var vUnit = UNIT({ share: vShare, gutterShare: hGutterShare, gutter: gutter });
310 // padidngTop and marginTop are used to maintain the given aspect ratio, as
311 // a percentage-based value for these properties is applied to the *width* of the
312 // containing block. See http://www.w3.org/TR/CSS2/box.html#margin-properties
313 style.paddingTop = DIMENSION({ unit: vUnit, span: spans.row, gutter: gutter});
314 style.marginTop = POSITION({ unit: vUnit, offset: position.row, gutter: gutter });
318 // Fraction of the gutter size that each column takes up.
319 var vGutterShare = (rowCount - 1) / rowCount;
321 // Percent of the available vertical space that one row takes up.
322 var vShare = (1 / rowCount) * 100;
324 // Base vertical size of a row.
325 var vUnit = UNIT({share: vShare, gutterShare: vGutterShare, gutter: gutter});
327 style.top = POSITION({unit: vUnit, offset: position.row, gutter: gutter});
328 style.height = DIMENSION({unit: vUnit, span: spans.row, gutter: gutter});
335 function getGridStyle(colCount, rowCount, gutter, rowMode, rowHeight) {
340 style.height = DIMENSION({ unit: rowHeight, span: rowCount, gutter: gutter });
341 style.paddingBottom = '';
345 // rowHeight is width / height
346 var hGutterShare = colCount === 1 ? 0 : (colCount - 1) / colCount,
347 hShare = (1 / colCount) * 100,
348 vShare = hShare * (1 / rowHeight),
349 vUnit = UNIT({ share: vShare, gutterShare: hGutterShare, gutter: gutter });
352 style.paddingBottom = DIMENSION({ unit: vUnit, span: rowCount, gutter: gutter});
356 // noop, as the height is user set
363 function getTileElements() {
364 return [].filter.call(element.children(), function(ele) {
365 return ele.tagName == 'MD-GRID-TILE' && !ele.$$mdDestroyed;
370 * Gets an array of objects containing the rowspan and colspan for each tile.
371 * @returns {Array<{row: number, col: number}>}
373 function getTileSpans(tileElements) {
374 return [].map.call(tileElements, function(ele) {
375 var ctrl = angular.element(ele).controller('mdGridTile');
378 $mdMedia.getResponsiveAttribute(ctrl.$attrs, 'md-rowspan'), 10) || 1,
380 $mdMedia.getResponsiveAttribute(ctrl.$attrs, 'md-colspan'), 10) || 1
385 function getColumnCount() {
386 var colCount = parseInt($mdMedia.getResponsiveAttribute(attrs, 'md-cols'), 10);
387 if (isNaN(colCount)) {
388 throw 'md-grid-list: md-cols attribute was not found, or contained a non-numeric value';
393 function getGutter() {
394 return applyDefaultUnit($mdMedia.getResponsiveAttribute(attrs, 'md-gutter') || 1);
397 function getRowHeight() {
398 var rowHeight = $mdMedia.getResponsiveAttribute(attrs, 'md-row-height');
400 throw 'md-grid-list: md-row-height attribute was not found';
403 switch (getRowMode()) {
405 return applyDefaultUnit(rowHeight);
407 var whRatio = rowHeight.split(':');
408 return parseFloat(whRatio[0]) / parseFloat(whRatio[1]);
414 function getRowMode() {
415 var rowHeight = $mdMedia.getResponsiveAttribute(attrs, 'md-row-height');
417 throw 'md-grid-list: md-row-height attribute was not found';
420 if (rowHeight == 'fit') {
422 } else if (rowHeight.indexOf(':') !== -1) {
429 function applyDefaultUnit(val) {
430 return /\D$/.test(val) ? val : val + 'px';
436 function GridListController($mdUtil) {
437 this.layoutInvalidated = false;
438 this.tilesInvalidated = false;
439 this.$timeout_ = $mdUtil.nextTick;
440 this.layoutDelegate = angular.noop;
443 GridListController.prototype = {
444 invalidateTiles: function() {
445 this.tilesInvalidated = true;
446 this.invalidateLayout();
449 invalidateLayout: function() {
450 if (this.layoutInvalidated) {
453 this.layoutInvalidated = true;
454 this.$timeout_(angular.bind(this, this.layout));
459 this.layoutDelegate(this.tilesInvalidated);
461 this.layoutInvalidated = false;
462 this.tilesInvalidated = false;
469 function GridLayoutFactory($mdUtil) {
470 var defaultAnimator = GridTileAnimator;
473 * Set the reflow animator callback
475 GridLayout.animateWith = function(customAnimator) {
476 defaultAnimator = !angular.isFunction(customAnimator) ? GridTileAnimator : customAnimator;
482 * Publish layout function
484 function GridLayout(colCount, tileSpans) {
485 var self, layoutInfo, gridStyles, layoutTime, mapTime, reflowTime;
487 layoutTime = $mdUtil.time(function() {
488 layoutInfo = calculateGridFor(colCount, tileSpans);
494 * An array of objects describing each tile's position in the grid.
496 layoutInfo: function() {
501 * Maps grid positioning to an element and a set of styles using the
504 map: function(updateFn) {
505 mapTime = $mdUtil.time(function() {
506 var info = self.layoutInfo();
507 gridStyles = updateFn(info.positioning, info.rowCount);
513 * Default animator simply sets the element.css( <styles> ). An alternate
514 * animator can be provided as an argument. The function has the following
517 * function({grid: {element: JQLite, style: Object}, tiles: Array<{element: JQLite, style: Object}>)
519 reflow: function(animatorFn) {
520 reflowTime = $mdUtil.time(function() {
521 var animator = animatorFn || defaultAnimator;
522 animator(gridStyles.grid, gridStyles.tiles);
528 * Timing for the most recent layout run.
530 performance: function() {
532 tileCount: tileSpans.length,
533 layoutTime: layoutTime,
535 reflowTime: reflowTime,
536 totalTime: layoutTime + mapTime + reflowTime
543 * Default Gridlist animator simple sets the css for each element;
544 * NOTE: any transitions effects must be manually set in the CSS.
548 * transition: all 700ms ease-out 50ms;
552 function GridTileAnimator(grid, tiles) {
553 grid.element.css(grid.style);
554 tiles.forEach(function(t) {
555 t.element.css(t.style);
560 * Calculates the positions of tiles.
562 * The algorithm works as follows:
563 * An Array<Number> with length colCount (spaceTracker) keeps track of
564 * available tiling positions, where elements of value 0 represents an
565 * empty position. Space for a tile is reserved by finding a sequence of
566 * 0s with length <= than the tile's colspan. When such a space has been
567 * found, the occupied tile positions are incremented by the tile's
568 * rowspan value, as these positions have become unavailable for that
571 * If the end of a row has been reached without finding space for the
572 * tile, spaceTracker's elements are each decremented by 1 to a minimum
573 * of 0. Rows are searched in this fashion until space is found.
575 function calculateGridFor(colCount, tileSpans) {
578 spaceTracker = newSpaceTracker();
581 positioning: tileSpans.map(function(spans, i) {
584 position: reserveSpace(spans, i)
587 rowCount: curRow + Math.max.apply(Math, spaceTracker)
590 function reserveSpace(spans, i) {
591 if (spans.col > colCount) {
592 throw 'md-grid-list: Tile at position ' + i + ' has a colspan ' +
593 '(' + spans.col + ') that exceeds the column count ' +
594 '(' + colCount + ')';
600 // TODO(shyndman): This loop isn't strictly necessary if you can
601 // determine the minimum number of rows before a space opens up. To do
602 // this, recognize that you've iterated across an entire row looking for
603 // space, and if so fast-forward by the minimum rowSpan count. Repeat
604 // until the required space opens up.
605 while (end - start < spans.col) {
606 if (curCol >= colCount) {
611 start = spaceTracker.indexOf(0, curCol);
612 if (start === -1 || (end = findEnd(start + 1)) === -1) {
621 adjustRow(start, spans.col, spans.row);
622 curCol = start + spans.col;
633 adjustRow(0, colCount, -1); // Decrement row spans by one
636 function adjustRow(from, cols, by) {
637 for (var i = from; i < from + cols; i++) {
638 spaceTracker[i] = Math.max(spaceTracker[i] + by, 0);
642 function findEnd(start) {
644 for (i = start; i < spaceTracker.length; i++) {
645 if (spaceTracker[i] !== 0) {
650 if (i === spaceTracker.length) {
655 function newSpaceTracker() {
657 for (var i = 0; i < colCount; i++) {
668 * @module material.components.gridList
671 * Tiles contain the content of an `md-grid-list`. They span one or more grid
672 * cells vertically or horizontally, and use `md-grid-tile-{footer,header}` to
673 * display secondary content.
675 * ### Responsive Attributes
677 * The `md-grid-tile` directive supports "responsive" attributes, which allow
678 * different `md-rowspan` and `md-colspan` values depending on the currently
679 * matching media query.
681 * In order to set a responsive attribute, first define the fallback value with
682 * the standard attribute name, then add additional attributes with the
683 * following convention: `{base-attribute-name}-{media-query-name}="{value}"`
684 * (ie. `md-colspan-sm="4"`)
686 * @param {number=} md-colspan The number of columns to span (default 1). Cannot
687 * exceed the number of columns in the grid. Supports interpolation.
688 * @param {number=} md-rowspan The number of rows to span (default 1). Supports
695 * <md-grid-tile-header>
696 * <h3>This is a header</h3>
697 * </md-grid-tile-header>
704 * <md-grid-tile-footer>
705 * <h3>This is a footer</h3>
706 * </md-grid-tile-footer>
710 * Spanning multiple rows/columns:
712 * <md-grid-tile md-colspan="2" md-rowspan="3">
716 * Responsive attributes:
718 * <md-grid-tile md-colspan="1" md-colspan-sm="3" md-colspan-md="5">
722 function GridTileDirective($mdMedia) {
725 require: '^mdGridList',
726 template: '<figure ng-transclude></figure>',
729 // Simple controller that exposes attributes to the grid directive
730 controller: ["$attrs", function($attrs) {
731 this.$attrs = $attrs;
736 function postLink(scope, element, attrs, gridCtrl) {
738 element.attr('role', 'listitem');
740 // If our colspan or rowspan changes, trigger a layout
741 var unwatchAttrs = $mdMedia.watchResponsiveAttributes(['md-colspan', 'md-rowspan'],
742 attrs, angular.bind(gridCtrl, gridCtrl.invalidateLayout));
744 // Tile registration/deregistration
745 gridCtrl.invalidateTiles();
746 scope.$on('$destroy', function() {
747 // Mark the tile as destroyed so it is no longer considered in layout,
748 // even if the DOM element sticks around (like during a leave animation)
749 element[0].$$mdDestroyed = true;
751 gridCtrl.invalidateLayout();
754 if (angular.isDefined(scope.$parent.$index)) {
755 scope.$watch(function() { return scope.$parent.$index; },
756 function indexChanged(newIdx, oldIdx) {
757 if (newIdx === oldIdx) {
760 gridCtrl.invalidateTiles();
767 function GridTileCaptionDirective() {
769 template: '<figcaption ng-transclude></figcaption>',
774 ngmaterial.components.gridList = angular.module("material.components.gridList");