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

feat: support sticky items #124

Merged
merged 3 commits into from
Jun 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/great-lemons-whisper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-cool-virtual": minor
---

feat: support sticky items
53 changes: 51 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,15 @@
- 💅🏼 Apply styles without hassle, just [few setups](#basic-usage).
- 🧱 Supports [fixed](#fixed-size), [variable](#variable-size), [dynamic](#dynamic-size), and [real-time resize](#real-time-resize) heights/widths.
- 🖥 Supports [responsive web design (RWD)](#responsive-web-design-rwd) for better UX.
- 📌 Supports [sticky items](#sticky-items) for building on-trend lists.
- 🚚 Built-ins [load more callback](#infinite-scroll) for you to deal with infinite scroll + [skeleton screens](https://uxdesign.cc/what-you-should-know-about-skeleton-screens-a820c45a571a).
- 🖱 Imperative [scroll-to methods](#scroll-to-offsetitems) for offset, items, and alignment.
- 🛹 Out of the box [smooth scrolling](#smooth-scrolling) and the effect is DIY-able.
- ⛳ Provides `isScrolling` indicator to you for UI placeholders or [performance optimization](#use-isscrolling-indicator).
- 🗄️ Supports [server-side rendering (SSR)](#server-side-rendering-ssr) for a fast [FP + FCP](https://developers.google.com/web/updates/2019/02/rendering-on-the-web#server-rendering) and better [SEO](https://developers.google.com/web/updates/2019/02/rendering-on-the-web#server-rendering).
- 📜 Supports [TypeScript](#working-in-typescript) type definition.
- 🎛 Super flexible [API](#api) design, built with DX in mind.
- 🦔 Tiny size ([~ 2.8kB gzipped](https://bundlephobia.com/result?p=react-cool-virtual)). No external dependencies, aside for the `react`.
- 🦔 Tiny size ([~ 3kB gzipped](https://bundlephobia.com/result?p=react-cool-virtual)). No external dependencies, aside for the `react`.

## Why?

Expand Down Expand Up @@ -299,6 +300,45 @@ const List = () => {

> 💡 If the item size is specified through the function of `itemSize`, please ensure there's no the [measureRef](#items) on the item element. Otherwise, the hook will use the measured (cached) size for the item. When working with RWD, we can only use either of the two.

### Sticky Items

This example demonstrates how to make sticky items when using React Cool Virtual.

```js
import useVirtual from "react-cool-virtual";

const List = () => {
const { outerRef, innerRef, items } = useVirtual({
itemCount: 1000,
itemSize: 75,
stickyIndices: [0, 10, 20, 30, 40, 50], // The values must be provided in ascending order
});

return (
<div
style={{ width: "300px", height: "300px", overflow: "auto" }}
ref={outerRef}
>
<div ref={innerRef}>
{items.map(({ index, size, isSticky }) => {
let style = { height: `${size}px` };
// Use the `isSticky` to style the sticky item, that's it ✨
style = isSticky ? { ...style, position: "sticky", top: "0" } : style;

return (
<div key={someData[index].id} style={style}>
{someData[index].content}
</div>
);
})}
</div>
</div>
);
};
```

> 💡 Scrollbars disappear when using Chrome in Mac? If you encounter [this issue](https://bugs.chromium.org/p/chromium/issues/detail?id=1033712), you can add `will-change:transform` to the outer element to workaround this problem.

### Scroll to Offset/Items

You can imperatively scroll to offset or items as follows:
Expand Down Expand Up @@ -806,6 +846,14 @@ The number of items to render behind and ahead of the visible area (default = 1)

To enable/disable the [isScrolling](#items) indicator of an item (default = false). It's useful for UI placeholders or [performance optimization](#use-isscrolling-indicator) when the list is being scrolled. Please note, using it will result in an additional render after scrolling has stopped.

### stickyIndices

`number[]`

An array of indexes to make certain items in the list sticky. See the [example](#sticky-items) to learn more.

- The values must be provided **in ascending order**, i.e. `[0, 10, 20, 30, ...]`.

### scrollDuration

`number`
Expand Down Expand Up @@ -918,6 +966,7 @@ The virtualized items for rendering rows/columns. Each item is an `object` that
| width | number | The current content width of the outer element. It's useful for a [RWD row/column](#responsive-web-design-rwd). |
| start | number | The starting position of the item. We might only need this when [working with grids](#layout-items). |
| isScrolling | true \| undefined | An indicator to show a placeholder or [optimize performance](#use-isscrolling-indicator) for the item. |
| isSticky | true \| undefined | An indicator to make certain items become [sticky in the list](#sticky-items). |
| measureRef | Function | It's used to measure the [dynamic size](#dynamic-size) or [real-time resize](#real-time-resize) of the item. |

### scrollTo
Expand Down Expand Up @@ -996,8 +1045,8 @@ You could use dynamic imports to only load the file when the polyfill is require
## To Do...

- [ ] Unit testing
- [ ] Supports sticky items
- [ ] `scrollBy` method
- [ ] Input element example

## Contributors ✨

Expand Down
30 changes: 18 additions & 12 deletions app/src/App/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,47 +105,53 @@ import styles from "./styles.module.scss";
const sleep = (time: number) =>
// eslint-disable-next-line compat/compat
new Promise((resolve) => setTimeout(resolve, time));

const getMockData = (count: number, min = 25) =>
// eslint-disable-next-line no-plusplus
new Array(count).fill({}).map((_, idx) => ({
text: uuidv4(),
size: min + Math.round(Math.random() * 100),
}));

const mockData = getMockData(10);
const mockData = getMockData(30);

export default (): JSX.Element => {
const [sz, setSz] = useState(50);
const [test, setTest] = useState(false);
const { outerRef, innerRef, items } = useVirtual<
HTMLDivElement,
HTMLDivElement
>({
itemCount: mockData.length,
stickyIndices: [5, 10, 15, 20],
overscanCount: 0,
});

return (
<div className={styles.app}>
<div className={styles.outer} ref={outerRef}>
<div style={{ position: "relative" }} ref={innerRef}>
{/* <div
className={`${styles.item} ${styles.sticky}`}
style={{ height: "50px" }}
>
Sticky
</div> */}
<div ref={innerRef}>
{items.map(({ index, size, isScrolling, measureRef }) => (
<div
key={index}
className={`${styles.item} ${index % 2 ? styles.dark : ""}`}
style={{ height: `${mockData[index].size}px` }}
// style={{ height: `${index === 1 ? sz : size}px` }}
ref={measureRef}
className={`${styles.item} ${index % 2 ? styles.dark : ""} ${
index === 3 && test ? styles.sticky : ""
}`}
// style={{ height: `${mockData[index].size}px` }}
style={{ height: `${size}px` }}
// ref={measureRef}
>
{index}
</div>
))}
</div>
</div>
<button
type="button"
onClick={() => setSz((prev) => (prev === 50 ? 200 : 50))}
>
<button type="button" onClick={() => setTest(!test)}>
Resize
</button>
</div>
Expand Down
11 changes: 9 additions & 2 deletions app/src/App/styles.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ body {
.outer {
margin: 0 auto;
width: 300px;
height: 50%;
height: 300px;
border: 1px solid #000;
margin-bottom: 1rem;
overflow: auto;
Expand All @@ -30,4 +30,11 @@ body {
&.dark {
background: #ddd;
}
}

&.sticky {
position: -webkit-sticky;
position: sticky;
top: 0;
z-index: 100;
}
}
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface Item {
readonly size: number;
readonly width: number;
readonly isScrolling?: true;
readonly isSticky?: true;
measureRef: RefCallback<HTMLElement>;
}

Expand Down Expand Up @@ -91,6 +92,7 @@ export interface Options {
horizontal?: boolean;
overscanCount?: number;
useIsScrolling?: UseIsScrolling;
stickyIndices?: number[];
scrollDuration?: number;
scrollEasingFunction?: ScrollEasingFunction;
loadMoreCount?: number;
Expand Down
2 changes: 2 additions & 0 deletions src/types/react-cool-virtual.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ declare module "react-cool-virtual" {
readonly size: number;
readonly width: number;
readonly isScrolling?: true;
readonly isSticky?: true;
measureRef: MeasureRef;
}

Expand Down Expand Up @@ -99,6 +100,7 @@ declare module "react-cool-virtual" {
horizontal?: boolean;
overscanCount?: number;
useIsScrolling?: UseIsScrolling;
stickyIndices?: number[];
scrollDuration?: number;
scrollEasingFunction?: ScrollEasingFunction;
loadMoreCount?: number;
Expand Down
35 changes: 35 additions & 0 deletions src/useVirtual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export default <
horizontal,
overscanCount = 1,
useIsScrolling,
stickyIndices,
scrollDuration = 500,
scrollEasingFunction = easeInOutCubic,
loadMoreCount = 15,
Expand All @@ -81,6 +82,7 @@ export default <
const msDataRef = useRef<Measure[]>([]);
const userScrollRef = useRef(true);
const scrollToRafRef = useRef<number>();
const stickyIndicesRef = useRef(stickyIndices);
const isItemLoadedRef = useRef(isItemLoaded);
const loadMoreRef = useLatest(loadMore);
const easingFnRef = useLatest(scrollEasingFunction);
Expand Down Expand Up @@ -318,6 +320,9 @@ export default <
innerRef.current.style[sizeKey] = `${innerSize}px`;

const nextItems: Item[] = [];
const stickies = Array.isArray(stickyIndicesRef.current)
? stickyIndicesRef.current
: [];

for (let i = oStart; i <= oStop; i += 1) {
const { current: msData } = msDataRef;
Expand All @@ -329,6 +334,7 @@ export default <
size,
width: outerRectRef.current.width,
isScrolling: uxScrolling || undefined,
isSticky: stickies.includes(i) || undefined,
measureRef: (el) => {
if (!el) return;

Expand Down Expand Up @@ -365,6 +371,35 @@ export default <
});
}

if (stickies.length) {
const stickyIdx =
stickies[
findNearestBinarySearch(
0,
stickies.length - 1,
vStart,
(idx) => stickies[idx]
)
];

if (oStart > stickyIdx) {
const { size } = msDataRef.current[stickyIdx];

nextItems.unshift({
index: stickyIdx,
start: 0,
size,
width: outerRectRef.current.width,
isScrolling: uxScrolling || undefined,
isSticky: true,
measureRef: () => null,
});

innerRef.current.style[marginKey] = `${margin - size}px`;
innerRef.current.style[sizeKey] = `${innerSize + size}px`;
}
}

setItems((prevItems) =>
shouldUpdate(prevItems, nextItems, { measureRef: true })
? nextItems
Expand Down
6 changes: 3 additions & 3 deletions src/utils/findNearestBinarySearch.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
export default (
low: number,
high: number,
offset: number,
input: number,
getVal: (idx: number) => number
): number => {
while (low <= high) {
const mid = ((low + high) / 2) | 0;
const val = getVal(mid);

if (offset < val) {
if (input < val) {
high = mid - 1;
} else if (offset > val) {
} else if (input > val) {
low = mid + 1;
} else {
return mid;
Expand Down