키밸류 코딩이란

NSObject는 Objective-C의 표준 라이브러리라 할 수 있는 Foundation에서 가장 기본이 되는 최상위 클래스에 해당한다. 커스텀 클래스를 만들 때 아무 생각없이 상속받는 이 클래스는 Objective-C에서 클래스라는 것이 마땅히 갖추어야 하는 여러 가지 기능들을 미리 구현해둔 것이 아주 많이 있다. 그 중에서도 키밸류 코딩이라는 기술을 위한 기본적인 기능이 NSKeyValueCoding이라는 비정규 프로토콜에 정의되어 있고, NSObjects는 이를 따르고 있다. 따라서 몇가지 간단한 규칙을 지키면서 프로퍼티를 정의하기만 하면, 우리가 작성하는 모든 클래스의 프로퍼티들이 키밸류 코딩 호환이 될 수 있다. 그렇다면 키밸류 코딩은 무엇이고, 또 어떻게 활용되는 것인지에 대해서 살펴보자.

프로퍼티

키밸류 코딩은 어떠한 객체의 프로퍼티 값에 대해서 미리 정해진 접근자가 아닌 해당 프로퍼티의 이름 키를 사용해서 특정한 객체의 프로퍼티를 액세스하는 것을 말한다. 예를 들어서 어떤 클래스 Foo 에서 bar 라는 프로퍼티를 가지고 있다고 가정하고, 클래스 Foo를 작성하는 과정을 살펴보자. 먼저 Objective-C에서 어떤 클래스가 임의의 값을 저장하고 있으려면 그 값을 저장할 스토리지 변수가 필요하다. Objective-C의 클래스는 본질적으로 그 내부를 알 수 없는 불투명 구조체의 포인터이며, 구조체 내부의 멤버 변수는 인터페이스 선언부 최상단에 블럭을 사용해서 선언한다. 그리고 이렇게 선언된 멤버 변수는 외부와 완전히 격리되면서 외부에서는 액세스할 수 없고, 어떤 멤버 변수를 가지고 있는지 조차 알 수 없다. (이렇게 선언된 멤버 변수는 인스턴스 변수라 하고 흔히 ivar 라 지칭한다.) 따라서 이 변수에 값을 세팅하거나, 변수 값을 알아낼 수 있는 두 개의 메소드가 필요하다.

@interface Foo: NSObject
{
  NSString* _bar;
}
- (NSString*)bar;
- (void)setBar:(NSString*)newValue;
@end

Foo의 외부에서 해당 프로퍼티를 bar라는 이름으로 액세스하고, bar를 세팅하는 메소드를 -setBar:라고 이름붙였다. 멤버변수의 이름을 사실상 무엇이 되더라도 무관한데, 관습적으로는 getter의 이름과 똑같이 하거나 그 앞에 언더스코어를 붙인다. (언더스코어를 붙이는 이름이 멤소드 이름과 혼동을 줄이기 때문에 조금 더 권장된다.)

만약 이 bar라는 프로퍼티가 copy 시멘틱을 따른다고 하면, 두 메소드의 구현은 다음과 같이 작성될 것이다.

@implementation Foo
/// 초기화 시에 ivar를 초기화한다.
- (instancetype)init {
  self = [super init];
  _bar = nil;
}

- (NSString*)bar { return _bar; }
- (void)setBar:(NSString*)newValue]
{
  NSString* newBar = [[newValue copy] retain];
  [_bar release];
  _bar = newBar;
}
...
@end

즉 어떤 오브젝트가 그 내부에 어떤 값을 저장할 수 있고, 객체 외부에서 그 값을 액세스하려고 한다면 이 클래스는 다음의 세 가지 조건을 갖추어야 한다.

  1. 값을 저장할 수 있는 스토리지 변수
  2. 스토리지 변수를 액세스할 수 있는 getter 접근자
  3. 스토리지 변수를 업데이트할 수 있는 setter 접근자

만약 getter/setter 접근자가 모두 없는 경우라면, 해당 ivar는 클래스 내부에서만 참조할 수 있고, 외부에서는 액세스할 수 없는 값이 된다. 또 getter 메소드만 제공되는 경우라면, 객체 외부에서는 그 값을 getter 메소드를 통해서 읽을 수는 있지만 업데이트를 할 수 없는 읽기 전용의 값이 될 것이다. 이것이 Objective-C의 선언 프로퍼티의 핵심 내용이다.

