Enjoy My Posts

react-native - 기기 변경에 유연한 모바일 UI

Posted on By Geunwon Lim

모바일 앱 개발을 처음 해보면서 평소 궁금했던 부분이 조금씩 해결되고 있다. 특히 반응형 UI가 궁금했는데, 이 이슈가 이제까지 겪은 프론트 이슈 중 가장 어려웠지만 그만큼 성취감이 있었다. 내가 고민했던 것들을 적을건데 잘한건지 모르겠다. 그리고 리액트 네이티브를 쓰면서 이슈를 해결한 과정이므로 다른 기술을 쓰면 이 방법이 아닌 다른 방법으로 쉽게 UI를 유연하게 만들수도 있을 것 같다.

일단 제목을 기기 변경에 유연한 모바일 UI로 굉장히 넓게 잡았는데, 얘기하고 싶은 두 가지를 포괄하고 싶어서 이렇게 지었다. 이 제목을 두 가지로 나누면,

첫째, 퍼센트를 픽셀로 변환하여 기기별 스크린 크기가 달라지더라도 UI 구성요소들을 비슷하게 보이게 하기

둘째, 기기를 추상화하여 기기별로 레이아웃이 조금씩 달라지더라도 UI 구성요소들을 비슷하게 보이게 하기

이다.

1. 퍼센트를 픽셀로 변환하여 기기별 스크린 크기가 달라지더라도 UI 구성요소들을 비슷하게 보이게 하기

결론부터 얘기하자면, 개발자는 컴포넌트의 크기 단위를 비율로 입력하고, 어플리케이션은 그 비율을 픽셀로 변환하여 렌더링하는 것이 개인적으로 좋아보인다.

UI 컴포넌트의 크기 단위로는 크게 Pixel과 비율(%)가 있는데, 각각 장단점이 있는 것 같다. Pixel은 정확하다는 장점이 있지만 과하게 고정된다는 단점이 있다. 비율은 유연하다는 장점이 있지만 과하게 유연하다는 단점이 있다.

안드로이드 개발자에게 이것과 관련하여 물어보니, 안드로이드에서는 dp로 입력하는 걸로 충분하다는 얘기를 들었다. 안드로이드에서는 dp로 크기를 입력하면 해상도를 고려하여 렌더링해준다고 한다. 근데 react-native에서는 dp로 입력이 안드로이드에서처럼 되지 않았다. dp는 px와 같은 의미로 쓰였다.

앱 개발을 시작할 때는 ‘UI를 예쁘게 하기’는 우선순위에서 밀렸기 때문에 일단 대부분 컴포넌트의 사이즈 단위로 ‘%’를 사용했다. 개발을 하기 전부터 대강 기기별로 스크린 크기가 다르다는 것 정도는 알고 있었고, %를 쓰면 스크린 크기가 달라지더라도 UI 컴포넌트가 비슷하게 보일 것 같았기 때문이다. 개발 초기에는 생각보다 원하는대로 UI 컴포넌트가 보였기 때문에 ‘큼직큼직한 것은 %로 하고, 세밀한 것들은 pixel로 해야겠다’ 라고 생각했다. 그러던 중 애뮬레이터에서 키보드가 올라올 때 처음 %의 단점을 느꼈다. 단위에 ‘%’를 쓰니 기존 UI 컴포넌트가 뭉개졌다. 동적인 부분만(키보드랑 관련된 부분 등) 픽셀로 입력하는 것도 방법이 될 수 있겠지만, %는 과하게 유연하기 때문에 내가 예상치 못한 부분에서 UI가 뭉개지는 경우가 생길 것 같았다. 게다가 디자이너와 프론트 개발자가 협업하는 것을 먼발치에서 바라볼 때 픽셀 단위로 소통하는 걸 본적이 있어서 픽셀의 장점에 눈이 갔다.

