콘텐츠로 건너뛰기
Home » CGLayer를 사용한 핑거 드로잉 구현 (Objective-C)

CGLayer를 사용한 핑거 드로잉 구현 (Objective-C)

코어 그래픽(Core Graphics)은 저수준의 드로잉 명령 API들을 통해서 화면이나 비트맵이미지, PDF 등에 시각적 요소를 그릴 수 있게 하는 프레임워크이다. 예전에는 Quartz, CoreGraphics라는 이름으로 분리되어 있었는데 iOS5 부터 UIKit의 일부로 완전히 편입되었다. 간단한 모양의 시각적 오브제를 표현하기 위해 비트맵 이미지를 사용하는 것보다 런타임에 오브제를 빠르게 그리고, 이를 재사용할 수 있게 하는 등의 기능을 제공한다. 실제로 많은 앱들이 현재에도 코어 그래픽을 사용해서 UI를 표현하는 경우가 많이 있다. 이번 글에서는 코어 그래픽 API를 사용해서 손가락으로 화면에 그림을 그리는 간단한 캔버스 앱을 구현하는 방법을 살펴보기로 하겠다.

코어 그래픽을 사용할 때에는 이 프레임워크의 핵심 객체인 그래픽 컨텍스트에 대한 이해가 필요하다. 그래픽 컨텍스트는 개념상, 가상의 캔버스라 생각하면 된다. 우리는 그래픽 컨텍스트라는 이 가상의 캔버스에 그림을 그리게 되고, 쿼츠 엔진은 이 가상의 캔버스에 그려진 그림을 필요한 출력으로 가져다 렌더링한다.

이 때 그래픽 컨텍스트는 장치 독립적인 성격을 가지며, 하나의 그래픽 컨텍스트는 다른 장치를 위한 그래픽으로 쉽게 전환이 가능하다. 따라서 iOS 및 macOS에서 그래픽 출력은 아이폰 및 아이패드용 화면 출력 뿐 아니라, 인쇄나 PDF를 만들기도 쉽게 지원된다. 실제로 macOS를 보면 모든 뷰는 PDF로 변환이 가능하고, 매우 간단하게 출력기능을 붙일 수 있는데, 이것은 macOS의 드로잉 체계가 컨텍스트라는 개념을 중심으로 장치독립적으로 추상화되어 있기 때문에 가능한 것이다.

뷰(UIView, NSView)는 화면에 콘텐츠를 표현하기 위해 사용하는 클래스이고, 그 콘텐츠는 그려지는 그 순간에는 비트맵 데이터가 될 것이다. 뷰가 화면에 그려낼 콘텐츠를 제공하는 주체가 바로 그래픽 콘텍스트가 된다. 가상의 캔버스인 컨텍스트에 코어 그래픽 API를 사용하여 그림을 그리면 이 데이터가 그래픽 버퍼로 덤프되고, 그 결과 이미지가 화면에 뿌려지게 된다.

보통 특정한 뷰에 이렇게 그림을 그리기 위해서는 해당 뷰에서 “현재 컨텍스트”를 얻고 여기에 그림을 그리면 된다. 현재 컨텍스트에 그리기 명령을 내리는 것은, 뷰에 내용이 즉각 반영될 것이다. 만약 추가적으로 별도의 컨텍스트를 생성한다면, 화면에 그리는 것과 별개로 일종의 그래픽 데이터를 메모리 내에 준비해 둘 수 있다.

별도로 생성한 그래픽 컨텍스트의 내용은 현재 출력중인 화면에 그리는 것 외에 CGLayer상에 그리는 것도 가능하다. 이렇게 화면이 아닌 곳에 그래픽을 그리는 기법을 오프스크린 드로잉(offscreen drawing)이라고 한다. 오프스크린 드로잉은 주 스레드가 아닌 백그라운드에서 비트맵을 미리 생성할 수 있고, 여기에 CGLayer의 캐시 기능을 활용하면 체감 성능에 큰 영향을 주지 않고 큰 비트맵을 빠르게 그려낼 수 있는 좋은 방법이 된다.

오프스크린 드로잉 구현 절차

손가락으로 그림을 그릴 수 있는 뷰를 작성해보도록 하자. 대략의 구현 방법은 다음과 같다.

  1. 뷰와 크기가 똑같은 CGLayer를 하나 준비한다.
  2. 손가락이 화면을 지나면, 이 궤적을 따라 레이어에 라인을 그려준다. 매 touchMoved 이벤트마다 이전 터치 위치와 현재 터치 위치의 사이에 라인을 그린다.
  3. 그림이 그려진 레이어를 뷰에 다시 덧그린다.