따라서 어떤 클래스가 bar 라는 프로퍼티를 가지고 있다는 것은 그 프로퍼티가 -bar 혹은 -setBar: 라는 접근자 메소드를 가지고 있음을 의미한다. 그리고 그 객체에서 해당 프로퍼티를 액세스하는 것은 해당 접근자 메소드를 호출해야 하는 일이고, 따라서 객체로부터 어떤 값을 얻어와서 사용한다는 것은 “하드 코딩된 코드에서 미리 정해진 접근자 메소드를” 사용해야 한다는 것이다.

키밸류 코딩 – 문자열 기반 이름으로 동적인 프로퍼티 액세스

그런데, 임의의 객체 인스턴스 zoo 가 있다고 하자. 이 객체로부터 어떤 프로퍼티를 액세스해서 그 값을 얻으려고 한다. 그런데 어떤 프로퍼티를 가져올 것인지 혹은 갱신할 것인지가 컴파일 타임에 결정되지 않는다면 어떻게 해야 할까? 즉 “어떤 접근자 메소드를 호출할 것인지”를 코드를 작성하는 시점에 알 수 없는 것이다. “pee”라는 이름의 프로퍼티일 수도 있고, “tee”라는 이름의 프로퍼티 일수도 있는 것이다. 물론 객체 zoo가 이러한 접근자 메소드를 갖고 있는지 아닌지 여부조차 알 수 없을 수도 있다.

이처럼 컴파일 타임에 정의되지 않은 접근자 이름을 사용해서 런타임에 특정한 이름의 프로퍼티에 접근할 수 있는 기술이 키밸류 코딩이다. 키 밸류 코딩은 간단히 다음의 네 개의 메소드에 의존한다.

  • - (id)valueForKey:(NSString*)key / -(id)valueForKeyPath:(NSString*)keyPath
  • - (void)setValue:(id)obj forKey:(NSString*)key / - (void)setValue:(id)obj forKeyPath:(NSString*)keyPath

이 메소드들은 NSObject에 의해서 이미 구현되어 있다. 이 메소드들을 호출하여 성공적으로 특정한 프로퍼티에 액세스하기 위해서는 처음에 프로퍼티를 정의할 때, ivar와 접근자 메소드들의 이름이 중요하다.

  • valueForKey: 에서 키이름이 getter 메소드와 같거나
  • 키 이름과 동일한 ivar 혹은 앞에 언더스코어가 붙은 키 이름의 ivar가 있다.
  • setValue:forKey:는 키 이름을 첫글자를 대문자로 바꾸고 그 앞에 set-을 붙인 setter 메소드가 있다.

이러한 가정을 두고 있는 것이다. 만약 [zoo getValueForKey:@"bar"] 라고 했을 때,   zoo 가 Foo의 인스턴스라면, 이 메시지는 Objective-C 런타임 내부에서 [zoo bar] 로 번역될 것이다. 그리고 [zoo setValue:@"hello" forKey:@"bar"]라는 메시지를 받는다면 이는 다시 [zoo setBar:@"hello"];로 변경되어 호출될 것이다.1

키밸류 코딩을 따르는 방법

키밸류 코딩 호환 클래스를 작성하는 방법은 간단하다. 키밸류 코딩은 결국 키 이름을 기반으로 그에 매칭되는 접근자 메소드 및 인스턴스 변수를 런타임이 동적으로 찾아서 액세스해주는 기술이기 때문에 어떤식으로 프로퍼티 이름을 짓느냐는 것만, 관습을 따르면 되며, 그 관습이란 앞서 소개한 Foo의 bar와 같다.

  • 기본적으로 getter 이름이 프로퍼티 이름이며, 이것이 곧 키 이다.
  • setter 이름은 setKeyName: 과 같은 식으로 작성한다. getter이름의 첫글자를 대문자로 바꾸고 앞에 set을 붙인다.
  • ivar 이름은 getter이름과 똑같거나, 앞에 언더스코어를 붙인다.

그리고 이 관습은 @property 문법을 쓰면 자동으로 지켜진다.

@interface Foo: NSObject
@property (copy, nonatomic) NSString* bar;
@end

