Skip to main content
← 블로그

0.4초의 차이 — 자동 포커스 구현기

VauDium ·

태스크 시작 시 자동 포커스, 완료 시 자동 해제를 구현하면서 마주친 타이밍, 애니메이션, 상태 동기화 문제들을 다룹니다.

0.4초의 차이

구조: 포커스 슬롯

Fecit의 Tasks 탭은 FlatList 위에 포커스 슬롯이 있는 구조입니다. achiever.focusedTaskRecordId가 존재하면 슬롯이 나타나고, 해당 태스크가 FlatList에서 빠져나와 슬롯에 렌더링됩니다.

┌─────────────────────┐
│  [Focus Slot]       │  ← focusedTaskRecordId가 있으면 표시
├─────────────────────┤
│  FlatList           │  ← 포커스된 태스크는 필터링
│  - Task A           │
│  - Task B           │
│  ...                │
└─────────────────────┘

자동 포커스는 이 흐름에 끼어드는 것입니다. 상태 변경 시 focusedTaskRecordId를 세팅하면 슬롯이 나타나고, 해제하면 사라집니다.

Optimistic Update

포커스 API는 네트워크 왕복이 필요합니다. 시작 버튼을 누르고 응답을 기다렸다가 슬롯을 띄우면 눈에 보이는 지연이 생깁니다. 그래서 optimistic update를 씁니다.

// 즉시 로컬 상태 반영
setAchiever((prev) => ({
  ...prev,
  focusedTaskRecordId: task.id,
}));

// API는 fire-and-forget
setTaskRecordIsFocused(task.id, true).catch(() => {
  // 실패 시 롤백
  setAchiever((prev) =>
    prev?.focusedTaskRecordId === task.id
      ? { ...prev, focusedTaskRecordId: undefined }
      : prev
  );
});

버튼을 누르는 순간 슬롯이 나타납니다. API가 실패하면 조용히 되돌립니다. 성공하면 아무 일도 일어나지 않습니다. 이미 맞는 상태니까.

등장 애니메이션: 네 가지를 하나로

포커스 슬롯이 나타나는 것 외에도, 태스크가 목록에 등장하는 상황이 세 가지 더 있습니다: 신규 생성, 리뉴(날짜 갱신), 포커스 해제 후 목록 복귀.

원래 네 가지가 전부 다른 애니메이션이었습니다. 신규 생성은 spring scale, 리뉴는 border flash, 포커스/해제는 height expand. 통일하기로 했습니다.

FlatList의 CellRendererComponent에서 height expand를 공통으로 적용합니다:

const shouldExpand = taskId != null && (
  taskId === justUnfocusedIdRef.current ||
  taskId === justCreatedIdRef.current ||
  taskId === justModifiedIdRef.current
);

const entering = shouldExpand
  ? (values) => ({
      initialValues: { opacity: 0, height: 0 },
      animations: {
        opacity: withTiming(1, { duration: 200 }),
        height: withTiming(values.targetHeight, { duration: 240 }),
      },
    })
  : undefined;

기본 등장은 같되, 위에 얹는 장식은 다릅니다. 신규 생성은 glow overlay, 리뉴는 border flash. 등장 자체가 통일되니 목록의 리듬감이 생겼습니다.

포커스 슬롯은 다르게

그런데 포커스 슬롯만은 height expand가 어울리지 않았습니다. 슬롯은 FlatList 위의 별도 Animated.View이고, overflow: "hidden"이 걸려 있어서 커튼처럼 열리는 느낌이었습니다. FlatList 셀에서는 아래 아이템들이 밀려나면서 자연스럽게 자리를 만들지만, 슬롯은 독립된 공간이라 같은 애니메이션이 다른 인상을 줍니다.

scale up으로 바꿨습니다:

entering={() => ({
  initialValues: {
    opacity: 0,
    transform: [{ scaleY: 0.95 }, { scaleX: 0.98 }],
  },
  animations: {
    opacity: withTiming(1, { duration: 200 }),
    transform: [
      { scaleY: withTiming(1, { duration: 250, easing: Easing.out(Easing.cubic) }) },
      { scaleX: withTiming(1, { duration: 250, easing: Easing.out(Easing.cubic) }) },
    ],
  },
})}

살짝 납작한 상태에서 원래 크기로 펴지는 애니메이션. 250ms, Easing.out(cubic). 같은 “등장”이지만 맥락에 맞는 방식입니다.

배경 틴트 펄스

