ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • React + RxJS Todo App (with 오버엔지니어링?)
    프로그래밍/Web 2020. 5. 3. 09:01

    처음에는 React에 RxJS를 섞어보는 것을 목표로 하는 플젝이었는데

    재미로 이것저것 넣다보니 산으로 갔다ㅋㅋ

    아직 만드는 중이지만 벌써 5월이 되서 일단 소개해본다.

    회사 지원해야뎌..

     

    https://github.com/black7375/React-RxJS-Todo

     

    black7375/React-RxJS-Todo

    Contribute to black7375/React-RxJS-Todo development by creating an account on GitHub.

    github.com

     

     

    목표는

    • 성능
    • 코드의 간결함
    • 나중에 다른 플젝에서도 우려먹을 디자인 컨셉
    • 반응형 | 유동적 디자인
    • 시간나면 PWA, Web Component까지도 도입
    • 등등 각종 개념 증명용 또는 오버엔지니어링 수행

     

    우선 생긴건 대충 이런식.

    다양한 기술이나 개념을 조화롭게 접목시키는 것이 목적인 프로젝트다보니 기능은 최소한으로 유지했다.

     

    성능에 신경을 어느정도 써서 프로덕션이 아닌 개발용 모드 ,캐시 비활성화, CPU 쓰로틀링(6배)에도 쓸만한 수치를 보여준다.

    초기 리스트 갯수는 2500개.

     

    로드 시간: 773ms

     

    체크박스를 On/Off시 렌더싱 시간은 6.4ms

    아직 많은 최적화 기법을 적용하지 않아서 개선 여지가 많은데도 쓸만하다.

    특이한 점으로 App과 TodoTemplate, TodoInsert는 렌더링에서 제외된 것을 볼 수 있다.

    (버블링이 일어나지 않음)

     

    간단한 설명들

    기술/라이브러리

    아직까지 사용한 주요 기술/라이브러리는 다음과 같다.

    • 메인: ReactJS(with @types), RxJS, Typescript, Node SASS(with @types)
    • 서브: ImmutableJS(with @types), RecyclerListView
    • 기타: Normalize.css, React Icons, Classnames, Postinstall-Postinstall, TS-Object-Utils

     

    사용목적과 고른 이유들을 설명하자면

     

    메인

    • ReactJS : 아무래도 사용하는 사람이 많고, 나도 프론트엔드 3대장중 가장 관심이 가는 녀석
    • RxJS: 앵귤러쪽에서 주로 사용되기는 하지만, 내 포스트들을 보면 Rx나 함수형 패러다임쪽을 선호하는 편이라.
    • Typescript: 동적/인터프린터 언어인 JS를 생으로 쓰기는 좀,, 싶기도 하고 자동완성, 에러체크등 때문에 정적인 언어를 좋아하기 때문에
    • SCSS: CSS-in-JS를 몇개 살펴보긴 했으나 PostCSS에 있는 CSSNext 및 각종 기능들과 섞어쓰기에 SCSS가 나을 듯하여(SASS는 중괄호 블럭이 없어서 싫다. 불명확하며 인공지능 코딩할 때가 생각나기도 해서;;)

     

    서브

    • ImmutableJS: Immer도 충분히 매력적이지만, 함수형 자료구조들을 파워풀하게 사용할 수 있고 성능도 더 나아서
    • RecyclerListView: 안드로이드에서 RecyclerView와 ViewPager2를 잘 쓰던 기억과 React-Window/Virtualized와 다르게 뷰를 재활용한다는 점 때문에 매력적이여서 선택했으나.. 커다란 고비가. 애증의 라이브러리.

     

    기타

    • Normalize.css: CSS 초기화
    • React Icons: 다양한 SVG 아이콘을 쓸 수 있어서.
    • Classnames: props로 클래스를 적용/해제시 간단하게 할 수 있다
    • Postinstall-Postinstall: yarn에서 add를 할 때만 postinstall이벤트가 작동하나, 이 패키지를 설치하면 remove시에도 작동
    • TS-Object-Utils: 다른건 아니고, 라이브러리 커스텀을 할 때 필요해서 사용했다.

     

    고민했던 것

    이것저것 기술을 적용해보자는 플젝이라 리덕스나 MobX를 넣어볼까 하다가 RxJS 위주로 사용하기 위해 브런치로 분리하여 구현만해두고, 합치지 않았다.

    TSTL(타입스크립트 STL) + Immer VS ImmutableJS를 써볼까하다 데이터구조 + 불변성을 한꺼번에 지원하는 Immutable로 결정. 

     

    폴더 구조

    최상위

    patches 폴더가 추가된 것 빼고는 별다를 것 없다.

     

    ITCSS 철학을 기반으로 만들어졌다.

    • Settings: 전역적인 변수들 (Color, Font 등)
    • Tools: 자주 쓰여질 만한 유틸이나 믹스인
    • Generic: 프로젝트 첫번째 레이어(모델, Nomalize 등)
    • Layouts: 꾸미는 것, 컨텐츠들보다 정렬 또는 컨테이너
    • Components: 컴포넌트들
    • Service: RxJS를 이용한 이벤트 관리

     

    디자인 컨셉

    지난학기 내가 디자인했던 플젝에서 따왔다.

    종이처럼 스큐어모피즘스러운 편안한 느낌과 플랫/타이포그래피 위주의 디자인.

     

    상충하는 스큐어모피즘과 플랫이 만날수 있는 지점, 개인적으로 좋아하는 타이포그래피를 이용하기 위한 디자인, 눈이 편안한 색감을 찾다보니 이런 결과가 나올 수 밖에 없더란.

    어느때나 블로그에 예전자료를 개시해도 상관없도록 매달 산돌과 폰트릭스에게 상납하고 있다. 흒흒

     

    포인트 컬러는 옥색이라 해야 하나, 민트초코색이라 해야 하나.

    예전에 넷스케이프를 쓸때 감명받아 좋아하게된 색인데 만자로를 쓰면서 primary 색으로 사용해도 좋겠다는 생각이 들어 적용했다.

    단, Todo App에서는 홈버튼처럼 확 띄어야하거나, 오밀조밀한 UI에 섞여 구분하기 어려운 상황이 아니기 때문에 자연스래 섞이게 만들기 위해 채도를 낮췄다.

     

    주요 코드 소개

    아직은 별것 없지만 어떻게 설계했는지 볼 수 있다.

     

    Generic/TodoModel.tsx

    인터페이스로 기본 아이템 값을 만들어준뒤, ImmutableJS로 데이터 모델을 생성하여 사용한다.

    interface TodoItemI {
      id:      number;
      text:    string;
      checked: boolean;
    }
    
    export const TodoItem: Record.Factory<TodoItemI> = Record({
      id:      0,
      text:    "",
      checked: false,
    } as TodoItemI);
    export type TodoItemT = Record<TodoItemI> & Readonly<TodoItemI>;
    
    export type TodoItemsT = List<TodoItemT>;

     

    App.tsx

    이렇게 내용이 없어도 될 정도인가 싶을정도로 간단하다.

    App.tsx에서 필요한 글로벌 이벤트 관련 로직은 TodoService로 분리하여 Rx를 이용해 관리하기 때문.

    function App() {
      return (
        <TodoTemplate>
          <TodoInsert />
          <TodoList />
        </TodoTemplate>
      );
    }
    
    export default App;

     

    Services/TodoService.tsx

    App.tsx에 있어야 할 것들이 모여있어 상당히 긴편이다.

    하지만 역할 구분이 잘 되어있어 어렵지 않다.

     

    • Large Size Sample: 초기 로딩할 데이터
    • Core Event: 업데이트, 생성, 삭제, 토글 이벤트 타입정의 및 생성
    • Data: 아직 리덕스나 MobX를 적용하지 않아 글로벌 상태는 여기에서 관리한다. todo$는 각 이벤트 발생시 todos => todos인 함수를 적용
    • Event Implementation: 각 이벤트가 일어날 때 처리방법 구현 및 업데이트 처리
    • Interface: TodoService를 사용해 외부에서 Observable이나 이벤트 발생을 받아들이기 위한 인터페이스
    // == Large Size Sample ========================================================
    const largeItemSize = 2500;
    const largeInitItems = List((() => {
      const array = [];
      for (let i = 0; i < largeItemSize; i++) {
        array.push(new TodoItem({
          id: i,
          text: `Task ${i}`,
          checked: false
        }));
      }
      return array;
    })());
    
    
    // == Core Events ==============================================================
    const update$ = new BehaviorSubject((todos: TodoItemsT) => todos);
    const insert$ = new Subject<TodoItemT>();
    const remove$ = new Subject<TodoItemT['id']>();
    const toggle$ = new Subject<TodoItemT['id']>();
    
    
    // == Data =====================================================================
    let nextId = largeItemSize + 1;
    const todos$ = update$.pipe(
      scan((todos, operation) => operation(todos), largeInitItems),
    
      // cache
      publishReplay(1),
      refCount()
    );
    
    
    // == Events Implementation ====================================================
    insert$.pipe(
      map((todo) => (todos: TodoItemsT) => todos.push(todo))
    ).subscribe(update$);
    
    remove$.pipe(
      map((id)   => (todos: TodoItemsT) => todos.filter(todo => todo.id !== id))
    ).subscribe(update$);
    
    toggle$.pipe(
      map((id)   => (todos: TodoItemsT) => todos.map(todo => todo.id === id
        ? todo.set("checked", !todo.checked)
        : todo
      ))
    ).subscribe(update$);
    
    
    // == Interface ================================================================
    const TodoService = {
      initData: largeInitItems,
      todos$: todos$,
    
      onInsert: (text: TodoItemT['text']) => {
        insert$.next(new TodoItem({
          id:      nextId,
          text:    text,
          checked: false
        }));
        nextId++;
      },
      onRemove: (id: TodoItemT['id']) => remove$.next(id),
      onToggle: (id: TodoItemT['id']) => toggle$.next(id)
    };
    
    export default TodoService;

     

    Components/TodoListItem.tsx

    그럼 필요한 props와 콜백은 어떻게 전달될까?

    꼭 필요한 props만 의존하고, 글로벌 이벤트는 TodoServcie로부터 불러와 사용한다.

    TodoService가 이벤트를 관리하기 때문에 App까지 이벤트 버블링이 필요없고, 상태변화 후 렌더링시에도 영향이 제한된다.

    const cx = stylesBind(styles);
    
    interface TodoListItemProps {
      todo: TodoItemT;
      key: TodoItemT['id'];
    }
    
    const TodoListItem = ({ todo }: TodoListItemProps) => {
      const { id, text, checked } = todo;
      const onRemoveDown = useCallback(() => TodoService.onRemove(id), [id]);
      const onToggleDown = useCallback(() => TodoService.onToggle(id), [id]);
    
      return (
        <div className={cx('TodoListItem')}>
          <div className={cx('checkbox', { checked })} onPointerDown={onToggleDown} >
            {checked ? <IoMdCheckmarkCircleOutline /> : <IoIosRadioButtonOff /> }
            <div className={cx('text')}>{text}</div>
          </div>
          <div className={cx('remove')} onPointerDown={onRemoveDown} >
            <BsTrash />
          </div>
        </div>
      );
    };
    
    export default React.memo(TodoListItem);
    

     

    Components/TodoList.tsx

    로딩과 렌더링 시 성능향상의 핵심인 RecyclerListView를 사용한다.

    처음 초기화시를 제외하고, Observable로 상태를 바로 업그레이드 하도록 만들었다.

    const cx = stylesBind(styles);
    
    const ListViewType = {
      TODOLISTITEMS: 0
    }
    
    const TodoList = () => {
      const width = window.innerWidth;
      const renderData = new ListDataProvider(
        (r1: TodoItemT, r2: TodoItemT) => r1 !== r2
      ).cloneWithRows(TodoService.initData);
    
      const [dataProvider, setDataProvider] = useState(renderData);
      useEffect(() => {
        const sub = TodoService.todos$.subscribe((todos) => {
          setDataProvider((dataProvider) => dataProvider.cloneWithRows(todos));
          }
        );
        return () => { sub.unsubscribe(); };
      }, []);
    
      const layoutProvider = new LayoutProvider(
        (index) => { return ListViewType.TODOLISTITEMS },
        (type, dim) => {
          switch(type) {
            case ListViewType.TODOLISTITEMS: {
              dim.width  = width; // 630.4 584
              dim.height = 55;    // 55.2  54.4
              break;
            }
            default:
              dim.width  = 0;
              dim.height = 0;
          }
        }
      );
    
      const rowRenderer = (viewType: React.ReactText, todo: TodoItemT) => {
        switch (viewType) {
          case ListViewType.TODOLISTITEMS: {
            return (<TodoListItem todo={todo} key={todo.id} />);
          }
          default:
            return null;
        }
      }
    
      return (
        <div className={cx('TodoList')}>
          <RecyclerListView dataProvider={dataProvider} layoutProvider={layoutProvider}
                            rowRenderer={rowRenderer} />
        </div>
      );
    };
    
    export default React.memo(TodoList);

     

    Tools/RecyclerProvider.ts

    빠르게 만들려고 도입했던 RecyclerListView.

     

    처음에 도입하려했더니 ImmutableJS의 List는 사용할 수 없고, 오직 기본 []만 쓸 수 있었다.

    그래서 데이터 주입 및 관리하는 부분을 까보면서 인터페이스의 내부 부분을 싹 재설계한 결과.

     

    물론 여기서도 성능향상이 있었다.

    • 커다란 List를 toArray나 toJS로 바꾸는데 드는 비용이 상당히 큰데 이를 최소화(이게 크다)
    • 데이터 구조의 특성을 이용한 빠른 비교

    타입을 지원하기 위해 아래 코드창에는 없는 제네릭 클래스 부분부터 다 만들었지만, 타입변환이 필요없는 것에서 오는 최적화는 이해하기 쉬우니 두번째 항목인 빠른 비교 위주로 설명하려 한다.

     

    그 중 핵심은 처음 바뀐 인덱스를 반환해주는 getFirstIndexChange.

     

    원래의 DataProvider에서 

    • 배열 크기 비교 후 작은 배열에 맞춤
    • 크기가 작은 배열의 크기까지 각 인덱스별 데이터가 바뀌었는지 반복하며 확인

    이었던 구조를

     

    • Immutable 컬렉션의 equal을 사용해 같다면 현재 크기 사용
    • 크기 비교 후 작은 List에 맞춘 후
    • 조건을 만족하는 첫번째 인덱스를 반환하는 .findIndex를 사용하여 .get으로 접근 횟수를 줄이며 최적화 가능성을 최대한 활용

    로 바꾸었다.

     

    // == RecyclerListView Dataprovider with IMMUTABLE.JS ==========================
    export abstract class ListBaseDataProvider<T> extends GenericDataProvider<T, List<T>> {
      public abstract newInstance(
        rowHasChanged: (r1: T, r2: T)  => boolean,
        getStableId?:  (index: number) => string  ): ListBaseDataProvider<T>;
    
      public getDataForIndex(index: number): T | undefined {
        return this.m_data.get(index);
      }
    
      // Fast Matching Lists
      private getFirstIndexChange(newData: List<T>, newSize: number): number {
        if(this.m_data.equals(newData)) {
          return this.m_size;
        }
    
        if(this.m_size > newSize) {
          const sizeData = newData.setSize(this.m_size);
          return (this.m_data as List<T>)
            .findIndex((value, index) => this.rowHasChanged(value, sizeData.get(index)));
        } else {
          const sizeData = this.m_data.setSize(newSize);
          return (sizeData as List<T>)
            .findIndex((value, index) => this.rowHasChanged(value, newData.get(index)));
        }
      }
    
      //No need to override this one
      //If you already know the first row where rowHasChanged will be false pass it upfront to avoid loop
      public cloneWithRows(newData: List<T>, firstModifiedIndex?: number): DataProvider {
        const dp        = this.newInstance(this.rowHasChanged, this.getStableId);
        const newSize   = newData.size;
    
        dp.m_firstIndexToProcess = ObjectUtil.isNullOrUndefined(firstModifiedIndex)
                                ? this.getFirstIndexChange(newData, newSize)
                                : Math.max(Math.min(firstModifiedIndex, this.m_data.size), 0);
    
        if (dp.m_firstIndexToProcess !== this.m_data.size) {
          dp.m_requiresDataChangeHandling = true;
        }
        dp.m_data = newData;
        dp.m_size = newSize;
        return dp;
      }
    }
    

     

    ../patches/의 patch 삼총사와 yarn 라이프사이클

    방금 RecyclerListView에서 List<>를 사용할 수 없게 되었다고 하였다.

    데이터 프로바인더 내부 말고,  props의 타입 정의 또한 any와 any[]로 이루어져있기 때문에 패치해주었다.

     

    patch_list.sh는 타겟 위치, 패치된 파일 위치, 패치를 생성할 위치를 정의한다.

    # Patch Path
    ## TargetPath, PatchPatch, CreatedPatchPath
    PATCH_PATH=(../node_modules/recyclerlistview/dist/web/core/RecyclerListView.d.ts
                ./recyclerlistview/RecyclerListView.d.ts
                ./created/recyclerlistview-recyclerlistview.patch
    
                ../node_modules/recyclerlistview/dist/web/core/dependencies/DataProvider.d.ts
                ./recyclerlistview/dependencies/DataProvider.d.ts
                ./created/recyclerlistview-dataprovider.patch
               )
    

     

    create_patch를 사용하면 두파일을 비교해 생성할 위치에 패치를 생성한다.

    DIR는 현재 파일의 절대경로를 생성해주는 트릭.

    10년 넘는 리눅스 생활 덕. ㅎㅎ

    DIR=$( cd "$(dirname "$0")" ; pwd )
    
    # Source Patch List
    source ${DIR}/patch_list.sh
    
    # Create Patch File
    create_patch() {
      local target=$1
      local patched=$2
      local patch=$3
      diff -Naur ${DIR}/${target} ${DIR}/${patched} > ${DIR}/${patch}
    }
    
    for (( i = 0; i<${#PATCH_PATH[@]}; i++ )); do
      create_patch ${PATCH_PATH[i]} ${PATCH_PATH[i+1]} ${PATCH_PATH[i+2]}
      ((i+=2))
    done

     

    마지막으로 apply_patch는 node_modules에 패치를 적용한다.

    DIR=$( cd "$(dirname "$0")" ; pwd )
    
    # Source Patch List
    source ${DIR}/patch_list.sh
    
    # apply patch
    apply_patch() {
      local target=$1
      local patch=$2
      patch --forward ${DIR}/${target} < ${DIR}/${patch}
    }
    
    for (( i = 0; i<${#PATCH_PATH[@]}; i++)); do
       apply_patch ${PATCH_PATH[i]} ${PATCH_PATH[i+2]}
       ((i+=2))
    done

     

    그 후, package.json의 postinstall에 bash로 실행해주라는 명령만 날리면 매 패키지 설치마다 실행이 된다.

      "scripts": {
        "start": "react-scripts start",
        "build": "react-scripts build",
        "test": "react-scripts test",
        "eject": "react-scripts eject",
        "postinstall": "bash ./patches/apply_patch.sh"
      },

     

    하지만 패키지 삭제시에는 안됐는데 Postinstall-Postinstall 패키지로 해결.

    이렇게 RecyclerListView를 사용하려는 노오력이 있었다.

     

    이외에도 3일동안 해매서 아이템 상태변화 시 스크롤 위치 초기화 문제를 겨우 잡았기 때문에 애증의 라이브러리라 할만하다. ㅠㅠㅠㅠ

    재랜더링시 가까운 아이템 요소 크기로 움직이는 문제도 있었는데 깃헙의 도움으로 해결되었다.

     

    Layouts/TodoTemplate.module.scss

    클래스들이 BEM 네이밍 스타일로 만들어도록 하기위해 CSS Module을 이용하고 있다.

    이 프로젝트는 개념 증명용으로 만들었기 때문에 변수와 믹스인을 최대한 활용하도록 코드를 짜보기도 했다.

    Atomic CSS에서 영감을 받아서 이리 구성해봤는데 처음 네이밍에 익숙해지는 것만 넘으면 의외로 쓸 만한듯.

     

    뷰포트가 작을 때 레이아웃이 깨지는 것을 보고 도입한 반응형 레이아웃이 적용되어 있다.

    @import '~include-media-or/dist/include-media';
    @import '../Settings/Colors.scss';
    @import '../Settings/Fonts.scss';
    @import '../Settings/Layouts.scss';
    @import '../Tools/Tools.scss';
    
    .TodoTemplate {
      @include flex-container-start-top;
      @include flex-container-alignY-center;
      @include font-legibility;
      font-family:      $notoSans;
      font-weight:      $medium;
      height:           $full-height;
      color:            $black;
      background-color: $white;
    
      .app-title {
        @include container-all;
        @include flex-item-alignY-fill;
        @include font-size($header-font-size, $header-font-max-size);
        font-family: $notoSerif;
        font-weight: $heavy;
        text-align:  center;
        padding:     $default-padding * 2;
        max-height:  $half-height;
      }
      .content {
        @include container;
        @include font-size($default-font-size, $default-font-max-size);
        @include media(">=laptop") {
          width: 40rem;
        }
        @include media(">=tablet") {
          margin-left:  30%;
          margin-right: 30%;
        }
        width: 80%;
      }
    }

     

    Tools/Tools.scss

    반응형 웹에 대한 글들을 보면

    처럼 레이아웃에 대한 것들이 대부분이다.

     

    타이포그래피에 나름 관심이 많은 나는 크기에 대하여 찾아봤는데

     

    그냥 대략적인 수치정도만 나올 뿐이었다.

     

    레이아웃은 %나 뷰포트 관련값으로 실시간 크기 조정이 되지만,

    폰트는 뷰포트나 % 값을 사용한 유동적 조정을 하기가 매우 어려웠기에 자료를 찾아보았다.

     

    다행히

    란 글을 통해

     

    글씨 최소/최대크기와 화면 최소/최대 크기가 있으면

    • 고정값 + 뷰포트 비율

    를 산출하여 뷰포트 크기에 따라 자동으로 크기가 조정할 수 있는 방법을 알아냈다.

     

    여기서 2차 멘붕이 몰려왔다.

    모바일~데스크톱 크기 그리고 HD~4K 해상도등을 고려해 글씨의 최소/최대 크기를 지정해야 하는 것이다.

    내가 디자이너였다면 감(?)이나 시각 디자인 이론에 따라 각 장치에 맞는 크기를 얻어낼 수 있겠지.

    그치만 나는 공돌이인 걸..

     

    다양한 장치에서 일관된 크기를 결정할 방법을 찾기 시작했다.

    처음에는 IOS의 pt, 안드로이드의 dp/sp 단위처럼 ppi 기반으로 사용해볼까 생각했다.

    하지만

    란 글을 보고 난 후 생각을 접었고...

     

    장치와 사용자와의 거리를 포함하지 않는 단위라는 점도 매력적이지 않았다.

    (예를들어 같은 크기라도 가까이서 보는 모바일에서 느껴지는 체감크기가 PC 모니터의 크기보다 클 수밖에 없다)

     

    하루동안 열쇠를 찾기위해 고심했고.

    한때 장래희망이어서 열심히 공부했던 천문학에서 핵심 아이디어를 가져왔다.

    연주시차(각도)로 거리를 재는 것처럼 각도와 약간의 삼각함수를 쓰면 일관된 크기를 보여줄 수 있지 않겠냐는 것이었다.

     

     

    이는

    • 눈과 기기와의 거리
    • 기기의 크기
    • 해상도

    를 모두 포함하여 필요한 크기를 계산할 수 있는 단위이며 시력검사를 하는 원리이기도 했다.

     

    세부적인 이론과 자료는

    를 참고하면 됩니다.

     

    임의적으로 각도로 산출해낸 크기를 '적합 크기(Fit Size)'라고 부르는 중인데

    고해상도 > 폰 > 데스크탑 > 랩탑 > 태블릿 순이란 결과를 얻을 수 있었으며 합리적으로 변환을 해주었다.

    각 장치에 맞는 크기 생성

     

    하지만 사람이 매번 수동적으로 기기와의 거리,  기기의 크기, 가로 해상도, 세로 해상도를 입력해 각 장치에 맞는 크기를 구하기는 복잡했고, 인터페이스를 만들었다.

     

    그냥 폰트 지정하듯

    @include font-size($size, $max-size: 옵션);

    만 지정해주면

     

    1. 크기 입력
    2. 가장 작게 보일 장치에서의 각도 계산 (또는 기준이 될 장치를 지정해볼 수 있겠죠?)
    3. 각도를 기반으로 각 장치별 적합 크기 생성
    4. 장치 사이의 유동성 제공

    란 과정을 통해 각장치 맞는 크기를 생성하고 유동적으로 크기가 조절된다.

     

    나중에 라이브러리로 따로 뺄 예정.

    // ** Responsive ***************************************************************
    // == Size =====================================================================
    $DEFAULT-BREAK: default !default;
    $DEFAULT-SIZE:  16px    !default;
    
    @function zip-responsive() {
      @return zip(
        map-values($screen-distances), map-values($screen-sizes      ),
        map-values($breakpoints     ), map-values($breakpoints-height)
      );
    }
    
    @function calc-ppi($screen-width, $screen-height, $screen-size) {
      @return sqrt(pow($screen-width, 2) + pow($screen-height, 2)) / $screen-size;
    }
    
    @function calc-angle($size) {
      $size:     num(px($size));
      $values:   zip-responsive();
      $smallest: null;
    
      @each $value in $values {
        $screen-distance: nth($value, 1) * 100;
        $screen-size:     nth($value, 2);
        $screen-width:    num(nth($value, 3));
        $screen-height:   num(nth($value, 4));
    
        $ppi: calc-ppi($screen-width, $screen-height, $screen-size);
        $independent-value: $screen-distance * $ppi;
        @if (($smallest == null) or ($smallest > $independent-value)) {
          $smallest: $independent-value;
        }
      }
    
      $angle: $size * 54 / $smallest;
      $visual-angle: atan($angle) * (10800 / pi());
      @return count-round($visual-angle, 2);
    }
    
    @function calc-size($visual-angle) {
      $values:      zip-responsive();
      $break-sizes: ();
    
      @each $value in $values {
        $screen-distance: nth($value, 1) * 100;
        $screen-size:     nth($value, 2);
        $screen-width:    num(nth($value, 3));
        $screen-height:   num(nth($value, 4));
    
        $ppi: calc-ppi($screen-width, $screen-height, $screen-size);
        $angle: tan(pi() * $visual-angle / 10800);
        $size: $screen-distance * $angle * $ppi / 54;
    
        $break-sizes: append($break-sizes, ($size));
      }
    
      @return $break-sizes;
    }
    
    // https://gist.github.com/eduardoboucas/84144cd85cbd2ad4db1ca8b902585ca0
    @function im-to-em($breakpoints, $base-value: 16px) {
      $new-breakpoints: ();
    
      @each $key, $value in $breakpoints {
        $em-value: ($value / $base-value) * 1em;
        $new-breakpoints: map-merge($new-breakpoints, ($key: $em-value));
      }
    
      @return $new-breakpoints;
    }
    
    // https://www.madebymike.com.au/writing/fluid-type-calc-examples/
    @function fluid-rate($start-size, $end-size, $min-screen, $max-screen) {
      @return ($end-size - $start-size) / ($max-screen - $min-screen);
    }
    @function fluid-basic-size($start-size, $min-screen, $rate) {
      @return $start-size - $rate * $min-screen;
    }
    
    @function fluid-size($start-size, $end-size, $min-screen, $max-screen) {
      $start-size: em($start-size);
      $end-size:   em($end-size);
      $min-screen: em($min-screen);
      $max-screen: em($max-screen);
    
      $rate: fluid-rate($start-size, $end-size, $min-screen, $max-screen);
      $basic-size: fluid-basic-size($start-size, $min-screen, $rate);
    
      $sign: "+";
      @if ($basic-size < 0) {
        $sign: "-";
        $basic-size: abs($basic-size);
      }
      @return calc(#{$rate*100}vw #{$sign} #{$basic-size});
    }
    @function fluid-limit-break($start-size, $end-size, $max-size,
                                $min-screen, $max-screen) {
      $start-size: px($start-size);
      $end-size:   px($end-size);
      $max-size:   px($max-size);
      $min-screen: px($min-screen);
      $max-screen: px($max-screen);
    
      $rate: fluid-rate($start-size, $end-size, $min-screen, $max-screen);
      $basic-size: fluid-basic-size($start-size, $min-screen, $rate);
    
      @return ($max-size - $basic-size) / $rate;
    }
    
    @mixin fluid-media($property, $sizes, $max-size: null) {
      $fluid-sizes: to-unit-map($sizes, px);
      $fluid-breakpoints: map-merge(($DEFAULT-BREAK: 0px), to-unit-map($breakpoints, px));
      @if not map-has-key($fluid-sizes, $DEFAULT-BREAK) {
        $default-map: ($DEFAULT-BREAK: $DEAFULT-SIZE);
        $fluid-sizes: map-merge($default-map, $fluid-sizes);
      }
      $limit-break: null;
    
      $last-size: length($fluid-breakpoints) - 1;
      @each $i in range($last-size) {
        $now-key:  map-nth($fluid-breakpoints, $i);
        $next-key: map-nth($fluid-breakpoints, $i + 1);
    
        $now-size:   map-get($fluid-sizes, $now-key );
        $next-size:  map-get($fluid-sizes, $next-key);
        $now-break:  map-get($fluid-breakpoints, $now-key );
        $next-break: map-get($fluid-breakpoints, $next-key);
    
        @if ($max-size != null) {
          $limit-break: fluid-limit-break($now-size,  $next-size, $max-size,
                                          $now-break, $next-break);
    
          $max-size:  px($max-size);
          $now-size:  if($now-size  > $max-size, $max-size, $now-size );
          $next-size: if($next-size > $max-size, $max-size, $next-size);
        }
    
        $fluid-size: fluid-size($now-size, $next-size, $now-break, $next-break);
        @if $now-key == $DEFAULT-BREAK {
          #{$property}: $fluid-size;
        }
        @else if($max-size != null) and ($i == $last-size) {
          @include media(">=#{$limit-break}") {
            #{$property}: $max-size;
          }
        }
        @else {
          @include media(">=#{$now-key}") {
            #{$property}: $fluid-size;
          }
        }
      }
    }
    
    @mixin fluid($property, $size, $max-size: null) {
      $scaled-size: calc-size(calc-angle($size));
    
      $keys:   join($DEFAULT-BREAK, map-keys($breakpoints));
      $values: join($size, $scaled-size);
    
      @include fluid-media($property, to-map($keys, $values), $max-size);
    }
    
    // -- Interface ----------------------------------------------------------------
    // Font
    @mixin font-size($size, $max-size: null) {
      @include fluid(font-size, $size, $max-size);
    }

     

    댓글 0

Designed by black7375.