폰 앱을 아이패드에서 데스크톱처럼 — 폭 하나로 두 레이아웃 살리기
같은 코드베이스에서 폰은 탭바, 아이패드는 상단 탑바와 마스터-디테일로. 가로 잠금이 부른 크래시, 떠 보이던 서식 툴바, 폭 기준 분기 하나로 두 모습을 공존시킨 이야기.
폰 앱을 아이패드에서 데스크톱처럼 — 폭 하나로 두 레이아웃 살리기
fecit은 폰 앱으로 시작했습니다. 하단 탭바, 한 번에 한 화면, 위로 쌓이는 네비게이션. 그런데 데스크톱 웹은 이미 다른 모습이었습니다. 상단 바에 로고와 메뉴, 왼쪽에 목록 오른쪽에 상세를 같이 보는 마스터-디테일.
아이패드는 그 사이에 끼어 있었습니다. 폰 앱을 그대로 키우면 화면이 휑하고, 데스크톱을 그대로 가져오면 터치에 안 맞습니다. 같은 앱이 화면 크기에 따라 다른 몸을 입어야 했습니다.
기준은 기기가 아니라 폭
가장 먼저 정한 건 “언제 데스크톱 모습으로 바꿀까”였습니다.
Platform.isPad로 가르고 싶은 유혹이 있었지만, 안 그러기로 했습니다. 아이패드도 split-view로 화면을 반만 쓰면 폰만큼 좁아지고, 그땐 폰 레이아웃이 맞습니다. 그래서 기기가 아니라 폭으로 갈랐습니다. 데스크톱 웹이 쓰던 768px breakpoint를 그대로 미러했습니다.
export const LARGE_SCREEN_BREAKPOINT = 768;
export function useIsLargeScreen() {
const {width} = useWindowDimensions();
return width >= LARGE_SCREEN_BREAKPOINT;
}
useWindowDimensions를 쓰니 회전, split-view, Stage Manager 리사이즈에 다 반응합니다. 좁은 split-view 창은 의도대로 폰 레이아웃으로 떨어지고, 나중에 안드로이드 태블릿을 켜도 같은 코드가 동작합니다. 기준이 폭 하나니까요.
탭바를 상단 바로
폰에서는 하단 탭바가 다섯 화면을 오갑니다. 큰 화면에선 이게 어색합니다. 화면은 넓은데 손은 아래 구석까지 가야 하고, 위쪽 공간은 비어 있죠.
그래서 큰 화면에선 하단 탭바를 걷어내고 상단 바(AppTopBar) 로 대체했습니다. 로고, 서브뷰 스위처, 아이콘 네비게이션, 그리고 그 화면의 액션(검색·생성·추가·메뉴)을 한 줄에 모았습니다. 폰에서 화면 안에 흩어져 있던 버튼들이, 큰 화면에선 상단 바의 플레인 아이콘으로 올라갑니다.
한 번에 한 화면 → 목록과 상세를 나란히
진짜 작업은 레이아웃이었습니다. Tasks·Library·Memo는 큰 화면에서 마스터-디테일로 바꿨습니다. 왼쪽에 목록, 오른쪽에 상세. 왼쪽엔 폴더 트리 사이드바도 붙었습니다.
폰에서는 항목을 누르면 상세 화면이 위로 쌓이고, 뒤로가기로 돌아옵니다. 큰 화면에선 상세가 오른쪽 pane에 그냥 열립니다. 같은 상세 화면 컴포넌트인데, 한쪽에선 풀스크린으로 밀려 올라오고 다른 쪽에선 pane 안에 박힙니다.
여기서 까다로운 게 하나 있었습니다. pane 안에 박힌 상세에는 뒤로가기 버튼이 있으면 안 됩니다. 풀스크린일 땐 필요하지만, 옆에 항상 떠 있는 pane에선 돌아갈 데가 없으니까요. 같은 컴포넌트가 자기가 어디에 들어가 있는지 알아야 했습니다. 그래서 pane 안에 있다는 걸 알려주는 컨텍스트를 두고, 그 안에서는 뒤로가기를 숨겼습니다.
Community와 Me는 결이 조금 다릅니다. 목록-상세가 아니라 메뉴-콘텐츠라, 왼쪽 메뉴 오른쪽 콘텐츠로 나눴습니다.
가로 잠금이 부른 크래시
큰 화면 레이아웃은 가로를 가정합니다. 그래서 앱을 가로 전용으로 잠갔습니다. 그랬더니 크래시가 났습니다.
원인은 RN의 Modal이었습니다. 앱은 가로로 잠겨 있는데, 모달이 세로(portrait)를 기본으로 열려고 하면서 둘이 충돌했습니다. 잠긴 방향과 모달이 원하는 방향이 안 맞으면 그냥 죽는 거였습니다.
해결은 모달마다 supportedOrientations를 명시해서 앱의 잠금과 맞춰주는 것이었습니다. 화면 방향 같은 건 한 번 정하면 끝일 줄 알았는데, 모달이라는 별도 레이어가 자기 방향을 따로 들고 있었던 셈입니다.
떠 보이던 서식 툴바
큰 화면에선 글쓰기에 키보드 단축키(⌘ 조합)도 붙였습니다. 하드웨어 키보드를 쓰니까요. 그리고 서식 툴바(굵게·기울임 등)를 description 입력칸 위에 sticky로 두려고 했습니다.
처음엔 이 툴바가 떠 있는 얇은 바처럼 보였습니다. 입력 컨테이너의 패딩(8px) 안에 들어가 있어서, 라벨과 툴바 사이에 빈 띠가 생기고 좌우로도 끝까지 안 닿았습니다. 작은 차이인데 눈에 거슬렸습니다.
음수 마진으로 툴바를 풀폭으로 펴고 라벨에 딱 붙인 다음, 툴바 아래에만 8px 간격을 둬 입력칸과 분리했습니다. 그 뒤로도 높이를 50에서 38로, 라벨 아래 간격을 8에서 4로 줄여 더 컴팩트하게 다듬었습니다. “툴바를 단다”는 한 줄이 실제로는 여백을 몇 번이고 다시 재는 일이었습니다.
큰 정리 하나
이 작업을 하면서 가장 큰 변화는 눈에 안 보이는 곳에서 일어났습니다. Tasks 탭 파일 하나가 1,100줄 넘게 줄었습니다.
폰과 큰 화면이 같은 화면을 다르게 그려야 하니, 한 파일에 두 레이아웃을 다 욱여넣으면 금방 손댈 수 없게 됩니다. 공통 로직과 레이아웃을 갈라내고, split layout·상단 바·사이드바를 각자의 자리로 빼냈습니다. 결과적으로 분기는 useIsLargeScreen 한 줄로 모이고, 나머지는 각 레이아웃 컴포넌트가 책임집니다.
배운 것
-
반응형의 기준은 기기가 아니라 폭이다.
isPad로 가르면 split-view에서 어긋난다. 폭으로 가르면 한 코드가 폰·아이패드·태블릿·리사이즈를 다 덮는다. -
같은 상세 컴포넌트라도 자기가 어디 들어갔는지 알아야 한다. 풀스크린이면 뒤로가기, pane이면 없음. 컨텍스트로 알려주면 컴포넌트가 알아서 처신한다.
-
방향 잠금은 모달까지 따라가지 않는다. 앱을 가로로 잠가도 모달은 자기 방향을 따로 들고 있다.
supportedOrientations를 명시하지 않으면 충돌로 죽는다. -
여백은 한 번에 안 맞는다. “툴바를 단다”는 패딩·마진·높이·간격을 몇 번이고 다시 재는 일이다. 떠 보이면 대개 부모 패딩 안에 갇혀 있는 것이다.
-
두 레이아웃을 공존시키려면 먼저 갈라야 한다. 한 파일에 다 넣으면 못 고친다. 분기는 한 곳에 모으고, 레이아웃은 각자 빼낸다.
폰 앱 하나를 만들었다고 생각했는데, 화면이 커지자 같은 앱이 다른 몸을 원했습니다. 결국 둘은 폭 하나로 갈리는 같은 뼈대였습니다.