포커스 되는 순간을 좀 더 인지시키기 위해 배경 틴트 펄스를 추가했습니다. FocusedTaskWrapper에 Animated.View를 하나 얹고, PRIMARY400 색상의 8% 불투명도 배경이 150ms에 걸쳐 나타났다가 600ms에 걸쳐 사라집니다.

if (isJustFocused) {
  tintOpacity.value = withSequence(
    withTiming(1, { duration: 150, easing: Easing.out(Easing.quad) }),
    withTiming(0, { duration: 600, easing: Easing.inOut(Easing.ease) }),
  );
}

8%라는 값은 신규 생성의 NewListItemIndicator와 동일합니다. 앱 전체에서 “방금 일어난 일”의 시각적 강도가 통일됩니다.

해제의 타이밍

완료 시 자동 해제에서 가장 많이 조정한 건 타이밍입니다.

0ms: 완료를 누르면 바로 슬롯이 사라짐. 기능적으로 맞지만 “끝냈다”를 느끼기 전에 치워지는 느낌.

1500ms: 처음 시도한 값. 너무 길었음. 다음 일로 넘어가고 싶은데 슬롯이 계속 남아있음.

400ms: 완료 상태로 바뀐 걸 한 박자 보여주고 슬롯이 접히기 시작. 이게 맞았습니다.

if (autoUnfocus) {
  setTimeout(() => {
    setAchiever((prev) => {
      if (prev?.focusedTaskRecordId !== task.id) return prev;
      return { ...prev, focusedTaskRecordId: undefined };
    });
  }, 400);
}

슬롯이 사라지면 태스크가 FlatList로 돌아와야 합니다. 이때도 타이밍이 중요합니다.

필터 지연: 480ms

포커스된 태스크는 FlatList에서 필터링됩니다. 포커스 해제 시 이 필터를 바로 풀면 슬롯이 접히기 전에 같은 태스크가 리스트에도 나타납니다. 하나가 둘이 됩니다.

delayedFocusFilterId로 필터 해제를 480ms 지연시킵니다. 슬롯이 완전히 접힌 뒤에 태스크가 리스트에 등장합니다. 이때 CellRenderer의 height expand entering 애니메이션이 발동합니다.

[0ms]   완료 버튼 누름
[400ms] focusedTaskRecordId = undefined → 슬롯 접히기 시작
[480ms] 필터 해제 → 태스크가 리스트에 height expand로 등장

이 40ms ~ 80ms의 오버랩이 슬롯 → 리스트 전환을 매끄럽게 만듭니다.

CellRenderer와 ref

CellRendererComponent는 useMemo(() => ..., [])로 의존성 없이 생성됩니다. FlatList가 셀 컴포넌트를 교체하면 전체 리마운트가 발생하기 때문입니다.

문제는 justCreatedIdjustModifiedId 같은 atom 값에 접근할 수 없다는 것입니다. 의존성 배열이 비어있으니까. justUnfocusedIdRef처럼 ref로 미러링하는 패턴을 씁니다:

const justCreatedIdRef = useRef<string | undefined>(undefined);
const justModifiedIdRef = useRef<string | undefined>(undefined);

// 렌더 중에 동기화
justCreatedIdRef.current = justCreatedId;
justModifiedIdRef.current = justModifiedId;

CellRenderer 안에서는 ref의 .current를 읽습니다. ref는 의존성에 포함되지 않아도 항상 최신 값을 가리킵니다.

맥락에 따른 제안 모달

자동 포커스를 끈 사용자에게는 “포커스할까요?” 모달을 보여줍니다. 하지만 모든 곳에서 보여주면 안 됩니다.

태스크 상세 화면(BrowseTaskRecordPage)에서 시작 버튼을 눌렀을 때 모달이 뜨면, 상세를 나온 뒤에 목록 위에 느닷없이 모달이 있습니다. 맥락이 끊깁니다.

suppressFocusSuggestion prop으로 상세 화면에서는 제안을 억제합니다. 목록에서 시작했을 때만 모달이 뜹니다. 자동 포커스는 어디서든 동작하되, 수동 제안은 맥락에 맞는 곳에서만.

숫자들

자동 포커스 한 기능에 관여하는 타이밍 값들:

  • 200ms: opacity fade in
  • 240ms: height expand (리스트)
  • 250ms: scale up (포커스 슬롯)
  • 150ms + 600ms: 틴트 펄스 (in + out)
  • 400ms: 완료 후 해제 지연
  • 480ms: 필터 해제 지연
  • 1500ms: 별 춤 지속 시간

각각은 작은 숫자지만, 이 숫자들이 맞물려야 “시작하면 올라오고, 끝나면 내려간다”가 자연스러워집니다.