Skip to content

Instantly share code, notes, and snippets.

@richardbutler
Created January 23, 2015 17:02
Show Gist options
  • Save richardbutler/71395b3c985d02d4f9b1 to your computer and use it in GitHub Desktop.
Save richardbutler/71395b3c985d02d4f9b1 to your computer and use it in GitHub Desktop.
React Layout Management Prototype
/*global TimelineLite,TweenMax,Expo,Back,Ease,BezierEasing,_ */
(function () {
'use strict';
/**
* Returns the expected container width and height based on
* the child items that it contains. Should the container not
* have an explicit size, we need to calculate it.
*/
function calculateContainerBounds (items, options, container) {
var padding = options.padding || 0;
var bounds = items.reduce(function (bounds, item) {
bounds.left = Math.max(bounds.left, item.left + item.width);
bounds.top = Math.max(bounds.top, item.top + item.height);
return bounds;
}, {
left: 0,
top: 0
});
return {
width: Math.max(container.width, bounds.left + (padding * 2)),
height: Math.max(container.height, bounds.top + (padding * 2))
};
}
/**
* A final process for the laid out items - add padding and
* such like that we don't need to complicate to initial
* layout.
*/
function postProcessItems (items, options) {
var padding = options.padding || 0;
// Apply padding to items.
return items.map(function (item) {
return _.extend({}, item, {
left: item.left + padding,
top: item.top + padding
});
});
}
/**
* Generates (curries) a linear layout - abstraction to keep
* behaviour DRY between horizontal and vertical layouts.
*/
function createLinearLayout (dimension, style) {
return function (options) {
var gap = options.gap || 0;
return function (items, container) {
var coordinateValue = 0;
var items = items.map(function (item, index) {
var itemValue = coordinateValue;
coordinateValue += item[dimension] + (gap || 0);
var item = _.extend(item, {
left: 0,
top: 0
});
item[style] = itemValue;
return item;
});
return {
items: postProcessItems(items, options),
container: calculateContainerBounds(items, options, container)
};
};
};
}
var HorizontalLayout = createLinearLayout('width', 'left');
var VerticalLayout = createLinearLayout('height', 'top');
function GridLayout (options) {
var padding = options.padding || 0;
var gap = options.gap || 0;
var itemSize = options.itemSize;
var variableRowHeight = options.variableRowHeight || false;
// Should we fill the width of the container with equal columns?
var fillWidth = options.fillWidth;
// Stretch the height of the items to the height of the largest item
// in its row?
var stretchHeight = options.stretchHeight || false;
return function (items, container) {
// Prefer an explicit width given to the container, via CSS or inline
// styles, but fall back to the measured size.
var containerWidth = container.width || container.measuredWidth;
// Total width minus any padding.
var availableWidth = containerWidth - (padding * 2);
var rows = (function () {
// X-coordinate cursor
var left = 0;
// The items in the current row - used to find the size of the
// largest item when this row has finished being laid out.
var currentRow = [];
return items.reduce(function (rows, item, index) {
// Get the right-hand bound for this item.
var right = left + item.width;
// If we're outside the width of the container, or the last item,
// move to the next row.
if (right > availableWidth) {
left = 0;
// Begin a new row.
currentRow = [item];
// Add the new row.
rows.push(currentRow);
} else {
// Append to the current row.
currentRow.push(item);
}
// Increment the x-coordinate.
left += item.width + gap;
return rows;
}, [currentRow]);
})();
items = (function () {
// Y-coordinate cursor
var top = 0;
// If we're using fillWidth, we need to store a static width.
var itemWidth;
// Standard height for each item, if variableRowHeight not used.
var itemHeight;
// If we don't want a variable row height, store the height of the
// largest item in the set.
if (!variableRowHeight) {
itemHeight = items.reduce(function (itemHeight, item) {
return Math.max(itemHeight, item.height);
}, 0);
}
return _(rows)
.map(function (currentRow, rowIndex) {
// X-coordinate cursor
var left = 0;
// Use the fixed height, or pick the tallest item in the row...
var rowHeight = itemHeight || _.pluck(currentRow, 'height').sort().pop();
// If we're filling the width, we need to work out the equal
// item width and apply it to all. Unfortunately we need to
// do this here, after we've assigned the items to currentRow.
//
// We can remove the !itemWidth check if we want to not use a
// constant width, but vary by column - i.e. a single item at
// the end of the list, for example, will span the entire width
// of the container. Maybe an additional option.
if (fillWidth && !itemWidth) {
itemWidth = (availableWidth - (gap * (currentRow.length - 1))) / currentRow.length;
}
currentRow = currentRow.map(function (item) {
// If we're using stretchWidth, manually set the width
// of each item.
if (itemWidth) {
item.width = itemWidth;
}
// If we're using stretchHeight, we have to extend the
// height of all items to that of the standard.
if (stretchHeight) {
item.height = rowHeight;
}
item = _.extend(item, {
left: left,
top: top
});
// Increment the x-coordinate.
left += item.width + gap;
return item;
});
// ...and use it to increment to top position cursor.
top += rowHeight + gap;
return currentRow;
})
.flatten()
.value();
})();
return {
items: postProcessItems(items, options),
container: calculateContainerBounds(items, options, container)
};
};
}
var DataLayoutContainer = React.createClass({
getDefaultProps: function () {
return {
dataProvider: [],
itemRenderer: 'div'
};
},
getInitialState: function () {
return {
measurePending: true
};
},
// First render flag - we can't store this in state because updating
// it would trigger React's render cycle.
firstRender: true,
render: function () {
var style = _.extend({
position: 'relative'
}, this.getContainerMeasurements());
var output = (
<div { ...this.props } className="Layout" style={ style }>
{ this.props.dataProvider.map(this.renderItem) }
</div>
);
if (this.firstRender && !this.state.measurePending) {
this.firstRender = false;
}
return output;
},
renderItem: function (item, index, items) {
var pos = this.getMeasuredItemPositionAt(index);
var style = {
position: 'absolute',
visibility: pos ? 'visible' : 'hidden'
};
var classSet = React.addons.classSet({
"Layout__item": true,
"is-selected": item.selected
});
_.extend(style, _.pick(pos, 'width', 'height'));
if (pos) {
style.transform = 'translate(' + pos.left + 'px, ' + pos.top + 'px)';
}
// Turn off transitions whilst we measure - we will release
// them back on when layout is complete.
if (this.firstRender || this.state.measurePending) {
// TODO: Not removed properly after first render.
style.transition = 'none';
}
function onClick () {
items.forEach(function (i) {
i.selected = item === i;
});
this.setProps({ dataProvider: this.props.dataProvider });
}
return (
<this.props.itemRenderer
className={ classSet }
ref={ 'item-' + index }
key={ index }
data={ item }
style={ style }
onClick={ onClick.bind(this) }
/>
);
},
getMeasuredItemPositionAt: function (index) {
var measurements = this.state.measurements;
return measurements && measurements.items[index];
},
getContainerMeasurements: function () {
var measurements = this.state.measurements;
return measurements && measurements.container;
},
componentWillReceiveProps: function (newProps) {
if ('dataProvider' in newProps) {
this.setState({ measurePending: true });
}
},
componentDidMount: function () {
this.measureAndLayout();
},
componentDidUpdate: function () {
this.measureAndLayout();
},
measureAndLayout: function () {
if (!this.state.measurePending) return;
var measuredItems = (this.props.dataProvider || [])
.map(function (item, index) {
var el = this.refs['item-' + index].getDOMNode();
return {
width: el.offsetWidth,
height: el.offsetHeight
};
}, this);
var el = this.getDOMNode();
var computedStyle = window.getComputedStyle(el);
var container = {
width: parseInt(computedStyle.width),
height: parseInt(computedStyle.height),
measuredWidth: el.offsetWidth,
measuredHeight: el.offsetHeight
};
var measurements = this.props.layout(measuredItems, container);
this.setState({
measurements: measurements,
measurePending: false
});
}
});
var horizontal = HorizontalLayout({ gap: 10, padding: 10 });
var vertical = VerticalLayout({ gap: 20, padding: 20 });
var grid = GridLayout({ gap: 10, padding: 20, variableRowHeight: true, fillWidth: true, stretchHeight: true });
var dataProvider = [0, 1, 2, 3, 4, 5].map(function (i) {
return { value: i, selected: false };
});
React.render(
<DataLayoutContainer layout={ grid } dataProvider={ dataProvider } />,
document.querySelector('.container')
);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment