kth 개발자 블로그

개발자가 행복한 회사 kth

 

Path의 모든것 #4 – iOS용 Path 2 UI 코드레벨 퀵리뷰

January 2, 2012

kth iOS앱팀 김윤봉

 

1. 개요

Path 2가 출시된 후 관련 종사자분들을 비롯해 SNS에서도 관심이 집중되고 있습니다. 기획, 디자인, 개발 모두 배울 점이 많은 Path 2는 몇몇 버그가 존재함에도 불구하고 꾸준한 사용자 증가를 유도하고 있으며 분석 관련 글들이 많이 눈에 띄고 있습니다. 특히 음악 공유의 경우 시스템의 언어가 한글인 경우에는 크래쉬 나는 오류가 있지만 언어를 영어로 설정하면 공유가 가능하다는 치명적인 버그에도 불구하고 음악 공유를 하고자 시스템 언어까지 바꾸어 사용하는 사용자들이 있을 정도로 사용자들은 점점 Path 2에 빠져가고 있습니다. 개인적으로도 UI/UX를 비롯해 데이터 처리부분까지 어떻게 구현했을까? 라고 의구심을 가진 요소가 많이 있었고 이에 대해서 개발자 관점에서의 간략하고 지루한 code-level 리뷰를 path 2와 유사하게 구현하며 작성해보았습니다. 왼쪽이 Path 2의 원래(original)화면이고 오른쪽은 제가 따라하기 하면서 구현한 화면입니다.


동작영상 – Path에서 보기 , Youtube 에서 보기

2. 애니메이션

학습효과는 절대 무시하지 못하는 사용성의 요인 중 하나입니다. 사용자들은 이전 컴퓨팅 환경인 딱딱하고 정적인 화면에 익숙해져있다가 Android, iOS와 같은 스마트폰 운영체제의 애니메이션 효과를 경험하면서 정적인 화면에 실증을 느끼게 되는 사용자가 늘어나기 시작하였고 그에 걸맞게 창의 전환, ActivityIndicator에만 적용되던 애니메이션 적용의 범위가 점점 커지게 되어 애니메이션 효과가 적용되지 않은 앱이 없을 정도로 필수적인 요건이 되고 있습니다. 이런 시점에 Path 2가 소개되었고 이를 설치한 후 신기하다고 이것저것 눌러보며 지인들에게 추천하는 분들이 늘기 시작했습니다. 여러 요소들에 애니메이션을 적용하여 사용자에게 새로운 경험을 제공하였습니다.

iOS의 경우 애니메이션을 구현하는 방법은 크게 세 가지로 나뉩니다.

첫 번째는 UIView 객체 자체에서 수행할 수 있는 애니메이션으로 프로퍼티를 이용한 애니메이션입니다. 프로퍼티의 경우 중복 사용이 가능하며 또한 애니메이션 타임을 초 단위로 설정이 가능합니다. 예를 들어, 다른 위치로 이동하면서 투명도를 1.0 에서 0.0으로 변경 등이 이에 해당됩니다. 한 가지 설명을 더 하자면 아래와 같이 target view를 curlUp/curlDown 되도록 구현도 가능합니다. 가장 간단하게 애니메이션을 줄 수 있는 방법입니다.

이를 위해서는 기존의 begin / commit 방법과 iOS4.0 부터 추가된 block-coding 타입의 메소드 호출로 가능합니다. 참고로 begin/commit 방법과 block-coding 의 차이점은 아래 코드로 확인하실 수 있습니다.

begin / commit 방법

[UIView beginAnimations:nil context:NULL];
[UIView setAnimationDuration:1.0f];
[UIView setAnimationTransition:UIViewAnimationTransitionCurlUp forView:_frontView cache:YES];

_frontView.alpha = 0.0f;

[UIView commitAnimations];

block-coding style 방법

[UIView animateWithDuration:1.0f animations:^(void) {

   [UIView setAnimationTransition:UIViewAnimationTransitionCurlUp forView:_frontView cache:YES];
   _frontView.alpha = 0.0f;

}];

