Optimised re-renders with sortable tiles

This commit is contained in:
Mitchell McCaffrey 2021-06-15 20:08:45 +10:00
parent 6b927b4456
commit bc97f4838a
6 changed files with 159 additions and 95 deletions

View File

@ -4,6 +4,7 @@ import MapTile from "./MapTile";
import MapTileGroup from "./MapTileGroup";
import SortableTiles from "../tile/SortableTiles";
import SortableTilesDragOverlay from "../tile/SortableTilesDragOverlay";
import { getGroupItems } from "../../helpers/group";
@ -57,7 +58,12 @@ function MapTiles({ mapsById, onMapEdit, onMapSelect, subgroup }) {
}
}
return <SortableTiles renderTile={renderTile} subgroup={subgroup} />;
return (
<>
<SortableTiles renderTile={renderTile} subgroup={subgroup} />
<SortableTilesDragOverlay renderTile={renderTile} subgroup={subgroup} />
</>
);
}
export default MapTiles;

View File

@ -1,26 +1,24 @@
import React from "react";
import { createPortal } from "react-dom";
import { DragOverlay } from "@dnd-kit/core";
import { SortableContext } from "@dnd-kit/sortable";
import { animated, useSpring, config } from "react-spring";
import { Badge } from "theme-ui";
import { moveGroupsInto } from "../../helpers/group";
import { keyBy } from "../../helpers/shared";
import Vector2 from "../../helpers/Vector2";
import SortableTile from "./SortableTile";
import {
useTileDrag,
useTileDragId,
useTileDragCursor,
useTileOverGroupId,
BASE_SORTABLE_ID,
GROUP_SORTABLE_ID,
GROUP_ID_PREFIX,
} from "../../contexts/TileDragContext";
import { useGroup } from "../../contexts/GroupContext";
function SortableTiles({ renderTile, subgroup }) {
const { dragId, overId, dragCursor } = useTileDrag();
const dragId = useTileDragId();
const dragCursor = useTileDragCursor();
const overGroupId = useTileOverGroupId();
const {
groups,
selectedGroupIds: allSelectedIds,
@ -46,15 +44,6 @@ function SortableTiles({ renderTile, subgroup }) {
const disableSorting = (openGroupId && !subgroup) || filter;
const disableGrouping = subgroup || disableSorting || filter;
const dragBounce = useSpring({
transform: !!dragId ? "scale(0.9)" : "scale(1)",
config: config.wobbly,
position: "relative",
});
const overGroupId =
overId && overId.startsWith(GROUP_ID_PREFIX) && overId.slice(9);
function renderSortableGroup(group, selectedGroups) {
if (overGroupId === group.id && dragId && group.id !== dragId) {
// If dragging over a group render a preview of that group
@ -68,57 +57,6 @@ function SortableTiles({ renderTile, subgroup }) {
return renderTile(group);
}
function renderDragOverlays() {
let selectedIndices = selectedGroupIds.map((groupId) =>
activeGroups.findIndex((group) => group.id === groupId)
);
const activeIndex = activeGroups.findIndex((group) => group.id === dragId);
// Sort so the draging tile is the first element
selectedIndices = selectedIndices.sort((a, b) =>
a === activeIndex ? -1 : b === activeIndex ? 1 : 0
);
selectedIndices = selectedIndices.slice(0, 5);
let coords = selectedIndices.map(
(_, index) => new Vector2(5 * index, 5 * index)
);
// Reverse so the first element is rendered on top
selectedIndices = selectedIndices.reverse();
coords = coords.reverse();
const selectedGroups = selectedIndices.map((index) => activeGroups[index]);
return selectedGroups.map((group, index) => (
<DragOverlay dropAnimation={null} key={group.id}>
<div
style={{
transform: `translate(${coords[index].x}%, ${coords[index].y}%)`,
cursor: dragCursor,
}}
>
<animated.div style={dragBounce}>
{renderTile(group)}
{index === selectedIndices.length - 1 &&
selectedGroupIds.length > 1 && (
<Badge
sx={{
position: "absolute",
top: 0,
right: 0,
transform: "translate(25%, -25%)",
}}
>
{selectedGroupIds.length}
</Badge>
)}
</animated.div>
</div>
</DragOverlay>
));
}
function renderTiles() {
const groupsByIds = keyBy(activeGroups, "id");
const selectedGroupIdsSet = new Set(selectedGroupIds);
@ -156,7 +94,6 @@ function SortableTiles({ renderTile, subgroup }) {
return (
<SortableContext items={activeGroups} id={sortableId}>
{renderTiles()}
{createPortal(dragId && renderDragOverlays(), document.body)}
</SortableContext>
);
}

View File

@ -0,0 +1,93 @@
import React from "react";
import { createPortal } from "react-dom";
import { DragOverlay } from "@dnd-kit/core";
import { animated, useSpring, config } from "react-spring";
import { Badge } from "theme-ui";
import Vector2 from "../../helpers/Vector2";
import { useTileDragId } from "../../contexts/TileDragContext";
import { useGroup } from "../../contexts/GroupContext";
function SortableTilesDragOverlay({ renderTile, subgroup }) {
const dragId = useTileDragId();
const {
groups,
selectedGroupIds: allSelectedIds,
filter,
openGroupId,
openGroupItems,
filteredGroupItems,
} = useGroup();
const activeGroups = subgroup
? openGroupItems
: filter
? filteredGroupItems
: groups;
// Only populate selected groups if needed
let selectedGroupIds = [];
if ((subgroup && openGroupId) || (!subgroup && !openGroupId)) {
selectedGroupIds = allSelectedIds;
}
const dragBounce = useSpring({
transform: !!dragId ? "scale(0.9)" : "scale(1)",
config: config.wobbly,
position: "relative",
});
function renderDragOverlays() {
let selectedIndices = selectedGroupIds.map((groupId) =>
activeGroups.findIndex((group) => group.id === groupId)
);
const activeIndex = activeGroups.findIndex((group) => group.id === dragId);
// Sort so the draging tile is the first element
selectedIndices = selectedIndices.sort((a, b) =>
a === activeIndex ? -1 : b === activeIndex ? 1 : 0
);
selectedIndices = selectedIndices.slice(0, 5);
let coords = selectedIndices.map(
(_, index) => new Vector2(5 * index, 5 * index)
);
// Reverse so the first element is rendered on top
selectedIndices = selectedIndices.reverse();
coords = coords.reverse();
const selectedGroups = selectedIndices.map((index) => activeGroups[index]);
return selectedGroups.map((group, index) => (
<DragOverlay dropAnimation={null} key={group.id}>
<div
style={{
transform: `translate(${coords[index].x}%, ${coords[index].y}%)`,
}}
>
<animated.div style={dragBounce}>
{renderTile(group)}
{index === selectedIndices.length - 1 &&
selectedGroupIds.length > 1 && (
<Badge
sx={{
position: "absolute",
top: 0,
right: 0,
transform: "translate(25%, -25%)",
}}
>
{selectedGroupIds.length}
</Badge>
)}
</animated.div>
</div>
</DragOverlay>
));
}
return createPortal(dragId && renderDragOverlays(), document.body);
}
export default SortableTilesDragOverlay;

View File

@ -16,17 +16,14 @@ function Tile({
children,
}) {
const [ref, inView] = useInView({ triggerOnce: true });
return (
<Flex
<Box
sx={{
position: "relative",
width: "100%",
height: "0",
paddingTop: "100%",
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
userSelect: "none",
}}
@ -128,7 +125,7 @@ function Tile({
)}
</>
)}
</Flex>
</Box>
);
}

View File

@ -5,6 +5,7 @@ import TokenTileGroup from "./TokenTileGroup";
import TokenHiddenBadge from "./TokenHiddenBadge";
import SortableTiles from "../tile/SortableTiles";
import SortableTilesDragOverlay from "../tile/SortableTilesDragOverlay";
import { getGroupItems } from "../../helpers/group";
@ -61,7 +62,12 @@ function TokenTiles({ tokensById, onTokenEdit, subgroup }) {
}
}
return <SortableTiles renderTile={renderTile} subgroup={subgroup} />;
return (
<>
<SortableTiles renderTile={renderTile} subgroup={subgroup} />
<SortableTilesDragOverlay renderTile={renderTile} subgroup={subgroup} />
</>
);
}
export default TokenTiles;

View File

@ -1,4 +1,4 @@
import React, { useState, useContext } from "react";
import React, { useState, useContext, useEffect } from "react";
import {
MouseSensor,
TouchSensor,
@ -16,7 +16,9 @@ import { moveGroupsInto, moveGroups, ungroup } from "../helpers/group";
import usePreventSelect from "../hooks/usePreventSelect";
const TileDragContext = React.createContext();
const TileDragIdContext = React.createContext();
const TileOverGroupIdContext = React.createContext();
const TileDragCursorContext = React.createContext();
export const BASE_SORTABLE_ID = "__base__";
export const GROUP_SORTABLE_ID = "__group__";
@ -61,7 +63,7 @@ export function TileDragProvider({
} = useGroup();
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { distance: 5 },
activationConstraint: { distance: 3 },
});
const touchSensor = useSensor(TouchSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
@ -70,16 +72,23 @@ export function TileDragProvider({
const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
const [dragId, setDragId] = useState();
const [overId, setOverId] = useState();
const [dragId, setDragId] = useState(null);
const [overId, setOverId] = useState(null);
const [dragCursor, setDragCursor] = useState("pointer");
const [preventSelect, resumeSelect] = usePreventSelect();
const [overGroupId, setOverGroupId] = useState(null);
useEffect(() => {
setOverGroupId(
(overId && overId.startsWith(GROUP_ID_PREFIX) && overId.slice(9)) || null
);
}, [overId]);
function handleDragStart(event) {
const { active, over } = event;
setDragId(active.id);
setOverId(over?.id);
setOverId(over?.id || null);
if (!selectedGroupIds.includes(active.id)) {
onGroupSelect(active.id);
}
@ -93,7 +102,7 @@ export function TileDragProvider({
function handleDragOver(event) {
const { over } = event;
setOverId(over?.id);
setOverId(over?.id || null);
if (over) {
if (
over.id.startsWith(UNGROUP_ID) ||
@ -111,8 +120,8 @@ export function TileDragProvider({
function handleDragEnd(event) {
const { active, over, overlayNodeClientRect } = event;
setDragId();
setOverId();
setDragId(null);
setOverId(null);
setDragCursor("pointer");
if (active && over && active.id !== over.id) {
let selectedIndices = selectedGroupIds.map((groupId) =>
@ -165,8 +174,8 @@ export function TileDragProvider({
}
function handleDragCancel(event) {
setDragId();
setOverId();
setDragId(null);
setOverId(null);
setDragCursor("pointer");
resumeSelect();
@ -210,8 +219,6 @@ export function TileDragProvider({
return closestCenter(otherRects, rect);
}
const value = { dragId, overId, dragCursor };
return (
<DragContext
onDragStart={handleDragStart}
@ -221,19 +228,37 @@ export function TileDragProvider({
sensors={sensors}
collisionDetection={customCollisionDetection}
>
<TileDragContext.Provider value={value}>
{children}
</TileDragContext.Provider>
<TileDragIdContext.Provider value={dragId}>
<TileOverGroupIdContext.Provider value={overGroupId}>
<TileDragCursorContext.Provider value={dragCursor}>
{children}
</TileDragCursorContext.Provider>
</TileOverGroupIdContext.Provider>
</TileDragIdContext.Provider>
</DragContext>
);
}
export function useTileDrag() {
const context = useContext(TileDragContext);
export function useTileDragId() {
const context = useContext(TileDragIdContext);
if (context === undefined) {
throw new Error("useTileDrag must be used within a TileDragProvider");
}
return context;
}
export default TileDragContext;
export function useTileOverGroupId() {
const context = useContext(TileOverGroupIdContext);
if (context === undefined) {
throw new Error("useTileDrag must be used within a TileDragProvider");
}
return context;
}
export function useTileDragCursor() {
const context = useContext(TileDragCursorContext);
if (context === undefined) {
throw new Error("useTileDrag must be used within a TileDragProvider");
}
return context;
}