이상의 코드만으로 키밸류 코딩에서 요구하는 인스턴스변수, getter 메소드, setter 메소드를 모두 작성한 것과 다름없는 결과를 얻을 수 있다. 이것은 언어의 기능이라기보다는 컴파일러가 소스코드를 처리하기 직전에 자동으로 관련 코드를 만들어서 삽입해준다고 보면 된다. (이전에는 @synthesize bar; 같은 구문을 구현부에 써야했는데, LLVM 컴파일러는 이런 처리도 모두 자동으로 해주기 때문에 굳이 쓸 필요없다.)

키밸류 코딩은 왜 중요한가

그렇다면 키밸류 코딩은 왜 중요한가? 이것은 특정한 프로퍼티가 변경될 때, 자동으로 옵저버들에게 통지가 가는 키밸류 옵저빙을 비롯하여, 이 기술을 기반으로 하고 있는 코코아 바인딩등에서 기본 가정으로 “모든 참여 객체가 KVC/KVO 호환이다”라는 것을 가정하기 때문이다.

  • 키밸류 코딩 이름 규칙을 지원하면 valueForKey:, setValueForKey:는 따로 구현하지 않더라도 자동으로 지원된다.
  • 키밸류 코딩 규칙을 따르더라도 _bar = @"hello";와 같이 인스턴스 변수를 직접 변경해버리면 이는 KVO와 호환되지 않는다.
  • KVO에서는 반드시 [foo setValue:@"hello" forKey:@"bar"]를 쓰지 않아도 된다. [foo setBar:@hello]라고만 써도, 런타임에서 자동으로 통지를 보낼 수 있다. self.bar = @"hello"; 역시 setter 메소드 호출과 1:1로 치환되므로 KVO 호환이 된다. 이는 KVC 호환인 메소드는 필요한 경우 런타임에 의해 자동으로 다른 내부 메소드로 치환되기 때문에 적용가능하다. 물론 메소드 이름이 정해진 규칙을 벗어나면 이러한 기능은 지원되지 않는다.

기본적인 키밸류 코딩은 특정한 단일 값 프로퍼티의 변경을 런타임에서 동적으로 관리하는 수준에서 적용된다. 하지만 Foundation에서는 배열이나 Set과 같은 집합형식 자료 구조에 대해서도 KVC/KVO를 지원한다. 이는 단순히 이름 규칙만으로는 지원될 수 없으며, 별도의 메소드들을 추가로 작성해주어야 하는데 (대부분 NSMutableArray, NSMutableSet의 메소드들 간단힌 래핑하는 수준의 구현이다.) 이를 지원하도록 하는 것은 다음 기회에 추가로 소개하도록 하겠다.

참고자료

 

 

 

 

 


  1.   물론 키밸류 코딩은 이렇게 간단한 일차원적 변환 이상의 것이다. 실질적으로 @property 문법이 확립되어 적용되기 이전부터 존재해온 기술이기 때문에 탐색 패턴은 좀 더 많은 경우를 순차적으로 따르게 된다. 

[iOS] UIScrollView 사용법

업데이트

UIScrollView를 Swift에서 사용하는 방법에 대한 (적어도 이 글 보다는 나은) 새 버전을 참고하세요.

UIScrollView는 gesture recognizer를 내장하여 실제 뷰 영역보다도 큰 영역을 스크롤하여 내용을 볼 수 있도록 해주는 클래스이다. 사진 앨범 앱의 사진 보기 화면에서 이 스크롤뷰가 사용된다. (카메라롤의 사진 목록 역시 스크롤뷰로 구현되어 있다.)

스크롤뷰는 관성 이동은 물론 내부 컨텐츠를 확대/축소하는 방법을 아주 간단히 처리할 수 있어 주로 이미지와 관련된 화면에서 상당히 유용하게 활용할 수 있다.

스크롤뷰를 사용하는 방법은 UIViewController와 거의 유사하다. 인스턴스를 생성해서 하위뷰를 추가해주면 된다. 스크롤뷰의 뷰 크기는 실제 화면에 노출되는 영역의 크기이고, 실제 전체 컨텐츠의 영역을 ContentSize로 지정해주어야 스크롤이 제대로 동작한다.

또한 줌을 위해서는 최대스케일 값과 최소 스케일 값을 지정해야 하며 (이는 IB에서도 할 수 있다.) 실제 줌 동작에 반응하기 위해서는 델리게이트가 어떤 뷰가 줌을 받게 되는지를 지정해 주어야 한다. 아주 간단한 예제를 통해 알아보도록 한다.