두 번째 방법은 CoreAnimation을 이용하는 방법입니다. CoreAnimation은 Adobe 플래시나 MS의 실버라이트와 유사하게 타임라인을 두고 시간이 흐르면서 값이 변경되도록 구현하는 방법이라고 생각하면 쉽게 이해가 될 것이라고 생각이 됩니다. 이는 앞에서 설명한 UIView의 프로퍼티를 이용한 방법이 아닌 Layer를 이용한 방법으로 섬세한 애니메이션을 구현할 때 이용이 됩니다. Path 2에서는 아래에서 설명이 된 QuadCurveMenu에 적용이 되어 있습니다. 내용이 방대하여 상세한 설명은 제외하겠으나 원리는 CAKeyframeAnimation, CABasicAnimation으로 정의한 객체들을 CAAnimationGroup에 원하는 순서대로 배열로 입력하고 이를 addAnimation하여 구현하는 방법입니다. 좌표 계산, 애니메이션 정의 등 앞서 설명한 프로퍼티를 이용한 애니메이션보다 손이 더 많이 가는 방법입니다. CoreAnimation의 경우 내용이 책 한 권일 정도로 방대한 내용이 담겨있고 쉽게는 플래시에서 에니메이션을 구현하는 것으로 생각하시면 쉽게 이해가 될 것이라 생각이 됩니다. (UIView의 개념은 포토샵의 레이어 개념으로 이해하는 것과 유사합니다.) CoreAnimation에 대한 개념과 간략한 설명은 Bob McCune이 SlideShare에 공개한 문서를 참고하십시요.

  1. 이 Core Animation은 QuartzCore 프레임워크에 포함된 클래스로 이해도를 더 높이기 위해서는 “Programing with Quartz” 라는 책을 참고하시면 유용합니다
  2. 아래 화면은 아임IN핫스팟에 적용된 UIPageViewController로 단순히 애니메이션이 시작되고 끝나는 것이 아닌 실제 책을 넘기는 것처럼 사용자가 터치 후 움직임에 따라 페이지 모양이 변경되는 것을 보여줍니다.

세 번째 방법은 OpenGL ES 엔진을 이용하는 방법으로 소위 3D로 구현하는 방법입니다. 게임에서 많이 이용하고 있으며 객체의 3차원 변형, 이펙트 등이 적용 가능합니다. 이 부분에 대한 설명은 하지 않도록 하겠습니다.

a. QuadCurveMenu

path 2에서 가장 눈에 띄는 부분은 QuadCurveMenu라고 명명된 메뉴 버튼입니다. 화면 왼쪽 하단에 위치한 빨간 “+” 버튼을 터치하면 그 안에서 기능 아이콘들이 동그랗게 원으로 배열됩니다. 그와 함께 “+” 버튼은 약간 회전되어 “x” 버튼으로 바뀝니다. 이 “x” 버튼을 다시 터치하면 버튼은 또 약간 회전하여 “+”버튼이 되고 배열되었던 기능 버튼들은 버튼 뒤로 숨겨집니다. 하나의 버튼을 아주 잘 활용한 예이지 않을까요? 모두 이 버튼의 구현 방법에 대해서 궁금해하고 따라해보고 있을 때 github에 코드가 MIT 라이센스를 품고 공개가 되었습니다.

iOS에서 UIBarButtonItem 객체들을 UIToolBar에 배치하는 것과 동일한 구조로 구현이 됩니다. QuadCurveMenuItem 객체에 적용할 이미지를 적용하고 이를 QuadCurveMenu에 배열로 적용하여 배치하고자 하는 Parent View에 addSubview하면 기본적인 구현은 끝입니다. “+” 버튼은 화면 가운데에 위치하고 버튼을 터치했을 때 동그랗게 배치됩니다. 이 버튼의 위치를 바꾸기 위해서는 QuadCurveMenu.m 에 정의된 STARTPOINT 값을 적절하게 변경하면 되며, 아이콘 배치를 방사형이 아닌 특정 각도 내에서만 하고자 할 때는 MENUWHOLEANGLE의 값을 적절히 변경하면 됩니다. 물론 아이콘 개수는 배열로 입력된 item 수로 조정하면 됩니다. 또한 아이콘이 나타나는 시간을 조정하고 싶다면 TIMEOFFSET의 값을 줄이거나 늘리면 됩니다. 이 QuadCurveMenu의 경우 앞서 설명한 애니메이션 구현 방법 중 UIView 객체를 이용한 방법과 CoreAnimation을 이용한 방법이 혼합되어 구현되었습니다.