실제로 손가락이 움직이는 궤적은 눈에 보이지 않는 캔버스에 그림을 그리는 것이다. 참고로 3의 동작을 언제 수행할 것인지에 따라 손가락이 움직이는 동안 라인을 그릴 수도 있고, 손가락이 떼지는 시점에 그릴 수도 있다.

프로젝트 시작

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

앱 실행시에 해당 뷰가 전면에 표시되도록 이 캔버스 뷰가 들어갈 뷰 컨트롤러의 파일에서 <span class="wp-method">viewDidLoad</span>를 다음과 같이 수정해서 루트 뷰에 캔버스뷰를 추가한다. (혹은 UI빌더에서 UI뷰를 하나 삽입하고, 그 클래스를 CanvasView로 선택해도 된다.)

viewcontroller.h 수정하기

기본으로 세팅된 메인 뷰가 로드되면 캔버스뷰를 만들어서 자기 위에 얹도록 코드를 작성한다. 만약 스토리보드에서 메인 뷰의 클래스를 Canvas로 변경했다면 이 코드는 작성하지 않는다.

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

캔버스뷰의 인스턴스 변수 정의

CanvasView의 헤더를 작성하자. 뷰 내에서 오프스크린 드로잉을 담당할 레이어를 위한 CGLayerRef 변수와, 그 레이어에 그림을 그릴 수 있는 CGContext 타입 변수를 선언한다. 이 두 변수는 OpaqueType 이며, Objective-C 클래스가 아니므로 * 를 붙이지 않음에 유의하자.

#import UIKit;

@interface CanvasView: UIView
{
    // *~Ref 인 타입은 그 자체로 Opaque Pointer임.
    CGContextRef _layerContext;
    CGLayerRef _drawingLayer;
} 
@end

초기화

통상의 UIView 초기화 메소드에서 컨텍스트와 레이어를 초기화할 별도의 메소드를 호출해주도록한다. 여기서는 비트맵 컨텍스트를 생성하고, 이 비트맵 컨텍스트와 연결된 CGLayer를 생성한다.

  1. 비트맵 컨텍스트를 하나 생성한다.
  2. 1의 컨텍스트를 기반으로 CGLayer를 생성한다. 사실 이 때 1의 과정은 필요 없을 수도 있는데, 직접 생성해주면 조금 더 최적화된다는 이야기가 있다.
  3. 2에서 생성한 레이어로부터 실제 레이어의 콘텐츠를 담을 컨텍스를 얻어서, 이를 _drawingContext 값으로 대입한다.
  4. _drawingContext를 사용해서, 그려질 선의 색이나 굵기등을 세팅할 수 있다.
#import "CanvasView.h"
@implementation CanvasView

-(id) initWithFrame: (CGRect)frame
{
    self = [super initWithFrame: frame];
    if(self) {
        [self setupContext];
    }
}
-(id) initWithCoder: (NSCoder*)aDecoder
{
    [supert initWithCoder:aDecoder];
        if(self) {
        [self setupContext];
    }
}


-(void) setupContext {
    CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB();
    CGContextRef ctx = CGBitmapContextCreate(
                  NULL,  // 메모리 블럭, NULL을 주어 자동으로 할당한다.
                  10,    // 픽셀 크기
                  10,    // 픽셀 크기
                  8,     // 컴포넌트당 비트
                  0,     // 한줄당 바이트.
                  cs,    // 컬러스페이스
                  kCGBitmapAlphaPremulipliedLast);
                  // 알파채널의 정렬방식

    // 레이어와 컨텍스트 생성/초기화.
    _drawingLayer = CGLayerCreateWithContext(ctx, self.bounds.size, NULL);
    _layerContext = CGLayerGetContext(drawingLayer);
    
    // 컨텍스트에 라인을 그릴 색과 선 굵기 설정
    CGContextSetStrokeColorWithColor(_layerContext, [[UIColor redColor] CGColor]);
    CGContextSetLineWidth(_layerContext, 4.8f);
    
    // create 한 객체들은 release 해준다.
    CGContextRelease(_ctx);
    CGColorSpaceRelease(_cs);
}

이 코드에서 좀 이상하게 보이는 부분이 있는데, 맨 처음 비트맵 컨텍스트를 생성할 때, 사이즈를 화면 크기가 아니라 10×10으로 좀 제멋대로 주었다는 점이다. 크기가 다른 그래픽 컨텍스트를 넘겼음에도 나중에 확인해보면 이 코드는 정상적으로 작동할 것이도. 왜 그럴까?