그렇다고 픽셀로 다 바꾸기는 또 뭐한게, 바뀌는 기기에 따라 픽셀을 바꾼다고 생각하니 아득했다. 기기는 너무 많다. 이런 기기일 때는 몇픽셀, 저런 기기일때는 몇픽셀 이런식으로 대응하면 끝이 없을 것 같았다.

그래서 모바일 UI 컴포넌트의 크기 입력은 퍼센트로 하고, 그걸 렌더링할 때는 픽셀로 바꿔주는 게 좋을 것 같았다.

리액트 네이티브에서는 Dimension을 활용하면 다음과 같이 스크린의 width와 height를 알 수 있다.

const runningScreen: ScaledSize = Dimensions.get('screen');
const width: number = runningScreen.width;
const height: number = runningScreen.height;

전체 길이만 알면 특정 UI 컴포넌트의 크기를 %에서 px로 바꾸면 과하게 유연하던 걸 적당히 고정적이게 만들 수 있다.

아쉬운점

아마 나 혼자하는 프로젝트이기 때문에 가능한 방법일 것 같다. 실제로 프론트 작업을 할 때는 디자이너가 함께하기 때문에 ‘대강 비슷하게 보이면 되는’ 수준이 아닐 것이다.

이 점에 대해서 ‘디자이너가 알려주는 특정 기기에 대한 픽셀 값을 퍼센트로 바꾸고, 그 퍼센트를 다시 다양한 기기에 맞게 픽셀로 바꾸는’ 것을 생각해봤다. 아직 이렇게 픽셀과 퍼센트를 왔다갔다하는 건 구현이 안됐는데, 이 방법이 현업에서도 통할지, 그게 아니라면 어떤 방식으로 기기에 따라 UI 컴포넌트를 구현하는지 궁금하다.

2. 기기를 추상화하여 기기별로 레이아웃이 조금씩 달라지더라도 UI 구성요소들을 비슷하게 보이게 하기

결론적으로 기기를 추상화하여 기기별로 달라지는 것에 대응해봤다. 내가 추상화한 기기는 다음과 같다.

export interface MobileDevice {
  readonly backActionIcon: IconSource;
  getHeightOf(percentage: Percentage): Pixel;
  getWidthOf(percentage: Percentage): Pixel;
  getStatusBarOnScreenHeight(): Pixel;
  getCenterSectionHeightOn(event?: KeyboardEvent): Pixel;
  getCenterSectionBottomOn(event?: KeyboardEvent): Pixel;
}

문제는 퍼센트를 픽셀로 변환하여 UI 컴포넌트의 크기를 적당히 유연하게 만들더라도, 기기가 달라지면 UI 컴포넌트가 비슷해보이지 않는다는 것이었다.

개발 초기에는 ios 시뮬레이터로만 확인했기 때문에 별 문제가 없었다. 그런데 안드로이드 애뮬레이터로도 확인 해보면서 내가 원하는대로 ui가 보이지 않는다는 것을 알았다. 예를 들어 ios에서는 스크린의 맨 밑에 글자 입력칸이 보이는데 안드로이드에서는 네비게이션 바에 가려서 보이지 않았다. 혹은 ios에서는 채팅 말풍선이 잘 보였지만 안드로이드에서는 말풍선이 글자 입력칸과 겹쳐보였다. Pixel 2 애뮬레이터로 확인 후 ‘플랫폼 별로 렌더링하는 게 다른가보다’라고 생각했는데, 옛날 Nexus로 확인해보니까 안드로이드끼리도 달랐다.

플랫폼별, 기기별로 UI가 확확 달라지는 건 어쩌면 react-native-gifted-chat이라는 라이브러리를 썼기 때문일수도 있을 것 같다. 로우 레벨에서 UI를 구현했다면 굳이 기기 추상화를 하지 않아도 비슷하게 렌더링 될 것 같기도 하다.

