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

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

Mac 및 iOS에서의 그래픽은 장치독립적 그래픽이라는 이상을 추구하는데, 어떤 화면이나 기기 혹은 화면/출력물 등의 차이에 구애받지 않는 그래픽 구현을 목표로 한다. 쉬운 예를 들어 PC모니터에서는 1인치에 72개 이상의 픽셀을 찍는 것이 어렵지만 (물론 요즘은 레티나 같은 디스플레이가 나와서 거의 프린터 수준의 해상력을 갖는 것이 가능하고, 아이폰3GS의 경우에도 100ppi 이상의 해상력을 가지고 있기는 하다.) 프린터로 출력하는 경우에는 1인치에 300개의 픽셀을 찍는 것도 가능하다.

기기 혹은 매체마다 화면 해상도가 다르다보니, 보통 PC에서 보던 이미지를 종이에 출력하면 해상도 차이 때문에 아주 작게 줄어들어서 표현되거나 하는 경우가 있다. 애플은 화면에 표시되는 내용과 출력물의 내용의 괴리를 최대한 줄이고, 장치에 구애 받지 않고 최대한 비슷한 그래픽을 구현하기 위해 시스템 그래픽 엔진 자체를 벡터로 구현하는 방식을 추구하였고, 이에 대한 연구의 산물이 바로 쿼츠 엔진이다.OSX의 화면 효과가 자연스럽고, 멋진 이유 중 하나는 다름 아닌 이 쿼츠 엔진의 덕이라 볼 수 있다.

이 쿼츠는 iOS에서도 적용되어 있다. 따라서 이미지를 화면에 올릴 때 크기를 정수가 아닌 실수(float)형으로 크기를 지정해 주는데, 이는 픽셀 단위가 아니라 포인트(pt)단위로 그림의 크기를 지정하는 것이다. (pt 단위는 픽셀과 달리 cm, inch와 같은 실제 물리적인 길이 단위이다. 1포인트는 1/72 인치의 실제 길이 단위이다.)

그래픽 컨텍스트

쿼츠를 사용하여 그래픽을 구현하기 위해서는 그래픽 컨텍스트에 대한 이해가 필요하다. 그래픽 컨텍스트는 개념상, 가상의 캔버스라 생각하면 된다. 우리는 그래픽 컨텍스트라는 이 가상의 캔버스에 그림을 그리게 되고, 쿼츠 엔진은 이 가상의 캔버스에 그려진 그림을 실제 장치의 디스플레이에 투영하여 그래픽을 출력해주는 것이다. 그래픽 컨텍스트는 장치 독립적인 성격을 가지므로, 하나의 그래픽 컨텍스트는 다른 장치를 위한 그래픽으로 쉽게 전환이 가능하다. 따라서 그래픽 컨텍스트에 적용된 그래픽은 아이폰 및 아이패드용 화면 출력 뿐 아니라, 인쇄나 PDF를 만들기도 쉽게 지원된다.

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

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

핑거드로잉

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

프로젝트 시작

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

앱 실행시에 해당 뷰가 전면에 표시되도록 ViewController 파일을 다음과 같이 수정해 준다.

ViewController.m

1
2
3
4
5
6
#import "CanvasView"
...
-(void)viewDidLoad {
CanvasView *canvas = [[CanvasView alloc] initWithFrame:self.view.frame];
[self.view addSubView:canvas];
}

인터페이스

새로 뷰를 만들 때 다음과 같은 형태를 구상할 수 있다.

  1. 터치할 때 레이어에 그림을 그린다.
  2. 레이어에 그려진 그림을 다시 현재 뷰의 컨텍스트에 그린다. (실제 그림이 그려진다.)
1
2
3
4
5
6
@interface CanvasView : UIView
{
CGContextRef layerContext;
CGLayerRef drawingLayer;
}
@end

헤더에서는 컨텍스트 1개와 레이어 1개를 선언한다. 터치가 움직일 때 그림을 그릴 레이어와 그 레이어의 그래픽 컨텍스트를 참조하기 위한 CGContextRef 구조체가 필요하다. 레이어를 생성하기 위해서는 역시나 컨텍스트가 필요한데, 이는 현재 뷰의 컨텍스트를 그대로 사용하기로 한다. 뷰를 코드 상에서 가져올 수도 (현재 예제) 있고 IB에서 끌어다 놓을 수도 있기 때문에 초기화 메서드는 2개를 작성한다.

구현부 – 초기화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#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];
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;
}
@end

터치가 발생하여 손가락이 움직이는 동안에는 손가락의 좌표를 추적하여, 해당 경로를 따라 선을 그린다. 이는 손가락이 움직일 때 마다 호출되는 메서드에 넣어 그려주면 된다.

구현부 – 터치 액션

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-(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: 메서드를 구현해야한다.

구현부 – 뷰 업데이트

1
2
3
4
5
-(void)drawRect:(CGRect)rect
{
CGContext currentContext = UIGraphicsGetCurrentContext();
CGContextDrawLayerInRext(currentContext, self.bounds, drawingLayer);
}

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

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

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

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