본문 바로가기
성장일기

smartphone emulation

by egas 2021. 8. 22.

4일 동안 미니 smartphone emulation을 만들어 보았다.

 

4개의 화면으로 구성되어있다.

  • 홈 화면
  • 알림 화면
  • 메모 화면
  • 사진 화면

각각의 화면에 대해 기능 별로 구역을 나눠서 구현을 했다. home은 root 태그 안에 추가되며, 홈 화면의 최상위 태그이다. 각 앱별로 navigation의 구성요소가 달라져서 navigationhome 내부 요소에 포함시켰다. home-inner는 실제 home의 content가 들어가는 공간이다. dragzonedragzone안의 draggable="true"인 앱들에 대해 드래그 할 수 있는 구역이다. navigation은 각 앱 상단에 위치하며, 현재 시간에 맞게 갱신된다.

홈 화면

alarm은 알람 화면의 최상위 태그이다. 알람 화면의 navigation에는 BACK과 NEW 버튼이 존재한다. BACK 버튼은 누르면 홈 화면으로 이동한다. NEW 버튼을 누르면 alarm-input이 렌더링 된다. alarm-input을 통해 알람을 등록할 수 있으며, 등록된 알람은 alarm-list에 렌더링 된다. alarm-list-element의 삭제 버튼을 누르면, 해당 알림이 삭제된다. 등록된 알람은 navigation 의 현재 시각을 기준으로 해당 시간이 되면 alert 창으로 알림을 알려준다.

알람 화면

memo는 메모 화면의 최상위 태그이다. 메모 화면의 navigation에는 BACK과 NEW 버튼이 존재한다. BACK 버튼은 누르면 홈 화면으로 이동한다. NEW 버튼을 누르면 memo-input이 렌더링 된다. memo-input 을 통해 메모를 추가할 수 있으며, 추가된 메모는 memo-list에 렌더링 된다. 메모는 2줄까지만 표시되며, memo-list-element를 누르면 전체 메모가 렌더링 된다.

메모 화면

photo는 사진 화면의 최상위 태그이다. 메모 화면의 navigation에는 BACK 버튼이 존재한다. BACK 버튼은 누르면 홈 화면으로 이동한다.  photo-scroll에는 로컬에서 저장된 사진들이 가로 스크롤로 표시된다. photo-scroll에서 사진을 클릭하면, photo-select에 해당 사진이 알맞은 비율로 표시된다.

사진 화면


모듈(파일) 구조

html 파일은 public 폴더 안에 존재한다. Javascript 파일은 src 폴더 안에 존재한다. 

 

  • public 폴더: html 파일이 존재하는 폴더이다.
  • src 폴더: 작성되는 모든 자바스크립트 코드는 src 폴더 안에 존재한다.
    • view 폴더: 전체적인 로직과는 관련없는 렌더링되는 HTML 태그 요소들을 정의하는 폴더이다.
    • model 폴더: Local Storage와 상호 작용하기위한 코드들을 정의하는 폴더이다.
    • controller 폴더: 어떤 이벤트가 발생했을때, 해당 이벤트를 처리하기 위한 코드를 정의하는 폴더이다.
    • assets 폴더: 앨범 화면에 렌더링되는 이미지들이 해당 폴더에 존재한다.
    • @shared 폴더: 유틸 함수나 상수등을 정의하는 폴더이다.
    • types 폴더: png, jp(e)g등의 이미지 파일 타입을 정의하는 폴더이다.

데이터를 저장하는 Local Storage와 상호 작용하는 코드(model), 이벤트 발생시 이벤트를 처리하기위한 코드(controller), 화면에 렌더링되는 코드(view)로 역할을 분리해서 코드를 작성하였다. 그외 공통적으로 사용하는 코드는 @shared 폴더에, 타입 정의는 types 폴더에, 사진들은 assets 폴더에 저장했다.

```bash
.
├── README.md
├── REQUIREMENTS.md
├── package-lock.json
├── package.json
├── ...
├── public
│   └── index.html
├── src
│   ├── @shared
│   │   ├── constants.ts
│   │   ├── router.ts
│   │   └── utils.ts
│   ├── assets
│   │   ├── index.ts
│   │   └── ...
│   ├── controller
│   │   ├── ...
│   │   └── index.ts
│   ├── index.ts
│   ├── model
│   │   ├── ...
│   │   └── index.ts
│   ├── style.css
│   ├── types
│   │   └── images.d.ts
│   └── view
│       ├── ...
│       └── index.ts
├── tsconfig.json
└── webpack.config.js
```

