Skip to main content
← 블로그

지금 어디를 편집하고 있지?

VauDium ·

문서형 태스크 에디터에서 편집 중인 필드를 시각적으로 강조하는 포커스 하이라이트 구현기입니다.

지금 어디를 편집하고 있지?

섹션이 너무 많다

Fecit의 태스크 편집 화면은 문서 같은 구조입니다. 설명, 의도(Target/Expectation), 준비물, 회고, 라벨, 프로젝트… 하나의 태스크 안에 편집 가능한 섹션이 많습니다. 스크롤해서 입력하다 보면 어느 순간 “지금 내가 어디에 타이핑하고 있지?”라는 느낌이 들 때가 있습니다.

커서가 깜빡이고 있으니 기술적으로는 위치를 알 수 있지만, 시각적으로 해당 섹션이 “활성 상태”라는 신호가 약했습니다. 특히 모바일에서 키보드가 올라오면 화면이 좁아지니까 더 그렇습니다.

편집 중인 필드를 명확하게 강조해주자. 간단한 목표였는데, 예상보다 시행착오가 있었습니다.

시행착오: 테두리부터 시작했다

1차 시도: 테두리 + 그림자

처음 시도한 건 전형적인 포커스 스타일이었습니다. 파란색 테두리에 그림자까지 넣었습니다. 결과는? 너무 무거웠습니다. Fecit의 편집 화면은 미니멀한 문서 스타일인데, 갑자기 한 섹션에 테두리와 그림자가 생기니 그 부분만 튀었습니다.

2차 시도: 테두리만

그림자를 빼고 테두리만 남겼습니다. 나쁘지 않았습니다. 하지만 여전히 뭔가 과했습니다. 테두리가 있으면 “이건 입력 필드입니다”라는 느낌을 주는데, Fecit의 에디터는 필드 경계가 드러나지 않는 문서형 디자인이라 방향이 안 맞았습니다.

3차 시도: 테두리 + 배경색

테두리를 좀 더 미묘하게 만들고, 배경색도 살짝 넣어봤습니다. 나아지긴 했지만, 테두리가 있는 한 레이아웃 시프트 문제가 남았습니다. 포커스 시 border가 생기면 레이아웃이 밀리고, 그 변화가 네이티브 인풋을 리마운트시키는 문제가 있었습니다.

“그러면 border를 항상 넣되 색만 바꾸면 되잖아?”라고 생각할 수 있는데, 투명 border를 항상 유지하는 것도 결국 불필요한 시각 요소였습니다. border-radius와 margin까지 조정하게 되면 전체적인 문서 느낌이 깨졌습니다.

최종: 배경색만

결국 가장 단순한 방법이 답이었습니다. 포커스된 섹션의 배경색을 PRIMARY50(아주 연한 파란색)으로 변경하고, 해당 섹션의 라벨과 아이콘 색을 PRIMARY500으로 바꿉니다. 그게 전부입니다.

테두리도 없고, 그림자도 없고, 레이아웃 변화도 없습니다. 배경색과 텍스트 색만 바뀌니 레이아웃은 완전히 동일합니다. 이게 중요했습니다.

핵심 제약: 레이아웃이 변하면 안 된다

React Native에서 포커스 하이라이트를 구현할 때 가장 중요한 제약이 있습니다. 포커스 상태 변경으로 레이아웃이 변하면 안 됩니다. 레이아웃이 변하면 네이티브 인풋 컴포넌트가 리마운트되고, 리마운트되면 포커스가 풀립니다. 포커스가 풀리면 하이라이트가 사라집니다. 무한 루프입니다.

그래서 border/margin/padding 변경은 전부 탈락이고, 색상 변경만이 안전합니다. 배경색, 텍스트 색, 아이콘 색. 이 셋만 바꿉니다.

FocusHighlightContainer

최종 구현은 render prop 패턴을 사용하는 컨테이너 컴포넌트입니다.

export const FocusHighlightContainer: FC<{
    children: (onFocusChange: (focused: boolean) => void, focused: boolean) => ReactNode;
    externalFocused?: boolean;
}> = ({children, externalFocused}) => {
    const palette = usePalette();
    const [internalFocused, setInternalFocused] = useState(false);
    const focused = externalFocused ?? internalFocused;

    return (
        <View style={{
            backgroundColor: focused ? palette.PRIMARY50 : undefined,
        }}>
            {children(setFocused, focused)}
        </View>
    );
};

children이 함수입니다. onFocusChangefocused 상태를 받아서 내부에서 자유롭게 사용합니다. 사용하는 쪽에서는 이렇게 씁니다:

