ZSCalendar
Skia 캔버스 하나로 그리는 달력입니다. 좌우 스와이프로 달을 넘기고, 위아래로 끌거나 아래 리스트를 스크롤하면 월간↔주간이 이어서 전환됩니다. 달력의 점(dot)과 아젠다 리스트가 같은 events 배열 하나에서 파생됩니다.
@shopify/react-native-skia, react-native-reanimated, react-native-worklets, react-native-gesture-handler 가 peer 의존성으로 필요합니다. 앱 루트에 GestureHandlerRootView 와 ThemeProvider 가 있어야 합니다.
ZSCalendar 는 iOS · Android 전용입니다. 그리드·페이저·전환이 전부 Skia 캔버스와 UI 스레드 제스처 위에 올라가 있어 웹에서는 같은 것을 만들 수 없습니다.
웹에서는 아무것도 렌더링하지 않고 개발 모드에서 한 번 경고합니다. children 도 함께 빠집니다 — 달력 컨텍스트 없이 남겨두면 그 안의 useCalendarAgenda() 가 곧바로 실패하기 때문입니다. 그래서 아래 미리보기는 빈 화면 대신 미지원 안내만 띄웁니다.
웹 빌드가 깨지지는 않으니, 웹도 함께 지원하는 앱이라면 Platform.OS 로 갈라 다른 UI를 두세요. 함께 공개된 유틸과 useCalendarAgenda({ index, selectedDate }) 는 Skia 를 건드리지 않으므로 웹에서도 그대로 씁니다.
예제를 선택하면 현재 저장소 소스로 만든 Expo Web 화면을 불러옵니다.
웹 미지원 또는 플랫폼 종속 기능은 실제 iOS·Android 앱에서 확인하세요.
기본 사용법
필요한 것은 일정 배열 하나와 선택 날짜 상태 하나입니다. 나머지는 기본값으로 동작합니다.
일정 배열 만들기
날짜는 전부 'YYYY-MM-DD' 로컬 문자열(DateString)입니다. timestamp 를 쓰지 않아 타임존·서머타임에서 하루가 밀리지 않습니다.
import { todayDateString, type CalendarEvent, type DateString } from '@0610studio/zs-ui';
type Memo = { title: string };
const today = todayDateString();
const EVENTS: CalendarEvent<Memo>[] = [
{ id: '1', date: today, color: '#D7B8FF', data: { title: '산책 30분' } },
{ id: '2', date: today, color: '#A7DBD1', data: { title: '아침 급여 45g' } },
];
color 는 달력에 찍히는 점 색이고, data 는 리스트에서 그릴 페이로드입니다. 둘 다 생략할 수 있습니다.
달력 놓기
import { useState } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { ZSCalendar, type DateString } from '@0610studio/zs-ui';
export default function Screen() {
const [selectedDate, setSelectedDate] = useState<DateString>(today);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<ZSCalendar
style={{ flex: 1 }}
events={EVENTS}
selectedDate={selectedDate}
onDateChange={setSelectedDate}
/>
</GestureHandlerRootView>
);
}
여기까지로 헤더·요일 줄·월간 그리드·좌우 스와이프·월↔주 전환이 모두 동작합니다. 선택 날짜를 화면에서 쓸 일이 없다면 selectedDate · onDateChange 도 빼도 됩니다 — 내부 상태로 오늘부터 시작합니다.
선택한 날짜의 목록 붙이기 (선택)
리스트를 ZSCalendar 의 children 으로 넣으면 useCalendarAgenda() 가 인자 없이 선택한 날짜의 일정과 스크롤 바인딩을 받습니다. 날짜별로 데이터를 다시 묶거나 상태를 따로 들고 있을 필요가 없습니다.
import { FlatList } from 'react-native';
import { ZSCalendar, ZSText, useCalendarAgenda } from '@0610studio/zs-ui';
function MemoList() {
const { items, bindScroll } = useCalendarAgenda<Memo>();
return (
<FlatList
{...bindScroll}
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <ZSText typo="body.3">{item.data?.title}</ZSText>}
/>
);
}
<ZSCalendar<Memo>
style={{ flex: 1 }}
events={EVENTS}
selectedDate={selectedDate}
onDateChange={setSelectedDate}
>
<MemoList />
</ZSCalendar>
{...bindScroll} 은 세 핸들러(onScroll · onScrollEndDrag · onMomentumScrollEnd)가 한 묶음이라 그대로 펼쳐 넣어야 합니다. 이걸 넣으면 리스트 스크롤이 월간↔주간 전환을 함께 구동합니다 (규칙). 스크롤 연동이 필요 없으면 bindScroll 없이 items 만 써도 됩니다.
전체 코드는 example/app/ZSCalendarExample.tsx 에 있습니다.
자주 쓰는 옵션
기본 구성에서 한두 개만 더 얹고 싶을 때 먼저 보는 것들입니다.
<ZSCalendar
events={events}
locale="en" // 요일·월 제목·접근성 라벨을 영어로
firstDayOfWeek={1} // 월요일 시작
enableModeTransition={false} // 월간 고정 (주간 전환 끔)
maxContentWidth={false} // 태블릿에서 폭 제한 없이 채우기
onTitlePress={openDatePicker} // 헤더 연월 탭 → 날짜 선택 시트 등
/>
| 하고 싶은 것 | 옵션 |
|---|---|
| 언어 바꾸기 | locale="en" · 국제화 |
| 주 시작 요일 | firstDayOfWeek={1} |
| 색만 조금 손보기 | calendarTheme · 토큰 표 |
| 월간 고정 | enableModeTransition={false} |
| 리스트 스크롤 전환만 끄기 | enableScrollModeTransition={false} |
| 다른 달 데이터 미리 받기 | onVisibleRangeChange · 데이터 미리 받기 |
| 헤더를 통째로 교체 | renderHeader |
| 앱 글꼴로 날짜 숫자 그리기 | ThemeProvider 의 themeFontAssets 또는 fonts |
커스텀 헤더·바텀시트 날짜 선택기·prefetch·오류 재시도까지 붙인 화면은 example/app/ZSCalendarAdvancedExample.tsx 에 있습니다.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
events | CalendarEvent<T>[] | [] | 달력 점과 리스트가 공유하는 단일 소스 |
onVisibleRangeChange | (range: DateRange) => void | undefined | 앞뒤 1페이지를 포함한 표시 범위 (prefetch 힌트) |
selectedDate | DateString | null | - | 선택된 날짜 (controlled) |
defaultSelectedDate | DateString | null | 오늘 | 선택된 날짜 초기값 (uncontrolled) |
onDateChange | (date: DateString) => void | undefined | 날짜를 탭했을 때 |
visibleMonth | DateString | - | 표시 중인 달 (controlled). 해당 월 1일로 정규화됩니다 |
defaultVisibleMonth | DateString | 이번 달 | 표시 중인 달 초기값 (uncontrolled) |
onMonthChange | (month: DateString) => void | undefined | 스와이프·헤더 화살표로 달이 바뀌었을 때 |
mode | 'week' | 'month' | - | 보기 모드 (controlled) |
defaultMode | 'week' | 'month' | 'month' | 보기 모드 초기값 (uncontrolled) |
onModeChange | (mode) => void | undefined | 전환이 양 끝(0 또는 1)에 도달했을 때만 호출됩니다 |
calendarTheme | CalendarThemeOverride | 테마 팔레트에서 파생 | 색 토큰 부분 오버라이드 |
fonts | { regular?, bold? } | undefined | 날짜 숫자 폰트 파일. 미주입 시 ThemeProvider 의 themeFontAssets(400·700), 그것도 없으면 시스템 폰트 |
firstDayOfWeek | 0 | 1 | 0 | 0=일요일, 1=월요일 시작 |
locale | 'ko' | 'en' | CalendarLocaleStrings | 'ko' | 요일·월 제목·접근성 라벨·스크린리더 안내 언어 |
labels | CalendarLabels | locale 을 따름 | locale 위에 요일 라벨·월 제목만 덮어쓴다 |
maxContentWidth | number | false | 520 | 태블릿에서 그리드 최대 폭. 넘으면 가운데 정렬 |
renderHeader | (ctx: CalendarHeaderContext) => ReactNode | 기본 헤더 | 헤더를 통째로 교체 |
onTitlePress | () => void | undefined | 제목을 눌렀을 때 (날짜 선택 시트 등) |
enableModeTransition | boolean | true | false 면 월간 고정 — 팬도 리스트 스크롤도 전환하지 않음 |
enableScrollModeTransition | boolean | true | false 면 아젠다 리스트 스크롤이 월간↔주간을 바꾸지 않음. 팬 제스처는 그대로 |
announceChanges | boolean | true | 월 이동·모드 전환을 스크린리더에 안내 |
style | StyleProp<ViewStyle> | undefined | 컨테이너 추가 스타일 |
children | ReactNode | undefined | 달력 아래, Provider 안에 렌더됩니다. useCalendarAgenda() 는 여기서만 인자 없이 동작합니다 |
testID | string | undefined | 컨테이너 testID |
특징
- 단일 데이터 소스: 달력 점과 아젠다 리스트가 같은
events인덱스를 조회하므로 날짜별로 데이터를 다시 묶을 필요가 없습니다 - 로컬 날짜 문자열: 모든 날짜가
'YYYY-MM-DD'이고 timestamp 를 쓰지 않아 타임존·서머타임에서 하루가 밀리지 않습니다. 형식이 잘못된 이벤트는 조용히 건너뛰고 개발 모드에서 한 번 경고합니다 - 제어·비제어 양쪽 지원:
selectedDate·visibleMonth·mode는 값을 넘기면 부모가 소유하고,default*만 넘기면 내부 상태로 동작합니다.mode를 밖에서 바꾸면 같은 스프링으로 전환 애니메이션이 재생됩니다 - 전환이 끝난 뒤에만 알림:
onModeChange는 전환이 양 끝(0 또는 1)에 도달했을 때만 호출됩니다 — 중간 값에서 알리면 소비자 상태가 전환 도중에 흔들립니다 - 참조 안정성이 곧 성능: 렌더마다
events배열을 새로 만들면 내부 캐시가 통째로 버려져 매번 세 페이지를 다시 그립니다.useMemo로 감싸거나 받아온 데이터를 누적하세요 - 컨텍스트 기반 아젠다:
useCalendarAgenda()를 인자 없이 쓰는 곳은ZSCalendar의 children 안입니다. 밖에서는index와selectedDate를 직접 넘깁니다
CalendarEvent
type CalendarEvent<T = unknown> = {
id: string;
date: DateString; // 'YYYY-MM-DD' (로컬 기준)
color?: string; // 점 색. 미지정 시 팔레트 primary
data?: T; // 리스트 렌더링에 쓸 페이로드
};
같은 날짜에 여러 건이 있으면 색마다 점을 하나씩 찍습니다(같은 색은 하나로 합칩니다). 한 줄이 차면 다음 줄로 감싸 두 줄까지 보이고 그 이상은 자릅니다. 개수 상한은 따로 없고 두 줄에 들어가는 만큼입니다(한 줄 개수는 셀 폭에 따라 5~7개, 태블릿은 더 많이).
useCalendarAgenda
function useCalendarAgenda<T>(options?: {
index?: EventIndex<T>; // 생략 시 <ZSCalendar> 컨텍스트
selectedDate?: DateString | null; // 생략 시 <ZSCalendar> 의 선택일
}): {
items: ReadonlyArray<CalendarEvent<T>>; // 선택한 하루의 일정, 들어온 순서 그대로
bindScroll: CalendarScrollBinding;
};
ZSCalendar 밖에서 쓰려면 index 와 selectedDate 를 직접 넘깁니다. 이 경우 bindScroll 은 아무 일도 하지 않는 빈 바인딩입니다.
const { items } = useCalendarAgenda({ index: buildEventIndex(events), selectedDate });
훅은 하루치만 다룹니다. 여러 날을 이어서 보여주고 싶다면 eventsInRange 로 직접 뽑으세요.
const items = eventsInRange(index, { startDate: selected, endDate: addMonths(selected, 1) });
한때 훅에 이어보기 옵션이 있었지만 뺐습니다. 목록 길이가 보기 모드에 따라 흔들리면(월간 3개월 → 주간 3주) 콘텐츠가 뷰포트보다 짧아지는 순간 스크롤이 0 으로 되감기고, 아래의 접기 규칙과 부딪혔기 때문입니다. 범위를 정하는 책임은 소비자에게 두는 편이 예측 가능합니다.
리스트 스크롤로 접기
bindScroll 을 넣으면 리스트 스크롤이 월간↔주간 전환을 구동합니다. 규칙은 네 가지입니다.