또한, Webpack을 통해 Typescript로 작성된 파일들은 Javascript로 변환한 후 하나의 파일로 번들링할 수 있다.


코드

우리가 작성할 자바스크립트에 의해 추가되는 DOM 요소들은 아래 <div id="app"></div> 하위에 추가 시킬 것이다.

// public/index.html
<!DOCTYPE html>
<html lang="kr">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>📱Smartphone-Emulation</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="app"></div>
  </body>
</html>

 

src/index.ts 의 app() 함수로 부터 시작된다. 처음 화면에 렌더링될 view에 대한 정보들을 renderView()를 통해서 불러오고, 처음에 등록할 이벤트리스너들을 initController()를 통해서 등록해준다.

// src/index.ts
import renderView from './view/index';
import initController from './controller/index';
import './style.css';

const app = (): void => {
  renderView();
  initController();
};

app();

view 폴더에는 각각 페이지에 대해 화면에 렌더링될 HTML 태그와 CSS 요소들이 정의된다. 브라우저에 무엇이 그려질지에 대한 부분이다.

view 요소들은 Wrapper 함수들로 HTML을 정의한다.

const alarmWrapper = (navigation: string, apps: string): string => {
  return `<section class="alarm">
            ${navigation}
            <article class="alarm-inner">
              ${apps}
            </article>
          </section>`;
};

 

정의된 HTML 함수들은 insertAdjacentHTML에 의해 public/index.html에 정의되어있는 <div id="app"></div>에 추가된다.

const renderAlarmPage = (navigation: string): void => {
  const appId = $('#app') as HTMLDivElement;

  appId.innerHTML = '';
  appId.insertAdjacentHTML('beforeend', alarmWrapper(navigation, alarmListWrapper()));
};

controller 요소들은 이벤트와 관련된 부분들에 대한 코드들이다. 아래와 같이 각각 페이지별로 이벤트를 등록해주고, 이벤트가 발생했을 때, 이벤트를 처리하기위한 코드들이 존재한다.

const homePageController = (): void => {
  $('.home-inner')?.addEventListener('dragstart', dragstartAppIconButtons);
  $('.home-inner')?.addEventListener('dragend', dragendAppIconButtons);
  $('.home-inner')?.addEventListener('dragover', dragoverAppIconButtons);
  $('.home-inner')?.addEventListener('dragenter', dragenterAppIconButtons);
  $('.home-inner')?.addEventListener('dragleave', dragleaveAppIconButtons);
  $('.home-inner')?.addEventListener('drop', dropAppIconButtons);
  $('.home-inner')?.addEventListener('click', clickAppIconButtons);
};

아래와 같이 각 페이지별로 파일을 나눠서 작성한것을 하나의 model에 재정의해줘서, 외부에서는 모든 model에 관련된 함수들을 model을 통해 접근할 수 있다.

import homePageModel from './home-page';
import alarmPageModel from './alarm-page';
import memoPageModel from './memo-page';

const model = {
  ...homePageModel,
  ...alarmPageModel,
  ...memoPageModel,
};

export default model;

 

모델은 Local Storage와 상호작용할 수 있는 코드들이 있다.

