20120116 :: 그래픽 컨텍스트를 사용하여 Finger Drawing 구현하기

Quartz 2D는 코어 그래픽의 프레임워크의 일부이며, iOS 및 OSX의 그래픽 엔진을 다루는 API 프레임워크로, 상당히 괜찮은 성능과 품질로 그래픽 처리를 할 수 있는 그래픽 엔진이다. 이전 버전의 iOS에서는 Quartz 프레임워크를 링크해야 사용할 수 있었는데, iOS5에서부터는 별다른 프레임워크 추가 없이 사용이 가능해졌다.

Mac 및 iOS에서의 그래픽은 장치독립적 그래픽이라는 이상을 추구하는데, 어떤 화면이나 기기 혹은 화면/출력물 등의 차이에 구애받지 않는 그래픽 구현을 목표로 한다.

예를 들자면 PC모니터와 프린터의 해상도는 많이 다르다. PC모니터는 통상 70~90 dpi 정도, 프린터는 300 dpi 이상의 해상도를 갖는다.

기기 혹은 매체마다 화면 해상도가 다르다보니, 보통 PC에서 보던 이미지를 종이에 출력하면 해상도 차이 때문에 모니터에서 보던 것 보다 아주 작게 줄어들어서 표현되거나 하는 경우가 있다.

애플은 화면에 표시되는 내용과 출력물의 내용의 괴리를 최대한 줄이고, 장치에 구애 받지 않고 최대한 비슷한 그래픽을 구현하기 위해 시스템 그래픽 엔진 자체를 벡터로 구현하는 방식을 추구하였고, 이에 대한 연구의 산물이 바로 쿼츠 엔진이다.

이 쿼츠는 iOS에서도 적용되어 있다. 흔히 그래픽 관련 코딩을 할 때 이해하기 힘든 것 중 하나가 프레임이나 선의 굵기를 지정할 때 float 타입의 값을 쓰는데, iOS나 OSX는 이 값들을 픽셀이 아닌 pt 단위를 붙여서 인식하게 된다.  (pt 단위는 픽셀과 달리 cm, inch와 같은 실제 물리적인 길이 단위이다. 1pt는 1/72 인치에 해당한다. 즉 72.0이라는 값은 실제로 봤을 때 1인치에 근접하게 표현된다.)

그래픽 컨텍스트

쿼츠를 사용하여 그래픽을 구현하기 위해서는 그래픽 컨텍스트에 대한 이해가 필요하다. 그래픽 컨텍스트는 개념상, 가상의 캔버스라 생각하면 된다. 우리는 그래픽 컨텍스트라는 이 가상의 캔버스에 그림을 그리게 되고, 쿼츠 엔진은 이 가상의 캔버스에 그려진 그림을 실제 장치의 디스플레이에 투영하여 그래픽을 출력해주는 것이다.

그래픽 컨텍스트는 장치 독립적인 성격을 가지며, 하나의 그래픽 컨텍스트는 다른 장치를 위한 그래픽으로 쉽게 전환이 가능하다. 따라서 그래픽 컨텍스트에 적용된 그래픽은 아이폰 및 아이패드용 화면 출력 뿐 아니라, 인쇄나 PDF를 만들기도 쉽게 지원된다. (OSX의 거의 모든 앱은 컨텐츠를 PDF로 만들 수 있을 뿐더러, 앱의 화면 그 자체를 PDF로도 만들 수 있다. 또한 미리보기와 같은 앱은 PNG 이미지를 그대로 PDF로 변환하기도 한다.)

뷰에 그림이 그려지는 것 또한 실제로는 이 그래픽 컨텍스트에 그림이 그려지는 것이라 생각하면 된다. 또한 컨텍스트에는 연관된 레이어(CGLayer)를 추가로 생성할 수 있고, 이 레이어에 그림을 그려 반복적으로 그려지는 이미지를 리소스를 절약하여 만들 수 있다. 재밌는 것은 레이어에도 그 자체의 컨텍스트가 따로 있다는 것이다. 레이어를 사용하여 오프스크린 그래픽[1. 화면에 곧바로 그리지 않고, 그려놓은 것을 숨겨뒀다가 재활용하는 기법]을 구현할 때에는 어느 컨텍스트에 그림을 그리는지를 명확하게 인식하고 있는 것이 중요하다.

