Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Masonry component [WIP] #618

Merged
merged 11 commits into from
Mar 27, 2017
1 change: 0 additions & 1 deletion docs/Grid.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,6 @@ Below is a very basic `Grid` example. The grid displays an array of objects with
import React from 'react';
import ReactDOM from 'react-dom';
import { Grid } from 'react-virtualized';
import 'react-virtualized/styles.css'; // only needs to be imported once

// Grid data as an array of arrays
const list = [
Expand Down
188 changes: 188 additions & 0 deletions docs/Masonry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
The `Masonry` component efficiently displays dynamically-sized, user-positioned cells using windowing techniques. Cell positions are controlled by an injected `cellPositioner` property. Windowing is vertical; this component does not support horizontal scrolling.

### Overview
#### Measuring and layout
Rendering occurs in two phases:

##### Phase 1: Measurement
This phase uses estimated cell sizes (provided by the `cellMeasurerCache` property) to determine how many cells to measure in a batch. Batch size is chosen using a fast, naive layout algorithm that stacks images in order until the viewport has been filled. After measurement is complete (`componentDidMount` or `componentDidUpdate`) this component evaluates positioned cells in order to determine if another measurement pass is required (eg if actual cell sizes were less than estimated sizes). All measurements are permanently cached (keyed by `keyMapper`) for performance purposes.
##### Phase 2: Layout
This phase uses the external `cellPositioner` to position cells. At this time the positioner has access to cached size measurements for all cells. The positions it returns are cached by `Masonry` for fast access later.

Phase one is repeated if the user scrolls beyond the current layout's bounds. If the layout is invalidated due to eg a resize, cached positions can be cleared using `recomputeCellPositions()`.

#### Animation Constraints
* Simple animations are supported (eg translate/slide into place on initial reveal).
* More complex animations are not (eg flying from one position to another on resize).

#### Layout Constraints
* This component supports a multi-column layout.
* Each item can have a unique, lazily-measured height.
* The width of all items in a column must be equal. (Items may not span multiple columns.)
* The left position of all items within a column must align.
* Cell measurements must be synchronous. Size impacts layout and async measurements would require frequent layout invalidation. Support for this may be added in the future but for now the use of the `CellMeasurer` render callback's async `measure` parameter is not supported.

### Prop Types
| Property | Type | Required? | Description |
|:---|:---|:---:|:---|
| cellCount | number | ✓ | Total number of items |
| cellMeasurerCache | mixed | ✓ | Caches item measurements. Default sizes help `Masonry` decide how many images to batch-measure. Learn more [here](CellMeasurer.md#cellmeasurercache). |
| cellPositioner | function | ✓ | Positions a cell given an index: `(index: number) => ({ left: number, top: number })`. [Learn more](#createmasonrycellpositioner) |
| cellRenderer | function | ✓ | Responsible for rendering a cell given an index. [Learn more](#cellrenderer) |
| className | string | | Optional custom CSS class name to attach to root `Masonry` element. |
| height | number | ✓ | Height of the component; this value determines the number of visible items. |
| id | string | | Optional custom id to attach to root `Masonry` element. |
| keyMapper | function | | Maps an index to a unique id to store cached measurement and position info for a cell. This prevents eg cached measurements from being invalidated when a collection is re-ordered. `(index: number) => any` |
| onCellsRendered | function | | Callback invoked with information about the cells that were most recently rendered. This callback is only invoked when visible cells have changed: `({ startIndex: number, stopIndex: number }): void` |
| onScroll | function | | Callback invoked whenever the scroll offset changes within the inner scrollable region: `({ clientHeight: number, scrollHeight: number, scrollTop: number }): void` |
| overscanByPixels | number | | Render this many additional pixels above and below the viewport. This helps reduce flicker when a user scrolls quickly. Defaults to 20. |
| role | string | | Optional override of ARIA role default; defaults to "grid". |
| scrollingResetTimeInterval | number | | Wait this amount of time after the last scroll event before resetting `pointer-events`; defaults to 150ms. |
| style | mixed | | Optional custom inline style to attach to root `Masonry` element. |
| tabIndex | number | | Optional override of tab index default; defaults to 0. |
| width | number | ✓ | Width of the component; this value determines the number of visible items. |

### cellRenderer

Responsible for rendering a single cell given its index. This function accepts the following named parameters:

```jsx
function cellRenderer ({
index, // Index of item within the collection
isScrolling, // The Grid is currently being scrolled
key, // Unique key within array of cells
parent, // Reference to the parent Grid (instance)
style // Style object to be applied to cell (to position it);
// This must be passed through to the rendered cell element.
}) {
return (
<CellMeasurer
cache={cellMeasurerCache}
index={index}
key={key}
parent={parent}
>
<div style={style}>
{/* Your content goes here */}
</div>
</CellMeasurer>
);
}
```

### createMasonryCellPositioner

`Masonry` provides a built-in positioner for a simple layout. This positioner requires a few configuration settings:

| Property | Type | Required? | Description |
|:---|:---|:---:|:---|
| cellMeasurerCache | `CellMeasurerCache` | ✓ | Contains cell measurements (eg item height). |
| columnCount | number | ✓ | Number of columns to use in layout. |
| columnWidth | number | ✓ | Column width. |
| spacer | number | | Empty space between columns; defaults to 0. |

You can use this layout as shown below:

```js
const cellPositioner = createMasonryCellPositioner({
cellMeasurerCache: cache,
columnCount: 3,
columnWidth: 200,
spacer: 10
})

let masonryRef

function renderMasonry (props) {
return (
<Masonry
cellMeasurerCache={cache}
cellPositioner={cellPositioner}
ref={ref => masonryRef = ref}
{...props}
/>
)
}
```

If any of the configuration settings change due to external changes (eg window resize event) you can update them using the `reset` method as shown below:

```js
cellPositioner.reset({
columnCount: 4,
columnWidth: 250,
spacer: 15
})

masonryRef.recomputeCellPositions()
```

### Basic `Masonry` Example

Below is a very basic `Masonry` example with a naive layout algorithm.

```jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {
CellMeasurer,
CellMeasurerCache,
createMasonryCellPositioner,
Masonry
} from 'react-virtualized';

// Array of images with captions
const list = [];

// Default sizes help Masonry decide how many images to batch-measure
const cache = new CellMeasurerCache({
defaultHeight: 250,
defaultWidth: 200,
fixedWidth: true
})

// Our masonry layout will use 3 columns with a 10px gutter between
const cellPositioner = createMasonryCellPositioner({
cellMeasurerCache: cache,
columnCount: 3,
columnWidth: 200,
spacer: 10
})

function cellRenderer ({ index, key, parent, style }) {
const datum = list[index]

return (
<CellMeasurer
cache={cache}
index={index}
key={key}
parent={parent}
>
<div style={style}>
<img
src={datum.source}
style={{
height: datum.imageHeight,
width: datum.imageWidth
}}
/>
<h4>{datum.caption}</h4>
</div>
</CellMeasurer>
)
}

// Render your grid
ReactDOM.render(
<Masonry
cellCount={list.length}
cellMeasurerCache={cache}
cellPositioner={cellPositioner}
cellRenderer={cellRenderer}
height={600}
width={800}
/>,
document.getElementById('example')
);
```
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@
"babel-runtime": "^6.11.6",
"classnames": "^2.2.3",
"dom-helpers": "^2.4.0 || ^3.0.0",
"interval-tree-1d": "^1.0.3",
"loose-envify": "^1.3.0"
},
"peerDependencies": {
Expand Down
20 changes: 15 additions & 5 deletions source/CellMeasurer/CellMeasurer.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { findDOMNode } from 'react-dom'
type Props = {
cache: mixed,
children: mixed,
columnIndex: number,
columnIndex: ?number,
index: ?number,
parent: mixed,
rowIndex: number,
style: mixed
rowIndex: ?number,
};

/**
Expand Down Expand Up @@ -42,7 +42,12 @@ export default class CellMeasurer extends PureComponent {
}

_maybeMeasureCell () {
const { cache, columnIndex, parent, rowIndex } = this.props
const {
cache,
columnIndex = 0,
parent,
rowIndex = this.props.index
} = this.props

if (!cache.has(rowIndex, columnIndex)) {
const node = findDOMNode(this)
Expand Down Expand Up @@ -84,7 +89,12 @@ export default class CellMeasurer extends PureComponent {
}

_measure () {
const { cache, columnIndex, parent, rowIndex } = this.props
const {
cache,
columnIndex = 0,
parent,
rowIndex = this.props.index
} = this.props

const node = findDOMNode(this)

Expand Down
14 changes: 11 additions & 3 deletions source/CellMeasurer/CellMeasurerCache.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,14 @@ export default class CellMeasurerCache {
: this._defaultWidth
}

get defaultHeight () : number {
return this._defaultHeight
}

get defaultWidth () : number {
return this._defaultWidth
}

hasFixedHeight () : boolean {
return this._hasFixedHeight
}
Expand All @@ -151,7 +159,7 @@ export default class CellMeasurerCache {

getHeight (
rowIndex: number,
columnIndex: number
columnIndex: ?number = 0
) : ?number {
const key = this._keyMapper(rowIndex, columnIndex)

Expand All @@ -162,7 +170,7 @@ export default class CellMeasurerCache {

getWidth (
rowIndex: number,
columnIndex: number
columnIndex: ?number = 0
) : ?number {
const key = this._keyMapper(rowIndex, columnIndex)

Expand All @@ -173,7 +181,7 @@ export default class CellMeasurerCache {

has (
rowIndex: number,
columnIndex: number
columnIndex: ?number = 0
) : boolean {
const key = this._keyMapper(rowIndex, columnIndex)

Expand Down
18 changes: 18 additions & 0 deletions source/Masonry/Masonry.example.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.Cell {
display: flex;
flex-direction: column;
border-radius: .5rem;
padding: 0.5rem;
background-color: #f7f7f7;
word-break: break-all;
}

.checkboxLabel {
margin-left: .5rem;
}
.checkboxLabel:first-of-type {
margin-left: 0;
}
.checkbox {
margin-right: 5px;
}
Loading