@cawfree/react-native-masonry
Version:
A bare-bones masonry layout for react-native.
274 lines (266 loc) • 7.02 kB
JavaScript
import React from 'react';
import {
ScrollView,
View,
StyleSheet,
} from 'react-native';
import PropTypes from 'prop-types';
class Masonry extends React.Component {
constructor(nextProps) {
super(nextProps);
this.state = ({
items: {
// XXX: Entries look like this:
// [key]: { column, height },
},
});
this.__getColumnHeights = this.__getColumnHeights.bind(this);
this.__onLayout = this.__onLayout.bind(this);
}
__getKeysByColumn(nextProps, nextState, column) {
const {
items,
} = nextState;
return Object.entries(
items
)
.filter((entry) => {
const value = entry[1];
return value && value.height > 0 && value.column === column;
})
.map((entry) => entry[0]);
}
__getItemsByColumn(nextProps, nextState, column) {
const columnKeys = this.__getKeysByColumn(
nextProps,
nextState,
column,
);
return nextProps
.items
.filter(
({ key }) => (columnKeys.indexOf(key) >= 0),
);
}
__getColumnHeights(nextProps, nextState) {
const {
columns,
} = nextProps;
const {
// TODO: What if columns dont exist
items,
} = nextState;
return [...Array(columns)]
.map((column, index) => {
const columnKeys = this.__getKeysByColumn(
nextProps,
nextState,
// TODO: Column itself should be the index.
index,
);
return columnKeys
.reduce(
(columnHeight, columnKey) => {
// XXX: Accumulate the total item heights belonging to
// the column; this gives the column height.
return columnHeight + items[columnKey].height;
},
0,
);
});
}
__getKeysPendingEvaluation(nextProps, nextState) {
const {
items,
} = nextState;
const evaluatedKeys = Object.keys(items);
return nextProps
.items
.filter(({ key }) => evaluatedKeys.indexOf(key) < 0);
}
// XXX: When the element pendingEvaluation has finished laying
// out, we know how tall it is. We trigger a re-render using
// its known height, which determines which column it is placed
// into. After re-rendering, a new element pendingEvaluation is
// rendered in the DOM, which causes the process to iterate again.
__onLayout(e, key) {
const {
height,
} = e.nativeEvent.layout;
const {
onItemPlaced,
} = this.props;
this.requestAnimationFrame(() => {
const {
items,
} = this.state;
const heights = this.__getColumnHeights(
this.props,
this.state,
);
const item = this.props.items
.filter((item) => item.key === key)[0];
const parentKeys = this.props.items
.map(({ key }) => key);
const minHeight = Math.min(...heights);
const column = heights.indexOf(minHeight);
this.setState(
{
// XXX: Here, alongside updating the item position,
// we also clean out any keys which are no longer
// referenced by the parent.
items: Object.entries({
...items,
[key]: {
height,
column,
},
})
.filter((entry) => {
return parentKeys.indexOf(entry[0]) >= 0;
})
.reduce(
(obj, entry) => {
return ({
...obj,
[entry[0]]: entry[1],
});
},
{},
),
},
() => {
return onItemPlaced(
item,
);
},
);
});
}
render() {
const {
style,
items,
columns,
renderItem,
ScrollComponent,
onItemPlaced,
...nextProps
} = this.props;
const {
} = this.state;
// XXX: The next item to determine render height; items
// are appended to the columns with the smallest
// accumulated height.
const pendingEvaluation = this.__getKeysPendingEvaluation(
this.props,
this.state,
)[0];
return (
<ScrollComponent
style={style || {}}
{...nextProps}
>
<View
style={styles.container}
>
<View
style={styles.bufferContainer}
>
{(!!pendingEvaluation) && ([... Array(columns)]).map(
(column, index) => {
if (index === 0) {
return (
<View
key={`eval(${pendingEvaluation.key})`}
style={styles.buffer}
onLayout={e => this.__onLayout(e, pendingEvaluation.key)}
>
{renderItem(pendingEvaluation)}
</View>
);
}
return null;
},
)}
</View>
{([...Array(columns)]).map(
(column, index) => {
const itemsInColumn = this.__getItemsByColumn(
this.props,
this.state,
// XXX: This must be the columnId.
index,
);
return (
<View
style={styles.column}
>
{itemsInColumn.map((item) => {
const {
height,
...extraProps
} = this.state.items[item.key];
return (
<View
style={{
flexDirection: 'row',
height,
}}
>
{renderItem(item)}
</View>
);
})}
</View>
);
},
)}
</View>
</ScrollComponent>
);
}
}
Object.assign(
Masonry.prototype,
require('react-timer-mixin'),
);
Masonry.propTypes = {
columns: PropTypes.number,
renderItem: PropTypes.func.isRequired,
ScrollComponent: PropTypes.shape({}),
containerStyle: PropTypes.func,
onCreate: PropTypes.func,
};
Masonry.defaultProps = {
columns: 2,
items: [],
ScrollComponent: ScrollView,
onItemPlaced: (item) => {
// XXX: Here, you can perform item-specific
// entry animation, if the animatable
// props are part of the item object.
},
};
const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row',
},
column: {
flex: 1,
},
buffer: {
flex: 1,
flexDirection: 'row',
},
// XXX: Used to layout elements and determine their height
// before deciding which column to add them to.
bufferContainer: {
position: 'absolute',
top: 0,
left: 0,
opacity: 0,
},
});
export default Masonry;