최근 사용자 경험은 UI 앱에 있어서 매우 중요하다. 데스크톱과 모바일 환경의 경계가 사라진 지금 앱 스토어에 등록된 앱의 수는 셀 수 없을 정도로 다양하며 기능이 유사하거나 사용 용도가 같은 소프트웨어도 무수히 많기 때문에 더 세련된 디자인은 물론, 사용하기 더 편한 앱이 사용자가 더 주목하는 시대이다. 다시 말하면, 같은 기능을 제공하는 앱이라면 사용자 경험이 더욱 뛰어난 소프트웨어가 사용자에게 매력을 더 호소할 수 있다. 그뿐만 아니라, 빠르고 부드러운 비주얼 인터렉션은 당연지사다. 앱 개발에 있어서 UI는 결코 사소한 요소에 해당하지 않는다. 앱 개발자는 더 효율적으로 UI 앱을 구현하기 위해 더욱 안정적이고 기능이 뛰어난 UI 프레임워크를 선호한다.


UI 프레임워크는 앱 개발자가 쉽고 빠르게 앱 화면에 UI 요소를 배치하고 사용자와 앱 사이의 상호작용을 수행할 수 있도록 도와준다. UI 프레임워크는 고성능의 화려한 비주얼 효과를 제공하기 위한 그래픽스 처리 엔진은 물론, UI 앱의 기능 로직과 UI 수행 로직 사이의 자연스러운 동작 연결을 제공한다. 이를 위해 앱 뼈대에 해당하는 기반 기능은 물론, 메인루프(Mainloop) 같은 핵심 동작을 내재하기도 한다. UI 프레임워크는 보다 쉬운 앱 개발을 위해 더욱 정교하게 설계된 프로그래밍 인터페이스는 물론, 어떠한 환경에서도 같은 기능을 제공할 수 있는 호환성을 갖추어야 한다.


UI 프레임워크를 이해하는 가장 단순하면서도 효과적인 방법은 직접 UI 앱 개발자가 되어서 필요한 기능을 사용해 보는 것이다. 이번 장에서는 앱 개발 관점에서 UI를 구성하는 메커니즘을 살펴보면서 UI 프레임워크의 기본 개념을 짚어보고자 한다. 그리고 다른 한편으로는 프레임워크 개발 관점에서 어떻게 그러한 기능을 제공할 수 있는지 알아본다. 만약, 여러분이 UI 앱을 개발해 본 적이 없다면 어쩌면 이번 장은 여러분에게 적합한 도입부가 될 것이라고 기대한다.



1. 학습 목표

