코코아 앱 따라하기 – 3

지난 시간까지 macOS용 TTS앱을 간단하게 구현하고, 현재 상태에 따라 UI를 업데이트하도록 다듬고, 테이블 뷰를 사용하는 등의 기본적인 앱 구현에 관한 내용을 살펴보았다. 인터페이스 빌더와 코드 편집기를 오가며 앱을 만드는 방법에 있어서 macOS앱이나 iOS앱은 큰 차이가 없는 듯 보였다. 이번 시간에는 macOS용 앱을 만들 때만 사용 가능한 코코아 바인딩이라는 기술을 통해서 이 앱을 처음부터 새로 작성하는 과정을 살펴볼 것이다.

이전 글들에서 아웃렛과 액션을 통해서 컨트롤러와 뷰사이를 연결하는 작업을 해본 바 있다. UI 컨트롤을 사용자가 조작했을 때, 어떠한 동작을 실행하고 싶다면 컨트롤러에 IBAction 이라는 힌트가 붙은 메소드를 작성하고 이를 인터페이스 빌더에서 연결해야 한다. 반대로 컨트롤러가 특정한 UI 요소의 값을 액세스하거나 조작하고 싶다면 IBOutlet이라는 힌트가 붙은 프로퍼티를 만들어서 코드상에서 이를 제어할 수 있게 된다.

컨트롤러는 모델의 데이터와 뷰 사이를 연결하는 일을 담당하는 위치에 있고, 대부분 이 일은 뷰와 모델을 연결하는 단순한 접착코드(glue code)인 경우가 많다. iOS와 달리 macOS앱은 그 환경이 환경인만큼 화면 상에 상당히 많은 UI 요소가 탑재될 수 있는데, 그에 대해서 일일이 이러한 코드를 양방향으로 작성해주는 것은 매우 지루하고 고단한 일이 될 수 있다. 코코아 바인딩은 이러한 코드를 작성하지 않고 UI 컨트롤에 제공되는 바인딩 요소를 인터페이스 빌더에서 설정하는 것으로 뷰의 상태와 데이터 값을 양방향으로 동기화할 수 있게 해주어, 단순한 상태값 변경에 관한 코드 대부분을 제거할 수 있다.

프로젝트를 제거하고 처음부터 새로 만들어볼 것이다. 새 프로젝트를 시작하고 앱 컨트롤러 클래스를 추가하는 것 까지는 지난 글과 동일한 과정을 따른다.  앱 컨트롤러 클래스를 프로젝트에 추가하였으면 해당 파일을 열고 편집을 시작하자.

앱 컨트롤러 만들기

앱 컨트롤러의 인터페이스는 기존과 같이 Foundation 대신에 Cocoa를 사용하도록 수정한다. 그리고 테이블 뷰와 관련된 프로토콜은 따르지 않아도 상관없다. 테이블 뷰를 다루는 모든 조작은 인터페이스 빌더 상에서 일어날 것이다.

헤더 편집

#import <Cocoa/Cocoa.h>

@interface AppController: NSObject <NSSpeechSynthesizerDelegate>
@end

액션 메소드와 프로퍼티 정의

AppController.m 파일로 이동해서, 내부 인터페이스를 아래와 같이 작성한다. 버튼이나 텍스트 필드에 대한 아웃렛은 모두 제거된다. 대신에 앱 컨트롤러는 사용자가 입력한 텍스트를 저장하는 words라는 NSString 타입 프로퍼티를 하나 가져야 한다. 그리고 UI의 상태를 좌우할 canSpeak라는 프로퍼티도 갖는다. 그외에 테이블 뷰의 선택 영역 정보를 동기화할 selectionIndexes라는 프로퍼티도 추가로 선언한다. 한꺼번에 써서 많아 보이지만, 실제로 지난 버전과 크게 다르지 않은 분량이다.

#import "AppController.h"

@interface AppController ()
@property (strong, nonatomic) NSSpeechSynthesizer* speech;
@property (copy, nonatomic) NSString* words;
@property (nonatomic) BOOL canSpeak;
@property (copy, nonatomic) NSIndexSet* selectionIndexes;
@property (weak, nonatomic) IBOutlet NSTableView* tableView;
@property (strong, nonatomic) NSArray* allVoices;
- (IBAction)startSpeaking:(id)sender;
- (IBAction)stop:(id)sender;
@end

선언한 프로퍼티들 실제로 초기화 코드가 필요한 것은 speechallVoices 뿐인데, 그 코드는 기존의 것과 동일하다. 참고로 이번 버전에서는 목소리의 이름만 뽑아낸 별도의 리스트를 쓰지 않을 예정인데, 어떻게 할건지는 조금 나중에 설명하겠다.

#pragma mark - Properties
- (NSSpeechSynthesizer*)speech
{
  if(!_speech) {
    _speech = [[NSSpeechSynthesizer alloc] init];
    _speech.delegate = self;
  }
  return _speech;
}

- (NSArray*)allVoices
{
  if(!_allVoices) {
    _allVoices= [[NSSpeechSynthesizer availableVoices] copy];
  }
  return _allVoices;
}

선택영역

selectionIndexes는 테이블 뷰에서 선택된 항목의 행번호를 가리키기 위한 프로퍼티이다. 이후에 테이블 뷰에서 “선택이 변경되면” 이전 버전과 달리 이 값이 자동으로 변경되게 될텐데, 이 때 선택한 행에 대응하는 목소리로 변경해야 한다. setter를 다음과 같이 커스텀한다. 내용은 현재값과 다른 인덱스 값으로 바뀌려한다면 목소리를 그에 대응하는 것으로 교체해주는 부분을 담고 있다. 바인딩된 객체의 요소값이 변경되면 이 값이 setter를 호출하는 방식을 통해서 변경될 것이므로, 이 때 목소리를 바꾸게 된다.