ts는 객체 지향 언어가 아니라고 알고 있었기에 대부분 객체화 하지 않았다. 대부분 변수 선언이나 함수로 구현하고 있었는데, 변수나 함수로 다양한 기기에 대응하려고 하다보니 나중에는 너무 복잡해져서 스스로 짠 코드인데도 수정하기가 어려웠다. 안드로이드에 맞추면 ios가 이상해지고, ios에도 맞추면 하나의 안드로이드는 괜찮지만 하나의 안드로이드는 이상해지는 식이었다. js 코딩에 익숙한 분들은 굳이 클래스로 만들지 않아도 기기에 잘 대응하실 것 같은데, 내게는 너무 복잡했다. 몇번씩 하나 맞추면 다른 곳에서 이상해기를 반복하다가, 아예 싹 리팩토링해야겠다고 생각하고 내게 익숙한 객체지향 방식을 활용했다.

지나고나서 보니 용어를 잘 정의하는 것이 중요한 것 같다. 용어만 잘 정의했어도 이리저리 왔다갔다 하진 않았을 것 같다. 예를 들어, ‘스크린’이라는 용어는 번역하면 ‘화면’이니까 ‘네모난, 소프트웨어가 보이는 상자’로 생각했다. 근데 UI 구현에서는 이렇게 생각했더니 하나 맞추면 다른게 이상해지는 식이 많아졌다. 왜냐면 내 생각에는 당연히 bottom: 0 이면 스크린 맨 밑바닥에 렌더링돼야 할 것 같은데, 기기에 따라 스크린 맨 밑바닥에 보이기도 하고 스크린안에 들어오지 않을 때도 있었다. 이러한 용어들 특징이 ‘대강 이러이러한 거’라고 생각해도 배경지식으로 쉽게 이해된다는 것인데, 그러다보니 한쪽에 맞추고나서 다른 한쪽이 이상해지면 ‘아 이 기기에서는 다르게 렌더링 됐었지’하며 다시 수정하기 일쑤였다. 그래서 헷갈렸던 용어들을 좀 정리해보겠다.

레이아웃: 여기에서 ‘레이아웃’은 ‘UI 요소들의 뼈대’ 정도로 쓰인다. 여전히 추상적인데, 이렇게 새롭게 정의하는 이유는 ‘보통 생각하는 큼직큼직한 구성요소’를 표현하고 싶었기 때문이다.

스크린: Dimensions.get(‘screen’)을 했을 때 나오는 width와 height 안의 네모이다.

윈도우: Dimensions.get(‘window’)을 했을 때 나오는 width와 heigth 안의 네모이다.

스크린과 윈도우는 거의 똑같다. 하지만 특정 기기에서는 다르다. 예를 들어 안드로이드 기기 중에는 밑의 네비게이션 바가 하드웨어로 나오는 경우도 있는데, 이럴 때 스크린은 그 네비게이션까지 포함하고 윈도우는 포함하지 않는다.

스크린은 크게 다음과 같이 세 개의 섹션으로 구성된다.

Header(=TopSection): status bar와 top bar를 포함하는 영역이다.

CenterSection: TopSection과 BottomSection을 제외한 부분이다. 보통 CenterSection이 한 화면의 핵심이다.

BackHeader(BottomSection): Bottom Navigation Bar 영역이다.

지금 생각하면 왜 헷갈렸나 싶지만, 예를 들어 채팅할 때 글자 입력하는 부분은 BottomSection이 아니라 CenterSection이다. 이제야 정리됐지만 처음에 구현할때는 헷갈려서 이랬다 저랬다 했던 부분이다.

위에 모바일 기기를 추상화한 걸 다시 보며 왜 이렇게 추상화했는지 얘기해보겠다.

export interface MobileDevice {
  readonly backActionIcon: IconSource;
  getHeightOf(percentage: Percentage): Pixel;
  getWidthOf(percentage: Percentage): Pixel;
  getStatusBarOnScreenHeight(): Pixel;
  getCenterSectionHeightOn(event?: KeyboardEvent): Pixel;
  getCenterSectionBottomOn(event?: KeyboardEvent): Pixel;
}

