리스티

리스티 (Listy)

가상 스크롤, 그룹 헤더, 스크롤 위치 제어를 지원하는 고성능 목록 컴포넌트예요. 긴 목록을 렌더링할 때 모든 행을 마운트하지 않고 화면에 보이는 행만 그려요.

출처: 문서

본문

언제 사용하나요 (When To Use)

  • 모든 행을 마운트하는 비용을 지불하지 않고 긴 목록을 렌더링해야 할 때 — virtual을 활성화하면 화면에 보이는 행만 렌더링해요.
  • 목록을 스티키 헤더가 있는 그룹 섹션으로 나눠야 할 때 사용해요.
  • 스크롤 위치를 명령형으로 제어(항목, 그룹, 픽셀 오프셋으로 점프)해야 할 때 사용해요.

예시 (Examples)

기본 (Basic)

기본 예시예요.

import React from 'react';
import { Listy } from 'antd';

interface Item {
  id: number;
  content: string;
}

const items = Array.from<any, Item>({ length: 20 }, (_, index) => ({
  id: index,
  content: `Item ${index}`,
}));

const App: React.FC = () => {
  return <Listy<Item> items={items} height={400} rowKey="id" itemRender={(item) => item.content} />;
};

export default App;

가상 스크롤 (Virtual scrolling)

10,000개 행의 긴 목록이에요. virtual과 height를 설정하면 가상 스크롤이 활성화되어 화면에 보이는 행만 렌더링돼요.

import React from 'react';
import { Listy } from 'antd';

interface Item {
  id: number;
  content: string;
}

const items = Array.from<any, Item>({ length: 10000 }, (_, index) => ({
  id: index,
  content: `Item ${index}`,
}));

const App: React.FC = () => {
  return (
    <Listy<Item>
      virtual
      items={items}
      height={400}
      rowKey="id"
      itemRender={(item) => item.content}
    />
  );
};

export default App;

그룹화와 스티키 헤더 (Grouping and sticky headers)

group으로 각 항목에서 그룹 키를 도출하고 그룹 헤더를 렌더링해요. sticky를 활성화하면 스크롤 중 현재 그룹 헤더가 위쪽에 붙어 있어요.

import React from 'react';
import { Avatar, Flex, Listy } from 'antd';

interface Contact {
  id: number;
  name: string;
}

const names = [
  'Aaron Baker',
  'Alice Adams',
  'Bella Carter',
  'Brian Diaz',
  'Chloe Evans',
  'Colin Foster',
  'Daisy Garcia',
  'David Hayes',
  'Elena Ingram',
  'Eric Jensen',
  'Fiona Kim',
  'Frank Lopez',
  'Grace Miller',
  'Gavin Nguyen',
  'Hannah Ortiz',
  'Henry Parker',
  'Iris Quincy',
  'Ivan Reed',
  'Jack Smith',
  'Julia Turner',
];

const contacts = names.map<Contact>((name, id) => ({ id, name }));

const colors = ['#f56a00', '#7265e6', '#ffbf00', '#00a2ae', '#87d068'];

const colorOf = (letter: string) => colors[(letter.charCodeAt(0) - 65) % colors.length];

const App: React.FC = () => (
  <Listy<Contact>
    items={contacts}
    rowKey="id"
    height={400}
    sticky
    group={{
      key: (contact) => contact.name[0],
      title: (letter) => letter,
    }}
    itemRender={(contact) => (
      <Flex align="center" gap="small">
        <Avatar size="small" style={{ backgroundColor: colorOf(contact.name[0]) }}>
          {contact.name[0]}
        </Avatar>
        {contact.name}
      </Flex>
    )}
  />
);

export default App;

풍부한 콘텐츠 (Rich content)

itemRender는 임의로 풍부한 콘텐츠를 렌더링할 수 있고, 행들의 높이가 같지 않아도 돼요.

import React from 'react';
import { Avatar, Flex, Listy, Typography } from 'antd';

interface Notification {
  id: number;
  user: string;
  message: string;
  time: string;
}

const users = ['Olivia', 'Liam', 'Emma', 'Noah', 'Ava', 'Ethan', 'Sophia', 'Lucas'];