앞서도 언급했지만 그래픽 컨텍스트는 장치독립적인 “빈 페이지”이며, 이 페이지는 앱의 윈도에 그려지거나, 프린터로 출력되거나 혹은 비트맵 이미지로 고정될 수 있다. 이 때 각각의 출력 디바이스에 따라서 다른 정보들이 사용되고, 이는 내부적인 타입이 구분되어 사용되는 것으로 이해할 수 있다. CGLayerCreateWinContext()함수를 사용하여 레이어를 생성하면, 실제로 레이어의 컨텍스트는 별도로 생성되는데 이 때 첫번째 인자로 전달받은 컨텍스트의 속성을 참조하여 레이어를 위해 생성될 컨텍스트의 속성을 최적화해준다. (실제로 이를 NULL로 전달하는 것보다, 이렇게 간단하게 만들어서 전달하는 경우, 드로잉 성능이 더 좋다고 한다.) 즉, 이상의 코드에서 _ctx != CGLayerGetContext(_drawingLayer) 가 될 것이다.
 

터치 동작에 따른 오프스크린 드로잉 구현

UIView에서 터치가 움직일 때, 뷰는 -touchesMoved:withEvent: 메시지를 받는다. 이 메소드를 오버라이딩하여 움직인 만큼 부분에 선을 그려넣도록 한다. 선을 그려넣는 작업은 현재 뷰의 컨텍스트가 아닌 _layerContext이다. 여기에 콘텐츠를 그려넣은 다음에 뷰에는 레이어를 그려넣으면 된다. 뷰에 실제로 레이어를 그려넣는 부분은 이벤트 처리 코드에 들어가지 않는다.

- (void)touchesMoved:(NSSet<NSTouch *>*)touches withEvent:(UIEvent *)event
{
    CGPoint lastTouch, currentTouch;
    UITouch *touch = [touches anyObject];
    lastTouch = [touch previousLocationInView: self];
    currentTouch = [touch locatioinInView: self];
    // 선을 그리자. 
    // 이전 위치로 이동후 현재 위치로 선을 추가한다. 
    CGContextBeginPath(_layerContext);
    CGContextMoveToPoint(_layerContext, lastTouch.x, lastTouch.y);
    CGContextAddLineToPoint(_layerContext, currentTouch.x, currentTouch.y);
    CGContextStrokePath(_layerContext);
    // 그려진 레이어를 뷰에 반영하기 위해 뷰 업데이트를 스케줄링한다.
    [self setNeedsDisplay];
} 

만약 손가락을 뗐을 때만 뷰가 업데이트되도록 하려면 [self setNeedsDisplay];-touchesEnded:withEvent:에서 호출하도록 한다.

뷰를 업데이트하기 – drawRect: 구현

뷰는 setNeedsDisplay 메시지를 받으면 자신의 상태가 유효하지 않다는 것을 감지하고 뷰 영역을 새로 그리려고 시도한다. (즉시 그려지는 것이 아니다.) 실제로 뷰가 그려질 때에는 뷰의 -drawRect:가 호출된다. 이미 지금까지 그려놓은 모든 페인팅 내용은 drawingContext에 남아있고, 해당 내용은 _drawingLayer를 뷰에 그려주는 것으로 간단히 적용된다.

- (void)drawRect: (CGRect) rect
{
  [super drawRect:rect];
  CGContext ctx = UIGraphicsGetCurrentContext();
  CGContextDrawLayerInRect(ctx, self.bounds, _drawingLayer);
}

정리

이제 모든 소스 내용을 검토해보자.

  1. 앱의 메인 뷰에는 캔버스 뷰가 적용됐다.
  2. 캔버스 뷰는 생성 직후 CGLayer를 하나 생성한다.
  3. 터치가 움직이면 그 궤적에 따라 레이어에 그림을 그린다.
  4. 다시, 캔버스 뷰가 업데이트 요청을 받으면 해당 레이어를 뷰에 덧씌워그린다.

조금 더 깊이

코어 그래픽 컨텍스트는 장치 독립적인 가상 캔버스라고 했다. 그리고 어떤 장치를 통해서 표현되느냐에 따라서 비트맵 형식일 수도 있고, PDF 형식일수도 있다. CGLayerCreateWithContext() 에서 넘겨지는 컨텍스트는 실제로 생성된 레이어의 컨텍스트를 특정 타입으로 최적화하기 위해 필요하며, 생성된 레이어에 대해 CGLayerGetContext()로 얻게되는 컨텍스트와는 동일할 수도, 그렇지 않을 수도 있다. 따라서 이 둘이 같은 것이라는 어떠한 가정도 해서는 안된다. 실제로 _layerContext를 비트맵 컨텍스트로 생성한 후, 이 컨텍스트를 참조로 레이어를 만든다면 컨텍스트에 그린 그림이 레이어에 전혀 반영되지 않을 것이다.