backActionIcon은 보통 플랫폼별로 다르게 사용했다. 뭔가 각 플랫폼의 컨벤션 같은 느낌이었다. 그래서 backActionIcon을 넣었다.

getHeightOf, getWidthOf는 기기별로 퍼센트를 픽셀로 바꿔주기 위해 만들었다.

getStatusBarOnScreenHeight, getCenterSectionHeightOn, getCenterSectionBottomOn등이 헷갈렸던 부분이다.

iphone 11에서는 statusBar가 스크린 안에 들어있다. 안드로이드에는 statusBar가 스크린 안에 들어있다(지금 생각해보니 이건 어쩌면 react-native-paper에서 Header를 쓸때만 그럴지도 모르겠다). 하지만 statusBar가 위에 있지 않고 밑에 있는것처럼 생각한다. 즉 topbar는 스크린 맨 위에 붙이고, 밑에 붙을 글자 입력은 스크린 맨 밑에서부터 statusBar만큼 올려줘야 한다.

센터 섹션과 관련된 인터페이스를 만든건 안드로이드의 네비게이션바 때문이다. 추측컨데 안드로이드 옛날 기기들은 네비게이션 바가 하드웨어로 있었고, 최근에는 네비게이션 바가 소프트웨어에서 보이는 것 같다. 즉 최신 안드로이드 기기에는 네비게이션바가 스크린 내부에 있고, 옛날 안드로이드 기기에는 네비게이션 바가 스크린 내부에 없다.

네비게이션바와 관련된 것임에도 navigationBar로 추상화하는 것이 아니라 centerSection으로 추상화한 이유는 UI 컴포넌트를 어떨 때는 바닥을 기준으로 배열하고, 어떨 때는 위를 기준으로 배열해야 했기 때문이다. 이건 어쩌면 내가 react-native-gifted-chat을 썼기 때문일 수도 있다. 즉 이걸 안쓰면 굳이 저렇게 추상화 안해도 될수도 있다.

근데 react-native-gifted-chat에서는 ui 컴포넌트를 특이하게(?) 꾸민다. 채팅과 관련된 모든 것(말풍선이 보이는 화면이나 글 입력칸 등)을 꾸미는 가장 큰 컨테이너가 있다. 그리고 그 컨테이너는 말풍선들을 표시해주는 메시지 컨테이너와 인풋바로 나뉜다. 그리고 메시지 컨테이너는 다시 컨테이너와 컨텐트로 나뉜다.

이러한 react-native-gifted-chat의 특징상, 내가 하나의 요소를 커스텀하면 다른 것과 겹치는게 다반사였다. 특정 기기에 하나 맞추면 다른 하나가 어긋나는 식이었다. 예를 들어, 인풋바는 밑을 기준으로 배치하고 메시지 컨테이너는 위를 기준으로 배치했는데, 센터 섹션을 명확히 정의하지 않았더니 어떤 기기에서는 잘 배치되고 어떤 기기에서는 인풋바가 말풍선을 가렸다.

그래서 기기 내 용어들을 명확히 정의하고, 그것에 따라 배치했더니 다 잘 맞았다.

아쉬운 점

당시에는 이런저런 고민을 하다가 getCenterSectionHeightOn, getCenterSectionBottomOn를 만들었는데, 이거는 내가 메시지 컨테이너는 위를 기준으로 배치하고 인풋바는 아래를 기준으로 배치했기 때문이었다. 지금 생각해보니 다 위를 기준으로 하거나 다 밑을 기준으로 하면 더 잘 추상화할 수 있을거같기도 하다.

그리고 현재 nexus 옛날버전, pixel2, iphone11 에서만 테스트해봤다. 내가 예상하는 범위 내는 쉽게 쉽게 대응하겠지만, 또 내가 예상하지 못한 곳에서 레이아웃이 바뀌면 또 추상화를 바꿔야 할 것이다.