아래 코드는 QuadCurveMenu 사용 예제입니다. 사용하기 참 쉽게 설계되어 있습니다.

/* 기능 아이콘에 적용할 이미지 정의 */
UIImage *storyMenuItemImage = [UIImage imageNamed:@"bg-menuitem.png"];
UIImage *storyMenuItemImagePressed = [UIImage imageNamed:@"bg-menuitem-highlighted.png"];

/* 기능 호출 버튼 이미지 */
UIImage *starImage = [UIImage imageNamed:@"icon-star.png"];

/* 각 기능 아이콘들은 QuadCurveMenuItem으로 지정 */
QuadCurveMenuItem *starMenuItem1 =
    [[QuadCurveMenuItem alloc] initWithImage:storyMenuItemImage
        highlightedImage:storyMenuItemImagePressed
        ContentImage:starImage
        highlightedContentImage:nil];
QuadCurveMenuItem *starMenuItem2 =
    [[QuadCurveMenuItem alloc] initWithImage:storyMenuItemImage
        highlightedImage:storyMenuItemImagePressed
        ContentImage:starImage
        highlightedContentImage:nil];
QuadCurveMenuItem *starMenuItem3 =
    [[QuadCurveMenuItem alloc] initWithImage:storyMenuItemImage
        highlightedImage:storyMenuItemImagePressed
        ContentImage:starImage
        highlightedContentImage:nil];
QuadCurveMenuItem *starMenuItem4 =
    [[QuadCurveMenuItem alloc] initWithImage:storyMenuItemImage
        highlightedImage:storyMenuItemImagePressed
        ContentImage:starImage
        highlightedContentImage:nil];
QuadCurveMenuItem *starMenuItem5 =
    [[QuadCurveMenuItem alloc] initWithImage:storyMenuItemImage
        highlightedImage:storyMenuItemImagePressed
        ContentImage:starImage
        highlightedContentImage:nil];

NSArray *menus = [NSArray arrayWithObjects:starMenuItem1,
    starMenuItem2, starMenuItem3, starMenuItem4, starMenuItem5, nil];
[starMenuItem1 release];
[starMenuItem2 release];
[starMenuItem3 release];
[starMenuItem4 release];
[starMenuItem5 release];

/* QuadCurveMenu에 items의 배열로 정의 */
QuadCurveMenu *menu =
    [[QuadCurveMenu alloc] initWithFrame:CGRectMake(0.0f, 0.0f, 320.0f, 460.0f - 44.0f) menus:menus];
menu.delegate = self;

[self.view addSubview:menu];
[menu release];

이렇게 실행을 하면 화면 가운데 + 버튼이 덩그러니 놓여있습니다. 이를 조정하기 위해서는 QuadCurveMenu.m 파일의 상단에 정의되어 있는 값을 적절하게 변경하시면 됩니다.

#define NEARRADIUS 110.0f
#define ENDRADIUS 120.0f
#define FARRADIUS 140.0f    // 기능 버튼들의 상태에 따른 반경값
#define STARTPOINT CGPointMake(30, 390)   // + 버튼이 위치할 포인트
#define TIMEOFFSET 0.036f   // 애니메이션 타임
#define ROTATEANGLE 0     // 회전값
#define MENUWHOLEANGLE  M_PI / 1.6    // 메뉴가 방사될 영역

적용 방법이 참 쉽죠? 더불어 애니메이션 구현 방법도 아래 코드를 보시면 CoreAnimation으로 어렵지 않게 구현했다는 것을 볼 수 있습니다.

- (CAAnimationGroup *)_blowupAnimationAtPoint:(CGPoint)p
{
    CAKeyframeAnimation *positionAnimation = [CAKeyframeAnimation animationWithKeyPath:@"position"];
    positionAnimation.values = [NSArray arrayWithObjects:[NSValue valueWithCGPoint:p], nil];
    positionAnimation.keyTimes = [NSArray arrayWithObjects: [NSNumber numberWithFloat:.3], nil];

    CABasicAnimation *scaleAnimation = [CABasicAnimation animationWithKeyPath:@"transform"];
    scaleAnimation.toValue = [NSValue valueWithCATransform3D:CATransform3DMakeScale(3, 3, 1)];

    CABasicAnimation *opacityAnimation = [CABasicAnimation animationWithKeyPath:@"opacity"];
    opacityAnimation.toValue  = [NSNumber numberWithFloat:0.0f];

    CAAnimationGroup *animationgroup = [CAAnimationGroup animation];
    animationgroup.animations =
        [NSArray arrayWithObjects:positionAnimation, scaleAnimation, opacityAnimation, nil];
    animationgroup.duration = 0.3f;
    animationgroup.fillMode = kCAFillModeForwards;

    return animationgroup;
}

