-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(component, mobile): masonry layout with virtual scroll support, …
…adapted with all docs (#9208) ### Preview ![CleanShot 2024-12-19 at 20.41.47.gif](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/60a701ea-bca0-42d5-8a06-f10af44c8fc8.gif) ### Render when scrolling ![CleanShot 2024-12-20 at 09.54.26.gif](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/df0008d7-5bd9-4e98-b426-cb1036dbb611.gif) ### api ```tsx const items = useMemo(() => { return { id: '', height: 100, children: <div></div> } }, []) <Masonry items={items} /> ```
- Loading branch information
Showing
20 changed files
with
572 additions
and
186 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './masonry'; |
80 changes: 80 additions & 0 deletions
80
packages/frontend/component/src/ui/masonry/masonry.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
import { ResizePanel } from '../resize-panel/resize-panel'; | ||
import { Masonry } from './masonry'; | ||
|
||
export default { | ||
title: 'UI/Masonry', | ||
}; | ||
|
||
const Card = ({ children }: { children: React.ReactNode }) => { | ||
return ( | ||
<div | ||
style={{ | ||
width: '100%', | ||
height: '100%', | ||
borderRadius: 10, | ||
border: `1px solid rgba(100, 100, 100, 0.2)`, | ||
boxShadow: '0 1px 10px rgba(0, 0, 0, 0.1)', | ||
padding: 10, | ||
backgroundColor: 'white', | ||
}} | ||
> | ||
{children} | ||
</div> | ||
); | ||
}; | ||
|
||
const basicCards = Array.from({ length: 10000 }, (_, i) => { | ||
return { | ||
id: 'card-' + i, | ||
height: Math.round(100 + Math.random() * 100), | ||
children: ( | ||
<Card> | ||
<h1>Hello</h1> | ||
<p>World</p> | ||
{i} | ||
</Card> | ||
), | ||
}; | ||
}); | ||
|
||
export const BasicVirtualScroll = () => { | ||
return ( | ||
<ResizePanel width={800} height={600}> | ||
<Masonry | ||
gapX={10} | ||
gapY={10} | ||
style={{ width: '100%', height: '100%' }} | ||
paddingX={12} | ||
paddingY={12} | ||
virtualScroll | ||
items={basicCards} | ||
/> | ||
</ResizePanel> | ||
); | ||
}; | ||
|
||
const transitionCards = Array.from({ length: 10000 }, (_, i) => { | ||
return { | ||
id: 'card-' + i, | ||
height: Math.round(100 + Math.random() * 100), | ||
children: <Card>{i}</Card>, | ||
style: { transition: 'transform 0.2s ease' }, | ||
}; | ||
}); | ||
|
||
export const CustomTransition = () => { | ||
return ( | ||
<ResizePanel width={800} height={600}> | ||
<Masonry | ||
gapX={10} | ||
gapY={10} | ||
style={{ width: '100%', height: '100%' }} | ||
paddingX={12} | ||
paddingY={12} | ||
virtualScroll | ||
items={transitionCards} | ||
locateMode="transform3d" | ||
/> | ||
</ResizePanel> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,220 @@ | ||
import { throttle } from '@blocksuite/affine/global/utils'; | ||
import clsx from 'clsx'; | ||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; | ||
|
||
import { observeResize } from '../../utils'; | ||
import { Scrollable } from '../scrollbar'; | ||
import * as styles from './styles.css'; | ||
import type { MasonryItem, MasonryItemXYWH } from './type'; | ||
import { calcColumns, calcLayout, calcSleep } from './utils'; | ||
|
||
export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> { | ||
items: MasonryItem[]; | ||
|
||
gapX?: number; | ||
gapY?: number; | ||
paddingX?: number; | ||
paddingY?: number; | ||
/** | ||
* Specify the width of the item. | ||
* - `number`: The width of the item in pixels. | ||
* - `'stretch'`: The item will stretch to fill the container. | ||
* @default 'stretch' | ||
*/ | ||
itemWidth?: number | 'stretch'; | ||
/** | ||
* The minimum width of the item in pixels. | ||
* @default 100 | ||
*/ | ||
itemWidthMin?: number; | ||
virtualScroll?: boolean; | ||
locateMode?: 'transform' | 'leftTop' | 'transform3d'; | ||
} | ||
|
||
export const Masonry = ({ | ||
items, | ||
gapX = 12, | ||
gapY = 12, | ||
itemWidth = 'stretch', | ||
itemWidthMin = 100, | ||
paddingX = 0, | ||
paddingY = 0, | ||
className, | ||
virtualScroll = false, | ||
locateMode = 'leftTop', | ||
...props | ||
}: MasonryProps) => { | ||
const rootRef = useRef<HTMLDivElement>(null); | ||
const [height, setHeight] = useState(0); | ||
const [layoutMap, setLayoutMap] = useState< | ||
Map<MasonryItem['id'], MasonryItemXYWH> | ||
>(new Map()); | ||
const [sleepMap, setSleepMap] = useState<Map<MasonryItem['id'], boolean>>( | ||
new Map() | ||
); | ||
|
||
const updateSleepMap = useCallback( | ||
(layoutMap: Map<MasonryItem['id'], MasonryItemXYWH>, _scrollY?: number) => { | ||
if (!virtualScroll) return; | ||
|
||
const rootEl = rootRef.current; | ||
if (!rootEl) return; | ||
|
||
requestAnimationFrame(() => { | ||
const scrollY = _scrollY ?? rootEl.scrollTop; | ||
const sleepMap = calcSleep({ | ||
viewportHeight: rootEl.clientHeight, | ||
scrollY, | ||
layoutMap, | ||
preloadHeight: 50, | ||
}); | ||
setSleepMap(sleepMap); | ||
}); | ||
}, | ||
[virtualScroll] | ||
); | ||
|
||
const calculateLayout = useCallback(() => { | ||
const rootEl = rootRef.current; | ||
if (!rootEl) return; | ||
|
||
const totalWidth = rootEl.clientWidth; | ||
const { columns, width } = calcColumns( | ||
totalWidth, | ||
itemWidth, | ||
itemWidthMin, | ||
gapX, | ||
paddingX | ||
); | ||
|
||
const { layout, height } = calcLayout(items, { | ||
columns, | ||
width, | ||
gapX, | ||
gapY, | ||
paddingX, | ||
paddingY, | ||
}); | ||
setLayoutMap(layout); | ||
setHeight(height); | ||
updateSleepMap(layout); | ||
}, [ | ||
gapX, | ||
gapY, | ||
itemWidth, | ||
itemWidthMin, | ||
items, | ||
paddingX, | ||
paddingY, | ||
updateSleepMap, | ||
]); | ||
|
||
// handle resize | ||
useEffect(() => { | ||
calculateLayout(); | ||
if (rootRef.current) { | ||
return observeResize(rootRef.current, calculateLayout); | ||
} | ||
return; | ||
}, [calculateLayout]); | ||
|
||
// handle scroll | ||
useEffect(() => { | ||
const rootEl = rootRef.current; | ||
if (!rootEl) return; | ||
|
||
if (virtualScroll) { | ||
const handler = throttle((e: Event) => { | ||
const scrollY = (e.target as HTMLElement).scrollTop; | ||
updateSleepMap(layoutMap, scrollY); | ||
}, 50); | ||
rootEl.addEventListener('scroll', handler); | ||
return () => { | ||
rootEl.removeEventListener('scroll', handler); | ||
}; | ||
} | ||
return; | ||
}, [layoutMap, updateSleepMap, virtualScroll]); | ||
|
||
return ( | ||
<Scrollable.Root> | ||
<Scrollable.Viewport | ||
ref={rootRef} | ||
data-masonry-root | ||
className={clsx('scrollable', styles.root, className)} | ||
{...props} | ||
> | ||
{items.map(item => { | ||
return ( | ||
<MasonryItem | ||
key={item.id} | ||
{...item} | ||
locateMode={locateMode} | ||
xywh={layoutMap.get(item.id)} | ||
sleep={sleepMap.get(item.id)} | ||
> | ||
{item.children} | ||
</MasonryItem> | ||
); | ||
})} | ||
<div data-masonry-placeholder style={{ height }} /> | ||
</Scrollable.Viewport> | ||
<Scrollable.Scrollbar /> | ||
</Scrollable.Root> | ||
); | ||
}; | ||
|
||
interface MasonryItemProps | ||
extends MasonryItem, | ||
Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'height'> { | ||
locateMode?: 'transform' | 'leftTop' | 'transform3d'; | ||
sleep?: boolean; | ||
xywh?: MasonryItemXYWH; | ||
} | ||
|
||
const MasonryItem = memo(function MasonryItem({ | ||
id, | ||
xywh, | ||
locateMode = 'leftTop', | ||
sleep = false, | ||
className, | ||
children, | ||
style: styleProp, | ||
...props | ||
}: MasonryItemProps) { | ||
const style = useMemo(() => { | ||
if (!xywh) return { display: 'none' }; | ||
|
||
const { x, y, w, h } = xywh; | ||
|
||
const posStyle = | ||
locateMode === 'transform' | ||
? { transform: `translate(${x}px, ${y}px)` } | ||
: locateMode === 'leftTop' | ||
? { left: `${x}px`, top: `${y}px` } | ||
: { transform: `translate3d(${x}px, ${y}px, 0)` }; | ||
|
||
return { | ||
left: 0, | ||
top: 0, | ||
...styleProp, | ||
...posStyle, | ||
width: `${w}px`, | ||
height: `${h}px`, | ||
}; | ||
}, [locateMode, styleProp, xywh]); | ||
|
||
if (sleep || !xywh) return null; | ||
|
||
return ( | ||
<div | ||
data-masonry-item | ||
data-masonry-item-id={id} | ||
className={clsx(styles.item, className)} | ||
style={style} | ||
{...props} | ||
> | ||
{children} | ||
</div> | ||
); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { style } from '@vanilla-extract/css'; | ||
|
||
export const root = style({ | ||
position: 'relative', | ||
selectors: { | ||
'&.scrollable': { | ||
overflowY: 'auto', | ||
}, | ||
}, | ||
}); | ||
|
||
export const item = style({ | ||
position: 'absolute', | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
export interface MasonryItem extends React.HTMLAttributes<HTMLDivElement> { | ||
id: string; | ||
height: number; | ||
} | ||
|
||
export interface MasonryItemXYWH { | ||
x: number; | ||
y: number; | ||
w: number; | ||
h: number; | ||
} |
Oops, something went wrong.