이번 글에서는 그래픽 컨텍스트를 사용하여 핑거 드로잉을 구현하는 부분에 대해 알아보고자 한다. 다소 이론적인 내용이 많은데, 자세한 내용은 개발자 문서를 참고하면 좋겠다.

핑거드로잉

손가락 터치에 대해 터치되는 부분을 따라 선이 그려지도록 하는 기능만을 우선 구현하기로 한다. 핑거드로잉을 지원하는 뷰 클래스 (UIView)를 하나 새로 만들 것이며, 이 뷰 클래스에서 핑거드로잉을 구현하도록 한다.

프로젝트 시작

새 프로젝트를 하나 만든다. 어차피 UIView 클래스를 새로 하나 만드는 것이 사실 구현의 전부이므로, single view 앱으로 시작한다. 프로젝트를 생성하였으면, 새 파일을 추가한다. Objective-C 파일을 추가하여 UIView를 선택한다. 이름은 CanvasView 정도가 좋을 것 같다.

앱 실행시에 해당 뷰가 전면에 표시되도록 이 캔버스 뷰가 들어갈 뷰 컨트롤러의 파일에서 viewDidLoad를 다음과 같이 수정해서 루트 뷰에 캔버스뷰를 추가한다.

viewcontroller.h

#import "CanvasView"
/* ... */
-(void)viewDidLoad {
    CanvasView *canvas = [[CanvasView alloc] initWithFrame:self.view.frame];
    [self.view addSubView:canvas];
}

인터페이스

뷰에는 다음과 같은 방식으로 그림이 그려진다. 먼저 터치를 하는 시점에 레이어에 패스를 사용하여 선을 그린다. 이 때는 레이어에만 그림이 그려졌을 뿐, 실제 화면에는 그림이 그려지지 않았다.

다시 이 레이어를 뷰에 그린다. 뷰에 그릴 때는 레이어를 이미지로 만들고, 이 이미지를 기존 뷰에 덧칠하듯 그리게 된다. 이 과정에서 우리는 레이어로부터 생성된 이미지를 그래픽 컨텍스트에 덮어 쓰게 된다.

따라서 이 클래스는 그래픽 컨텍스트 하나와 레이어 객체 하나를 인스턴스 변수로 가지게 된다. 코어 그래픽은 Objective-C가 아닌 C로 만들어져 있어서 프로퍼티로 선언하지는 않는다. (이들 객체는 C구조체로 되어있다.)

@interface CanvasView : UIView
{
    CGContextRef layerContext;
    CGLayerRef drawingLayer;
}
@end

헤더에서는 컨텍스트 1개와 레이어 1개를 선언했다. 터치가 움직일 때 그림을 그릴 레이어를 참고하는 구조체(CGLayerRef)와 그 레이어의 그래픽 컨텍스트를 참조하기 위한 CGContextRef 구조체가 필요하다.

레이어를 생성하기 위해서는 역시나 컨텍스트가 필요한데, 이는 현재 뷰의 컨텍스트를 그대로 사용하기로 한다. 위에서 이미 뷰는 코드를 통해 삽입하기로 했다. 아래 코드에서 initWithCoder:는 만약 이 뷰를 인터페이스 빌더를 통해 삽입한 경우에 수행된다.

구현부 – 초기화

@interface CanvasView : UIView
{
    CGContextRef layerContext;
    CGLayerRef drawingLayer;
}
@end

#import "CanvasView.h"

@implementation CanvasView