b. ScrollView의 indicator와 함께하는 타임뷰

Path 2에서 컨텐츠의 표현은 대부분 TableView에 타임라인으로 표현됩니다. 스크롤을 하다보면 스크롤 뷰 indicator 왼쪽에 컨텐츠가 작성된 시간이 아래의 왼쪽 화면처럼 나타납니다. iOS에서 사용하는 UITableView 객체는 기본적으로 UIScrollView를 상속하고 있으므로 delegate를 선언하면 UIScrollView delegate 메소드도 구현 가능합니다.

이 역시 튜토리얼로 구동방법을 설명한 포스팅이 존재합니다.4 UITableView가 아닌 UIScrollView에 layer를 이용한 방법으로 이를 참고하여 약간의 수정으로 아래의 오른쪽 화면처럼 구현해보았습니다.

사용된 delegate method는 – scrollViewWillBeginDragging:(UIScrollView *)scrollView, – scrollViewDidScroll:(UIScrollView *)scrollView, – scrollViewDidEndDecelerating:(UIScrollView *)scrollView입니다. 각 메소드에 구현한 내용은 아래 표와 같습니다.

메소드명 구현내용
- (void) scrollViewWillBeginDragging:(UIScrollView *)scrollView 시간을 표시할 타임뷰의 선언. scrollView의 subview 중 마지막에 위치한 indicatorView에 이를 붙이기
- (void) scrollViewDidScroll:(UIScrollView *)scrollView scrollView의 indicatorView frame의 변화에 따른 타임뷰의 이동 및 최상단과 최하단의 예외처리
- (void) scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView
- (void) scrollViewDidEndDecelerating:(UIScrollView *)scrollView
튜토리얼에서는 scrollViewDidEndScrollingAnimation으로 타임 뷰를 삭제하였으나 디버깅해보니 이 메소드 호출이 안되어서 scrollViewDidEndDecelerating으로 변경하였습니다. 또한 삭제하기 전에 alpha 값을 줘서 서서히 사라지는 애니메이션도 여기에서 구현하였습니다.

데모에 구현한 UIScrollView delegate 메소드입니다. 위에서 소개한 튜토리얼 포스팅의 내용과 거의 유사하게 사용하였습니다.

- (void)scrollViewWillBeginDragging:(UIScrollView *)aScrollView {

    if (![_infoPanel superview])
    {
            _infoPanel.frame = CGRectMake(-85.0f, 0.0f, 80.0f, 20.0f);

            UIView *indicator = [[aScrollView subviews] lastObject];

            CGRect indicatorFrame = [indicator frame];
            initialHeightOfScrollIndicator = indicatorFrame.size.height;

            [indicator addSubview:_infoPanel];

            // Center the info panel
            CGRect infoPanelFrame = [_infoPanel frame];
        infoPanelFrame.size = initialSizeOfInfoPanel;
        infoPanelFrame.origin.y =
            indicatorFrame.size.height / 2 - infoPanelFrame.size.height / 2;
        [_infoPanel setFrame:CGRectIntegral(infoPanelFrame)];

            _infoPanel.alpha = 1.0f;
    }
}

