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을 추가한 후,  그와 연결해준다. 그리고 제대로 저장되는지 확인해보자. (예시)

참고자료