스크롤뷰 스터디 샘플

설명의 편의를 위해 이번에는 IB를 전혀 사용하지 않고 코드로만 작업해 본다. 우선 프로젝트를 신규로 만든다. 이 때 템플릿은 Single View Application을 사용한다. 또한 스크롤 뷰를 위해 큼지막한 이미지를 하나 더 준비한다. 프로젝트가 생성되면 Xcode 창의 왼쪽 파일 네비게이션 영역으로 파일을 끌어다 놓으면 프로젝트에 이미지 파일을 추가할 수 있다.

ViewController.m

오늘은 이 파일에서 모든 것을 처리해보자. 앱이 실행되면 루트 뷰에 스크롤뷰를 하나 추가하는데, 이 스크롤뷰에는 UIImageView가 하나 추가된다. 이 이미지뷰 안에 방금 추가한 이미지를 넣어서 스크롤이 되도록 해 볼 것이다.

먼저 파일 이름을 따로 매크로로 만들어 두고, 바로 private interface를 정의하도록 한다.

#import "ViewController.h"

#define SCV_IMAGE_FILENAME @"이미지파일.JPG"

@interface ViewController() <UIScrollViewDelegate>
@property (strong, nonatomic) UIScrollView *scrollView;
@property (strong, nonatomic) UIImageView *imageView;
@property (strong, nonatomic) UIImage *anImage;
@end

인터페이스를 정의하면서 뷰컨트롤러가 UIScrollViewDelegate 프로토콜을 따르도록 했다. 이는 줌인/줌아웃을 어떤 뷰가 받을지를 스크롤뷰에게 알려주는 역할을 하기 위해서다. (물론 imageView가 하게 된다.)

다음은 synthesize 하면서 인스턴스 변수명을 함께 정해주자. (변수명 앞에 언더스코어를 붙이는 것은 혼동을 방지하기 위해서이다. 혹은 어떤 규칙이 있는지도 모르겠다.)
@synthesize scrollView = _scrollView, imageView = _imageView, anImage = _anImage;

이제 각 프로퍼티는 처음으로 호출될 때 초기화되도록 하면 된다. 이 때 스크롤뷰의 초기화 부분을 눈여겨 보라.

-(UIImage *)anImage
{
    if(!_anImage) _anImage = [UIImage imageWithName:SCV_IMAGE_FILENAME];
    return _anImage;
}

-(UIImageView *)imageView
{
    if(!_imageView) _imageView = [[UIImageView] alloc]initWithImage:self.anImage];
    return _imageView;
}

-(UIScrollView *)scrollView
{
    if(!_scrollView) {
        CGRect viewFrame = CGRectMake(0,0,320.0f,460.0f);
        _scrollView = [[UIScrollView alloc] initWithFrame:viewFrame];
        _scrollView.contentSize = self.imageView.frame.size;
        _scrollView.minimumZoomScale = 0.1f;
        _scrollView.maximumZoomScale = 3.0f;
        _scrollView.delegate = self;
        [_scrollView addSubview:self.imageView];
    }
    return _scrollView;
}

이제 앱이 실행되고 루트뷰가 로드될 때 스크롤뷰를 화면에 추가하도록 하자.

-(void)viewDidLoad
{
    [super viewDidLoad];
    [self.view addSubview:self.scrollView];
}

스크롤뷰에서 확대 축소하기

이제 앱을 빌드하고 실행하면 이미지가 화면에 표시되고, 드래그하여 스크롤이 되는 것을 확인할 수 있다. 하지만 아직 줌이 되지 않는다. 이는 위에서 이야기한 스크롤뷰에게 어떤 뷰가 줌이 되는지를 알려주지 않아서이다. UIScrollViewDelegate 프로토콜에 정의된 메소드 중 viewForZoomingInScrollView:를 추가해준다.

-(UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
{
    return self.imageView;
}

이제 이미지를 두 손가락으로 확대/축소할 수 있게 됨을 볼 수 있다.

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를 비트맵 컨텍스트로 생성한 후, 이 컨텍스트를 참조로 레이어를 만든다면 컨텍스트에 그린 그림이 레이어에 전혀 반영되지 않을 것이다.