const messages = [
  'commented on your merge request',
  'invited you to the quarterly planning review. Please confirm your availability before Friday so the agenda can be finalized in time.',
  'mentioned you in the design review thread',
  'assigned you a task that is due next Monday. It covers the remaining accessibility issues found in the latest audit.',
  'starred the report you shared yesterday',
  'requested changes on your pull request. Most of the comments are about naming and the test coverage of the new cache layer.',
];

const colors = ['#f56a00', '#7265e6', '#ffbf00', '#00a2ae'];

const colorOf = (user: string) => colors[users.indexOf(user) % colors.length];

const pad = (value: number) => String(value).padStart(2, '0');

const notifications = Array.from<any, Notification>({ length: 12 }, (_, index) => ({
  id: index,
  user: users[index % users.length],
  message: messages[index % messages.length],
  time: `${pad((8 + index) % 24)}:${pad((index * 17) % 60)}`,
}));

const App: React.FC = () => (
  <Listy<Notification>
    items={notifications}
    rowKey="id"
    height={400}
    itemRender={(item) => (
      <Flex gap="middle" align="flex-start">
        <Avatar style={{ backgroundColor: colorOf(item.user), flex: 'none' }}>
          {item.user[0]}
        </Avatar>
        <Flex vertical flex="auto" style={{ minWidth: 0 }}>
          <Flex justify="space-between" gap="small">
            <Typography.Text strong>{item.user}</Typography.Text>
            <Typography.Text type="secondary">{item.time}</Typography.Text>
          </Flex>
          <Typography.Text type="secondary">{item.message}</Typography.Text>
        </Flex>
      </Flex>
    )}
  />
);

export default App;

드래그 정렬 (Drag sorting)

서드파티 라이브러리 dnd-kit을 연동해 목록 항목의 드래그 정렬을 구현해요.

import React, { useState } from 'react';
import { HolderOutlined } from '@ant-design/icons';
import type { DragEndEvent } from '@dnd-kit/core';
import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  useSortable,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button, Flex, Listy } from 'antd';

interface Item {
  id: number;
  content: string;
}

const items = Array.from<any, Item>({ length: 20 }, (_, index) => ({
  id: index,
  content: `Item ${index}`,
}));

const SortableItem: React.FC<Readonly<Item>> = (props) => {
  const { id, content } = props;

  const {
    attributes,
    listeners,
    setNodeRef,
    setActivatorNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id });

  const style: React.CSSProperties = {
    transform: CSS.Translate.toString(transform),
    transition,
    ...(isDragging ? { position: 'relative', zIndex: 1 } : {}),
  };

  return (
    <Flex ref={setNodeRef} style={style} align="center" gap="small">
      <Button
        type="text"
        size="small"
        icon={<HolderOutlined />}
        style={{ cursor: 'move' }}
        ref={setActivatorNodeRef}
        {...attributes}
        {...listeners}
      />
      {content}
    </Flex>
  );
};

const Demo: React.FC = () => {
  const [data, setData] = useState<Item[]>(items);

  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: { distance: 1 },
    }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    }),
  );

  const onDragEnd = ({ active, over }: DragEndEvent) => {
    if (!over || active.id === over.id) {
      return;
    }
    setData((prev) => {
      const activeIndex = prev.findIndex((item) => item.id === active.id);
      const overIndex = prev.findIndex((item) => item.id === over.id);
      return arrayMove(prev, activeIndex, overIndex);
    });
  };

  return (
    <DndContext
      id="listy-drag-sorting"
      sensors={sensors}
      modifiers={[restrictToVerticalAxis]}
      onDragEnd={onDragEnd}
    >
      <SortableContext items={data.map((item) => item.id)} strategy={verticalListSortingStrategy}>
        <Listy<Item>
          items={data}
          height={400}
          rowKey="id"
          itemRender={(item) => <SortableItem {...item} />}
        />
      </SortableContext>
    </DndContext>
  );
};

export default Demo;

무한 로딩 (Infinite loading)

onScroll에서 목록이 바닥에 거의 닿았다는 것을 감지한 뒤 필요한 만큼 다음 페이지를 로드해 끝없이 스크롤해요. virtual과 결합하면 데이터가 아무리 쌓여도 화면에 보이는 행만 렌더링돼요.

