트랙킹 캔버스 뷰 만들기 (Cocoa)

예전에 코어 그래픽을 사용해서 UIView위에 손가락으로 그림을 그릴 수 있는 간단한 핑거 드로잉 캔버스를 구현해본 바 있는데, 똑같은 내용을 NSView에 적용해보고자 한다. 이전글이 Objective-C로 작성되어 있는데, 이번에는 Swift로 간단하게 작성해보려 한다. 원리는 동일하다. CGLayer를 하나 만들고, 마우스를 사용해서 뷰를 긁을 때(드래그할 때)마다  코어 그래픽을 사용해서 레이어에 그림을 그리고, 다시 뷰 리드로잉 사이클에서는 뷰에 레이어를 그리는 것이다.

class TrackDrawCanvasView: NSView {
  var previousPoint: NSPoint? = nil
  lazy var drawingLayer: CGLayer? = { [unowned self] in
    let cs = CGColorSpace(name:CGColorSpace.sRGB)!
    let ctx = CGContext(data: nil,
                        width: 100,
                        height: 100,
                        bitsPerComponent: 8,
                        bytesPerRow: 0,
                        space: cs,
                        BitmapInfo: CGImageAlphaInfo.nonSkipLast.rawValue)
   if let ctx = ctx {
     let layer = CGLayer(ctx, size: self.bounds.size, auxiliaryInfo: nil)
     return layer
   }
   return nil
  }()

  lazy var drawingContext: CGContext? = { [unowned self] in
    let ctx = self.drawingLayer?.context
    // 그래픽 컨텍스트 셋업
    ctx?.setStrokeColor(NSColor.green.cgColor)
    ctx?.setLineWidth(3.0)
    // 이벤트 위치를 프레임만큼 보정 
    ctx?.translateBy(x:-self.frame.origin.x, y:-self.frame.origin.y)
    return ctx
  }
}

터치할 때 마우스 위치와 선이 그려지는 위치를 맞추기 위해서 좌표계를 뷰의 위치만큼 거꾸로 이동시켰다. 다음은 화면을 그릴 차례이다. NSEvent는 드래그에 대해서 이전 위치를 가지고 있지 않기 때문에 이전 위치를 추적해 나가야 한다.

///
var previousPoint: NSPoint? = nil

override func mouseDown(wit틀h event: NSEvent) {
    previousPoint = event.locationInWindow
}

override func mouseDown(with event: NSEvent) {
    previousPoint = nil
}

override func mouseDragged(with event: NSEvent) {
    let currentPoint = event.locationInWindow
    drawingContext?.beginPath()
    drawingContext?.move(to: previousPoint)
    drawingContext?.addLine(to: currentPoint)
    drawingContext?.strokePath()
    previousPoint = currentPoint
    needsDisplay = true
}

최종적으로 뷰를 그릴 때는 레이어를 그려주면 된다.

override func draw(_ dirtyRect: NSRect) {
  super.draw(dirtyRect)
  if let ctx = NSGraphicsContext.current?.cgContext,
  let layer = drawingLayer {
    ctx.draw(layer, at: CGPoint.zero)
  }
}

인터페이스 빌더에서 뷰를 윈도에 하나 올린 후 드래실행해보자. 끝!

참고

 

[iOS] 코어그래픽에 손대기

코어그래픽은 뷰에 그림을 그리는 기술이다. 하지만 C로 짜여진 API에 온갖 혼란스러운 함수명하며, 컨텍스트와 같은 어려운 개념들이 들어가면서 나와 같은 초보자에게는 마치 ‘넘을 수 없는’ 장벽과 같이 느껴진다. 하지만 많은, 정말 많은 튜토리얼들이 코어그래픽을 사용해서 그림을 그리는 이유는 명확하다. 바로 “아주 빠르기” 때문이다. 코어 그래픽은 그래픽 메모리의 버퍼를 직접 다루므로 아주 빠르다. (다만 일부 기기에서는 느리기도 하더라 ㅠㅠ) 오늘은 “시작하는 마음가짐”으로 코어 그래픽에 대해 잠깐 살펴보도록 하자.

코어그래픽을 사용하는데는 ‘컨텍스트’라는 개념을 이해해야 하는데, 이는 뒤에서 설명하기로 하고 잠시동안 ‘컨텍스트’는 그림을 그리는 도화지, 혹은 포토샵에서의 레이어와 같다고 간주하자.
[iOS] 코어그래픽에 손대기 더보기

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

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

코어 그래픽을 사용할 때에는 이 프레임워크의 핵심 객체인 그래픽 컨텍스트에 대한 이해가 필요하다. 그래픽 컨텍스트는 개념상, 가상의 캔버스라 생각하면 된다. 우리는 그래픽 컨텍스트라는 이 가상의 캔버스에 그림을 그리게 되고, 쿼츠 엔진은 이 가상의 캔버스에 그려진 그림을 필요한 출력으로 가져다 렌더링한다. 따라서 그래픽 컨텍스트는 장치 독립적인 성격을 가지며, 하나의 그래픽 컨텍스트는 다른 장치를 위한 그래픽으로 쉽게 전환이 가능하다. 따라서 그래픽 컨텍스트에 적용된 그래픽은 아이폰 및 아이패드용 화면 출력 뿐 아니라, 인쇄나 PDF를 만들기도 쉽게 지원된다. 실제로 macOS를 보면 모든 뷰는 PDF로 변환이 가능하고, 화면에 그려지는 모든 것이 PDF인 동시에 PNG일 수 있는데, 이것은 macOS의 드로잉 체계가 컨텍스트라는 개념을 중심으로 장치독립적으로 추상화되어 있기 때문에 가능한 것이다.