- (void)scrollViewDidScroll:(UIScrollView *)aScrollView {
    UIView *indicator = [[aScrollView subviews] lastObject];
    CGRect indicatorFrame = [indicator frame];

    // We are somewhere at the edge (top or bottom)
    if (indicatorFrame.size.height < initialHeightOfScrollIndicator)     {      

                // The indicator starts shrinking,
                // so we need to adjust our info panel's y-origin to stays centered
                CGRect infoPanelFrame = [_infoPanel frame];
                if (indicatorFrame.size.height > infoPanelFrame.size.height + 2)
        {
            infoPanelFrame.origin.y = (indicatorFrame.size.height / 2) - (infoPanelFrame.size.height / 2);
        }
        // We are at the bottom of the screen and the indicator is now smaller than our info panel
        else if (indicatorFrame.origin.y > 0)
        {
            infoPanelFrame.origin.y = (infoPanelFrame.size.height - indicatorFrame.size.height) * (-1);
        }

        [_infoPanel setFrame:infoPanelFrame];

    }

    NSIndexPath *indexPath =
        [_timelineTableView indexPathForRowAtPoint:CGPointMake(indicatorFrame.origin.x,
            indicatorFrame.origin.y + indicatorFrame.size.height / 2)];
    _infoLabel.text = [NSString stringWithFormat:@"row : %d", indexPath.row];
}

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView  {

    [UIView animateWithDuration:0.1f animations:^(void) {

        _infoPanel.alpha = 0.0f;

        CGRect rect = _infoPanel.frame;
        _infoPanel.frame =
            CGRectMake(rect.origin.x + rect.size.width, rect.origin.y,
                rect.size.width, rect.size.height);

    } completion:^(BOOL finished) {

        if (finished) {
            [_infoPanel removeFromSuperview];
        }

    }];
}

아날로그 시계의 변화는 객체를 어떻게 구성하느냐의 문제일 것입니다. 입력된 시간에 따라 시침과 분침을 어떻게 배치하느냐는 현재의 값에서 CGAffineMakeRotation을 적용하여 부드럽게 처리한 것으로 생각됩니다. 이에 대한 튜토리얼은 AlterPlay 블로그5를 참고하시면 QuartzCore를 이용하여 쉽게 구현할 수 있습니다.

c. 새로운 사진 추가 시 애니메이션

이 부분이 사실 가장 난이도가 높은 부분이 아닐까라는 생각이 듭니다. 요즘 인기있는 단어인 ‘꼼수’를 사용하여야 하는 부분입니다. UITableView의 기본구조를 알아야 함이 가장 큰 관건이겠죠. 개인적으로 유추한 방법은 이미지의 크기를 점점 크게하며 특정 위치로 이동시키면서 -reloadSections:(NSIndexSet *)indexSet withRowAnimation:(UITableViewRowAnimation)animation 을 적용한 것으로 보입니다.

3. 제스쳐(Gesture)

Path 2에서 또 눈여겨 봐야할 부분은 Gesture입니다. Apple Tech Talk in Seoul에서 애플에 근무하는 개발자가 “왜 제스쳐를 사용하지 않죠?”라고 반문했던 것이 생각납니다. iOS에는 터치 이벤트와 더불어 다양한 제스쳐를 제공합니다. 특히 touchesBegin, touchedEnded, touchedMoved 등의 메소드 구현하기 까다로운 문제점을 인식해서인지 iOS 3.2부터 UIGestureRecognizer 클래스를 제공하여 tap, swipe 등에 대한 이벤트를 쉽게 구현할 수 있게 하였습니다.

메인 타임라인에서 왼쪽으로 swipe 했을 때는 메뉴가 나타나고 오른쪽으로 swipe 했을 때는 친구들 리스트가 나타납니다. 물론 화면 상단 네비게이션 바에 위치한 버튼을 눌러도 동일한 동작을 합니다.

이 기능을 구현하기 위해서 가장 중요한 것은 제스쳐에 따라 해당 view의 frame을 어떻게 처리할지입니다. swipe 방향에 따라 왼쪽 혹은 오른쪽으로 view를 이동할 수 있도록 하고 그 아래에 메뉴를 표현할 ViewController와 친구 리스트를 표현할 ViewController를 구현하였습니다. iOS4부터 적용된 block coding을 이용하면 전보다 쉽게 제어가 가능하며 상황에 따라서 UIView의 layer를 옮길지 View 자체를 옮길지는 정하면 됩니다.

UINavigationController를 상속받아 이 객체에서 swipe gesture에 대한 GestureRecognizer를 구현하여 상황에 따라 분기하여 작동하도록 하였으며 메뉴에서 설정 등을 눌렀을 때 걸터져있던 뷰가 잠깐 사라졌다가 나타나는데 사라질 때 viewControllers를 변경하여 나타나도록 하였습니다.

4. 데이터 처리