import React from 'react';
import { Flex, Listy, Spin, Typography } from 'antd';

interface Item {
  id: number;
  content: string;
}

const PAGE_SIZE = 50;

const makePage = (offset: number) =>
  Array.from<any, Item>({ length: PAGE_SIZE }, (_, index) => ({
    id: offset + index,
    content: `Item ${offset + index}`,
  }));

const App: React.FC = () => {
  const [items, setItems] = React.useState<Item[]>(() => makePage(0));
  const [loading, setLoading] = React.useState(false);
  const loadingRef = React.useRef(false);

  const onScroll: React.UIEventHandler<HTMLElement> = (event) => {
    const { scrollTop, clientHeight, scrollHeight } = event.currentTarget;
    if (scrollHeight - scrollTop - clientHeight > 200 || loadingRef.current) {
      return;
    }
    loadingRef.current = true;
    setLoading(true);
    setTimeout(() => {
      setItems((prev) => [...prev, ...makePage(prev.length)]);
      loadingRef.current = false;
      setLoading(false);
    }, 600);
  };

  return (
    <Flex vertical gap="small">
      <Listy<Item>
        virtual
        items={items}
        rowKey="id"
        height={400}
        itemRender={(item) => item.content}
        onScroll={onScroll}
      />
      <Flex justify="center" align="center" style={{ height: 24 }}>
        {loading ? (
          <Spin size="small" />
        ) : (
          <Typography.Text type="secondary">{items.length} items loaded</Typography.Text>
        )}
      </Flex>
    </Flex>
  );
};

export default App;

시맨틱 DOM 스타일링 (Custom semantic dom styling)

classNames와 styles로 Listy의 시맨틱 DOM 스타일을 커스터마이즈해요.

import React from 'react';
import { Listy } from 'antd';
import type { ListyProps } from 'antd';
import { createStaticStyles } from 'antd-style';

interface User {
  id: number;
  name: string;
  team: string;
}

const users: User[] = [
  { id: 0, name: 'Olivia', team: 'Design' },
  { id: 1, name: 'Liam', team: 'Design' },
  { id: 2, name: 'Emma', team: 'Design' },
  { id: 3, name: 'Noah', team: 'Engineering' },
  { id: 4, name: 'Ava', team: 'Engineering' },
  { id: 5, name: 'Ethan', team: 'Engineering' },
  { id: 6, name: 'Sophia', team: 'Marketing' },
  { id: 7, name: 'Lucas', team: 'Marketing' },
];

const classNames = createStaticStyles(({ css }) => ({
  root: css`
    border: 1px solid #91caff;
    border-radius: 8px;
    overflow: hidden;
  `,
  groupHeader: css`
    color: #1677ff;
    background: #e6f4ff;
  `,
}));

const styles: ListyProps['styles'] = {
  item: { fontStyle: 'italic' },
};

const App: React.FC = () => (
  <Listy<User, string>
    items={users}
    rowKey="id"
    height={260}
    sticky
    group={{ key: (user) => user.team, title: (team) => team }}
    itemRender={(user) => user.name}
    classNames={classNames}
    styles={styles}
  />
);

export default App;

API

Listy 컴포넌트는 [email protected]부터 사용할 수 있어요.

공통 props는 Common props를 참고해요.

속성 (Property) 설명 (Description) 타입 (Type) 기본값 (Default) 버전 (Version) 글로벌 설정
classNames 시맨틱 class 이름 { root?, item?, groupHeader? } - 6.6.0 6.6.0
group 그룹핑 설정. 아래 Group 참고 Group<T, K> - 6.6.0 ×
height 스크롤 컨테이너의 높이예요. 콘텐츠가 넘치면 스크롤돼요. number - 6.6.0 ×
itemRender 한 행을 렌더링해요. (item: T, index: number) => ReactNode - 6.6.0 ×
items 목록의 데이터 소스 T[] [] 6.6.0 ×
rowKey 항목의 고유 키로, 필드 이름 또는 getter예요. keyof T | (item: T) => Key - 6.6.0 ×
sticky 그룹 헤더가 위쪽에 붙어 있는지 여부 boolean false 6.6.0 ×
styles 시맨틱 인라인 스타일 { root?, item?, groupHeader? } - 6.6.0 6.6.0
virtual 가상 스크롤 활성화 여부예요. 화면에 보이는 행만 렌더링하며 height가 필요해요. boolean false 6.6.0 ×
onScroll 네이티브 스크롤 이벤트 핸들러 React.UIEventHandler<HTMLElement> - 6.6.0 ×