모든 화면에 출력되는 모든 뷰는 콘텐츠를 시각적으로 표현하는 도구이며, 그 콘텐츠를 뷰에 제공해주는 주체가 바로 그래픽 컨텍스트이다. 가상의 캔버스인 컨텍스트에 코어 그래픽 API를 사용하여 그림을 그리면 이 데이터가 그래픽 버퍼로 덤프되고, 그 결과 이미지가 화면에 뿌려지게 된다. 보통 특정한 뷰에 이렇게 그림을 그리기 위해서는 해당 뷰에서 “현재 컨텍스트”를 얻고 여기에 그림을 그리면 된다. 물론 컨텍스트는 별도로 생성할 수 있다. 별도로 생성한 컨텍스트에 그림을 그리는 것은 일종의 그래픽 데이터를 메모리 내에 준비하고 있는 것이 된다.

만약 추가적으로 생성한 그래픽 컨텍스트와 CGLayer를 결합하면, 컨텍스트의 데이터가 레이어의 콘텐츠를 제공하게 되고, 다시 이 레이어는 다른 컨텍스트에서 일종의 스탬프처럼 찍어서 사용할 수 있다. 이런식으로 직접 화면에 그리는 것이 아니라, 컨텍스트에 미리 그림을 그려놓고 이것을 재활용하여 반복적인 문양을 그리는 것을 (왜냐면 CGLayer는 재사용할 때 캐시된다!) 오프스크린 드로잉이라고 한다. 특히 오프스크린 드로잉은 백그라운드 스레드에서 처리가 가능하다는 장점이 있다. (iOS에서는 화면에 무언가를 그리기 위해서는 반드시 메인스레드에서 작업해야 한다.) 따라서 워커 스레드에서 미리 콘텐츠를 제작해놓고 메인스레드에서는 최종 결과물만 업데이트하는 식으로 처리하여 화면의 빠른 드로잉이 가능하게 할 수 도 있다.

플로우

핑거 드로잉은 말 그대로 화면에 손가락을 터치하고, 손가락이 터치해서 움직이는 경로를 따라 화면에 선이나 무늬를 그려넣는 것을 말한다. 즉, 가장 기본적인 인터랙티브 드로잉 방법이다. 이 핑거 드로잉을 지원하는 캔버스 뷰를 작성해보도록 하겠다. 기본 개념은 다음과 같다.

┌───CanvasView───┐                  ┌─Offscreen Layer─┐
│  View'sContext │                  │ Layer's Context │  
│                │    touch --->    │                 │
│                │                  │                 │
│                │                  │                 │
│                │       Draw       │                 │
└────────────────┘ <--------------- └─────────────────┘
  1. 뷰와 크기가 똑같은 레이어를 하나 준비한다.
  2. 손가락이 움직이는 궤적은 레이어에 그려진다.
  3. 레이어에 그림이 그려진 후에는 뷰에 레이어를 그린다.

실제로 손가락이 움직이는 궤적은 눈에 보이지 않는 캔버스에 그림을 그리는 것이다. 그리고 이렇게 그려진 데이터를 뷰에 언제 그리느냐에 따라서 손가락을 움직이는 사이사이에 선이 그려지게 할 것인지, 아니면 손가락을 떼는 시점에 그림이 나타나게 할 것인지를 결정할 수도 있다.

프로젝트 시작

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