- (void)setSelectionIndexes:(NSIndexSet*)selectionIndexes
{
  if(![_selectionIndexes isEqualToIndexSet:selectionIndexes) {
    _selectionIndexes = [selectionIndexes copy];
    [self.speech setVoice:[self.allVoicesobjectAtIndex:[_selectionIndexes firstIndex]]];
  }
}

그리고 canSpeak는 start 버튼을 누를 수 있는지를 나타내는 값이다. 지난 버전에서 각 버튼이나 텍스트 필드들의 활성화여부는 특정 액션 및 이벤트에서 모두 개별적으로 컨트롤했다. 이것을 컨트롤러의 상태값이 각 UI 컨트롤의 상태값에 반영되도록 할 것이다. 이렇게해서 코드 군데군데에서 UI 요소들의 enabled 상태값을 변경하는 코드와 많은 아웃렛들을 배제할 수 있게 되었다.

액션 메소드들

이 상태가 변하는 것은 읽기를 시작하는 시점과 읽기가 끝나는 (혹은 중지되는) 시점이다. 따라서 -startSpeaking:-stop: 은 다음과 같이 수정하면 된다. 버튼의 상태가 아닌 canSpeak 값을 조작하는 것으로 정리된다.

#pragma mark - Actions
- (void)startSpeaking:(id)sender
{
  if([self.words length] > 0) {
    [self.speech startSpeakString:self.words];
    self.canSpeak = NO;
  }
}

- (void)stop:(id)sender
{
  [self.speech stopSpeaking];
}

# pragma mark - NSSpeechSynthesizer Delegate
- (void)speechSynthesizerDidFinishSpeaking:(id)sender
{
  self.canSpeak = YES;
}

포매터 추가하기

목소리 정보는 macOS의 식별자 형식을 취하기 때문에 사람이 알아보기 불편한 문자열로 해석된다. 따라서 이전 버전에서는 목소리를 담고있는 배열외에 목소리의 이름을 담고 있는 배열을 추가로 만들었었는데, 이번에는 목소리 식별자를 목소리 이름 문자열로 변환해주는 무언가가 필요할 것이다. 이런 비슷한 일을 하는 것으로 NSFormatter가 있는데, 이 클래스의 자식 중 하나이면서 가장 널리쓰이는 포매터인 NSNumberFormatter는 정수나 실수의 숫자값을 “숫자 문자열”로 변환해주는 장치이다. 우리는 목소리 식별자를 이용해서 그 이름 문자열을 얻으려 하고 있으니, NSFormatter를 서브 클래싱하여 VoiceNameFormatter를 작성해보자.

cmd + N 키를 눌러서 새 코코아 클래스를 추가하고 그 이름을 VoiceNameFormatter라고 한다.  이 때, 그 부모 클래스는 NSObject가 아니라 NSFormatter로 한다. (만들 때 실수했더라도 나중에 코드에서 바로잡으면 되니까 상관없다.)

아래는 해당 클래스의 소스 코드이다. 헤더에서는 Foundation 대신에 Cocoa를 임포트하도록 수정하면 되며, 구현부에서는 -stringForValueObject: 라는 메소드를 구현해주면 된다. 목소리 값으로부터 이름을 구하는 부분은 지난 버전에 사용했던 코드와 동일하다.

/// VoiceNameFormatter.h

#import <Cocoa/Cocoa.h>  // <--  수정
@interface VoiceNameFormatter: NSFormatter
@end

/// VoiceNameFormatter.m
#import "VoiceNameFormatter.h"

@implementation VoiceNameFormatter
- (NSString*)stringForObjectValue:(id)obj
{
  return [[NSSpeechSynthesizer attributesForVoice:obj] objectForKey:NSVoiceName];
}
@end

놀랍게도 이제 작성해야 할 코드의 대부분을 완료했다. 이후에 남은 부분은 테이블 뷰의 초기 상태 변경해주는 부분(지난 글에서 awakeFromNib 작성)인데, 이 부분은 뒤에 추가하기로 하고 바로 인터페이스 구성으로 넘어가도록 하겠다.

UI 구성

이제 인터페이스 빌더를 열고 AppController를 독에 추가한 후, 메인 윈도 위에 텍스트 필드와 버튼, 테이블 뷰등을 배치한다.  이 때 이전 버전과 다른 점은 다음과 같다.

  1. 텍스트 필드의 속성 중에 Countinous에 체크한다.
  2. Stop 버튼에 대해서는 Enabled 체크를 그대로 둔다. 이 상태는 canSpeak에 의존하게 될 것이다.
  3. 테이블 뷰는 1컬럼으로 조절하되, 데이터 모드는 View Based 를 그대로 유지한다.

그리고 아웃렛을 연결한다. 연결해야하는 아웃렛과 액션은 총 3개이다.

  • AppController  > NSTableView (tableView)
  • Start > AppController (-startSpeaking:)
  • Stop > AppController (-stop:)

바인딩 설정하기

이제 이 글에서 가장 중요한 부분인, UI 요소들에 대한 바인딩을 연결할 차례이다. 텍스트 필드를 선택한 상태에서 인스펙터 중, 바인딩 인스펙터(opt + cmd + 7)를 선택한 다음, value 항목을 펼쳐보자. 다음과 같은  UI가 표시될텐데, 여기서 bind to 항목에서 AppController를 선택하고, model keyPath 항목에 words를 기입한 후 엔터한다. (model keyPath 항목은 디폴트로 self라고 되어 있는데, self.words라 해도 되고, words라고만 해도 상관없다.)

그리고 아래 옵션 중에서 Continuous updates Value 에도 체크해준다. 이렇게하면 글자 하나를 타이핑하거나 삭제할 때마다 AppController의 words 값이 동기화될 수 있다.

일단 여기까지 하고 앱을 빌드하고 실행해본다.  앱 컨트롤러가 텍스트 필드에 대한 아웃렛참조를 가질 필요 없이, 텍스트 필드에 입력된 내용을 읽을 수 있음을 확인할 수 있다.

UI의 활성화 상태 동기화하기

다시 인터페이스 빌더에서 텍스트필드를 선택한 상태에서, 바인딩 중에서 Enabled 항목을 살펴보자. 이 때에도 bind to: AppController 이고, model key path는 canSpeak가 되어야 한다.

이번에는 테이블뷰와 버튼에 대해서도 동일하게 Enabled 항목을 설정한다. AppController의 canSpeak 항목에 바인딩하면 된다. 이 때 중요한 것은 Stop 버튼인데, Stop 버튼은 canSpeak가 NO일 때만 활성화되어야 한다. Enabled 바인딩 옵션 중에 하단에 Value Transformer가 있다. 여기에 선택 박스 중에서 NSNegateBoolean을 선택해준다. 이 값은 BOOL 값을 반전하여 적용한다는 것이다.

다시 앱을 빌드해보자. 어? 그런데 처음 앱이 실행된 시점에 Stop 버튼만 활성화되어 있다. 왜 그럴까? LLVM 컴파일러는 프로퍼티를 선언하면 그에 대한 backing storage 변수를 자동으로 선언하고 그 값을 NULL로 초기화한다. 이것이 객체인 경우에는 nil이 되고 그외 값은 0 혹은 NO가 된다는 의미이다. 따라서 우리는 앱 컨트롤러의 canSpeak 값을 처음에 YES로 만든 상태에서 시작해야 한다.

AppController의 초기화 메소드나 -awakeFromNib을 수정하는 방법도 있지만, 여기서는 인터페이스 빌더에서 초기 값을 설정해보자. 왼쪽 독에서 AppController를 설정한 상태에서 identity 인스펙터를 보자. 이 때 사용자 설정 런타임 속성 (User Defined Runtime Attributes) 설정하는 작은 테이블이 보일 것이다. 여기에 + 버튼을 클릭한 다음, 키 이름을 canSpeak 이라고 입력하고 타입은 BOOL을 선택한다. 여기에 체크가 되어 있으면 이 값이 YES로 설정된 상태가 된다.

다시 앱을 빌드하고 실행해보자. 모든 UI 요소의 활성/비활성 상태가 자동으로 적절하게 변경되는 것을 확인할 수 있다.

코코아 바인딩으로 테이블 뷰를 설정하기

이제 테이블 뷰에 목소리 목록이 표시되도록 만들 차례이다. 조금 전 AppController의 코드에서 봤겠지만, 델리게이트나 데이터소스에 대한 어떤 코드도 AppController에는 포함되어 있지 않다. (IBOutlet을 하나 만들었지만, 이것은 선택영역으로 스크롤하기 위해 필요한 것이다.)

테이블 뷰는 코드(데이터소스 + 델리게이트)가 아닌 바인딩으로도 데이터를 populating할 수 있다. 이미 AppController는 여기에 필요한 데이터를 가지고 있고, 그것은 allVoices라는 키로 정의되어 있다.

가장 먼저해야 할 것은 배열 컨트롤러를 독에 추가하는 것이다. 객체 팔레트에서 Array라고 검색해서 Array Controller를 선택해서 독에 추가한다. 그런다음에 identity 인스펙터에서 Label 영역에서 이름을 바꿔주자. 목소리 목록을 제어하는 역할이니 Voices Controller라고 이름을 바꾼다.

바인딩 설정하기

Voices Controller에서는 두 개의 바인딩을 연결한다.

바인딩 이름 바인딩대상(bind to:) 모델 키
Content Array AppController voices
Selection Indexes AppController selectionIndexes

위 내용은 아래의 그림과 같이 설정 된다는 의미이다.

이번에는 테이블 뷰와 Voices Controller 사이의 바인딩이다. 참고로 바인딩을 연결하는 대상이 컨트롤러인 경우에는 모델 키가 아닌 컨트롤러 키에 키 이름을 입력해야 한다.

바인딩 이름 바인딩 대상(bind to:) 컨트롤러 키
Contents Voices Controller arrangedObjects
Selection Indexes Voices Controller selectionIndexes

이렇게 한다고 해서 당장 빌드하고 실행해보면 테이블 뷰에 내용이 나타나지는 않는다. 왜냐하면 테이블 내의 각 셀의 뷰가 표시해야 할 내용을 아직 받지 못했기 때문이다. 셀 뷰 내에는 기본적으로 NSTextField가 하나 포함되는데, 이 텍스트 필드가 셀 뷰의 objectValue값을 표현하도록 텍스트 필드와 테이블 셀 뷰 사이의 바인딩을 만들어야한다. 따라서 독에서 테이블 뷰 하위의 단계를 내려가서 Table Cell View 아래에 있는 Text Field를 선택한다. 바인딩 대상은 Table Cell View를 하고 모델 키는 objectValue를 쓰면 된다.

여기까지의 연결 상태를 보자. 테이블 뷰는 Voice Controller를 통해서 다시 AppController.voices의 값을 그 콘텐츠로 연결하고, 각 행의 셀 뷰에 대해서 대응하는 인덱스의 객체값을 분배해줄 것이다. 그리고 테이블 뷰에서 행을 선택해서 선택 영역을 변경하면, 이 역시 Voice Controller를 통해서 AppController.selectionIndexes를 업데이트할 것이다. 그리고 이때의 setter에 의해서 목소리가 변경될 것이다.

앱을 빌드하고 실행하기 전에, 테이블 뷰에서 표시될 내용에 포매터를 추가해야 한다. 객체 팔레트로부터 Formatter를 골라서 테이블 뷰 위의 텍스트 필드에 추가하자. 이 때에는 캔버스 상에서 하면 자동으로 텍스트 필드에 매칭이 될 것이다. (잘 모르겠으면 역시 독으로 가져가서 TextField > TextField Cell 아래에 놓자.) 포매터를 선택한 상태에서 Identity 인스펙터에서 그 클래스를 VoiceNameFormatter로 변경해준다.

이제 앱을 빌드하고 실행해보자. 실행에 성공했다면 거의 다온 것이다. 첫 서두에서 구현하지 않았던 마무리 작업을 마저 하러 AppController.m 파일로 이동할 차례이다.

마무리

이제 마무리 단계로, 앞선 버전과 같이 실행 시에 디폴트 목소리가 선택된 상태로 표시되도록 한다. -awakeFromNib을 다음과 같이 추가해준다. 이전버전과 같이 디폴트 목소리를 구하고 그 인덱스로 selectionIndexes 값을 만들어주면 된다. 그러면 바인딩에 의해 테이블 뷰가 해당 행을 선택한 상태가 되고, 스크롤만 더해주면 끝이다.

#pragma mark - ETC
- (void)awakeFromNib
{
  NSInteger i = [self.voices indexOfObject:[NSSpeechSynthesizer defaultVoice]];
  self.selectionIndexes = [NSIndexSet indexSetWithIndex:i];
  [self.tableView scrollRowToVisible:i];
}

이제 빌드하고 실행해보자. 이전 버전보다 간결한 코드를 사용해서 UI의 상태를 적절하게 업데이트하고 테이블 뷰의 데이터를 제대로 표시하는 부분까지 코드 없이 해냈다. 이처럼 코코아 바인딩을 사용하면 UI 컨트롤과 모델의 값 속성을 양방향으로 연결하고 한쪽에서 변경이 발생할 때 다른 한 쪽이 함께 변경되도록 할 수 있는데, 어느 수준까지 코코아 바인딩을 사용할 것인지는 본인의 선택에 달렸다. 간단한 UI 글루 코드를 없애는 수준에서만 사용해도 되며, 코코아 바인딩을 사용한다고 해서 아웃렛을 쓰지 말라는 법도 없으니 적절한 수준에서 사용하면 된다. 물론 이 글에서는 코코아 바인딩을 최대한 많이 써서 이렇게 할 수 있다는 것만 보였는데, 코코아 바인딩은 설정에 실수가 있으면 가차없이 앱이 실행조차 되지 않기 때문에 디버깅이 매우 번거롭다는 단점은 있다.

참고자료

 

NSPersistentContainer를 통한 코어데이터 스택생성하기

macOS Sierra로 업데이트되면서 코어데이터에 NSPersistentContainer 클래스가 추가되었다. 이 클래스를 사용하면 코어데이터 스택을 셋업하는 여러 귀찮은 과정을 생략하고 간단하게 처리할 수 있다. 사실 코어데이터 스택을 수동으로 셋업하는 과정에서 필요한 정보는 코어데이터 모델 파일의 이름과, 저장소 파일을 생성할 위치 정도이며, 그외의 대부분의 코드는 보일러 플레이트라 할 수 있다.  저장소 파일 위치는 적당한이름(?)으로 사용자 라이브러리 내에 만들어지므로 결국 최소한으로 필요한 정보는 데이터 모델 파일 이름이 된다.

수동 셋업 과정은 다음과 같다.

/// Objective-C
@interface AppController: NSObject
@property (readonly, strong) NSPersistentContainer* persistentContainer;
@end

@implmentation AppController
@synthesize persistentConatainer=_persistentContainer;

- (NSPersistentContainer*)persistentContainer
{
  if(!_persistentContainer) {
    _persistentContainer = [[NSPersistentContainer alloc]
                            initWithName: @"MyDataModel"]; // 이름에 확장자는 붙이지 않는다.
    [_persistentContainer loadPersistentStoresWithCompletionHandler:^(NSPersistentStoreDescription *desc, NSError *error){
       if(!error) {
          /// 저장소 로딩 중 에러가 발생
          NSLog(@"Unresolved error %@, %@", error, error.userInfo);
       }
    }];
  }
  return _persistentContainer;
}

매우 간단하게 모든 설정이 완료됐다. 관리객체모델과 컨텍스트는 managedObjectModel, viewContext 프로퍼티로 액세스할 수 있다. 컨텍스트만 따로 외부로 노출하려한다면, 다음과 같이 프로퍼티를 선언한다.

@property (readonly, nonatomic) NSManagedObjectContext* context;
....
- (NSManagedObjectContext*) context { return self.persistentContainer.viewContext }

저장소 디스크립션을 사용하기

NSPersistentStoreCoordinator를 직접 셋업하는 경우에는 저장소 위치와 타입등의 정보를 설정해야 했다. 이러한 설정정보를 하나의 클래스로 묶은 것이 저장소 디스크립션으로 NSPersistentStoreDescription이라는 클래스로 만들어져있다. NSPersistentContainer는 이 디스크립션을 이용해서 “각각의 저장소들을” 생성한다. 기본적으로 아무런 정보가 주어지지 않으면 컨테이너는 SQLite 타입의 저장소를 라이브러리 디렉토리 내에 저장하게 된다. 만약 저장소 위치를 옮기고 싶거나, 타입을 바꾸고 싶으면 새로운 저장소 디스크립션을 생성해서 바꿔주면 된다. 대신 이 동작이 유효하려면 loadPersistentStores...:를 호출하기 전에 설정을 변경해두어야 한다.

/// 위치를 사용자 문서 폴더로 바꾸고 싶을 때
  NSURL* dirURL = [[[NSFileManager defaultManager] 
                       URLsForDirectory:NSDocumentDirectory
                       inDomains:NSUserDomainMasks] lastObject];
  NSURL* storeURL = [dirURL URLByAppendingPahtComponent:@"mydata.db"];
  NSPersistentStoreDescription* desc = [[NSPersistentStoreDescription alloc] initWithURL:storeURL];
  desc.type = NSSQLiteStoreType;
  [_persistentContainer setPersistentStoreDescriptions:@[desc]];
  [_persistentContainer loadPersistentStoresWithHandler:^ ...

NSPersistentStoreCoordinator를 사용하는 경우에도 -addPersistentStoreWithDescription:completionHandler:를 사용할 수 있으니 참고하자.

Swift 버전 코드

같은 내용인데, Swift 버전의 코드는 아래와 같다.

lazy var container: NSPersistentContainer = {
  let container = NSPersistentContainer(name:"MyDataModel")
  contaner.loadPersistentStore{ desc, error in 
    if error {
      fatalError("Fail to load : \(error)")
    }
}()

var context: NSManagedObjectContext {
  return self.container.viewContext
}

조금 더 깊이

Swift에서 init(name:) 은 편의 이니셜라이저이다. 만약 이 컨테이너를 서브 클래싱할 때 편의 이니셜라이저를 만드려면 지정 이니셜라이저를 호출해야한다. 컨테이너의 지정 이니셜라이저는 init(name:managedObjectModel:) 이므로 전달된 이름을 가지고 관리 객체 모델을 구해서 이를 호출해야 한다. 관리 객체 모델은 수동 셋업때와 같이 모델 파일로부터 로딩해서 생성하면 된다.

convenience init(completionHandler: @escaping () -> ()) {
  guard let mURL = Bundle.main.url(forResource:"MyDataModel", withExtension:"momd")
  else {
    fatalError("Can't load model from bundle.")
  }

  guard let mom = NSManagedObjectModel(contensOf:mURL) else { 
    fatalError("Error initializing MOM")
  }
  init(name:"MyDataModel", managedObjectModel:mom)
  completionHadler()
}

참고 자료

코코아 바인딩의 기초

MVC 패턴에서 프로그래머가 가장 많이 작성하는 코드는 크게 두 가지인데, 하나는 모델 데이터에서 발생한 변경을 뷰에 반영하는 것이고, 다른 하나는 뷰에서 사용자의 조작에 의해서 변경된 값을 모델 데이터에 반영하는 것이다. 사실 이것이 MVC에서 컨트롤러가 수행하는 일이다. GUI프로그램을 작성할 때 가장 많이 하게되는 이러한 작업을 조금 더 간단하게 (가급적이면 코드를 작성하지 않고) 구현하는 방법이 있으면 제법 편하지 않을까? 이럴 때 사용하는 코코아 바인딩은 Cocoa에서 macOS에서 데이터 모델과 뷰 사이의 양방향 연결을 만드는 일종의 ‘마법’이다. 예를 들어 아래 그림과 같은 간단한 앱을 생각해보자.

코코아 바인딩 샘플 앱

이 앱은 매우 간단한 구조를 가지고 있다. 뷰에는 숫자를 표시하는 레이블하나와 슬라이더가 하나있다. 이 앱이 하는 일은 사용자가 슬라이더를 좌우로 옮길 때마다, 슬라이더의 값이 레이블에 숫자로 반영되는 것이다. 이를 위해서는 뷰 컨트롤러에 대해서 다음의 프로퍼티와 액션이 정의되고, 그에 대한 코드가 구현되면 된다.

  1. 뷰 컨트롤러는 숫자값 하나를 저장할 NSNumber타입의 프로퍼티를 하나 가지고 있다. (꼭 NSNumber가 아니어도 된다. 그냥 float이나 double 타입의 값이기만 해도 된다.)
  2. 뷰 컨트롤러는 텍스트레이블에 대한 아웃렛 연결을 가지게 된다. (이 아웃렛을 통해서 레이블을 참조하고 그 값을 변경할 수 있다.)
  3. 슬라이더는 사용자가 옮길 때마다 뷰 컨트롤러에게 액션 메시지를 보내게 된다. 이 메소드에서는 슬라이더의 값을 받아서 레이블의 값을 세팅하는 코드를 작성해주어야 할 것이다.

코코아 바인딩은 특정한 프로퍼티에 대해서 UI 컨트롤과의 양방향 연결을 구성하는 테크닉이다. 기본적으로 뷰 내의 컨트롤과 뷰 컨트롤러 사이의 연결을 만들 수 있지만, 반드시 뷰와 뷰 컨트롤러 사이의 관계일 필요는 없다. 샘플 프로젝트는 추가적인 클래스 없이 앱 델리게이트를 이용하기로 한다. 기본 macOS용 앱을 위한 프로젝트를 시작하고, 앱 델리게이트 파일을 다음과 같이 하나의 프로퍼티를 추가한다. (그외에 다른 아웃렛이나 액션을 선언하지 않아도 된다.)

Put Together

MainMenu.xib 파일을 열어서 메인 윈도우에 레이블 하나와 슬라이더 하나를 아래와 같이 배치한다. 참고로 슬라이더를 추가한 후에는 속성 인스펙터에서 Continuous에 체크한다. 슬라이더는 기본적으로 움직였다가 마우스를 릴리즈하는 시점에 타깃에게 액션 메시지를 보내는데, Continuous가 체크되면 슬라이더 노브를 움직이는 동안에 계속적으로 메시지가 전달된다. (즉 움직이는 중에 숫자값이 계속 바뀌는 것을 볼 수 있다.)

그리고 숫자값 프로퍼티 v에 대해 초기값을 설정해보자. 왼쪽 객체 목록에서 앱 델리게이트를 선택하고, 속성 인스펙터에서 User Defined Runtime Attributes 아래에 있는 + 버튼을 클릭한다. 키 패스에는 v를 입력하고 타입은 Number, 값은 50으로 입력한다.

참고로, nib 파일은 뷰, 컨트롤, 컨트롤러등의 여러 객체들이 어떠한 프로퍼티값을 가지고 있는 상태 그대로를 직렬화한 파일이다. 따라서 인터페이스 빌더에서 정의하는 크기와 위치(곧, 뷰의 frame 속성이다.)라던지 색상 및 컨트롤의 유형 등의 정보는 객체의 인스턴스를 만들어서 코드상에서 설정해야 하는 번거로움을 덜어주는 역할이라고 보면 된다.

이번에는 바인딩을 연결하자. 먼저 텍스트 레이블을 선택한다. 참고로 이 레이블에 대해서는 앱 델리게이트에서 어떤 아웃렛 연결이나 액션메시지 연결은 없었다는 점을 상기하자. 우측 인스펙터의 7번째 탭이 바인딩 탭이다. Value 라고 되어 있는 부분을 보면 몇 가지 값 속성이 있는데, 그 중 value 를 찾아서 세부 항목을 열어본다. 그러면 팝업박스를 통해서 어떤 객체에 연결할 것인지를 고를 수 있다.1 여기서 앱 델리게이트를 선택한다.

다음은 모델키패스(model keypath)를 입력해야 한다. 연결하고자 하는 숫자값은 앱델리게이트 내의 v 라는 이름을 가진 NSNumber이다. 이 키패스는 바인딩되는 객체를 기준으로, self.v 라고 입력한다. (self.을 생략하고 v만 입력해도 된다.)

다음은 슬라이더를 선택해서 똑같이, Bind to 는 Delegate로, 키 패스는 self.v 로 입력해준다.

앱 델리게이트의 프로퍼티와 텍스트레이블, 슬라이더를 바인딩으로 연결 완료했다. 재차 강조하지만 여기에는 프로퍼티 정의 외에는 어떠한 코드도 작성되지 않았다. 하지만 이것으로 앱은 완성이다.

이제 앱을 빌드하고 실행해보자. 화면에 나타나는 앱 윈도에서 슬라이더를 좌우로 움직여보자. 슬라이더를 움직이면 레이블의 숫자값이 쫘르르르 변하는 것을 볼 수 있다.

어떤 원리로 동작하나

코코아 바인딩은 과연 어떤 마법이길래, 이러한 뷰와 데이터 모델 간의 양방향 바인딩이 코드 한 줄 없이 자동으로 이루어질 수 있을까? 이는 기본적인 코코아의 기본 패턴/기능 몇 가지를 멋지게 조합한 결과물이다.

  • 컨트롤의 특정한 프로퍼티와 컨트롤러 내의 모델 프로퍼티는 그 이름을 기준으로 연결된다.
  • 컨트롤이 조작되면 바인딩이 연결된 객체와 키패스를 이용해서 해당 값을 업데이트한다.
  • 반대로 컨트롤러 내의 어떤 값이 변경되면 그 값과 연결된 컨트롤들이 통지를 받고 값을 업데이트한다.

먼저 샘플 프로젝트에서 사용된 예를 살펴보자. 여기에는 NSLabelNSSlider 가 각각 하나씩 사용되었는데, 이들은 모두 NSControl의 자식 클래스들이다. 이쯤에서 NSControl에 대해서 간략히 살펴보자

NSControl

NSControl은 화면상에 표시되는 여러 컨트롤 디바이스를 표현하는 클래스이다. 표면적으로는 셀 이라는 것을 이용해서 시각적으로 표현되는데, 여기서는 그게 중요한 게 아니고 컨트롤은 어떤 값을 조정/조작하는데 사용되는 클래스라는 점이다. 실질적으로는 아무런 값을 가지지 않는 컨트롤(버튼 등)도 있고, 텍스트를 표현하는 레이블이나, 텍스트를 편집할 수도 있는 텍스트 필드, 슬라이더 등등 화면상에 표현되는 표준 UI 컴포넌트들은 모두 NSControl이라고 볼 수 있다.

NSControl은 어떤 값을 조작하는 UI 단위이다. 이 값은 기본적으로 objectValue 라고 하는 id 타입 객체이며, 하나의 컨트롤은 그 포맷에 따라 doubleValue, intValue, stringValue  등의 여러 타입의 프로퍼티를 가지고 있다. 하지만 이들은 모두 컨트롤 내부에서 추상클래스와 포매터를 기반으로 하나의 값의 다른 측면을 바라보는 것이다.  따라서 여러 프로퍼티에 혼란스러워하지 않고 컨트롤러 1개는 값 1개를 조작한다고 생각하면 된다.

NSControl은 코코아의 디자인패턴 중 target-action이 적용되는 전형적인 클래스이다. 타깃-액션은 컨트롤에 어떤 타깃이 주어지면, 사용자가 자신을 조작할 때 미리 정해진 타깃으로 액션 메시지를 전송하게 된다. 우리가 보통 인터페이스 빌더에서 컨트롤러 객체로 액션 메시지를 연결하는 행위는 해당 컨트롤이 트리거링되는 조작을 받았을 때, 어떤 컨트롤러에게 어떤 메시지를 보내야 하는지를 알려주는 것이라 해석할 수 있다.

키밸류 코딩

컨트롤이 조작되었을 때, 컨트롤을 타깃에게 어떠한 액션 메시지를 보내게 된다. 이 때 보통은 타깃이 뷰 컨트롤러이고, 코드상에서는 NSViewController 등의 클래스를 서브 클래싱하면서 IBAction 메소드를 정의하여 인터페이스 빌더에서 컨트롤과 뷰 컨트롤러간에 action 연결을 만들게 된다.

바인딩에서 액션은 임의의 커스텀 메소드를 사용하지 않았다. 대신에 이 액션은 하고자 하는 일이 명확하다. 바인딩하는 대상의 특정한 프로퍼티 키패스를 컨트롤러의 값으로 세팅하는 것이다. 우리는 샘플 프로젝트에서 AppDelegatev 라는 이름의 프로퍼티를 만들었다. AppDelegateNSObject의 서브 클래스이므로, 키밸류 코딩 컨벤션을 따른다.(이는 NSKeyValueCoding이라는 비정규 프로토콜에서 정의된다.) 따라서 바인딩되는 객체와 키 패스를 알고 있다면, 슬라이더는 그 자신이 조작되는 시점에 타깃에게 "self.v"라는 키패스의 값을 세팅하라는 메시지를 보낼 것이다.

[self.target setValue:self.objectValue forKeyPath:@"self.v"];
 ^^^^^^^^^^^ 1                                    ^^^^^^^^^^ 2
  1. 타깃은 바인딩될 객체로 설정된다.
  2. self.v는 모델 키패스로 설정됐다.

이를 통해서 슬라이더를 움직이면 AppDelegate의 프로퍼티 v는 슬라이더의 값이 된다. 그러면 이 값이 변경되었을 때, 레이블은 어떻게 업데이트 되는 것일까?

키밸류 옵저빙

프로퍼티의 구조를 이해하고 있다면, 어떤 프로퍼티 값이 변경되었을 때 특정한 동작을 취하게 하는 것을 구현하는 방법에 대해서 감을 잡을 것이다. 보통은 setter 메소드를 정의하는 것이다. 샘플 프로젝트의 v 는 copy, nonatomic 시멘틱으로 정의되어 있다. 따라서 컴파일러는 다음과 같이 메소드를 합성해 낼 것이다.

- (void)setV:(NSNumber *)v
{
  NSNumber* newV = [v copy];
  __v = newV;
}

만약, 이 시점에 레이블의 텍스트를 변경하고 싶다면 다음과 같이 코드를 한 줄 추가해주면 된다.

- (void)setV:(NSNumber *)v
{
  NSNumber* newV = [v copy];
  __v = newV;
  [self.vLabel setObjectValue: self.v];
}

문제는 이렇게하려면 앱 델리게이트가 해당 레이블에 대한 참조를 가져야한다는 것이다. 그런데 우리는 샘플 프로젝트에서 어떠한 아웃렛도 선언하거나 연결한 적이 없다. 그러면 어떻게 레이블은 그 값을 자동으로 변경할 수 있을까? 그 비밀은 키-밸류 옵저빙이다. 키 밸류 옵저빙은 어떤 객체가 다른 객체의 키패스에 대한 값이 변경될 때, 그것에 대한 통지를 받게 되는 매커니즘이다. 이와 관련된 내부적인 처리는 모두 런타임에서 수행되며, 우리는 그저 그 키패스에 해당하는 프로퍼티가 키밸류 코딩 규칙을 따르도록 정의하면 되는 것이다.2

바인딩을 세팅하게 되면, 앞서 살펴본바와 같이 타깃-액션 메시지를 보내는 기능을 동적으로 생성하는 동시에, UI컨트롤은 모델 키패스에 대하여 자신을 옵저버로 등록한다. 그리고 해당 프로퍼티가 변경되면, 그 프로퍼티의 값을 자신의 objectValue로 설정한다.

대략 다음과 같은식의 코드들이 자동으로 붙게 된다. 여기서 대상 객체를 target으로 했지만, 실제로 하나의 컨트롤은 여러 개의 모델과 바인딩될 수 있으므로 별도의 객체 식별자를 사용할 것으로 보인다.

...
// 아마도 awakeFromNib 쯤에...
[self.taget addObserver:self
            forKeyPath:@"self.v"
            options:NSKeyValueObservingNew
            context:NULL];
...

// 그리고 바인딩된 모델 키패스의 변경을 감지하도록
- (void)observeValueForKeyPath:(NSString*)keypath 
        ofObject:(id)object 
        change:(NSDictionary<NSKeyValueChangeKey, id>*)change
        context:(void*)context
{
   if([keypath isEqualToString:@"self.v") {
     // 변경된 값을 자신의 값으로 치환한다.
     (id)newVaue = [change objectForKey:NSKeyValueChangeNewKey];
     self.objectValue = newValue;
   } else {
     [super observeValueForKeyPath:keypath
            ofObject:object
            change:change
            context:context];
  }
}


// 해제되기 직전에는 옵저버 등록을 해제해야 함
- (void)dealloc
{
  [self.target removeObserverForKeyPath:@"self.v"];
}

슬라이더와 레이블 모두가 이런 식으로 특정한 모델 데이터에 바인딩 되어 있다. 샘플 프로젝트에서는 슬라이더를 통해서만 모델 값을 변경할 수 있었지만, + , – 버튼을 이용해서 값을 조정한다던가, 레이블 대신에 텍스트 필드를 사용해서 값을 입력받을 수 있게한다면 이 모든 관계들이 실시간으로 영향을 받으면서 변경되는 것이다.

Swift와 코코아 바인딩

키밸류코딩과 키밸류옵저빙은 모두 Objective-C 런타임에 전적으로 의존하는 매커니즘이다. 그러면 Swift로 작성하는 코코아 앱에서는 코코아바인딩을 사용할 수 없을까? 당연히 사용할 수 있다. 대신에 몇가지 조건이 있다.

  1. 모델 키패스를 포함하는 객체의 클래스는 NSObject의 서브 클래스여야 하며
  2. Objective-C런타임에서 식별할 수 있게 @objc 접두어를 써서 정의해야 한다.
  3. 모델 키패스가 되려는 프로퍼티 역시 @objc 접두어를 써야 한다.
  4. 동시에 접근자 메소드를 동적으로 생성해내기 위해서 dynamic 키워드도 써야 한다.

따라서 샘플 프로젝트의 앱 델리게이트는 Swift로 작성됐다면 다음과 같은 모양이어야 할 것이다.

@objc
class AppDelegate: NSObject, NSApplicationDelegate
{
  @IBOutlet weak var window: NSWindow!
  @objc dynamic var v: float = 50.0;
}

그외에…

  1. 코코아 바인딩은 인터페이스 빌더에서 설정하는 것이 간편하다. 하지만 실제로 이 기능은 NSKeyValueBindingCreation이라는 비정규 프로토콜에 정의된 메소드를 호출하는 것으로 축약된다.
  2. 샘플 앱 프로젝트는 여기에서 다운받을 수 있다.(Swift 버전은 여기)

참고자료

 


  1. 특정한 값을 바인딩하기 위한 선택사항으로는 동일 nib 파일 내에 있어야 한다는 제약은 있다. 이는 스토리보드 상에서도 같이 적용될 것이다. 
  2. 키밸류 코딩 규칙을 따르도록 하는 것은 전혀 어렵지 않다. @property를 써서 정의하면 된다. 키밸류 코딩 규칙은 getter, setter 메소드와 인스턴스 변수이름 사이의 관계로만 정의되어 있다. 

트랙킹 캔버스 뷰 만들기 (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)
  }
}

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

참고

 

NSResponder – Cocoa에서 키보드 이벤트를 처리하는 방법

사용자가 키보드를 두드리면 macOS는 각 키 타이핑에 대한 키 이벤트를 받게 된다. 이벤트 처리의 기본은 이벤트에 대해서 그 이벤트를 핸들링하는 어떤 함수가 실행되는 것이다. 시스템에 들어온 키 이벤트를 누가 어떻게 처리하게 될까?

제 1 응답자

마우스 이벤트의 경우, 이벤트를 받아서 처리해야 하는 주체가 분명하다. 마우스는 마우스 포인터를 통해서 화면 상에 표시되는 뷰와 상호작용한다. 하지만 키보드 이벤트는 어떤가? 키보드 이벤트를 처리하는 주체는 상황과 문맥에 따라 달라질 수 있다. 다만 사용자로서 우리는 어떠한 경우에 어떤 뷰가 키보드 타이핑을 받을 수 있는지 알 수 있다. 주로 파란색으로 포커스 링이 그려진 텍스트 필드나 텍스트 뷰가 그 역할을 맡게 되며, 그 때 해당 필드 내부에는 커서가 깜빡거리고 있는 것을 보게 된다.

이렇게 특정한 윈도우 안에서 키보드 타이핑에 의한 입력을 처리하는 주된 뷰를 제 1 응답자라고 한다. 제 1 응답자는 윈도우의 아웃렛으로도 존재하며, 일종의 플레이스 홀더에 해당한다. macOS에서 모든 키 입력을 모두 제 1 응답자가 처리하느냐? 꼭 그런것은 아니다. 키보드 이벤트는 대체로 우리가 타이핑하는 각각의 키에 해당하지만, Shift, Cmd, Option 키등과 함께 눌려지는 단축키들도 있다. 단축키는 특정한 뷰가 처리하는 레벨의 단축키도 있지만, 앱에서 처리되는 단축키도 있고 또 시스템 전역으로 처리되는 단축키도 있다. 즉 대부분의 키보드 이벤트는 제 1 응답자가 처리하지만, 그 보다 우선적으로 처리되기로 정해진 이벤트들은 이벤트 전달 체인에서 그 상위에 위치한 노드들에서 처리된다. 대략 다음과 같은 순서대로 처리될 것이다.

  1. 시스템 전역에서 처리되어야 하는 키는 시스템 레벨에서 처리된다.
  2. 그외에는 활성화된 앱으로 넘어온다.
  3. 앱에서 먼저 처리해야 할 키 (특정 메뉴 호출 등)는 메뉴에서 처리한다.
  4. 만약 문서 기반 앱인 경우,문서 컨트롤러가 그 다음 책임을 맡게 되고, 이내 활성화된 문서가 그 뒤를 따른다.
  5. 각 문서는 윈도 컨트롤러를 가질 수 있는데, 윈도 컨트롤러가 그 다음번 이벤트 처리자가 될 수 있다.
  6. 다음은 현재 문서/앱의 키 윈도우가 그 처리자가 될 수 있다.
  7. 윈도우 내에서는 뷰 컨트롤러, 그리고 뷰 순으로 처리 레벨이 내려오는데, 이 때 키 윈도 내의 제 1 응답자인 뷰가 키 이벤트를 처리하게 된다.

NSResponder

이벤트 체인과 처리에 관계되는 클래스인 NSWindow, NSWindowController, NSView는[^NSViewController] 모두 NSResponder의 서브 클래스이다. 이 클래스는 이벤트 처리에 관련한 기능을 담고 있는 베이스 클래스이다. 키보드 이벤트 처리와 관련된 이 클래스의 메소드들은 크게 다음과 같이 분류될 수 있다.

  1. 제1응답자 관련한 프로퍼티 및 메소드
  2. 실제 키 이벤트를 받았을 때 처리하기
  3. 해석된 키 이벤트에 따른 동작을 처리하기

[NSViewController]: NSResponder의 클래스 레퍼런스에서는 NSViewController에 대한 언급이 없는데, 최근에 NSViewController 역시 응답 체인에 추가되었다.

First Responder

NSResponder는 제 1 응답자가 되는 것과 관련해서 세 개의 메소드 및 프로퍼티를 가지고 있다. 사실 Objective-C에서 이들은 모두 “메소드”로 취급해도 문제가 없었는데, Swift로 넘어오면서 구분 기준이 조금 모호한 느낌이 있다. 각각의 의미는 다음과 같다.

  • acceptFirstResponder – ( )가 없으므로 프로퍼티이다. 제 1 응답자가 되려하기 직전에 체크된다. true 값이 리턴되면 제 1 응답자가 되기를 수락한다.
  • resignFirstResponder() – 제 1 응답자 상태에서 벗어나기 직전에 호출된다.
  • becomeFirstResponder() – 제 1 응답자 상태로 진입하기 직전에 호출된다.

각각의 메소드가 Bool 값을 리턴해야 한다는 점에 주의하자. 이들 메소드는 상태 변경을 알릴 때 호출되는 것이 아니라, 상태 변경 직전에 호출된다. 예를 들어서 필수로 입력해야 하는 항목을 비워놓은채로 다른 필드를 선택하지 못하게 하는 경우를 생각해본다면, resignFirstResponder()false를 리턴하여 상태 변경을 허용하지 않을 수 있어야 한다는 말이다.

Key Events 처리

사실 키 이벤트 처리 방법은 생각보다 단순하다. 키 이벤트 핸들러인 keyDown(with:)가 호출될 때, 눌려진 키의 정보는 인자로 넘겨지는 NSEvent 객체의 character속성에 들어있다. 이를 직접 사용하는 방법이 가능한데, 애플은 이방법을 그리 권장하지는 않는다. 대신에 macOS의 입력 관리 시스템을 활용하는 방법을 권장한다. NSResponderinterpretKeyEvents(_:)라는 메소드를 제공하고 있다. keyDown(with:)을 오버라이딩하면서 해당 메소드를 호출하도록하는 것으로 키 이벤트 처리 코드를 모두 대신할 수 있다. 이 메소드에서 전달된 이벤트는 시스템에 의해서 해석된다. 그리고 그 해석 결과에 따라 수행해야 할 액션 메소드가 다시 시스템에 의해서 호출된다.

해석된 이벤트에 따라 처리해야할 액션 메시지는 NSResponder의 레퍼런스 중에서 Responding to Action Messagess 부분을 보면 된다. 주로 사용하는 메소드에는 다음과 같은 것들이 있다.

  • insertText(_:Any) – 입력된 키가 글자 타이핑으로 처리되는 경우. 인자는 문자열이며 NSString 혹은 NSAttributedString 타입이다.
  • insertTab(_:Any?) – 탭 키가 입력됐을 때. 인자는 sender 이다.
  • insertBacktab(_:Any?) – 백탭(Shift + tab)이 입력된 경우
  • deleteBackward(_:Any?) – 백스페이스 키를 통해 한 글자를 지운 경우
  • deleteForward(_:Any?) – fn + delete를 통해 앞으로 한 글자를 지우는 경우

샘플 앱

한개의 문자를 표시할 수 있는 커스텀 뷰를 장착한 간단한 앱을 만들어보겠다. (이 예제는 아론 힐리가스의 OSX Programming With Cocoa의 19장, 20장에서의 예제이다. 단, Swift4.0 기준으로 작성했으며, macOS 10.12 기준이다. 국내에 번역된 3판의 경우 Objective-C로 작성된 XCode 3.0 기준의 내용이라, 좀 다른 부분들이 있다.)

이 앱에 포함될 몇 가지 기능이다.

  1. 커스텀 뷰는 제 1 응답자가 되어 키 입력을 받을 수 있다. 입력 받은 키의 문자는 뷰에 그려진다.
  2. 커스텀 뷰가 활성화되었을 때와 그렇지 않을 때를 배경색을 이용해서 시각적으로 구분한다.
  3. 포커스링도 그려준다.
  4. 탭 키를 이용해서 키 뷰 변경을 가능하게 한다.
  5. PDF를 저장한다.

BigLetterView

책에서와 비슷하게 BigLetterView.swift 라는 새로운 파일을 하나 만든다. 이는 우리가 만들 커스텀 뷰에 관한 클래스이다. 기본적으로 제 1 응답자가 되어 키 입력을 받고, 입력받은 문자를 내부에 저장한 후 이를 다시 뷰를 통해 그리는 일을 수행한다.

프로퍼티

기본적으로 다음의 프로퍼티가 필요하다.

  1. 그려낼 문자열을 저정하는 String 타입 프로퍼티. 값이 변경될 때, 뷰를 새로 그리도록 한다.
  2. 배경색. 현재 제 1 응답자인지 여부에 따라 다른색을 쓸 것이므로 computed property가 되어야 한다.
  3. 현재 제 1 응답자인지 여부. 역시 computed.

그외에 문자를 그리는 부분에서 추가적인 프로퍼티가 필요할텐데, 이는 그 때 가서 추가로 정의하기로 한다.  우선 아래는 기본적인 프로퍼티와 그리기 함수의 기본 내용이다.

class BigLetterView: NSView {
  var string: String = " " {
    didSet {
      needsDisplay = true
      // OBJC에서 [setNeedsDisplay: YES];가 이렇게 바뀐다.
    }
  }
  var isFirstResponder: Bool {
    if let fr = window?.firstResponder {
      return fr === self
    }
    return false
  }
  var bgColor: NSColor {
    return isFirstResponder ? NSColor.white : NSColor.lightGray
  }

  override func draw(_ dirtyRect: NSRect) {
    // 배경색을 그린다.
    bgColor.set()
    NSBezierPath.fill(bounds)
    // 글자를 그린다.
    // ...
  }
}

제 1 응답자 관련

제 1 응답자가 되거나, 되었다가 그만둘 때 배경색이 달라져야 하므로 다음과 같이 오버라이딩한다.

/// in BigLetterView
override var acceptFirstResponder: Bool { return true }

override func becomeFirstResonder() -> Bool {
  needsDisplay = true
  return true
}

override func resignFirstResponder() -> Bool {
  needsDisplay = true
  return true
}

키 이벤트 처리

제 1 응답자가 되었을 때 키 이벤트를 처리한다. 처리해야 하는 이벤트의 종류는 앞에서 설명한 주요 이벤트와 동일하다.

/// in BigLetterView

override func keyDown(with event: NSEvent) {
  interpretKeyEvents([event])
}

override func insertString(_ insertString) {
  if let s = insertString as? String {
    string = s
  }
}

override func insertTab(_ sender: Any?) {
  // 다음 키 뷰로 옮기는 액션은 NSWindow가 한다.
  window?.selectKeyView(following: self)
}

override func insertBacktab(_ sender: Any?) {
  window?.selectKeyView(preceding: self)
}

override func deleteBackward(_ sender: Any?){
  s = " "
}

override func deleteForward(_ sender: Any?) {
  s = " "
}

참고로 다음 키 뷰(nextKeyView) 속성은 인터페이스 빌더에서 연결해주면 된다. 이 때 뷰 끼리의 연결은 우버튼 드래그나 ctrl 드래그로는 할 수 없으므로 (오토 레이아웃 연결 모드가 된다.) connection 인스펙터를 열어서 처리하도록 한다.

포커스 링 그리기

포커스링은 라이언이후의 macOS에서는 앱킷에서 자동으로 그려준다. 따라서 그에 필요한 API를 오버라이드한다.

// in BigLetterView
override var focusRingMaskBounds: NSRect { return bounds }
override func drawFocusRingMask() { 
  NSBezierPath.fill(bounds)
}

속성있는 문자열 그리기

속성있는 문자열을 만들어서 이를 뷰에 그리는 작업을 해보겠다. 속성 있는 문자열(NSAttributedString)을 만들기 위해서는 표시할 문자열과 그 시각적 속성정보가 필요하다. 속성있는 문자열은 뷰에 그리는 시점에 별도로 생성하는 계산 프로퍼티가 될 것이고, 시각 속성정보는 한 번 생성해두면 계속 재사용할 정보이다. 따라서 다음과 같이 정의한다. 참고로 공백 문자인 경우에는 그릴 필요가 없으니 옵셔널로 정의해버리자.

// in BigLetterView

lazy var attributes: [NSAttributedStringKey: Any] = {
  return [
    .font: NSFont.systemFont(ofSize: 75.0),
    .foregroundColor: NSColor.red
  ]
}()

var attributedText: NSAttributedString? {
  guard string != " " else { return nil }
  return NSAttributedString(string: string, attributes: attributes)
}

속성 있는 문자열을 그리기 위해서는 뷰의 draw(_:) 등과 같이 드로잉 명령 콜이 가능한 컨텍스트에서 해당 문자열 객체의 draw(at:) / draw(in:) 메소드를 사용하면 된다. 따라서 뷰의 어디에, 얼마만큼 그릴 것이냐 하는 부분이 필요하다. 그려질 위치를 정하기 위해서는 그려지는 문자열의 화면상의 크기를 알고 있어야 한다. (그래야 한 가운데에 그리지) 이 크기 역시 size() 메소드를 이용해서 얻을 수 있다. 다음 프로퍼티를 추가한다.

// in BigLetterView

/// 그려질 문자의 프레임
var characterFrame: NSRect {
  guard let size = attributedText?.size() { return NSRect.zero }
  let (width, height) = (size.width, size.height)
  let x = bounds.midX - width / 2
  let y = boudns.midY - height / 2
  return NSRect(x:x, y:y, width:width, height:height)
}

자 그러면 최종적으로 문자를 그리는 코드는 매우 간단하게 처리할 수 있다.

override func draw(_ dirtyRect: NSRect) {
  bgColor.set()
  NSBezierPath.fill(bounds)
  attributedText?.draw(in: characterFrame)
}

인터페이스 구성

인터페이스 빌더에서 아래와 같이 뷰들을 구성한다.

두 개의 텍스트 필드는 탭 키를 눌러서 포커스를 옮길 수 있도록 하는 부분을 확인하는데 사용하려 한다. 커스텀뷰 > 텍스트필드1 > 텍스트필드2 > 커스텀뷰 와 같이 nextKeyView 속성이 꼬리에 꼬리를 물도록 설정한다. 대부분의 코드는 뷰 클래스 내에 패키징 되어 있으므로 추가적인 설정은 필요하지 않을 것이다. 프로젝트 소스 코드는 다음 링크에서 내려받을 수 있다.

BigLetterView 사용 앱 소스 : https://app.box.com/s/gfmf6sst4gq9brqt2hvjfg10iw71rs4d

보너스

문자를 그리는 커스텀 뷰를 만들면서 draw(_:) 메소드를 직접 구현했다. 이렇게 직접 그리기 명령을 통해서 자신을 그릴 수 있는 뷰들은 손쉽게 PDF로 만들 수 있다. NSView의 dataWithPDF(inside:)draw(_:)를 PDF 문서를 위한 컨텍스트 상에서 호출하여 뷰의 내용을 그대로 PDF 데이터로 복제할 수 있다.   PDF 파일을 저장하기 위해서는 다음의 준비 단계를 거치면 된다.

  1.  기본적으로 Xcode9의 코코아 앱은 샌드박스 앱이다. 따라서 User selected Files에 대한 쓰기 권한을 주도록 설정해야 한다.
  2. NSSavePanel을 이용해서 저장할 파일 위치를 결정한다.
  3. 이후 실제 저장한다.

첫번째로 앱에 쓰기 권한을 주기 위해서는 프로젝트 설정에서 Capability 탭에서 File Access 항목을 본다. 사용자 선택 파일에는 기본적으로 Read 권한만이 주어지는데,  이에 대해서 Read/Write로 설정을 변경해서 파일을 쓸 수 있는 앱으로 빌드할 수 있어야 한다. (그렇지 않은 경우, NSSavePanel을 만드는 순간, 에러가 난다.)

2, 3의 과정은 하나의 함수 내에서 처리 가능하다. 다만, NSSavePanel의 완료 핸들러는 원칙적으로 escaping 이기 때문에 명시적으로 self 에 대해서는 약한 참조를 하도록 해야 한다는 점마나 주의하자.

/// save PDF in BigLetterView

@IBAction func savePDF(_ sender: Any?) {
  let panel = NSSavePanel()
  panel.allowedFileTypes = ["pdf"]
  guard let window = window else { return }
  // 클로저이므로 [unowned self] 혹은 [weak self]를 명시하고
  // self의 속성에 대해서는 self를 명시해야 한다.
  // 이 클로저의 실행 시점은 self의 라이프사이클과 일치하지 않을 수 있음에 주의.
  panel.beginSheetModal(for: window) { [unowned self] res in
    switch res {
    case .OK:
      let data = self.dataWithPDF(inside:self.bounds)
      do {
        data.write(to:panel.url!)
      } catch {
         // 에러가 발생한 경우 표시한다. 
         let na = NSAlert(error: error)
         na.beginSheetModal(for: self.window!)
      }
    default:
      return
  }
}

이 함수를 메뉴 상에서 새로운 Menu Item을 추가한 후,  그와 연결해준다. 그리고 제대로 저장되는지 확인해보자. (예시)

참고자료