이번 장을 통해 다음 사항을 학습해 보자.

  • UI의 기본 원소와 앱 UI 구성 원리를 이해한다.
  • UI 컨트롤과 그 동작을 제어하는 이벤트 메커니즘을 배운다.
  • UI 앱의 라이프사이클과 UI 엔진의 메인루프을 이해한다.
  • 스케일러블 UI를 구현하는 방법을 살펴본다.
  • 컨테이너의 기본 개념을 이해하고 정렬과 가중치 그리고 이들의 사용 방식을 살펴본다.
  • 더 높은 UI 호환성을 위한 추가 기법들을 살펴본다.


  • 2. 모던 UI 앱 개발 이해

    2.1 UI 기본 원소

    본격적인 시작에 앞서 우리는 UI의 기본 원소를 먼저 살펴볼 것이다. UI의 기본 원소는 앱 UI를 구성하기 위한 가장 기본 요소에 해당한다. 다음과 같은 앱 UI를 구성하기 위해서 우리는 어떤 리소스가 필요할까?

    그림 1UI 앱 화면 구성 예 (Google Chrome)

    그림 1은 우리가 잘 알고 있는 크롬(Chrome) 브라우저의 구글 페이지 화면이다. 크롬 역시 하나의 UI 앱으로 간주할 수 있으며, 얼핏 보기에 UI가 복잡해 보이지만 사실 UI를 조목조목 하나씩 뜯어보면 결국에는 이미지와 텍스트 두 요소로 구성되어 있음을 확인할 수 있다.


    그림 2: UI 앱 화면을 구성하는 기본 원소


    앱의 화면을 구성하는 기본 원소는 화면을 화려하게 장식해 주는 이미지와 문맥 정보를 전달하는 텍스트 두 개로 축약해 볼 수 있다. 다소 기능이 원시적일지라도, 사실 이 두 기능만 존재한다면 어떠한 종류의 앱 화면도 정확히 구현할 수 있다. 이를 증명하기 위해 코드 1은 그림 1의 검색 상자(Search Box)를 어떻게 구현할 수 있는지 보여준다.

    //검색 상자 searchBox = UIImage(): //이미지 생성

    .path = “./res/SearchBox.png” //이미지 리소스 .geometry = {80, 300, 350, 50} //이미지 위치 및 크기 //검색 상자 가이드 텍스트 guideText = UIText(): //텍스트 생성 .text = “Search Google or type URL” //텍스트 설정 .color = “lightgray” //텍스트 색상 .geometry = {90, 310, 130, 40} //텍스트 위치 및 크기 //검색 상자 음성 아이콘 icon = UIImage(): //이미지 생성 .path = “./res/VoiceRecognition.png” //이미지 리소스 .geometry = {400, 310, 20, 25} //이미지 위치 및 크기

    코드 1: 앱 화면 구성 예

    코드 1처럼 이미지와 텍스트를 이용한다면 그림 1의 다른 부분도 똑같이 구현할 수 있다.


    어쩌면, 텍스트도 미리 준비된 이미지로 대처할 수 있지 않을까 생각할 수도 있다. 불가능한 것은 아니지만 언어, 폰트 등 시스템 환경에 따라 텍스트가 유연하게 변경되기 위해서는 텍스트를 이미지로 출력하는 것은 여러 측면에서 제약점이 많다.


    사실 안드로이드, IOS, MS 윈도우와 같은 최신 플랫폼에서 제공하는 UI 기능을 살펴보면, 앱 개발자가 위와 같은 원시적인 방법으로 UI를 구현하는 것은 상상할 수 없는 일이다. UI 프레임워크는 화면을 구성하는 공통된 기능과 특성을 UI 컨트롤(또는 위젯)로서 제공하며 프로그래밍 관점에서 UI 컨트롤은 UI 객체(UI Object)로서 통용된다.


    그림 3: 다양한 종류의 UI 컨트롤 (Polaris UI)


    UI 프레임워크에서 제공하는 UI 컨트롤을 활용한다면, 앱 개발자는 보다 쉽고 빠르게 앱 화면을 구축할 수 있다.

    //검색 상자 UI 컨트롤 searchBox = UISearchBox(): //검색 상자 생성 .text = “Search Google or type URL” //가이드 텍스트 설정 .icon = “./res/VoiceRecognition.png” //음성 아이콘 설정 .geometry = {80, 300, 350, 50} //검색 상자 위치 및 크기

    코드 2: UI 컨트롤을 이용한 앱 화면 구성

    같은 목적을 구현하는 코드 1과 코드 2를 비교해 보면 앱 구현이 얼마나 간단해지는지 알 수 있다. 하물며 UI 컨트롤은 화면을 출력하는 기능뿐만 아니라, 사용자와의 상호작용을 위한 기능 동작까지 제공한다. 검색 상자에 사용자가 입력한 텍스트가 실시간으로 출력되는 기능을 생각해본다면, 앞서 이미지와 텍스트를 이용한 원시적 구현 방식과는 비교할 수 없을 정도로 구현 분량이 축소될 수 있다.


    사용자로부터 클릭(Click) 입력을 받는 버튼을 예로 들어보자. 버튼은 사용자의 클릭 이벤트를 전달받고 앱에게 그 상태를 전달한다. 버튼은 클릭 이벤트를 가시적으로 표현하기 위해 클릭에 대한 상태 정의(Normal, Press)는 물론, 상태별 이미지도 출력할 수 있어야 한다. 만약 상태 전이 애니메이션까지 존재한다면 버튼의 구현은 훨씬 더 복잡하다.


    그림 4: 버튼의 클릭 상태 전이


    만약 원시적 방법으로 버튼의 기능을 구현한다면, 구현량은 물론 개발 난이도까지 급상승한다. 기본적으로 UI 컨트롤은 앱이 작성해야 할 코드 분량을 줄여주는 것은 물론, 룩앤필(Look & Feel)을 갖춘 테마 특성, 애니메이션, 이벤트, 예상치 못한 사용자 입력 처리 등 생각보다 많은 작업을 대신 정의하고 구현한다.


    일반적으로 UI 컨트롤의 동작과 룩앤필 특성은 프레임워크에서 정의한 테마에 의존한다. 달리 말하면, 앱이 어떤 UI 프레임워크를 기반으로 작성되었느냐에 따라 앱의 UI 특성은 완전히 달라질 수 있다. 사용 중인 UI 프레임워크가 제공하는 테마는 UI 앱의 시각적 감성에 큰 영향을 준다.


    그림 5동일 UI 컨트롤의 테마별 출력 결과


    그림 6동일 UI 앱의 테마별 출력 차이 (Tizen)


    만약 앱에서 필요한 UI 컨트롤을 UI 프레임워크에서 제공하지 않거나 제공은 하지만 앱 디자인 컨셉과 정확히 부합하지 않다면 코드 1처럼 직접 이미지와 텍스트를 가지고 앱 화면을 구성할 수 있다. 다만, 기능적 측면을 고려했을 때 효율적인 방법은 아니므로 프로토타입 및 테스트 수준의 앱 개발에만 고려할 만하다. 그 외의 경우라면 UI 컨트롤의 디자인을 수정하거나 기능을 확장한 사용자 정의의 새 컨트롤 제작 방법이 있다. 일반적으로 UI 프레임워크에서 제공하는 테마 커스터마이징 기능은 기본 기능 범주에 해당한다.



    2.2 UI 이벤트 핸들링

    앞 절에서는 우리는 UI 컨트롤이 무엇인지 개략적으로 살펴보았다. 이번 절에서 우리는 지구상에서 가장 단순한 UI 컨트롤인 버튼(Button)을 이용할 예정이다. 이 예제로부터 우리는 UI 컨트롤과 앱 사이의 상호작용을 어떤 식으로 구현할 수 있는지 살펴볼 것이다.


    그림 7: 버튼

    버튼(Button)은 어떤 UI 프레임워크를 막론하고 공통으로 제공되는 UI의 기본 타입 중 하나이다. 사용자는 버튼을 클릭함으로써 앱으로 신호를 전달한다. 앱은 사용자가 버튼을 클릭하였는지 신호를 감지할 수 있으며 그 신호에 상응하는 어떤 동작을 적절히 취할 수 있도록 구현을 한다. 이 예제에서는 버튼이 선택되었을 때 버튼 텍스트를 변경한다.

    그림 8버튼 클릭 결과

    버튼을 앱 화면에 배치하기 위해 앱 개발자는 대략 다음과 같은 버튼 생성 코드를 작성할 수 있을 것이다.

    myBtn = UIButton(): //버튼 생성

    .text = “My Button” //버튼 출력 텍스트 .geometry = {50, 50, 100, 100} //버튼 위치 및 크기

    코드 3: 버튼 생성

    매우 간단하다. 화면에 버튼을 추가했다면, 사용자 입력 신호는 어떻게 처리할 수 있을까? 프로그래밍 관점에서 이벤트 리스너(Event Listener) 혹은 콜백 함수(Callback Function) 같은 메커니즘을 이용한다면 입력 신호가 발생했을 때 이벤트 처리도 가능하다. 버튼이 클릭 된 시점에 앱 개발자가 등록한 이벤트 콜백 함수가 호출된다면, 앱 개발자는 해당 함수 내에서 원하는 동작을 구현할 수 있을 것이다.


    /* 버튼 클릭 이벤트를 클로져(Closure) 형태로 구현. Clicked 이벤트 발생 시 closure f()가 수행된다.

    obj 인자로 myBtn 인스턴스가 전달된다. */ f = closure(UIButton obj, ...):

    obj.text = "Button Pressed"!


    myBtn.EventClicked += f

    코드 4버튼 클릭 이벤트 콜백 구현

    눈치챘겠지만, UI 컨트롤을 잘 활용하기 위해서는 앱 개발자는 해당 컨트롤이 제공하는 이벤트의 종류를 이해할 수 있어야 한다. 버튼의 경우 “클릭” 이벤트를 제공하지만, 추가로 “눌림(Pressed)”, “눌림 해제(Unpressed)”, “롱프레스(Longpressed)” 와 같은 다른 이벤트도 제공할 수 있다. 앱 개발자는 각 이벤트에 대한 명세 내용을 정확히 이해해야 사용자 시나리오에 맞게 그 기능을 잘 구현할 수 있다.

    //Pressed 이벤트 발생 시 pressedCb()이 수행된다.

    pressedCb = closure(UIButton obj, ...): ...


    //Unpressed 이벤트 발생 시 unpressedCb()이 수행된다.

    unpressedCb = closure(UIButton obj, ...):

    ...


    //Longpressed 이벤트 발생 시 longpressedCb()이 수행된다.

    longpressed = closure(UIButton obj, ...): ...

    myBtn.EventPressed += pressedCb

    myBtn.EventUnpressed += unpressedCb

    myBtn.Eventlongpressed += longpressedCb

    코드 5: 버튼에 여러 이벤트 등록

    입력 이벤트가 발생하는 세부 과정은 6장에서 자세히 다루도록 한다.


    한편, UI 프레임워크에서는 UI 컨트롤의 동작 정의는 물론, 해당 기능을 앱 개발자가 이해할 수 있도록 명확한 인터페이스 설계 및 문서화가 필요하다.

    /**
     * @defgroup UIButton Button
     * @ingroup UISystem
     *
     * This is a push-button. Press it and run some function. It can contain
     * a simple label and icon object and it also has an autorepeat feature.
     *
     * This widget inherits from the @ref Layout one, so that all the
     * functions acting on it also work for button objects.
     * …
     * This control emits the following signals, besides the ones sent from Layout.
     * @li EventClicked: the user clicked the button (press/release).
     * @li EventPressed: button was pressed.
     * @li EventUnpressed: button was released after being pressed.
     * @li EventLongpressed: the user pressed the button without releasing it.
     * ...
    

    코드 6: Doxygen 형식으로 작성된 버튼 문서화 예시 (EFL)

    UI 프레임워크는 서로 다른 UI 컨트롤일지라도 유사 동작의 경우 같은 인터페이스를 갖추도록 고려해야 한다. 인터페이스를 형식화하면 앱 개발자가 새로운 기능을 더욱 쉽고 빠르게 이해하는 데 도움이 된다. 앱 개발자는 하나를 배움으로써 다른 UI 컨트롤의 동작도 쉽게 유추할 수 있다.

    myRadio = UIRadio(): //라디오 컨트롤

    //EventClicked 이벤트 발생 시 본 동작을 수행한다.

    f = closure(UIRadio obj, ...):

    obj.text = "Radio selected!"


    /* 앱 개발자가 라디오 컨트롤을 잘 모를지라도, Clicked 동작이 무얼 의미하는지 유추할 수 있다. */ .EventClicked += f

    코드 7동일 인터페이스의 접근성

    하나의 UI 컨트롤에 이벤트 콜백 함수의 복수 등록도 고려해야 한다. 예를 들면, 앱 개발자는 버튼이 클릭 되었을 때, 어떤 메시지를 출력함과 동시에 특정 이미지를 출력하고 싶을 수 있다. 두 동작을 하나의 함수 내에서 처리할 수 있지만 서로 다른 개별 동작이라면 같은 이벤트일지라도 이들을 분리하여 코드를 작성해야 할 수 있어야 한다. 이는 앱 개발자가 유연한 로직을 작성할 수 있도록 도와준다. 결과적으로, 이벤트 처리도 복수 등록이 가능하도록 인터페이스를 설계해야 앱 개발이 보다 유연해진다.


    //EventClicked 이벤트 발생 시 아래 클로져 함수가 수행된다.

    f1 = closure(UIRadio obj, ...):

    //메시지 출력...

    //EventClicked 이벤트 발생 시 아래 클로져 함수가 수행된다. f2 = closure(UIRadio obj, ...):

    //이미지 출력...

    myRadio.EventClicked += f1

    myRadio.EventClicked += f2

    코드 8복수 이벤트 등록 시나리오

    복수의 이벤트 콜백 함수가 등록된 경우, 어떤 함수가 먼저 호출되어야 하는지 UI 프레임워크는 명확한 정책을 제시해야 한다. 왜냐하면, 함수 호출 순서로 인해 앱 로직이 달라질 수 있기 때문이다. 이 경우 LIFO(Last In First Out) 정책과 유사하게 나중에 등록된 함수가 먼저 호출되도록 기본 정책으로 정할 수 있다. 하지만, 요구에 따라 앱 개발자가 함수 호출 순서를 변경할 수 있는 방침도 필요하다.  


    myRadio.EventClicked += {f1, priority = 2}

    //위 이벤트보다 우선순위가 높기 때문에 f2가 먼저 불린다. myRadio.EventClicked += {f2, priority = 1}

    코드 9: 이벤트 우선 순위 지정

    사실, 호출 순서에 의존하는 앱의 이벤트 콜백 함수가 존재한다면 이를 개선할 수 있는지 검토해 보아야 한다. 예로, 클릭 이벤트에 복수의 콜백 함수가 등록된 경우 각 콜백 함수 구현부는 서로 독립적이어야 앱 로직의 복잡도도 줄일 수 있다. 만약 콜백 함수 사이에 의존성이 존재한다면 이를 하나의 콜백 함수 구현부로 합치는 것이 앱 유지관리 측면에서 더 바람직하다.


    한편, 어떤 상황에서는 등록한 동작을 취소하기 위해 콜백 함수를 제거해야 할 수도 있다.


    f = closure(UIButton obj, ...):

    ...

    UIEventCb myEvent = (myBtn.EventClicked += f)

    ...

    //앞서 등록한 콜백 함수를 제거한다. myBtn.EventClicked -= myEvent

    코드 10등록한 이벤트 콜백 함수 제거

    사용자 조건을 만족하기 전까지 앱의 특정 기능을 비활성화해야 하는 경우가 있다. 이 경우 특정 기능을 발동하는 UI 컨트롤도 비활성(disabled) 상태로 존재해야 한다. 해당 컨트롤을 비활성화하는 기능을 제공하면 사용자는 해당 기능을 사용할 수 없다는 점을 인지할 수 있다. 비활성화된 UI 컨트롤은 기본 동작을 수행하지 않음은 물론, 제공하는 어떠한 이벤트도 발생시키지 않는다.

    그림 9: 버튼 비활성화

    myBtn.disable() //버튼을 비활성화한다. 다시 활성화하려면 enable()을 호출한다.

    코드 11: 버튼 비활성화


    비활성화된 버튼은 UI 외양이 달라졌을 뿐만 아니라, 클릭과 같은 기본 동작 역시 수행하지 않는다.



    2.3 UI 엔진과 앱 라이프사이클

    앞 절에서 우리는 UI 앱과 사용자 간 상호작용 메커니즘의 구현 방안을 살펴보았다. 이를 위해 UI 컨트롤의 이벤트 처리를 어떤 식으로 구현할 수 있는지  살펴보았다. 이번 절에서는 UI 앱이 UI 프레임워크와 어떻게 연동되어 동작할 수 있는지 큰 그림 수준에서 이해해 보도록 하자.


    일반적으로 UI 앱은 UI 프레임워크에서 제공하는 다양한 기능을 사용하기 위해 UI 프레임워크의 엔진(이하 UI 엔진)을 초기화하고 가동하는 작업을 수행해야 한다. 이러한 작업은 앱 프로세스 내에서 유효하기 때문에, 앱 프로세스가 시작되면 반드시 이러한 작업은 요청되어야 한다. 우리는 C, C++, 자바 등 현대의 대표적인 프로그래밍 언어에서 main() 함수가 프로그램의 시작점임을 알고 있다. 이러한 언어를 사용하는 환경이라면,  main() 함수 호출 이후에는 반드시 UI 엔진을 초기화하는 호출이 존재해야 한다.


    여기서 UI 엔진은 UI 컨트롤을 포함하여 UI를 화면에 출력하기 위한 여러 주요 기능을 수행하는 UI 프레임워크의 핵심 모듈이라고 볼 수 있다. 물론, 앱 개발자가 UI 엔진의 자세한 사항을 모를지라도 UI 앱 개발에 있어서 별다른 문제는 되지 않아야 한다. 하지만 만약 앱 개발자가 UI 엔진의 동작 원리를 이해하고 있다면, UI와 관련된 문제 해결은 물론, 앱 최적화 측면에서 도움이 될 가능성은 있다. 한편, 사용하기 좋은 UI 프레임워크일수록 앱 개발자는 UI 엔진 내부의 동작 방식에 간섭을 받지 않고 더욱더 자유로운 방식으로 쉽고 빠르게 UI 앱을 개발할 수 있어야 한다. 일반적으로 UI 엔진은 UI 앱 로직과는 별개로 UI 앱의 그래픽 출력을 위해 복잡한 로직과 연산 처리를 무대 뒤에서 열심히 수행하는 것은 물론, UI 앱의 어떠한 동작 시나리오에 대해서도 문제없이 가장 빠른 방식으로 동작할 수 있어야 한다.


    /* * UIEngine은 UI 컨트롤의 기능을 구동하는 모듈이다. * UIEngine이라는 명칭은 임의로 정했기 때문에 실제로는 UI 프레임워크 특성에 따라 * 특수한 이름을 사용할 수 있다. */ main(): UIEngine.init() //엔진 초기화 UIEngine.run() //엔진 가동. 내부에서 메인루프(MainLoop)가 가동한다. UIEngine.term() //엔진 종료

    코드 12: UI 엔진 초기화 및 가동

    코드 12는 UIEngine을 초기화, 가동, 종료하는 코드이다. init()에서는 UI 엔진이 사용하는 리소스를 불러오고 엔진을 원활히 구동할 수 있도록 준비 작업을 수행한다. run()에서는 init()에서 준비한 리소스를 가지고 실제 엔진을 가동한다. 그 이후에는 UI 엔진이 종료 요청을 받기 전까지 메인루프를 통해 run()을 계속 가동해야 한다. 만약 run() 메서드가 종료된다면 앱 프로세스 역시 main() 함수와 함께 종료될 것이다. 이 경우, 앱의 UI는 화면에서 지속될 수가 없다. run() 내부적으로 메인루프를 가동하면서 매 사이클이 반복될 때마다 지정된 어떤 작업을 수행한다. term()는 run()이 끝난 앱의 종료 시점에 호출되며 엔진에서 사용한 리소스를 모두 정리하는 작업을 수행한다. UI 엔진은 UI 컨트롤 등 사용자가 사용한 리소스가 term() 호출 시점에서 남아있다면 내부적으로 알아서 정리해줘야 한다.


    일부 플랫폼에서 코드 12과 같은 UI 엔진을 초기화, 가동, 종료하는 호출이 없다고 놀랄 필요가 없다. 일반적으로 앱을 구동하는 여러 플랫폼에서는 UI 엔진을 더욱 추상화한다. 플랫폼은 UI 엔진의 존재를 감추고 앱 개발자가 필요한 UI 컨트롤을 바로 사용할 수 있도록 코드 템플릿을 제공하거나 앱 프레임워크를 보강하여 제공한다. 결과적으로, main() 함수에서 직접 UI 엔진을 초기화, 가동, 종료하는 코드는 사용되지 않을 가능성이 크다. 일반적으로 플랫폼에 종속된 앱은 UI 엔진뿐만 아니라 사운드, 네트워크 등의 추가 라이브러리와 엔진 그리고 서비스를 동시에 사용하기 때문에 UI 엔진의 초기화 작업 및 가동은 앱 프레임워크 내부에서 일괄적으로 처리해 줄 수 있다. 코드 13는 이러한 부분을 추상화한 UIApp 클래스를 이용하는 예시이다.  


    main(): myApp = UIApp(): .init() .run() .term() /* * 앱 프레임워크에서 제공하는 기능이며 클래스 및 메서드 이름은 임의로 정했다. */ UIApp: /* UI 엔진 뿐만 아니라 여러 라이브러리 및 서비스를 초기화 한다. */ init(): ... UIEngine.init() ... /* UIEngine.run() 전후로 여러 기능이 수행될 수 있지만 UI 엔진을 가동하는 것이 핵심이다. */ run(): ... UIEngine.run() ... /* 마찬가지로 UI 엔진 뿐만 아니라 여러 라이브러리 및 서비스를 종료한다. */ term(): ... UIEngine.term() ...

    코드 13: UIApp 클래스를 활용한 main() 작성


    UIApp.run()이 호출되었다고 가정하자. 앱을 가동하면 앱은 첫 화면으로 무언가를 출력해야 한다. 3절에서 살펴보았던 버튼 구성 방식으로 여러 UI 컨트롤을 화면에 배치한다면 첫 화면 구성은 가능하다. 다만, 그전에 우리는 UI 컨트롤을 배치할 앱의 윈도우(Window)가 필요하다. 사실, 윈도우는 플랫폼마다 정의 및 특성이 조금씩 다를 수 있다. 안드로이드 경우에는 하나의 앱이 여러 뷰(View), 정확하게는 액티비티(Activity)를 보유할 수 있으며 액티비티마다 윈도우를 할당하는 반면, MS 윈도우나 리눅스의 전통 윈도우 시스템에서는 하나의 앱이 하나의 윈도우를 보유하며 윈도우에서 여러 뷰를 구성한다. 하지만, 지금은 윈도우가 디스플레이 장치에서 앱이 출력될 위치 및 크기를 결정하는 출력 영역 정도로 이해해도 무방하다. 일반적인 데스크탑 환경을 이용해 보았다면 윈도우 개념은 아주 어렵지 않을 것이다.


    그림 10앱은 윈도우를 통해 화면 출력 영역을 결정한다 (Enlightenment)

    앱의 첫 화면을 위해 윈도우를 생성하고 UI 컨트롤을 배치하는 작업은 run()의 무한루프가 본격적으로 수행되기 전에 수행되어야 할 것이다. 그렇다면, UIApp.init(), UIApp.run() 사이에서 수행하면 될까? UIEngine을 직접 사용한다면 init()과 run() 사이가 맞지만 UIApp 기반에서는 init()과 run() 사이에서 다른 추가 작업이 수행될 수도 있기 때문에 UIApp 클래스는 사용자에게 UI를 생성할 시점을 알려주는 것이 더 유리하다. 이를 위해, UIApp 클래스는 create() 인터페이스를 제공함으로써 앱이 UI를 구축할 수 있는 기회를 제공할 수 있다.


    사실 이러한 동작 인터페이스는 앱의 라이프사이클(Life-Cycle)과도 연관 있다. 앱의 라이프사이클 크게, 생성(Create), 가동(Running), 일시 정지(Pause), 재개(Resume), 종료(Terminate) 등의 동작으로 범주를 나눌 수 있는데 이러한 동작은 시스템의 지배를 받는다. 특히 모바일 앱의 경우, 모바일 특성으로 인해 앱이 시스템의 지배를 받는 경우가 더욱 분명히 드러난다. 예를 들면, 앱이 가동 중 전화가 발생하여 일시 정지(Pause)될 수가 있다. 이 경우 앱은 가동 중인 작업을 임시 중단시킨 후, 대기 모드로 돌입해야 한다. 또 다른 예로는, 사용자가 작업 관리자(Task Manager)를 통해 앱을 강제 종료(Terminate)할 수 있다. 이 경우 앱은 처리 중인 리소스를 정리한 후 자기 자신을 정상 종료해야 한다. 이처럼 라이프사이클과 관련된 시스템 요청은 앱의 생명 및 동작에 절대적 영향을 미치며 앱은 라이프사이클의 신호에 적절한 동작을 수행해야 한다.


    그림 11: 앱 라이프사이클 모델


    이 같은 시나리오를 고려하여 애플리케이션 프레임워크는 앱 개발자가 앱 라이프사이클에 맞는 동작을 구현할 수 있는 인터페이스를 제공한다. 앱이 첫 화면을 구성해야 하는 시점(Create) 역시 이러한 라이프사이클의 일부분으로써 제공할 수 있다. 다음 예제 코드는 애플리케이션 프레임워크가 UIApp과 UIAppLifeCylcle을 통해 라이프사이클 인터페이스를 제공하는 예시를 보여준다. 앱은 UIApp 클래스를 확장하여 라이프사이클의 동작을 구현할 수 있다.

    /* * AppLifeCycle은 앱 라이프사이클 인터페이스를 제공한다. * UIAppLifeCycle은 AppLifeCycle의 라이프사이클을 구현한다. * UIAppLifeCycle은 라이프사이클에 맞춰 UIApp의 동작을 제어한다. * 이 예제에서는 대표적으로 create(), destroy(), pause(), resume() 상태만 언급한다. */ UIAppLifeCycle implements AppLifeCycle: UIApp app //UIApp 인스턴스를 통해 앱의 실제 라이프사이클을 제어한다. /* * 생성자 */ constructor(UIApp app): .app = app /* * 앱이 최초 생성될 때 1회 호출된다. */ override create(): //다형성 특성을 통해 MyApp의 create()가 호출된다. .app.create() /* * 앱이 종료를 요청받을 경우 호출된다. * 앱 관리자에 의해 강제 종료되는 경우가 해당한다. */ override destroy(): //다형성 특성을 통해 MyApp의 destroy()가 호출된다. .app.destroy() /* * 앱이 백그라운드(Background)로 전환되거나 일시 정지될 경우 호출된다. * 갑자기 걸려온 전화로 전화 앱으로 전환되는 경우가 하나의 예이다. */ override pause(): //다형성 특성을 통해 MyApp의 pause()가 호출된다. .app.pause() /* * 앱이 포어그라운드(Foreground)로 전환되는 경우 호출된다. */ override resume(): //다형성 특성을 통해 MyApp의 resume()이 호출된다. .app.resume() /* * 라이프사이클 동작을 위한 UIApp도 재정의하자. */ UIApp: /* * 앱 구동에 필요한 사전 작업을 수행한다. * 첫 화면을 구성할 수도 있다. */ create(): ... /* * 앱이 종료되므로 사용한 리소스를 정리한다. */ destroy(): ... /* * 동작 중인 무언가가 있다면 일시 정지한다. * 애니메이션 등 불필요한 과도한 출력 처리는 여기서 정지하는 것이 좋다. */ pause(): ... /* * 일시 정지한 무언가가 있다면 재개한다. */ resume(): ... /* 아래는 외부에서 접근 가능한 메서드다. */ /* * UI 엔진뿐만 아니라 여러 라이브러리 및 서비스를 초기화한다. */ init(): /* UIAppLifeCylcle를 통해 UIApp 구현을 AppCore로 전달한다. */ lifeCycle = UIAppLifeCycle(self) /* App 코어 기능을 담당하는 모듈로 lifeCycle 정보를 전달한다. AppCore는 시스템 이벤트 발생 시 전달받은 lifeCycle의 인터페이스를 통해 라이프사이클과 관련된 이벤트를 발생시킨다. */ AppCore.registerLifeCycle(lifeCycle) ... UIEngine.init() ... /* * UIEngine.run() 전후로 여러 기능이 수행될 수 있지만, UI 엔진을 가동하는 것이 * 핵심이다. */ run(): UIEngine.run() ... /* * 마찬가지로 UI 엔진뿐만 아니라 여러 라이브러리 및 서비스를 종료한다. */ term(): UIEngine.term() ...

    코드 14UIAppLifeCycle과 UIApp 구현

    /* * 앱은 UIApp을 확장하여 MyApp 클래스를 구현한다. UIApp의 인터페이스를 override * 함으로써 앱 라이프사이클에 맞는 기능 동작을 수행한다. 이러한 인터페이스 구현은 * 애플리케이션 프레임워크로부터 호출된다. */ MyApp extends UIApp: UIWindow myWnd //윈도우 객체를 보관할 인스턴스 /* * 앱 개발자는 여기서 첫 화면을 구성한다. */ override create(): .myWnd = UIWindow(): //윈도우 생성 .title = “My Window” //윈도우 타이틀 .size = {400, 400} //윈도우 크기 //윈도우 생성 후 필요한 UI 컨트롤을 추가로 생성한다... myBtn = UIButton(): //버튼 생성 .text = “Exit” //버튼이 출력할 텍스트 .geometry = {50, 50, 100, 100} //버튼 위치 및 크기 //버튼 클릭 시 앱 종료

    f = closure(UIButton obj, MyApp app, ...):

    app.exit()

    .EventClicked += f override destroy(): /* 앱 종료 시 생성한 윈도우를 제거한다. UIEngine.term()에 의해 자동으로 수행될 수 있으므로 사실 필수는 아니다. */ .myWnd = null override pause(): .myWnd.visible = false //윈도우를 숨긴다... 꼭 필요할까? override resume(): .myWnd.visible = true //윈도우를 나타낸다… 꼭 필요할까? main(): app = MyApp(): .init() .run() .term()

    코드 15 UIApp을 확장한 MyApp 구현 코드

    그림 12: 라이프사이클 클래스 호출 도식화


    코드에 대한 전반적인 설명은 주석으로 대체하였다. 예제에서는 생략했지만, 코드 16의 19번째 줄을 보면 버튼을 생성하는데 하나의 앱이 복수의 윈도우를 보유할 수도 있기 때문에 생성하는 버튼이 어느 윈도우에서 출력되어야 하는지 명시해야 할 수도 있다. 이 경우 버튼의 생성 인자로 윈도우 객체를 전달하는 것도 방법이다. 두 번째로 26번째 줄을 보면 .exit()를 통해 앱 종료를 요청하는 작업을 볼 수 있는데 실제로 UIApp.exit()는 UIEngine이 메인루프를 중단하는 작업을 요청한다.


    UIApp.exit(): ... UIEngine.stop() //UIEngine의 동작을 중단한다. ...

    코드 17: UIApp.exit() 코드

    마지막으로, pause()와 resume()에서 윈도우를 숨기고 나타내는 작업을 앱이 직접 요청하고 있는데 이러한 호출이 필수인지 의문이 들 수도 있다. 만약 앱이 직접 호출해야 한다면 일시 정지 시 악의적으로 윈도우를 숨기지 않을 수도 있기 때문에 사실 이 부분은 앱의 역할이라기보다는 윈도우를 관장하는 윈도우 매니저(Window Manager)의 작업이 더 적절할 수도 있다. 여기서는 윈도우 매니저가 다소 생소한 독자들을 위해 앱이 직접 윈도우의 visible()을 호출하도록 코드를 남겨두었다.


    실제로 플랫폼의 앱 라이프사이클의 정의와 실제 동작 시나리오는 본 예제보다 훨씬 더 복잡할 것이다. 여기서는 라이프사이클 모델을 최대한 단순화하여 기본 개념만 이해하고 넘어가도록 한다. 다음 그림은 윈도우10 UWP(Universal Windows Platform) 앱의 대표 라이프사이클을 보여준다.


    그림 13: 윈도우10 UWP 앱 라이프사이클

    이쯤에서 UIEngine의 메인루프를 도식화해보자. 실제 메인루프에서 수행해야 하는 작업은 훨씬 더 복잡할 테지만 지금까지 설명한 내용을 토대로 한다면, 대략 다음과 같은 작업을 예상할 수 있다.


    그림 14: UIEngine 메인루프

    UIEngine.run()으로 수행되는 메인루프는 매 사이클마다 사용자의 입력을 비롯한 새로운 이벤트가 발생했는지 확인한 후, 이벤트가 존재한다면 이벤트를 처리한다. 그리고 이벤트에 의해 UI 객체(UI 컨트롤)에 변화가 발생했는지 확인한다. UI 객체에 변화가 있다면 이를 반영하여 렌더링(Rendering)을 수행하고 최종적으로 앱의 화면을 갱신한다. 이러한 절차의 작업은 UIEngine.stop()이 호출될 때까지 지속해서 반복된다.



    3. 스케일러블 UI

    UI 앱이 다양한 기기와의 호환성을 갖추기 위해서는 UI 프레임워크와 앱은 기기 특성을 프로파일별로 분류하고 이에 맞춰서 해상도는 물론 DPI(Dots Per Inch)를 고려한 스케일러블 UI를 설계하고 제작해야 한다. 여기서 스케일러블 UI(Scalable UI)란 다양한 해상도 및 크기의 화면에 대응하는 UI를 의미한다. 단순한 문제는 아니지만 스케일러블 UI를 고려함으로써 모바일 기기, 데스크톱, TV 등 프로파일별로 여러 해상도의 기기와 호환성을 갖춘 UI를 표현할 수 있다.


    UI 프레임워크는 앱 개발자들이 스케일러블 UI 문제를 더욱더 쉽고 빠르게 대응하기 위한 발판을 마련해 줘야 한다. 스케일러블 UI의 원칙을 정의하고 이를 위한 견고한 인터페이스와 가이드를 제공하여 앱 디자이너는 물론 개발자들이 더 직관적이고 단순한 설계로 호환성을 갖춘 UI를 구현할 수 있도록 도와줄 수 있다.



    3.1 절대 좌표계


    스케일러블 UI를 구현하기 위해서 우리는 먼저 UI 좌표계를 이해할 필요가 있다.


    • 절대 좌표계

    절대 좌표계(Absolute Coordinates)는 원점(origin)으로부터 지정한 거리만큼 떨어진 픽셀 위치를 가리킨다. 일반적으로 앱에서 원점은 앱 화면의 좌측 상단에 해당하며 UI 시스템 특성에 따라 화면의 좌측 하단 또는 화면 중심이 원점이 될 수도 있다. 이때 수치 단위는 픽셀에 해당하며 서브픽셀 정밀도(sub-pixel precision)가 가능한 UI 시스템이면 소수점 이하로도 좌표 지정이 가능하다. 예시로 코드 17은 버튼의 위치, 크기(geometry)를 절대 좌표로 지정한다.


    myBtn = UIButton(): //버튼 생성 .geometry = {50, 50, 100, 100} //버튼 위치 및 크기 (단위는 픽셀이다.)

    코드 17: 버튼 생성

    코드 17에서 버튼은 앱 화면 원점을 기준으로 (50, 50) 떨어진 픽셀 위치에서 100x100픽셀 크기로 출력된다. 절대 좌표계는 추가 변환 작업을 거치지 않아 직관적이기 때문에 특정 해상도에서 정확한 위치를 지정할 경우 쉽게 구현할 수 있다. 문제는 단위가 픽셀인 까닭에 앱 화면 크기가 가변적인 경우 절대 좌표계는 유용하지 않을 수 있다. 하나의 사례를 다음 그림에서 확인할 수 있다.

    그림 15화면 크기별 절대 좌표 출력 결과


    그림 1.15를 보면, 200x200 해상도와 같이 앱 의도는 버튼을 화면 중앙에 배치하지만 300x300 해상도에서는 버튼이 좌측 상단으로 치우치면서 UI 심미성을 훼손시킨다. 더 큰 문제는 100x100 해상도에 있는데 이 경우 사용자는 버튼 용도조차 확인할 수가 없다. 결과적으로 절대 좌표계는 앱 개발 관점에서 직관적이고 적용하기 쉬운 장점이 있으나 스케일러블 UI를 제공하지 못하므로 고정 환경의 전용 앱 개발이 아니라면 절대 좌표계보다 더 나은 방법을 이용하여 UI를 구성해야 한다.


    • 정규 좌표계

    절대 좌표계의 대안으로서 정규 좌표계(Normalized Coordinates)는 좌표 단위가 픽셀이 아닌 정규화된 범위이다. 예를 들면 화면 크기 가로, 세로의 범위가 각각 0 ~ 1 사이의 범위로 정규화되었다고 가정하자. 이때 좌표 (0, 0)은 화면 좌측 상단 꼭짓점에 해당하고 (0.5, 0.5)는 화면의 중앙, (1, 1)은 화면 우측 하단 꼭짓점에 해당한다. 정규 좌표를 이용하면 앱 개발자는 임의의 화면 크기에 대응하는 UI 객체를 배치할 수 있으며 절대 좌표계의 고정 UI 문제점을 개선할 수 있다.

    정규 좌푯값을 구하는 식은 단순하다. 배치하고자 하는 UI 객체의 절대 좌푯값을 앱의 화면 크기로 나눠주면 정규화된 좌푯값을 계산할 수 있다.


    그림 16정규 좌표계식 (nx: 화면 가로 크기,ny: 화면 세로 크기)


    다음 코드는 정규 좌표계를 이용한 버튼의 지오메트리를 지정하는 예이다.


    //좌측 상단 꼭짓점 위치: (50/200, 50/200), 우측 하단 꼭짓점 위치: (150/200, 150/200) myBtn.relative = {0.25, 0.25, 0.75, 0.75}

    코드 18: 정규  좌표값을 이용한 버튼 배치

    그림 17정규 좌표를 이용한 버튼 배치 


    코드 18와 같이 정규 좌푯값을 이용하면 화면 해상도에 비례하는 버튼의 크기를 지정할 수 있다. 정규 좌표계의 경우 화면 해상도가 높은 기기에서는 버튼 크기가 커지고 낮은 경우에는 버튼 크기가 작아진다. 만약 해상도가 200x200인 경우 버튼의 위치는 (50, 50), 크기는 100x100이 되며 해상도가 300x300인 경우 버튼 위치는 (75, 75), 크기는 150x150이 된다. 결과적으로 버튼은 해상도에 상관없이 화면 영역 (0.25, 0.25) 위치부터 (0.75, 0.75) 위치까지의 영역에 배정된다.

    그림 18해상도별 정규 좌표 출력 결과


    스케일러블 UI를 구현하기 위해 정규 좌표계를 무조건 적용하는 것이 만사는 아니다. 정규 좌표계는 대체로 위치 및 레이아웃 영역을 지정할 때 유용하지만 콘텐츠의 크기는 고정 크기가 적합할 경우가 많다. 예로 다음 그림과 같이 아이콘의 크기를 정규 좌푯값으로 배정하면 이미지가 훼손된다.


    그림 19: 정규 좌표의 오용 예


    그림 19처럼 앱 UI를 구성하다 보면 콘텐츠의 종횡비를 유지하거나 크기를 고정해야 하는 경우가 빈번히 발생한다. 앱 개발자가 프레임워크의 가이드를 무시한 경우가 아니라면 UI 컨트롤은 예외 경우일지라도 외양이 훼손되지 않아야 하고 앱 개발자는 이러한 문제로부터 자유롭게 UI 기능을 이용할 수 있어야 한다. UI 컨트롤을 설계하고 구현하는 관점에서는 이 부분을 반드시 염두에 둬야 한다.


    정규 좌표계를 이용하는 경우 UI 엔진이 해야 할 일이 더 많아진다. 렌더링을 수행하기 전 UI 엔진은 사용자가 지정한 UI 컨트롤의 정규 좌푯값을 픽셀 좌푯값으로 변환해야 한다. 이때 픽셀 좌푯값은 앱 화면 해상도 내지 레이아웃 크기를 기준으로 결정한다. 


    /*

    * UI 컨트롤의 기저(base) 클래스에 해당하며 기본 동작 및 속성을 구현한다.

    */ UIObject:

    Geometry geometry = {0, 0, 0, 0} //오브젝트 지오메트리 (절대 좌푯값)

    BoundBox relative = {0, 0, 0, 0} //오브젝트 지오메트리 (정규 좌푯값)

    Bool useRelative= false

    ...

    /*

    * 객체의 출력 영역을 정규 좌푯값으로 지정

    * @p x1: Var

    * @p y1: Var

    * @p x2: Var

    * @p y2: var

    */

    relative(x1, y1, x2, y2):

    .relative= {x1, y1, x2, y2} //새로운 좌표 저장

    //이제부터 정규 좌푯값을 이용

    .useRelative = true

    /*

    * 절대 좌표계를 이용할 경우에는 useRelative를 off 한다.

    */

    relative(x1, y1, x2, y2):

    ...

    .useReleative= false

    /*

    * 오브젝트를 새로 갱신한다.

    * UI 엔진의 메인루프로부터 매 루프마다 호출된다고 가정한다.

    */

    update(...):

    /* 정규 좌푯값이 변경되었거나 캔버스 사이즈가 바뀌면 정규 좌표계식을 이용하여 지오메트리의 픽셀 좌푯값을 새로 갱신한다.

    여기서 canvas는 오브젝트 출력 영역이다. update()의 인자로 전달되었거나 UIObject의 속성 데이터라고 가정한다.

    객체의 지오메트리는 빈번히 바뀌지 않음으로 한번 계산한 픽셀 좌푯값은 캐싱하여 재사용할 수도 있다. */

    if(.useRelative)

    .geometry.x = (canvas.size.w - 1) * .relative.x1

    .geometry.y = (canvas.size.h - 1) * .relative.y1

    .geometry.w = (canvas.size.w - 1) * .relative.x2 - .geometry.x

    .geometry.h = (canvas.size.h - 1) * .relative.y2 - .geometry.y

    ...

    코드 19정규 좌푯값으로부터 픽셀 좌푯값 계산

    • 상대 좌표계

    상대 좌표계(Relative Coordinates)는 좌표의 원점을 특정 객체로 지정한다. 컨테이너와 같이 어떤 UI 컨트롤이 특정 컨트롤의 위치와 크기에 의존하는 경우 상대 좌표계는 유용하다. 기본적으로 상대 좌표계는 정규 좌표계와 함께 사용될 수 있으며 상대 좌표를 지정하기 위해서는 상대를 지정하는 인터페이스가 제공되어야 한다.


    /* 이미지 생성. 좌표는 앱 출력 영역 기준 */

    myImg = UIImage():

    .path = "./res/star.png"

    .relative = {0.0, 0.0, 0.25, 0.25}

    /* 이미지 생성. 좌표는 myImg 기준 */

    myImg2 = UIImage():

    .path = "./res/star.png"

    .relativeTo = myImg //상대 좌표 대상 지정

    .relative = {1.0, 0.0, 2.0, 1.0}

    코드 20: 상대 좌표 구현 예


    그림 20코드 1.20 출력 결과 도식화


    다양한 유스케이스를 충족하기 위해 relativeTo()를 세분화하고 x1, y1(relative1)과 x2, y2(relative2)에 대해 상대 좌표 대상을 지정할 수 있는 인터페이스를 제공하는 것도 고려해볼 만하다.


    myObj.relative1To = target1 //좌측 상단 꼭지점 상대 좌표 대상 지정

    myObj.relative1 = {1.0, 1.0} //target1의 우측 하단 꼭짓점을 가리킨다.

    myObj.relative2To = target2 //우측 하단 꼭짓점 상대 좌표 대상 지정

    myObj.relative2 = {0.0, 0.0} //target2의 좌측 상단 꼭짓점을 가리킨다.

    코드 21: 상대 좌표 지정 인터페이스 세분화


    그림 21코드 1.21 출력 결과 도식화


    구현 단계에서 상대 좌표 대상 객체의 크기 및 위치를 결정할 수 없는 경우 상대 좌표는 더욱더 유용하다. 예를 들면, 길이가 가변적인 텍스트의 우측에 어떤 아이콘을 배치한다고 가정해 보자. 구현 단계에서는 텍스트 길이를 알 수 없음으로 아이콘의 위치를 결정할 수 없다. 이 경우 상대 좌표는 필요하다.


    그림 22상대 좌표가 필요한 경우



    3.2 크기 제약

    • 텍스트 축약

    3.1절에서 UI 컨트롤은 예외 경우일지라도 외양이 훼손되지 않아야 한다고 언급했다. 이 문제를 조금 더 자세히 짚어보기 위해 코드 1.18를 다시 확인한다. 다만 이번엔 해상도를 100x100으로 줄여서 출력물을 확인한다.


    그림 23100x100 해상도에서의 상대 좌표 버튼 출력 결과


    그림 23에서 확인할 수 있듯이 상대 좌표계에서도 문제점이 발생했다. 상대 좌표를 이용하여 버튼의 크기를 의도한 대로 축소했지만 버튼 텍스트는 줄어들지 않아서 텍스트가 버튼 영역을 벗어났다. 사실 위 문제는 버튼 텍스트를 … 과 같이 생략하고 텍스트 길이를 줄임으로써 해결할 수도 있다. 일반적으로 UI 프레임워크에서는 텍스트 축약(ellipsis) 기능을 제공하여 출력 영역이 부족할 경우 텍스트를 자동으로 생략하는 기능을 제공한다.


    myBtn.textEllipsis = true

    코드 22: 텍스트 축약 활성화


    그림 24텍스트 축약 적용 결과


    텍스트 축약은 본 문제를 해결할 방법으로 보이지만 깊이 생각해 보면 UI 컨트롤 안의 내용물이 텍스트만 존재한다고 가정할 수도 없을뿐더러 글자 길이를 줄이는 방식이 항상 옳은 것도 아니다. 예로 그림 1.25의 버튼을 보면 버튼 텍스트가 무얼 전달하고자 하는지 사용자는 이해하기 어렵다.


    그림 25텍스트 축약 문제


    그림 25는 온라인상에서 물건 구매를 위한 예시 화면이다. 왼편의 정상적인 화면과 달리 오른편 화면의 경우 영역의 부족으로 하단 버튼의 텍스트가 축약 처리되었다. 이 경우 사용자는 어떤 버튼을 눌러야 구매 확정 또는 예약되는지 알 수 없다. 이 시나리오는 예시일 뿐 여러 유사한 문제는 많이 존재한다. 참고로 이 문제는 부족한 영역만큼 폰트 크기를 줄이거나 툴팁(Tooltip)을 제공하여 사용자가 버튼의 텍스트 전체 내용을 확인할 수도 있다.


    • 가변 영역과 고정 영역

    더불어 우리는 본 문제의 해결책을 조금 더 근본적으로 접근하기 위해  UI의 크기 제약에 대해 이해해 보고자 한다. 이를 위한 예시로 다음과 같은 원형 모서리의 버튼이 있다.


    그림 26: 버튼 UI


    그림 26의 버튼은 아이콘과 텍스트 보조 콘텐츠를 제공할 뿐만 아니라 버튼 모서리가 원형 처리되어서 보다 부드러운 느낌을 제공한다. 버튼의 아이콘과 텍스트는 일단 제외하더라도 여기서 만약 버튼의 가로, 세로 크기가 바뀐다면 어떨까? 


    정규 좌표계를 이용하여 배치한 버튼이라면 영역 변화에 따라 버튼의 모습이 그림 27처럼 바뀔 수 있다. 그리고 결과적으로 버튼 모서리 외양에 문제가 발생했는데 이유는 버튼 크기 변경 시 버튼 원형 모서리의 종횡비(Aspect Ratio)를 무시했기 때문이다. 이를 위한 대안으로써 하나의 UI 요소에서 가변 영역(Stretchable Area)과 변경이 되면 안 되는 고정 영역(Fixed Area)을 정의한다. 가변 영역은 스케일러블한 영역으로써 크기 변경이 가능하고 고정 영역은 크기 조정이 불가능한 영역이다. 고정 영역에 변화가 발생하면 UI의 외양이 훼손되거나 사용에 문제가 발생하기 때문에 절대 크기를 보장해야 한다. 다시 말하자면 UI 컨트롤의 크기에 상관없이 항상 동일한 크기 내지 일정한 종횡비의 크기를 보장해야 한다. 


    그림 27크기 변화로 인해 훼손된 외양


    정규 좌표계를 이용하여 배치한 버튼이라면 영역 변화에 따라 버튼의 모습이 그림 27처럼 바뀔 수 있다. 그리고 결과적으로 버튼 모서리 외양에 문제가 발생했는데 이유는 버튼 크기 변경 시 버튼 원형 모서리의 종횡비(Aspect Ratio)를 무시했기 때문이다. 이를 위한 대안으로써 하나의 UI 요소에서 가변 영역(Stretchable Area)과 변경이 되면 안 되는 고정 영역(Fixed Area)을 정의한다. 가변 영역은 스케일러블한 영역으로써 크기 변경이 가능하고 고정 영역은 크기 조정이 불가능한 영역이다. 고정 영역에 변화가 발생하면 UI의 외양이 훼손되거나 사용에 문제가 발생하기 때문에 절대 크기를 보장해야 한다. 다시 말하자면 UI 컨트롤의 크기에 상관없이 항상 동일한 크기 내지 일정한 종횡비의 크기를 보장해야 한다. 


    그림 28가변 영역과 고정 영역


    • 이미지 나인패치

    가변 영역과 고정 영역을 활용한 방법으로 이미지 나인패치(9-patch)가 있다. 이미지 나인패치는 하나의 완성된 이미지를 위해 9장의 이미지 리소스를 따로 준비한 후 응용 단계에서 이들을 결합하는 방식이다. 핵심은 아홉 장의 이미지를 결합하여 배치한 후 이미지의 크기 바뀔 때마다 아홉 장의 이미지의 위치와 크기를 재계산하여 배치하는 것이다.


    그림 29이미지 나인패치

    이미지 나인패치는 효과적이지만 한 장의 완성된 이미지를 위해 아홉 장의 부분 이미지를 준비해야 한다는 점에서 다소 부담이 될 수 있다. 만약 프레임워크에서 이미지의 고정 영역을 지정할 수 있는 기능을 제공한다면 사용자의 편의를 향상할 수 있다. 다음 코드는 이러한 기능을 예시로써 보여준다.

    myImg = UIImage():

    .path = "./res/button.png"

    .fixedRegion = {w1, w2, h1, h2} //고정 영역 지정(좌, 우, 상, 하)

    .size = {200, 200} //고정 영역은 크기 변경에 영향을 받지 않는다.

    코드 23: 이미지 고정 영역 지정


    고정 영역을 지정하기 위해서는 이미지를 대상으로 좌, 우, 상, 하 영역의 크기를 지정한다. fixedRegion을 통해 고정 영역을 지정하면 렌더링 엔진에서는 이미지의 크기를 변경하는 단계에서 고정 영역은 제외한 영역만 크기를 변경하여 나인패치와 동일한 효과를 본다.


    동일한 개념으로써 안드로이드 시스템의 경우 가변 영역(Stretchable area)을 이미지 리소스에 직접 기재할 수 있다. 이를 위해 그림 1.30과 같이 이미지 리소스 좌측과 상단 모서리에 블랙 색상을 기록한다. 추가 코드 작성 없이 고정 영역과 가변 영역을 구분함으로 프로그래밍과 디자인 작업의 의존을 분리할 수 있다. 물론 이미지를 불러오는 과정에서 렌더링 엔진이 모서리의 블랙 픽셀값 길이를 통해 고정, 가변 영역을 구분하고 이미지 스케일링 과정은 동일하다.


    그림 30이미지 가변 영역 (안드로이드)


    • 최소/최대 크기

    UI 컨트롤 디자인 시 컨트롤 컨셉에 맞게 고정 크기 영역을 정의해야 한다. 이때 컨트롤에 존재하는 고정 크기 영역의 합을 컨트롤의 최소 크기로 정의할 수 있다.


    그림 31: 버튼 최소 크기


    버튼 아이콘의 경우 종횡비를 유지하는 선에서 버튼 크기에 따라 크기 조절이 가능하지만, 컨셉에 따라 크기를 고정할 수 있다. 기본적으로 텍스트 길이는 가변적이지만 어떠한 경우라도 텍스트를 온전히 출력해야 한다면 이 역시 버튼의 최소 크기에 영향을 준다. 그림 32는 이러한 사례를 도식화한다. 만약 버튼의 텍스트에 일립시스를 적용한다면 최소한 … 출력 크기는 버튼의 최소 영역에 포함해야 한다.


    그림 32아이콘, 텍스트를 포함한 버튼 최소 크기


    UI 컨트롤의 최소 크기를 결정하는 데 있어서 전제 조건은 사용자가 어떻게든 입력을 할 수 있어야 한다는 점이다. 예를 들어, 터치스크린 환경에서 버튼의 크기가 너무 작아 사용자가 정확한 터치를 할 수 없다면 본 컨트롤은 바람직하지 못하다. 스케일러블 UI에서 컨트롤의 크기는 가변적이므로 UI 엔진에서는 이를 스마트하게 보완할 수 있어야 한다. 컨트롤이 결정한 최소 크기와 시스템 환경으로 지정된 최소 크기 (손가락으로 터치할 수 있는 최소 크기)를 비교하고 두 값 중 큰 값으로 최소 크기를 결정하는 것이다.


    최소 크기와 반대로 최대 크기는 UI의 최대 크기를 명시함으로써 크기 제약을 설정한다. 최소, 최대 크기는 특히 정규 좌표계에서 유용한데 영역이 가변적인 경우 크기에 제약을 둠으로써 UI가 훼손될 수 있는 경우를 미연에 방지한다. 


    /* 오브젝트의 최소, 최대 크기 지정. 최소, 최대 크기는 다른 어떠한 크기 입력보다 우선순위가 높다. */

    obj.minSize = {100, 100}

    obj.maxSize = {1000, 1000}

    ...

    /* 아래 요청한 크기는 최소, 최대 크기를 벗어나므로 허용하지 않는다.

    이 경우 프레임워크는 에러값을 반환하거나 입력한 크기를 최소, 최대 크기 범위 내로 재조정할 수 있다. */

    obj.size = {50, 50}

    obj.size = {2000, 2000}

    코드 24: 최소, 최대 크기 지정


    앞서 버튼 예제를 살펴본 것처럼 UI 컨트롤은 고유의 디자인 개념을 기반으로 가변 영역과 고정 영역, 최소, 최댓값을 정의하고 이를 구현해야 한다. 그렇게 함으로써 앱 개발자가 UI 컨트롤의 개념을 자세히 모를지라도 여러 출력 환경에서 문제가 없도록 동작을 보장한다.


    이제 앞서 배운 스케일러블 UI의 내용을 토대로 버튼 UI를 다음과 같이 구축해 볼 수 있다.


    /*

    * 그림 32의 버튼 UI 구성

    * w1 = 10, w2 = 25, w3 = 가변, w4 = 10, h1 = 10, h2 = 10, h3 = 25, h4 = 가변

    * 코드가 다소 복잡하다면 그림을 그려가면서 이해해 보자.

    */

    composeButtonUI():

    //버튼

    btn = UIButton()


    //배경 이미지

    bg = UIImage():

    .path = "./res/button.png"

    .fixedRegion = {w1, w4, h1, h2} //이미지 고정 영역

    .relativeTo = btn //상대 좌표 대상 지정


    //아이콘

    icon = UIImage():

    .path = ... //앱 개발자가 요청한 이미지 리소스 명시

    .margin = {w1, 0, 0, 0} //좌, 우, 상, 하 여백 설정 (단위는 픽셀)

    .relativeTo = bg //상대 좌표 대상 지정. 아이콘은 bg 영역에 종속

    .align = {0, 0.5} //아이콘 기준은 bg 좌측 중심이 된다.

    .minSize = {25, 25} //아이콘 최소 크기

    .maxSize = {25, 25} //아이콘 최대 크기

    //텍스트 (크기는 텍스트 출력 결과에 의존)

    text = UIText():

    .text = ... //앱 개발자가 요청한 문자열 명시

    .margin = {w1 + w2, w4, 0, 0} //여백 설정

    .relativeTo = bg //상대 좌표 대상 지정. 텍스트는 bg 영역에 종속

    .align = {0, 0.5} //아이콘 기준은 bg 좌측 중심이 된다.

    .fontName = "Sans" //폰트 이름

    .fontSize = 20 //폰트 크기

    //버튼 최소 크기

    w = w1 + icon.size.w + text.length + w4

    h = max(h1 + h2, text.size.h)

    //시스템에서 지정한 최소 크기와 비교. 여기서는 fingerSize라고 명명한다.

    w = if(UIConfig.fingerSize > w) w = UIConfig.fingerSize

    h = if(UIConfig.fingerSize > h) h = UIConfig.fingerSize

    btn.minSize = {w, h}

    코드 25: 버튼 UI 구성


    코드 25를 부연해 설명하자면 19줄의 margin은 UI 객체의 여백을 할당한다. 할당된 여백은 객체의 크기에 포함되므로 실질적으로 아이콘 크기는 35x25와 같다. 21줄의 align은 지정된 영역에서 객체 배치 기준을 지정한다. 인자로 가로, 세로 값을 받으며 0~1 사이의 값을 지정한다. 예제에서는 아이콘의 크기가 35x25이고 배치될 영역은 상대 좌표로 지정된 bg에 해당하므로 아이콘을 bg 영역의 좌측, 가운데로 정렬한 것과 동일하다. 35줄의 text.length()은 최소 크기를 구하기 위해 객체 크기가 아닌 실제 텍스트의 가로 길이를 반환한다.

     



    3.3 컨테이너


    컨테이너(Container)는 화면 구성을 위한 레이아웃(Layout)을 제공한다. 일반적으로 UI 프레임워크는 여러 컨테이너를 제공하는데 컨테이너는 서로 다른 레이아웃 구성을 제공한다. 따라서 앱 개발자는 필요한 컨테이너를 조합하여 앱 화면을 쉽고 빠르게 구성할 수 있다. 특히 앞 절에서 살펴본 컨트롤 크기 제약을 앱 개발자가 충분히 이해하지 않더라도 컨테이너는 컨테이너에 추가된 UI 컨트롤의 특성을 이해하고 최적의 화면 구성을 보장해 준다. 따라서 앱은 스케일러블 UI를 구성하기 위해 UI 컨트롤을 화면에 직접 배치하는 것보다 컨테이너를 활용하는 편이 효율적이다. 참고로 여기서 컨테이너는 UI 시스템 사이에서 규격화된 용어는 아니지만 통용되는 용어로서 어떤 콘텐츠를 담는 목적을 제시한다.


    기본적으로 UI 프레임워크는 크게 비컨테이너(Non-container)와 컨테이너(Container) 두 부류의 UI 컨트롤을 제공한다. 비 컨테이너 경우에는 외양(Visual)을 기반으로 사용자와 상호작용을 수행하는 UI 컨트롤에 해당하며 대표적으로 버튼, 토글(Toggle), 체크박스(Checkbox) 등이 이에 해당한다. 반면 컨테이너는 비컨테이너 컨트롤을 화면에 배치하기 위한 레이아웃 기능 제공한다. 대체로 컨테이너는 외양이 없거나 이를 부수적으로 제공하며 스케일러블 UI를 보장하기 위한 레이아웃 룰과 기능 동작을 구현한다.


    그림 33컨테이너별 레이아웃 구성도


    • 레이아웃

    그림 33과 같이 UI 프레임워크에서 제공할 수 있는 컨테이너 종류는 다양하다. 종류와 기능 정의는 UI 프레임워크마다 조금씩 다르지만, 컨테이너의 핵심 기능은 크게 다르지 않다. 예를 들어 리니어 레이아웃(그림 33 좌측 상단)과 유사 기능을 수행하는 컨테이너는 많은 UI 프레임워크에서 동일하게 제공한다.


    그림 34리니어 레이아웃을 이용한 버튼 배치


    //수직 리니어 레이아웃 생성. 레이아웃은 윈도우 영역에 종속

    myLayout = UIVerticalLinearLayout():

    .relative = {0, 0, 1, 1}

    .relativeTo = .myWnd

    //레이아웃에 버튼1, 2, 3 추가

    .Contain += {

    myBtn1 = UIButton():

    .text = "Button 1"

    myBtn2 = UIButton():

    .text = "Button 2"

    myBtn3 = UIButton():

    .text = "Button 3"

    }

    코드 26: 리니어 레이아웃 사용 예


    코드 26은 수직 리니어 레이아웃을 생성한 후 세 개의 버튼을 순차적으로 레이아웃에 추가한다. 수직 리니어 레이아웃은 종속된 윈도우 크기에 맞춰 영역을 확보하고 영역을 균등히 분할하여 세 개의 버튼을 배치하는 작업을 수행한다. 버튼은 할당받은 영역에 대해 크기와 위치를 조정하고 최종적으로 화면에 출력된다. 결국 위 코드의 세 개의 버튼은 레이아웃 영역을 삼등분하여 동일한 크기로 출력될 수 있다. 수평 리니어 레이아웃의 경우 추가한 버튼을 수평 방향으로 전개한다. 


    여기서 만약 컨테이너에 추가된 버튼 위치와 크기를 각기 다르게 하려면 어떻게 해야할까? 이에 대한 방안으로 UI 프레임워크는 가중치(weight)와 정렬(align)이라는 개념을 도입할 수 있다.


    • 가중치와 정렬

    가중치를 이용하면 UI 객체가 할당받을 수 있는 영역의 비중을 결정할 수 있다. 예를 들어 만약 높이가 200인 수직 리니어 레이아웃에 두 개의 버튼을 추가한다고 가정하자. 여기서 추가하는 두 버튼의 가중치가 모두 1.0이라면 두 버튼이 할당받는 영역의 높이는 100이 된다. 하지만 이 중 하나는 0.5, 다른 하나는 1.0이라면 50과 150의 영역으로 재조정할 수 있다. 만약 세 개의 버튼이 공존하며 하나는 0.5 다른 두 버튼은 1.0이라면 50, 125, 125의 크기로 배정한다. 결과적으로 컨테이너에 있어서 가중치는 컨테이너 영역을 차지하는 UI 컨트롤 간 땅따먹기 싸움과도 같다. 주의할 점은 가중치 값이 0에 수렴하더라도 대상 객체의 최소 크기는 보장해야만 한다. 그렇지 않으면 스케일러블 UI를 보장할 수 없다.


    그림 35 가중치 0.5, 1.0으로 배치한 두 버튼


    프레임워크는 앱 개발자가 가중치를 설정할 수 있도록 다음과 같은 인터페이스를 제공한다.

    //넓이 = 1.0, 높이 = 0.5 가중치 설정

    myBtn.weight = {1.0, 0.5}

    코드 27 가중치 지정 예


    한편, 정렬(Alignment)은 컨테이너가 할당한 영역 내에서 UI 객체를 어느 위치에 둘 건지 결정한다. 만약 UI 객체가 영역을 가득 채우지 않는다면 정렬을 활용하여 UI 객체를 컨테이너 영역 중 어느 한 지점에 배치할 수 있다. 정렬을 명시하지 않을 경우 할당받은 컨테이너 영역에 UI 컨트롤이 가득 채우는 동작을 수행할 수 있다.


    그림 36 콘텐츠 정렬 결과


    myBtn.align = {UIAlignment.Left, UIAlignment.Top} //좌측, 상단 (0, 0)

    myBtn.align = {UIAlignment.Right, UIAlignment.Bottom} //우측, 하단 (1, 1)

    myBtn.align = {UIAlignment.Center, UIAlignment.Center} //가운데 정렬 (0.5, 0.5)

    myBtn.align = {UIAlignment.Fill, UIAlignment.Fill} //정렬 대신 영역을 가득 채운다.

    myBtn.align = {0.25, 0.25} //특정 위치 지정

    코드 28 정렬 지정 예


    다음 코드는 리니어 레이아웃에서 자신 영역에 추가된 콘텐츠 크기를 결정하기 위한 핵심 로직을 구현한다.


    /*

    * 콘텐츠(UI 객체)를 수직 배치하는 컨테이너 기능 수행

    */

    UIVerticalLinearLayout extends UILinearLayout:


    /* 다음 두 속성은 부모 클래스에 정의될 수 있다. */

    UIObject contents[] //컨테이너에 추가된 콘텐츠 목록

    Geometry geometry //컨테이너 지오메트리

    /* 컨테이너가 보유한 콘텐츠의 크기 및 위치를 결정한다.

    여기서는 크기를 결정하는 기본 로직만 구현하고 그 외 로직은 생략한다. */

    update():

    ...

    Size totalSize //컨테이너 최종 크기

    Size totalWeight //컨테이너가 요구하는 가중치 축적치

    Size contentSize[contents.count] //컨테이너에 추가된 콘텐츠 크기

    Bool finished = false //계산 완료 여부


    repeat(finished)

    newSize = {0, 0} //컨테이너 새 크기

    weightSum = 0 //누적된 콘텐츠 weight 합

    idx = 0

    finished = true


    foreach(.contents, content)

    /* 콘텐츠 크기 갱신 */

    content.update()


    /* 가중치는 0 ~ 1 범위 허용 */

    weight = content.weight

    /* 가중치 기반으로 크기 결정 */

    contentSize[idx].w = .geometry.w * weight.w

    contentSize[idx].h = .geometry.h / .content.count * weight.h

    /* 콘텐츠 최소 크기 보장 */

    if(content.minSize.w > contentSize[idx].w)

    contentSize[idx].w = content.minSize.w

    if(content.minSize.h > contentSize[idx].h)

    contentSize[idx].h = content.minSize.h

    /* Layout 크기 결정 */

    if(newSize.w < contentSize[idx].w)

    newSize.w = contentSize[idx].w


    newSize.h += contentSize[idx].h

    weightSum += weight.h

    ++idx


    /* 계산한 컨테이너 크기가 이전 크기보다 크면 최신 기준으로 다시 계산 */

    if(newSize.w > .geometry.w)

    .geometry.w = newSize.w

    finished = false

    if(newSize.h > .geometry.h)

    .geometry.h = newSize.h

    finished = false

    /* 실제 컨테이너 크기 대비 콘텐츠가 요구하는 크기 차를 구한다.

    만약 컨테이너 영역에 여분이 존재하면 남은 영역을 재분배한다. */

    Size diff = {.geometry.w - newSize.w, .geometry.h - newSize.h}

    idx = 0

    /* 콘텐츠 시작 위치 */

    base = .geometry

    foreach(.contents, content)

    /* 최종 가로 크기 결정 */

    contentSize[idx].w = content.weight.w * newSize.w

    if(diff.h > 0)

    contentSize[idx].h += diff.h * (content.weight.h / weight.sum)

    /* 정렬이 Fill인 경우 계산한 크기를 콘텐츠의 크기로 지정 */

    if(content.align.x == UIAlignment.Fill)

    content.geometry.w = contentSize[idx].w

    content.geometry.x = base.x

    /* 콘텐츠 가로 위치 결정 */

    else

    x = (contentSize[idx].w - content.size.w) * content.align.x

    content.geometry.x = (base.x + x)


    /* 정렬이 Fill인 경우 계산한 크기를 콘텐츠의 크기로 지정 */

    if(content.align.y == UIAlignment.Fill)

    content.geometry.h = contentSize[idx].h

    content.geometry.x = base.y


    /* 콘텐츠 세로 위치 결정 */

    else

    y = (contentSize[idx].h - content.size.h) * content.align.h

    content.geometry.y = (base.y + y)

    /* 다음 콘텐츠 시작 위치 */

    base.y += contentSize[idx].h

    ++idx

    코드 29 가중치와 정렬을 고려한 레이아웃 계산


    코드 29는 완벽하진 않지만, 컨테이너의 계산 로직의 주를 보여준다. 컨테이너와 콘텐츠의 크기를 상호 참조하며 연쇄적으로 계산하고 있음을 주목한다. 보충 설명하자면 가중치를 토대로 콘텐츠 크기를 계산하고 이를 참고하여 컨테이너(레이아웃) 크기를 계산한다. 만약 컨테이너 크기가 변경되면 새 크기를 기반으로 콘텐츠의 크기도 재계산한다. 마지막으로 컨테이너에 추가된 콘텐츠 크기가 각기 다를 수 있음으로 컨테이너 크기를 확정한 후에는 정렬을 참조하여 콘텐츠의 최종 위치와 크기를 결정한다.


    가중치와 정렬을 이용하면 앱 개발자는 컨테이너 정책 범위 내에서 UI 컨트롤을 원하는 대로 배치할 수 있다. 물론 가중치와 정렬은 하나의 방안을 제시할 뿐 UI 프레임워크마다 그 방안은 다를 수 있다. 하지만, 앞서 살펴본 예제처럼 컨테이너에 UI 컨트롤을 배정할 때 위치 및 크기를 결정할 수 있는 메커니즘은 필요하며 컨테이너는 해당 콘텐츠를 대상으로 스케일러블 UI를 보장하고 UI 컨트롤이 최대 크기를 초과하거나 최소 크기 이하로 작아지는 문제 등을 방지해야 한다.


    앞서 살펴본 리니어 레이아웃 외로 UI 프레임워크에서는 앱 개발에 필요한 컨테이너를 정의하고 이들의 기능과 동작을 정의할 수 있다. 실제로 일부 UI 프레임워크에는 테이블(Table), 그리드(Grid), 프레임(Frame), 스택(Stack), 박스(Box) 등의 컨테이너를 제공한다. 그 결과 앱 개발자는 UI 프레임워크에서 제공하는 다양한 컨테이너를 적재적소에 사용할 수 있고 여러 컨테이너를 조합함으로써 더욱 다양하고 효율적인 화면 구성도 가능하다.


    그림 37 컨테이너 조합을 통한 화면 구성



    4. 스케일러블 UI 응용

    앱 화면을 구성하는 콘텐츠의 최소 크기 합이 앱 출력 화면보다 더 큰 경우도 있지 않을까? 일반적인 경우가 아닐지라도 여러 환경의 기기를 고려한다면 종종 발생할 수 있는 문제이기도 하다. 극단적으로 데스크톱에서 앱 윈도우의 크기를 최대한 작게 줄여볼 수 있으며 이에 대한 대안으로 콘텐츠를 클리핑(Clipping)하거나 콘텐츠 최소 크기 이하로 윈도우 크기가 줄어드는 것을 막을 수 있다. 이는 하나의 예시이지만 해상도별 콘텐츠 크기 결정 문제와 해답은 제각기이고 앱과 프레임워크는 이러한 상황별 문제에 대비해야만 한다. 호환성 높은 앱이라면 다양한 화면 크기를 고려하여 해상도별 최적의 뷰를 디자인하고 해상도에 맞춰 디자인한 뷰를 출력하는 것도 가능하다. 하지만 이 경우 앱 개발 비용과 난이도도 그만큼 상승할 것이며 모든 앱이 이에 대응한다고 보장할 수도 없다. 최소한 효율성과 더불어 사용 불능 앱이 되지 않도록 UI 프레임워크에서 기반을 마련해 주어야 한다. 본 절에서는 앞 절에서 배운 스케일러블 UI의 연장선에서 응용 방안을 추가로 짚어본다.



    4.1 디바이스 독립적인 픽셀


    DPI(Dots Per Inch)는 1인치 내 존재하는 점의 개수를 의미한다. DPI는 보통 프린터에서 수치로 활용되는데 그 수치가 높을수록 더욱 정교한 프린터 출력이 가능하다. 유사 용어 중 PPI(Pixels Per Inch)는 디스플레이 장치에서 1인치 범위 내 표현 가능한 픽셀 수를 의미한다. PPI 역시 수치가 높을수록 더욱 정교한 화면 출력이 가능하므로 화면 출력 정밀도를 DPI, PPI 단위로 모두 표현할 수 있다.


    일반적으로 DPI/PPI가 높다는 것은 그만큼 고해상도 출력이 가능하다는 것을 의미한다. 예를 들면 두 디스플레이의 크기가 물리적으로 동일하더라도 DPI/PPI 수치가 다르면 두 디스플레이의 해상도에도 차이가 존재한다.


    그림 38 DPI별 물리적 출력 크기 차


    상대 좌표를 이용하거나 컨테이너 레이아웃 정책에 의존하는 경우가 아닌 절대 좌표 위치 및 크기를  지정할 경우에는 그림 1.38처럼 DPI별 출력 결과가 다를 수 있으며 이는 스케일러블 UI 출력 결과에 영향을 주게 된다. 그리고 실제로 제품 특성에 따라 UI 앱은 물리적으로 동일 크기의 UI를 보장해야 하는 경우도 있다.


    이 문제를 해결하기 위해서는 제시한 방법으로 디바이스 독립적인 픽셀(Device-Independent-Pixel) 또는 밀도 독립적인 픽셀(Density-Independent-Pixel)이 있으며 이는 앱 개발자가 지정한 UI 객체의 위치/크기 단위가 논리적 단위로서 DPI/PPI에 비례하여 작용한다. 따라서 UI 프레임워크에서는 본 기능을 제공하고 디자이너/앱 개발자가 해당 단위를 이용함으로써 견고한 UI를 구성할 수 있도록 도움을 줄 수 있다. 참고로 안드로이드에서는 dp(dip) 단위를 제시함으로써 본 방법을 구현한다.


    LinearLayout layout = new LinearLayout(this);

    LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) layout.getLayoutParams();

    layoutParams.height = dpToPx(400); //dp 단위에서 픽셀 단위로 변환

    layoutParams.width = dpToPx(400);

    layout.setLayoutParams(layoutParams);

    코드 31 디바이스 독립적인 픽셀 지정(안드로이드)


    public static int dpToPx(int dp) {

    /* 안드로이드 시스템에서 정한 기준 dpi는 160이다.

    따라서 160dpi 환경에서는 density 값으로 1을 반환한다. */

    float density = Context.getResource().getDisplayMetrices().density;

    return Math.round((float) dp * density);

    }

    코드 32 dp 단위에서 픽셀 단위로 변환 함수 (안드로이드)

    안드로이드 시스템은 160을 기준 dpi로 지정하였기 1 dp는 160dpi에서 1픽셀과 매칭되며 이때 반환되는 디스플레이 density의 값은 1이 된다. 그리고 160보다 높은 dpi에서는 반환되는 density의 값도 높아진다. 


    그림 39 디바이스 독립적인 픽셀 적용 후 DPI별 물리적 출력 크기 차


    추가로 dpi 범위별 그룹을 제시하므로 앱 UI 리소스 제작 시 지향하는 제품군을 결정하는 데도 참고할 수 있다.


    그림 40 DPI별 크기 차이 (안드로이드)



    4.2 스케일 팩터


    스케일러블 UI의 다른 응용 방안으로서 스케일 팩터(Scale Factor)를 고려해볼 수 있다. 스케일 팩터를 한마디로 정의하자면 크기 조정값으로 볼 수 있는데 스케일 팩터를 잘 정의하면 디바이스의 DPI에 상관없이 물리적으로 동일한 크기의 UI를 출력할 수 있고 반대로 디스플레이 크기 및 해상도에 비례하는 UI 결과도 보여줄 수 있다. 그뿐만 아니라 추가적 논리 단위를 정의하지 않고 픽셀 단위를 그대로 이용할 수 있는 점에서 스케일 팩터는 유용하다.


    그림 41 스케일 팩터를 이용한 계산기 앱 크기 변경


    스케일 팩터의 핵심은 계산한 UI를 구성하는 시각적, 비 시각적 요소의 지오메트리를 스케일 팩터로 동일하게 조정하는 것이다. 다시 말하자면, 레이아웃의 위치와 크기는 물론 폰트 크기, 콘텐츠 고정 영역 및 최소 크기, 패딩(Padding), 마진(Margin) 등 크기에 영향을 주는 모든 UI 속성에 스케일 팩터를 동일하게 곱한다. 결과적으로 출력 영역의 크기에 맞춰 스케일 값을 조정함으로써 앱 UI는 완전 동일하게 출력될 수 있다.

    스케일 팩터를 시스템에 적용할 때 유념할 점은 UI를 구성하는 패키지마다 스케일 팩터 기준값이 다를 수 있다는 점이다. 가령, WQHD(2560X1440) 해상도 기기에 설치된 UI 테마 에셋(Asset)은 WQHD 기준으로 작성되었더라도 여기에 설치될 앱은 그렇지 않을 수 있다. 일부 앱은 WQHD 기준으로 구현했을 수 있지만, 일부 앱은 다른 해상도를 기준으로 앱 UI를 구현했을 수 있다. 만약 앱이 작성한 UI가 UHD(3840x2160) 기준으로 작성되어 있다면 본 기기에서 앱 UI에 적용할 스케일 팩터값은 0.6666이 되고 테마에 종속된 UI의 스케일 팩터 값은 1.0이 된다. 따라서 모듈 또는 패키지 단위로 기준 해상도 값이 명시되어야 하고 시스템에서는 이를 참조하여 해당 UI의 스케일 팩터 값을 결정할 수 있다.

    그림 42 패키지별 스케일 팩터 독립 적용


    UI 시스템에서는 테마 구성하는 패키지 또는 앱 패키지 단위로 기준 스케일을 입력할 수 있어야 하고 이는 패키지를 구성하는 매니페스트(Manifest) 또는 코드에서 직접 명시할 수 있다. 예로 앱 프레임워크에서는 다음 인터페이스를 통해 UI 앱의 기준 해상도를 결정한다.

    /* 앱 런칭 시점에 UIApp 클래스를 이용하여 해상도를 지정한다. 이후 UI 엔진은 런타임

    시점에 현 디바이스의 해상도 정보를 얻고 스케일 팩터 값을 결정할 수 있다. UI 툴킷이 잘

    구비되어 있다면 본 설정을 앱 개발자가 직접 호출할 필요가 없다. */

    UIApp.targetResolution = {720, 1280}

    ...


    /* 이후 UI 앱에서 구현한 지오메트리 정보에 스케일 팩터를 적용한 예시이다.

    현재 앱에서 명시한 버튼의 지오메트리 정보는 HD 해상도 기준의 크기 값이므로

    다른 해상도의 기기에서는 스케일 팩터를 톻애 그 값을 조정한다.

    _S() 매크로는 입력한 값에 스케일 팩터를 곱한 값을 반환한다고 가정한다. */

    myBtn = UIButton():

    .text = "My Button"

    .geometry = {_S(50), _S(50), _S(100), _S(100)}

    코드 32 APP UI 스케일 팩터 적용 예시

    /* 한편 시스템 UI의 기준 스케일을 다르게 적용할 수 있다. */

    systemUI = UITheme():

    /* systemUI.theme은 시스템 UI를 구현한 정보를 보관하고 있다. */

    .open("SystemUI_Theme")

    /* 이해를 돕기 위한 예시일 뿐, 실제 해상도 정보는 SystemUI Theme 내에 기록되어 있다. */

    .targetResolution = {1440, 2560}

    코드 33 시스템 UI 스케일 팩터 적용 예시


    4.3 자동 스크롤


    앱 출력 공간(윈도우 또는 스크린)이 뷰의 최소 크기보다 작다면 스케일 팩터를 적용하여 뷰 전체를 축소할 수도 있지만 다른 대안으로서 스크롤 기능을 활성화하면 이 문제를 해결할 수도 있다. 일반적으로 앱 UI를 구성할 때 핵심 콘텐츠 영역에는 스크롤을 적용하지만, 앱 개발자가 미처 고려하지 못한 상황이 전개되면 앱 출력 영역이 부족할 수 있다. 스케일러블 UI를 잘 구축하였을지라도 최소 크기는 보장돼야 하므로 자동 스크롤은 최후의 상황에서 대책으로 사용할 수 있다.


    그림 43 자동 스크롤 활성


    자동 스크롤의 핵심은 앱 개발자가 스크롤을 직접 적용하지 않더라도 UI 엔진을 통해 뷰 단위에서 스크롤을 자동으로 개입하는 것이다. 일반적인 상황에서 스크롤 기능은 비활성화 상태이며 앱 UI에 영향을 주지 않지만, 저해상도 앱 출력 공간에서는 이를 활성화함으로써 UI가 무력해지는 것을 방지할 수 있다. 최적의 결과는 아니지만 여러 기기 간 호환성을 고려하지 않은 앱을 보호할 수 있는 장치로서 UI 프레임워크에서 해당 기능을 제공할 수 있다.



    4.4 어댑티브 UI


    스케일러블 UI는 다양한 화면 크기의 디바이스에서 동일한 UI 경험을 사용자에게 제공하는 현실적인 방법이지만 사용자에게 최고의 경험을 제공할 방법은 아니다. 가령, 스마트폰을 위해 디자인한 앱은 컴팩트한 UI를 고려해야 하지만, 데스크톱처럼 비교적 화면 공간에 여유 있는 앱이라면 좀 더 다양한 기능의 UI를 사용자에게 동시에 제공할 수 있다. 실제로 디자인 관점에서 스마트폰을 위한 UI는 데스크톱에서 최적의 디자인으로 보긴 어려우므로 그 대안으로서 UI 앱은 디바이스 프로파일별로 다른 UI를 구성할 수 있다. 이때 앱의 핵심 비즈니스 로직은 프로파일 간 공유하므로 개발 비용을 최소화한다.


    그림 44 어댑티브 UI 예 (Xamarin Forms) 


    구현 핵심은 디바이스 또는 해상도별 프로파일을 지정하고 프로파일 단위로 지원할 UI를 각각 구현하는 것이다. 모바일 환경에서는 세로 모드(Portrait), 가로 모드(Landscape)에 따라 다른 UI를 구현할 수도 있다. 그리고 하나의 패키지에 공용의 비즈니스 로직과 여러 UI 구현부를 프로파일별로 분리하여 제공할 수 있다. 이러한 어댑티브 UI를 이용하는 방식은 사용자에게 디바이스 또는 스크린 해상도별 최적의 UI를 제공함으로써 앱의 호환성을 높이고 사용자 경험을 향상한다.

    그림 45 프로파일별 UI 패키지 구성도 


    //디바이스 타입에 따라 앱 요구사항에 맞는 레이아웃을 구성한다.

    switch (UIConfig.deviceProfile)

    //데스크톱 UI

    DeviceProfile.Desktop: ...

    //모바일 UI

    DeviceProfile.Mobile: ...

    //태블릿 UI

    DeviceProfile.Tablet: ...

    코드 34 디바이스별 UI 구현

    //현 해상도에 맞는 최적의 UI를 구성한다.

    switch (window.screenProfile)

    WVGA, WSVGA, HD: ...

    1080P, WUXGA: ...

    2K, UWHD, WQHD: ...

    코드 35 해상도 프로파일별 UI 구현

    프로파일 단위로 UI 구현부를 모듈화할 경우 UI 프레임워크는 현재 구동 중인 디바이스의 환경에 가장 적합한 리소스를 선택하여 앱 UI로 적용할 수 있다. 이 경우 UI 모듈별 메타 정보를 기록하는 별도의 리소스(XML, JavaScript, JSON 등과 같은 스크립트 기반 UI 구성 정보)를 앱 패키지에 작성할 수 있도록 플랫폼 SDK 또는 UI 프레임워크에서 기반을 마련해 주어야 하고 UI와 앱 로직 간 의존성을 분리할 수 있는 UI 엔진을 잘 설계하여 UI 모듈의 교체에 따른 호환성 문제가 없도록 구조화해야 한다. 그렇게 함으로써 앱 개발자는 디바이스, 해상도별 경우의 수를 고려한 코드를 일일이 작성하지 않고도 어댑티브 UI를 비교적 쉽고 안정적으로 구현할 수 있다.


    5. 정리하기

    이상으로, 우리는 앱 개발에 필요한 UI 프레임워크의 기본 기능과 그 개념을 간략하게 살펴보았다. UI를 구성하는 데 있어서 가장 원시적인 방법으로 이미지와 텍스트를 이용하는 방법이 있으며 UI 컨트롤을 이용하면 보다 쉽고 빠른 앱 UI 구현이 가능함을 알 수 있었다. UI 컨트롤은 단순히 그래픽 출력뿐만 아니라 사용자와 앱 간의 상호작용을 위한 기능을 제공하며 이러한 기능은 UI 컨트롤의 이벤트로서 구현이 가능함을 배웠다. 전통적으로 UI 앱은 윈도우를 통해 화면 출력 영역을 확보하며 윈도우 내에 UI를 배치함으로써 화면을 구성할 수 있음을 알 수 있었고 앱 라이프사이클에 맞춰 UI 생성과 조작이 필요함을 배울 수 있었다. 그뿐만 아니라 UI가 가동될 수 있는 UI 엔진과 메인루프의 개념도 함께 살펴보았으며 스케일러블 UI를 지원하기 위한 기본 동작 개념과 여러 응용 사례도 함께 살펴보았다.