코코아 앱 따라하기 – 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 글루 코드를 없애는 수준에서만 사용해도 되며, 코코아 바인딩을 사용한다고 해서 아웃렛을 쓰지 말라는 법도 없으니 적절한 수준에서 사용하면 된다. 물론 이 글에서는 코코아 바인딩을 최대한 많이 써서 이렇게 할 수 있다는 것만 보였는데, 코코아 바인딩은 설정에 실수가 있으면 가차없이 앱이 실행조차 되지 않기 때문에 디버깅이 매우 번거롭다는 단점은 있다.

참고자료