<FocusHighlightContainer>
    {(onFocusChange, focused) => (
        <SomeSection>
            <Label color={focused ? palette.PRIMARY500 : palette.NEUTRAL500} />
            <TextInput
                onFocus={() => onFocusChange(true)}
                onBlur={() => onFocusChange(false)}
            />
        </SomeSection>
    )}
</FocusHighlightContainer>

왜 useMemo에 넣으면 안 되는가

처음에는 이 포커스 상태를 상위 컴포넌트에서 관리하려고 했습니다. 화면 전체의 useMemo 안에서 섹션들을 생성하는 패턴을 쓰고 있었는데, 여기에 포커스 state를 dependency로 넣었더니 문제가 생겼습니다.

포커스 상태가 바뀌면 → useMemo가 다시 계산되면서 → 섹션 컴포넌트가 새로 생성되고 → 네이티브 인풋이 리마운트되면서 → 포커스가 풀립니다. 정확히 피하려던 문제가 발생한 겁니다.

해결책은 간단했습니다. 포커스 상태를 상위에서 관리하지 않고, FocusHighlightContainer 안에서 자체적으로 관리하게 만드는 것입니다. 이 컴포넌트가 자기 자신의 useState를 갖고, 자기 자신의 배경색을 바꿉니다. 상위 컴포넌트의 리렌더링과 완전히 분리됩니다.

모달 피커로 확장

텍스트 입력 필드는 onFocus/onBlur로 깔끔하게 처리됩니다. 하지만 Fecit에는 모달로 열리는 피커도 많습니다. 만족도(SatisfactionPicker), 난이도(DifficultyPicker) 같은 것들입니다. 이것들은 텍스트 인풋이 아니라 터치하면 모달이 열리는 형태입니다.

이런 피커에는 onOpenChange 콜백을 활용했습니다. 모달이 열릴 때 onFocusChange(true), 닫힐 때 onFocusChange(false)를 호출합니다.

<FocusHighlightContainer>
    {(onFocusChange, focused) => (
        <SatisfactionPicker
            onOpenChange={(open) => onFocusChange(open)}
            labelColor={focused ? palette.PRIMARY500 : palette.NEUTRAL500}
        />
    )}
</FocusHighlightContainer>

모달이 열려 있는 동안 해당 섹션이 하이라이트되어 있으니, 모달을 닫았을 때 “내가 어떤 항목을 편집하고 있었는지”가 자연스럽게 보입니다.

프로젝트 피커: externalFocused

프로젝트 피커 같은 경우는 이미 모달 열림/닫힘 상태를 외부에서 관리하고 있었습니다. 이런 경우 FocusHighlightContainer의 내부 상태와 이중으로 관리하는 건 비효율적입니다.

이를 위해 externalFocused prop을 추가했습니다. 이 prop이 전달되면 내부 useState는 무시되고, 외부에서 전달한 값을 그대로 사용합니다. 기존 모달 상태를 그대로 연결하면 됩니다.

const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);

<FocusHighlightContainer externalFocused={isProjectPickerOpen}>
    {(_, focused) => (
        <ProjectPicker
            isOpen={isProjectPickerOpen}
            onToggle={() => setIsProjectPickerOpen(!isProjectPickerOpen)}
            labelColor={focused ? palette.PRIMARY500 : palette.NEUTRAL500}
        />
    )}
</FocusHighlightContainer>

30개 이상의 화면에 적용

이 패턴은 앱 전체의 편집/생성 화면에 적용되었습니다. 태스크 템플릿 생성, 태스크 레코드 생성, 서브태스크 편집, 준비물 항목 편집, 오버뷰 생성, 라벨 생성, 프로젝트 생성, 데일리 루틴, 위클리 리뷰, 프리보드… 30개 이상의 화면입니다.

컴포넌트 하나로 일관된 포커스 경험을 제공할 수 있게 된 건 render prop 패턴 덕분입니다. 각 화면의 레이아웃이 다르고, 내부 요소가 다르지만, FocusHighlightContainer로 감싸고 onFocusChange만 연결하면 동일한 하이라이트가 적용됩니다.

돌아보며

처음에는 “포커스 상태를 보여주는 거니까 테두리 넣으면 되겠지”라고 생각했습니다. 하지만 React Native의 네이티브 인풋은 레이아웃 변화에 민감하고, 문서형 에디터에서 테두리는 디자인적으로도 맞지 않았습니다.

결국 가장 좋은 답은 가장 가벼운 방법이었습니다. 배경색 하나, 텍스트 색 하나. Minimal to Maximal 원칙이 UI 디테일에서도 적용된 셈입니다. 작은 변화지만, 편집 화면을 쓸 때 “지금 여기를 편집하고 있다”는 확신이 확실히 생깁니다.