-(id)initWithFrame:(CGRect)frame
{ // 이 메서드는 코드상에서 이 뷰가 초기화될 때 호출됨
    self = [super initWithFrame:frame];
    CGContextRef = UIGraphicsGetCurrentContext();
    drawingLayer = CGLayerCreateWithContext(context,
            self.bounds.size,
            NULL);
    layerContext = CGLayerGetContext(drawingLayer);

    // 그래픽 컨텍스트에 그림을 그릴 때의 그래픽 속성
    CGContextSetStrokeColorWithColor(layerContext, [[UIColor redcolor] CGColor]);
    CGContextSetLineWidth(layerContext,1.3f);
    CGContextRelease(context);

    return self;
}

-(id)initWithCoder:(NSCoder *)aDecoder
{
    // 이 메서드는 IB에서 뷰가 추가되었을 때 초기화 시 호출됨
    self = [super initWithCoder:aDecoder];
    //  현재 그래픽 컨텍스트를 구해서 이것으로부터 새 CG레이어를 생성한다.
    CGContextRef context = UIGraphicsGetCurrentContext();
    drawingLayer = CGLayerCreateWithContext(context, self.bounds.size,NULL);
    layerContext = CGLayerGetContext(drawingLayer);

    // 그래픽 컨텍스트에 그림을 그릴 때의 그래픽 속성
    CGContextSetStrokeColorWithColor(layerContext, [[UIColor redcolor] CGColor]);
    CGContextSetLineWidth(layerContext,1.3f);
    //  사용하고 난 context는 릴리즈해준다.
    CGContextRelease(context);

    return self;
}

@end

터치가 발생하여 손가락이 움직이는 동안에는 손가락의 좌표를 추적하여, 해당 경로를 따라 선을 그린다. 이 때는 두 단계를 나눈다.

1. 먼저 터치가 발생할 때는 터치 이벤트를 처리하는 부분에서 레이어에 선을 그린다.

2. 그려진 선이 화면에 보이도록 하는 부분은 다른 곳에서 처리하면 안된다. 뷰가 필요시 업데이트 될 때 수행되는 drawRect: 메소드에서 구현해야 한다.

구현부 – touchesMoved:withEvent;

-(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
    CGPoint lastTouch, currentTouch;
    UITouch *touch = [touches anyObject];
    lastTouch = [touch previousLocationInView:self];
    currentTouch = [touch locationInView:self];

    // 레이어의 그래픽 컨텍스트에 패스를 그리기 시작한다.
    CGContextBeginPath(layerContext);
    CGContextMoveToPoint(layerContext,lastTouch.x, lastTouch.y);
    CGContextAddLineToPoint(LayerContext, currentTouch.x, currentTouch.y);
    CGContextStrokePath(layerContext);

    [self setNeedsDisplay];
}

터치 이벤트가 발생할 때 마다 레이어에 그림을 그렸다. 마지막 setNeedsDisplay는 뷰의 변경사항이 발생했을 때 뷰를 다시 그리도록 하기위해 보내는 메시지이다. 이 메서드는 따로 구현하지 않으며, 대신 drawRect: 메서드를 구현해야한다.

구현부 – 뷰 업데이트

-(void)drawRect:(CGRect)rect
{
    CGContext currentContext = UIGraphicsGetCurrentContext();
    CGContextDrawLayerInRect(currentContext, self.bounds, drawingLayer);
}

뷰를 업데이트하는 내용은 위와 같이 그려진 레이어를 현재 컨텍스트에 그리는 것으로 충분하다.

이제 앱을 빌드하고 실행하면, 까만 화면을 바탕으로 터치로 그림을 그리면, 빨간 선이 그려지는 것을 확인할 수 있게 된다.

다만 문제는 레티나 디스플레이를 장착한 아이폰4 및 아이팟터치 4 이상의 기기에서는 그림을 그리면 그릴수록 점점 느려진다. 특히 아이팟터치는 메모리 양이 작아서 그런지 이 증상이 심해진다. 이 증상에 대해서는 (아직 해결을 못한 것도 있고) 다음 기회에 따로 다루도록 하겠다.

오늘의 소스코드 : http://www.box.com/s/0dy3v15om4mgeee8a4i9