앱 실행시에 해당 뷰가 전면에 표시되도록 이 캔버스 뷰가 들어갈 뷰 컨트롤러의 파일에서 viewDidLoad를 다음과 같이 수정해서 루트 뷰에 캔버스뷰를 추가한다. (혹은 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
{
  CGContextRef layerContext;
  CGLayerRef drawingLayer;
} @end

초기화

초기화작업은 통상의 UIView의 초기화 프로세스를 따른 후, 두 개의 인스턴스 변수에 대한 초기화를 수행한다. 이 때의 순서는 다음과 같다.

  1. 비트맵 컨텍스트를 하나 생성한다.
  2. 1의 컨텍스트를 기반으로 CGLayer를 생성한다. 사실, 1에서 컨텍스트를 만들지 않고 참조 컨텍스트로는 NULL을 넣어도 상관없다. 하지만 넣어주는 경우에 조금 더 최적화된다고 한다.
  3. 2에서 생성한 레이어로부터 실제 레이어의 콘텐츠를 담을 컨텍스를 얻어서, 이를 drawingContext 값으로 대입한다.
  4. drawingContext를 설정한다. 선의 색이나, 굵기, 끝모양 등의 정보를 지정할 수 있다.
#import "CanvasView.h"

@implementation CanvasView
-(id) initWithFrame: (CGRect)frame
{
  self = [super initWithFrame: frame];
  if (self) {
    [self initContext];
  }
}

-(id) initWithCoder: (NSCoder*)aDecoder
{
  self = [super initWithCoder: aDecoder];
  [self initContext];
}

-(void) initContext {
  CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB();
  CGContextRef ctx = CGBitmapContextCreate(
                  NULL, 
                  10, 
                  10, 
                  8, 
                  0, 
                  cs, 
                  kCGBitmapAlphaPremulipliedLast);
  // 각각의 파라미터는 다음과 같은 의미이다.
  // 1 : NULL - 비트맵을 저장할 메모리 블럭, NULL을 넘겨주면 이 함수가 자동으로 할당한다.
  // 2, 3 : 10, 10 은 컨텍스트의 픽셀 크기이다. 
  // 4 : 8 - 컴포넌트당 비트. RGBA가 각각 8비트, 총 32비트 트루컬러를 사용할 것이다.
  // 5 : 0 - 한 줄당 바이트. 픽셀당 4바이트를 사용하고 총 10픽셀 폭이니 40이 될 것이다. 이는 1번 파라미터가 NULL인 경우
  //         0을 넘겨서 자동계산하게 할 수 있다. 
  // 6 : cs - 컬러스페이스
  // 7 : 비트맵정보 - 미리 정의된 CGImageAlphaInfo 상수를 사용한다.

  // 레이어와 컨텍스트 생성/초기화.
  drawingLayer = CGLayerCreateWithContext(ctx, self.bounds.size, NULL);
  drawingContext = CGLayerGetContext(drawingLayer);
  // 컨텍스트에 그림을 그리는 방법을 세팅한다.
  CGContextSetStrokeColorWithColor(drawingContext, [[UIColor redColor] CGColor]);
  CGContextSetLineWidth(drawingContext, 4.8f);

  // 참조용으로 생성한 객체들을 정리한다. 
  CGContextRelease(ctx);
  CGColorSpaceRelease(cs);
}

이 코드에서 주목해야 할 점은 맨 처음 비트맵 컨텍스트를 생성할 때, 사이즈를 화면 크기가 아니라 제멋대로 주었다는 점이다. 이 컨텍스트를 기반으로 레이어를 만들 때, 레이어만 뷰의 크기와 일치시켰다. CGLayerCreateWithContext() 함수에서 흔히 잘못알고 있는 점은 이 함수에서 넘겨지는 그래픽 컨텍스트 객체는 레이어에 그려지는 그림과는 실제 무관할 수 있다는 점이다. 실제로 비트맵 컨텍스트를 만들지 않고 NULL을 전달하여도 코드는 정상적으로 동작한다. 그렇다면 이는 왜 필요한 것일까?

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

 

터치 동작 구현

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

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

  // 선을 그리자. 
  // 이전 위치로 이동후 현재 위치로 선을 추가한다. 
  CGContextBeginPath(drawingContext);
  CGContextMoveToPoint(drawingContext, lastTouch.x, lastTouch.y);
  CGContextAddLineToPoint(drawingContext, currentTouch.x, currentTouch.y);
  CGContextStrokePath(layerContext);

  // 그려진 레이어를 뷰에 반영하기 위해 뷰 업데이트를 스케줄링한다.
  [self setNeedsDisplay];
} 

이상의 구현에서 특별한 점은 없다. 여기서는 터치가 조금씩 움직이는 주기마다 컨텍스트에 선을 추가하고, 뷰를 업데이트하도록 한다. 만약 손가락을 뗐을 때만 뷰가 업데이트되도록 하려면 [self setNeedsDisplay];를 touchesEnded:withEvent:에서 호출하도록 한다.

뷰 업데이트

setNeedsDisplay 메시지를 받으면 뷰는 자신의 상태가 유효하지 않다는 것을 감지하고 뷰 영역을 새로 그리려고 시도한다. 이는 drawRect: 메시지를 호출하여 그리게 된다. 이미 지금까지 그려놓은 모든 페인팅은 drawingContext에 남아있고, 이를 뷰에 찍기 위해서는 drawingLayer를 그려주면 된다. 여기서는 뷰의 현재 컨텍스트를 이용해서 레이어를 그리면 된다.

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

정리

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

  1. 앱이 시작되면 메인 뷰에 캔버스 뷰 인스턴스를 만들어서 붙인다.
  2. 캔버스 뷰는 생성되면 자신의 크기와 같은 오프스크린 레이어와 레이어에 그림을 그릴 컨텍스트를 생성한다.
  3. 뷰가 터치를 받고, 터치가 움직이면 이 궤적을 따라서 선을 만들 수 있고, 이 선은 레이어의 컨텍스트에 추가된다.
  4. 선을 그릴 때마다 뷰는 업데이트 요청을 받고, 컨텍스트의 비트맵 데이터가 투영된 레이어가 뷰에 찍힌다.

조금 더 깊이

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