export default {
  setLocalStorageAppData(key: string, appDatas: IappData[]): void {
    localStorage.setItem(key, JSON.stringify(appDatas));
  },
  getLocalStorageAppData(key: string): IappData[] | null {
    const data = localStorage.getItem(key);
    if (!data) {
      return null;
    }
    return JSON.parse(data);
  },
...

 


기능

홈 화면

각각의 앱을 클릭하면, 해당 앱이 렌더링 된다. 각각의 앱은 드래그 앤 드롭으로 위치를 이동할 수 있으며, 새로고침 시에도 변경된 위치는 유지된다.

 

알림 화면

알림화면은 new 버튼을 클릭해서 새로운 알람을 생성할 수 있으며, 15개 이하까지만 생성 가능하다. 15개가 초과하면 에러 메시지가 출력되며, 삭제 버튼을 누르면 알림이 삭제된다. 또한, 새로 고침 시에도 데이터는 유지된다.

 

등록된 알림은 해당 시각 0초가 되면 alert로 알림이 울리며, 해당 알림은 알림 리스트에서 삭제된다.

 

같은 시각에 알람이 여러 개 등록 되었을 때, 알람 개수만큼 alert창으로 알림이 울린 뒤 삭제된다.

 

다른 앱을 사용하고 있어도 알림은 울린다. 

메모 화면

메모는 두 줄까지만 나타나며, 해당 메모를 클릭하면 전체 내용이 보인다. NEW 버튼을 눌르면 입력창이 나오며, 내용 입력 후 엔터를 눌러서 메모를 등록할 수 있다. 또한, 새로 고침 시에도 데이터는 유지된다.

 

사진 화면

사진 화면에는 가로 스크롤로 저장된 사진들을 볼 수 있으며, 스크롤에 있는 사진을 클릭하면 화면에 알맞은 비율로 사진이 아래에 렌더링 된다.


Cypress

padStart 메소드가 es2017 버전부터 추가되어서, es6에는 존재하지 않는다. 따라서, 시간 측정에 알맞은 padStart를 만들고 테스트 코드로 검증했다.

 

const padStart = (targetLength: number, padString: string, str: string): string => {
  return str.length >= targetLength ? str : new Array(targetLength - str.length + 1).join(padString) + str;
};

 

describe('padStart polyfill test', () => {
  it('padding 길이 증가, 0~100', () => {
    for (let i = 0; i < 100; i++) {
      assert.equal(padStart(i + 1, '0', '1'), '0'.repeat(i) + '1');
    }
  });
  it('padLength: 2, padStr: "0" 일떄, 문자열 0~24까지 검증', () => {
    for (let i = 0; i < 10; i++) {
      assert.equal(padStart(2, '0', `${i}`), `0${i}`);
    }
    for (let i = 11; i < 25; i++) {
      assert.equal(padStart(2, '0', `${i}`), `${i}`);
    }
  });
});

 

atoi 테스트 코드도 짜보았다.

describe('atoi test', () => {
  it('숫자가 없을떄, null', () => {
    assert.equal(atoi('hello'), null);
    assert.equal(atoi(''), null);
  });

  it('숫자 잘 출력', () => {
    Array.from(Array(12), (_, index) => index).map((x) => {
      assert.equal(atoi(`${x}`), x);
    });
  });

  it('뒤의 문자 제거', () => {
    assert.equal(atoi('1hello'), 1);
    assert.equal(atoi('11hello'), 11);
    assert.equal(atoi('111hello'), 111);
  });
  it('padding 0 제거', () => {
    for (let i = 0; i < 100; i++) {
      assert.equal(atoi('0'.repeat(i) + '123456789hello'), 123456789);
    }
  });
});

Semantic Tag를 사용해보자!

 

nav 태그 적용

  • HTML <nav> 요소는 문서의 부분 중 현재 페이지 내, 또는 다른 페이지로의 링크를 보여주는 구획을 나타낸다.
  • 문서의 모든 링크가 <nav> 요소 안에 있을 필요는 없다.
  • <nav> 하나는 사이트 전체 탐색, 다른 하나는 현재 페이지 내 탐색으로 사용하는 등, 하나의 문서에서 여러 개의 <nav> 태그를 가질 수 있다.
  • 스크린 리더 등 장애를 가진 사용자를 위한 사용자 에이전트는 최초 렌더링에서 탐색 전용 콘텐츠를 제외할지 결정할 때 <nav>를 참고한다.

홈 화면
알람 화면

나의 경우 링크 이동 역할을 하는 부분은 크게 두 가지로 나뉘어 있다. 빨간 부분초록 부분이다. 빨간 부분은 홈 화면에서는 버튼이 없지만, 다른 화면, 예를 들어서 알람 화면에서는 뒤로 가기 버튼이 렌더링 되어, 알람 화면에서의 내비게이션 역할을 한다. 초록 부분은 각 페이지로의 링크 버튼이다. 따라서, 다음 두 부분을 nav 태그를 적용 시켰다.

 

const getNavigationWrapper = ({ currentTime, backButton, newButton }: IgetNavigationWrapper): string => {
  return `<nav>
           ${backButton === undefined ? '' : `<button class="nav__button">${backButton}</button>`}
           <span class="nav__time">${currentTime}</span>
           ${newButton === undefined ? '' : `<button class="nav__button">${newButton}</button>`}
          </nav>`;
};

 

const homeWrapper = (navigation: string, apps: string): string => {
  return `<div class="home">
            ${navigation}
            <nav class="home-inner">
              ${apps}
            </nav>
          </div>`;
};

 

article 태그 적용

HTML <article> 요소는 문서, 페이지, 애플리케이션, 또는 사이트 안에서 독립적으로 구분해 배포하거나 재사용할 수 있는 구획을 나타낸다. 사용 예제로 게시판과 블로그 글, 매거진이나 뉴스 기사 등이 있다. 

 

나의 경우 각각 화면에서 콘텐츠 부분인 빨간 네모 부분이 사이트 안에서 독립적으로 구분되는 기능들이기 때문에, article 태그를 사용했다.

알람 화면
메모 화면
앨범 화면

const alarmWrapper = (navigation: string, apps: string): string => {
  return `<div class="alarm">
            ${navigation}
            <article class="alarm-inner">
              ${apps}
            </article>
          </div>`;
};

const memoWrapper = (navigation: string, apps: string): string => {
  return `<div class="memo">
            ${navigation}
            <article class="memo-inner">
              ${apps}
            </article>
          </div>`;
};

const photoWrapper = (navigation: string, scrollBar: string, selectImage: string): string => {
  return `<div class="photo">
            ${navigation}
            <article class="photo-inner">
              ${scrollBar}
              ${selectImage}
            </article>
          </div>`;
};

이렇게 고치고 보니까 article 부모 노드가 각각 화면의 이름을 하고 있는데(예를 들어, 알람 화면은 class="alarm", 메모 화면은 class="memo"), 생각해보면 각각의 기능 역할은 article 태그 내에서만 이뤄지고 있으니까 아래 코드 구조로 나누었으면 조금 더 역할이 명확하게 나뉠 수 있겠다는 생각을 했다.

const alarmWrapper = (navigation: string, apps: string): string => {
  return `<section class="app-wrapper">
            ${navigation}
            <article class="alarm">
              ${apps}
            </article>
          </section>`;
};

 

section 태그 적용

HTML <section> 요소는 HTML 문서의 독립적인 구획을 나타내며, 더 적합한 의미를 가진 요소가 없을 때 사용한다.

 

적절하게 의미 없는 구역에 div 대신 section을 사용하자.

const photoWrapper = (navigation: string, scrollBar: string, selectImage: string): string => {
  return `<section class="photo">
            ${navigation}
            <article class="photo-inner">
              ${scrollBar}
              ${selectImage}
            </article>
          </section>`;
};

 

time 태그 적용

HTML <time> 요소는 시간의 특정 지점 또는 구간을 나타낸다. datetime 특성의 값을 지정해 보다 적절한 검색 결과나, 알림 같은 특정 기능을 구현할 때 사용할 수 있다.

 

시간이라는 정보를 알려주기 위해 아래 빨간색 네모 부분에 해당 태그를 사용해보자.

홈 화면
알람 화면

const getNavigationWrapper = ({ currentTime, backButton, newButton }: IgetNavigationWrapper): string => {
  return `<nav>
           ${backButton === undefined ? '' : `<button id="nav-back-button" class="nav__button">${backButton}</button>`}
           <span class="nav__time"><time>${currentTime}</time></span>
           ${newButton === undefined ? '' : `<button id="nav-new-button" class="nav__button">${newButton}</button>`}
          </nav>`;
};

const alarmListElementWrapper = ({ meridiem, hour, minute }: IalarmData): string => {
  return `<div class="alarm__list-element">
            <p><time>${meridiem === 'am' ? '오전' : '오후'} ${
    meridiem === 'pm' ? String(+hour + 12) : padStart(2, '0', hour)
  }시 ${padStart(2, '0', minute)}분</time></p>
            <button class="alarm__list-element-button" data-meridiem="${meridiem}" data-hour="${hour}" data-minute="${minute}">삭제</button>
          </div>`;
};

 

 

 

noscript 태그 적용

script를 지원하지 않는 브라우저나, script 기능을 꺼둔 브라우저를 위한 별도의 콘텐츠를 정의할 때 쓰인다.

 

script 사용이 불가할 때, 안내 문구를 출력할 수 있게 추가해주자!

<!DOCTYPE html>
<html lang="kr">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>📱Smartphone-Emulation</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="app"></div>
  </body>
</html>

 

728x90

댓글