스크린을 침범하는 노치, 펀치홀 어떻게 미리 볼 수 있을까?

Riiid Teamblog
Riiid Teamblog KR
Published in
12 min readApr 16, 2021

By 김요한

김요한(Yohan Kim) 님은 Riiid의 Frontend Engineer로 문제풀이 웹뷰 개발과 백오피스 개발을 맡고 있습니다.

시작하기 전에 …

뤼이드에서는 웹뷰를 효과적으로 개발 & 디버깅하기 위해 안드로이드 시뮬레이터와 같이 웹뷰 시뮬레이터를 만들어 개발하고 있습니다. 아랫글은 개발환경에서 노치 미리보기 기능을 추가하는 과정을 서술한 글입니다.

스크린을 침범하기 시작한 노치, 펀치홀

노치, 펀치홀의 다양한 베리에이션 이미지

2017년 말 애플의 아이폰X를 시작으로 노치, 펀치홀, 홈바 등등의 하드웨어들이 베젤이 좁아짐에 따라 스크린의 영역을 침범하기 시작했습니다.

생긴 것도 가지각색인데 웹 개발환경에서는 실제 배포 때 어떻게 나올지 미리 볼 수도 없어서 불편하고, 배포 후에는 기기에 따라서 하단에 홈바가 생겨 CTA를 가리기도 합니다. 이러한 문제는 매출에 영향을 주는 치명적인 장애가 될 수도 있습니다.

이렇게 모바일 네이티브 환경과 맞물린 UI/UX 장애들을 배포하기 전에 어떻게 데스크톱 개발환경에서 미리 알고 대응할 수 있을까요?

UI/UX 개발환경에서 노치, 펀치홀 미리보기

1단계: 요구사항 분석하기

먼저 요구사항을 분석해봅시다. 우선 영역을 더욱 쉽게 구분할 수 있어야 합니다.

  1. 영역을 개발자 or 디자이너 임의대로 조정할 수 있어야 합니다.
  2. 노치, 펀치홀과 같은 다양한 기기들을 선택할 수 있어야 합니다.
  3. 다양한 프리셋(아이폰X, 갤럭시노트10) 등을 미리 정의해놓고 쉽게 바꿀 수 있어야 합니다.

여기서 2번 3번은 디자이너의 도움이 필요하니 나중으로 미뤄두고 1번 2번을 구현해봅시다.

2단계: 화면 설계 및 코드 구현하기

Safearea 영역과 컴포넌트가 차지할 영역 구분표

[화면 설계하기]

  • 노치를 Top Safearea Inset 영역에, 홈바를 Bottom Safearea Inset 영역에 오버레이로 올립니다.
  • Header와 Footer는 각각 Safearea Inset만큼 padding을 가져야합니다.
  • 만약 safeAreaInset이 0이라면 노치와 홈바는 없어져야 합니다.

[코드 구현하기]

  • safeArea 관련하여 스크린 정보를 관리하는 코드를 구현합니다.
import {useState} from 'react';

export interface ScreenInfo {
borderRadius: number; // 화면 곡률
width: number; // 넓이
height: number; // 높이
safeAreaInsetTop?: number; // 상단 노치 영역
safeAreaInsetBottom?: number; // 하단 앱바 영역
hasBgOfSafeareaInset?: boolean; // safeAreaInset 음영 구분
}

export const useScreenInfo = (defaultValue?: Partial<ScreenInfo>) => {
return useState<ScreenInfo>({...DEFAULT_SCREEN_INFO, ...defaultValue});
};

// IphoneX
export const DEFAULT_SCREEN_INFO: ScreenInfo = {
borderRadius: 32,
width: 375,
height: 812,
safeAreaInsetTop: 40,
safeAreaInsetBottom: 20,
hasBgOfSafeareaInset: false,
};
  • 상단 노치 컴포넌트를 구현합니다. 지금은 임시로 css로 구현하였으나 SVG로 분리해서 별도로 관리하면 다양한 기기들을 쉽게 대응할 수 있겠죠?
import {css} from '@emotion/react';
import React from 'react';

export interface NotchProps {
height: number;
hasBgOfSafeareaInset?: boolean;
}

const Notch: React.FC<NotchProps> = ({height, hasBgOfSafeareaInset = true}) => {
if (!height) return <React.Fragment />;
return (
<div
css={css`
height: ${height}px;
position: absolute;
top: 0;
width: 100%;
${hasBgOfSafeareaInset
? `background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.05))`
: ''};
&::after {
content: '';
display: block;
position: absolute;
background-color: #040404;
height: ${height}px;
width: 209px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
left: 50%;
transform: translate(calc(-50%), 0);
}
`}></div>
);
};