대부분의 서비스 클라이언트 앱들은 서버에서 데이터를 가지고 오고 있거나 서버로 데이터를 전송중 일 때 동기식 통신을 이용함으로 ActivityIndicator를 화면에 나타내어 사용자가 전송이 완료될 때까지 기다리도록 유도합니다. 심지어 ActivityIndicator가 화면에 보일 때 사용자가 아무런 액션도 하지 못하는 경우도 있습니다. 이는 구현을 쉽게 할 수 있으나 UX 관점에서 본다면 사용성을 떨어뜨리는 요인이 됩니다. 반대로 이런 ActivityIndicator를 사용하지 않고 비동기식으로 처리를 한다면 사용성은 증가하지만 구현 상의 복잡도가 높아지고 동기식보다 많은 예외가 생겨 개발기간을 늘리게 되는 문제점 등이 존재합니다.

Path 2의 경우 UX를 위해 비동기식 통신을 적용하였고 단말기 내에 로컬 DB를 두어 네트워크 커넥션이 되지 않거나 지연이 심할 경우에도 이미 다운로드된 컨텐츠를 볼 수 있으며 일정 시간이 지나거나 사용자가 새로 고침 버튼을 눌렀을 경우 서버에 새로이 추가된 컨텐츠를 단말기 DB에 등록하고 화면을 갱신하는 방법을 사용하였습니다. 이로 인해서 일부 버그가 발견되고 있지만 UX에 지장을 크게 줄 정도로 불편하지는 않은 것 같습니다.

5. 결론

Path 2는 UI, UX, 개발 세 분야 모두에서 배울 점이 많은 클라이언트임에는 틀림이 없는 것 같습니다. 구현상 기술적인 부분에서는 복잡도가 높은 편은 아니며, UIView의 프로퍼티 변경으로만 가능한 애니메이션의 경우 복잡도가 낮아 쉽게 구현이 가능하며, CoreAnimation을 사용하여 구현하더라도 기술자체의 어려움보다는 복잡도가 조금 높을 뿐이며 시나리오 구성에 되려 시간이 더 소모되지 않을까라는 생각입니다. 그렇다면 특별한 기술도 없는 것 같은데 왜 너나할 것없이 이 앱에 관심을 가질까요? 제 개인적인 생각으로는 어떤 목적으로 또 어떤 시나리오로 꾸밀 것인가가 중요한 것이 아닐까 싶습니다. 많은 고급 기능이 들어가 있다고해도 시나리오가 엉켜있으면 사용자는 금방 싫증을 느끼게 됩니다. 또한 앱을 사용하는데 재미가 없으면 금방 사용자에게 버림받게 되고 경쟁사에 사용자를 뺏기게 됩니다. 이 재미 요소에는 여러 가지가 있겠지만 애니메이션과 제스쳐도 한 요인이라고 생각합니다. 앞에서도 언급했지만 왜 맨날 탭핑(Tapping)만 하게 만드는지 이해가 되지 않는다는 애플 본사 개발자의 반문이 이해가 간다면 기우일까요? 간단한 방법으로 목적한 바를 재미있게 사용할 수 있는 것이 진정 사용자들이 원하는 것은 아닐까 먼저 스스로 반성하면서 리뷰를 마치겠습니다.

 

Path의 모든것 – 연작글

About the author

김윤봉 엉뚱하고 지맘대로이며 온라인 수다쟁이인 iOS팀 김윤봉입니다.

작성일 : 2012/01/02 | 태그: , , , ,
글 분류 퀵 리뷰 | 작성자 김윤봉 ( 김윤봉 )

| Trackback URL


"Path의 모든것 #4 – iOS용 Path 2 UI 코드레벨 퀵리뷰" 의 관련글 3 개

  1. Path의 모든것 #1 – Path 1.x to 2.x to the Next | 파란 개발자 블로그 wrote:

    [...] Path의 모든것 #4 – iOS용 Path 2 UI 코드레벨 퀵리뷰 [...]

  2. UINavigationController에 UIGestureRecognizer 등록하기 | y8k.me wrote:

    [...] Path 2 리뷰를 했습니다. 리뷰 포스트는 여기를 누르시면 보실 수 [...]

  3. Path의 모든것 #2 – 사용자 관점에서의 Path 2.0 퀵리뷰 “Walk through Path” | 파란 개발자 블로그 wrote:

    [...] Path의 모든것 #4 – iOS용 Path 2 UI 코드레벨 퀵리뷰 [...]

 
Powered by Wordpress and MySQL. Theme by Shlomi Noach, openark.org