Group

속성 (Property) 설명 (Description) 타입 (Type)
key 항목이 속한 그룹 키를 계산해요. 같은 키를 가진 항목끼리 그룹화돼요. (item: T) => K
title 그룹 헤더를 렌더링해요. 그룹 키와 그 항목들을 받아요. (groupKey: K, items: T[]) => ReactNode

Ref

이름 (Name) 설명 (Description) 타입 (Type)
scrollTo 위치, 항목, 그룹으로 스크롤해요. (config?: ListyScrollToConfig) => void

ListyScrollToConfig는 다음 중 하나예요.

형태 (Shape) 설명 (Description)
number 픽셀 오프셋(scrollTop)으로 스크롤해요.
{ top?, left? } 절대 픽셀 위치로 스크롤해요.
{ key, align?, offset? } rowKey가 key와 일치하는 항목으로 스크롤해요.
{ groupKey, align?, offset? } 그룹 헤더로 스크롤해요.

align은 'top' | 'bottom' | 'auto'이며, offset은 정렬 후 적용되는 추가 픽셀 오프셋이에요.

시맨틱 DOM (Semantic DOM)

시맨틱 DOM 구조는 https://ant.design/components/listy/semantic.md 에서 확인할 수 있어요.

디자인 토큰 (Design Token)

컴포넌트 토큰 (Listy) (Component Token)

토큰 이름 (Token Name) 설명 (Description) 타입 (Type) 기본값 (Default Value)
itemPaddingBlock 목록 항목의 세로 패딩 number 12
itemPaddingInline 목록 항목의 가로 패딩 number 16

글로벌 토큰 (Global Token)

토큰 이름 (Token Name) 설명 (Description) 타입 (Type) 기본값 (Default Value)
colorBgContainer 컨테이너 배경색이에요. 기본 버튼, 입력 상자 등. colorBgElevated와 혼동하지 마세요. string
colorFillAlter 요소의 대체 배경색을 제어해요. string
colorFillQuaternary 가장 약한 단계의 fill 색으로, 얼룩말 줄무늬나 경계를 구분하는 색 블록처럼 주의를 끌지 않는 색 블록에 적합해요. string
colorSplit 구분자 색으로 사용돼요. colorBorderSecondary와 같은 색이지만 투명도를 가져요. string
colorText W3C 표준을 따르는 기본 텍스트 색이에요. 가장 어두운 중성색이기도 해요. string
colorTextDescription 텍스트 설명의 글자 색을 제어해요. string
controlItemBgHover 컨트롤 컴포넌트 항목을 hover할 때 배경색을 제어해요. string
fontFamily Ant Design의 글꼴은 시스템의 기본 인터페이스 글꼴을 우선시하고, 화면 표시에 적합한 대체 글꼴 라이브러리를 제공해 플랫폼과 브라우저에 따라 가독성을 유지하며 친근하고 안정적이며 전문적인 특성을 반영해요. string
fontSize 디자인 시스템에서 가장 널리 쓰이는 글자 크기로, 여기서 텍스트 그라데이션이 파생돼요. number
fontWeightStrong 제목 컴포넌트(h1, h2, h3 등)나 선택된 항목의 글자 굵기를 제어해요. number
lineHeight 텍스트의 줄 높이예요. number
lineType 기본 컴포넌트의 테두리 스타일 string
lineWidth 기본 컴포넌트의 테두리 두께 number
motionDurationMid 동작 속도, 중간 속도예요. 중간 요소의 애니메이션 상호작용에 사용돼요. string
motionEaseInOut 미리 정의된 모션 곡선이에요. string
paddingXS 요소의 아주 작은 패딩을 제어해요. number

더 알아보기 (Learn more)