export default Notch;
상단 노치 컴포넌트
  • 하단 홈바를 구현합니다.
import {css} from '@emotion/react';
import React from 'react';

export interface HomeBarProps {
height: number;
hasBgOfSafeareaInset?: boolean;
}

const HomeBar: React.FC<HomeBarProps> = ({height, hasBgOfSafeareaInset = true}) => {
if (!height) return <React.Fragment />;

return (
<div
css={css`
height: ${height}px;
width: 100%;
position: absolute;
left: 0;
bottom: 0;
${hasBgOfSafeareaInset
? `background: linear-gradient(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.6))`
: ''};
&::after {
content: '';
display: block;
position: absolute;
background-color: #505050;
height: 6px;
width: 180px;
border-radius: 130px;
left: 50%;
transform: translate(calc(-50%), 0);
bottom: 6px;
}
`}></div>
);
};

export default HomeBar;
하단 홈바 컴포넌트
  • 노치와 하단 바를 가지고 있고, children을 주입받는 Screen 컴포넌트를 구현합니다.
import {css} from '@emotion/react';

import HomeBar from '@src/components/HomeBar';
import Notch from '@src/components/Notch';
import {ScreenInfo} from '@src/hooks/useScreenInfo';

export interface ScreenProps {
screenInfo: ScreenInfo;
className?: string;
}

const Screen: React.FC<ScreenProps> = props => {
const {className, children, screenInfo} = props;
return (
<div
className={className}
style={{
borderRadius: screenInfo.borderRadius + 'px',
width: screenInfo.width + 'px',
height: screenInfo.height + 'px',
}}
css={css`
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid #a2a1a1;
box-shadow: 0 30px 60px 0 rgba(0, 0, 0, 0.3);
box-sizing: content-box !important;
`}>
<Notch height={screenInfo.safeAreaInsetTop ?? 0} hasBgOfSafeareaInset={screenInfo.hasBgOfSafeareaInset} />
{children}
<HomeBar height={screenInfo.safeAreaInsetBottom ?? 0} hasBgOfSafeareaInset={screenInfo.hasBgOfSafeareaInset} />
</div>
);
};

export default Screen;
스크린 컴포넌트
import React from 'react';
import {Global, css} from '@emotion/react';

import {useMobileEnvironment} from './mobile-environment';
import {ScreenInfo} from '@src/hooks/useScreenInfo';

function getSafeAreaInset(value: number | undefined | null, fallback: string) {
return value ? value + 'px' : fallback;
}

interface Props {
screenInfo?: ScreenInfo;
}

export const SafeAreaProvider: React.FC = ({screenInfo}) => {

return (
<Global
styles={css`
:root {
// 개발환경에서 임의로 넣어준 screenInfo가 있다면 넣어주고 없다면 user-agent env에 정의되어 있는 값을 넣어줍니다.
--safe-area-inset-top: ${getSafeAreaInset(screenInfo?.safeAreaInsetTop, 'env(safe-area-inset-top)')};
--safe-area-inset-bottom: ${getSafeAreaInset(screenInfo?.safeAreaInsetBottom, 'env(safe-area-inset-bottom)')};
}
`}
/>
);
};
  • Header, BottomCTA 등 스크린의 위 또는 아래에 붙는 컴포넌트에 var(--safe-area-inset-top) 과 같이 css 값을 줄 수 있습니다.
import React from 'react';
import {css} from '@emotion/react';
export const Header: React.FC = ({children}) => {
return <div
css={css`
padding-top: var(--safe-area-inset-top);
display: flex;
`}
>
{children}
</div>
}
export default Header;

3단계: 미리보기

이제 웹에서 노치 디자인을 미리 볼 수 있게 되었습니다!

노치, 펀치홀 미리보기를 구현한 개발환경
개발환경과 실제 디바이스 화면 비교

참고 글

  1. https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/
  2. https://wit.nts-corp.com/2019/10/24/5731
  3. https://developer.mozilla.org/en-US/docs/Web/CSS/env()
  4. https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties

--

--

Riiid Teamblog
Riiid Teamblog KR

교육 현장에서 실제 학습 효과를 입증하고 그 영향력을 확대하고 있는 뤼이드의 AI 기술 연구, 엔지니어링, 이를 가장 효율적으로 비즈니스화 하는 AIOps 및 개발 문화 등에 대한 실질적인 이야기를 나눕니다.