커스텀 뷰로 만든 UI 컴포넌트의 포커스링 그리기 – Cocoa

포커스 링

코코아 UI 요소에서 현재 포커스를 받은 UI 컴포넌트는 외부에 흐릿한 푸른 색 후광이 그려지며, 현재 포커스를 받고 있는 입력 디바이스라는 점을 시각적으로 피드백한다.  시중의 코코아 관련한 대부분의 책에서는 다음과 같이 draw: 메소드 내에서 포커스링을 그리는 것으로 포커스링을 흉내낼 수 있다고 한다. 1

override func draw(_ dirtyRect: NSRect) {
    // 배경을 칠하고,
    super.draw(dirtyRect)
    self.bgColor.set()
    NSBezierPath.fill(bounds)

    // 자신이 속한 윈도에서 자기가 제1응답자라면?
    if let fr = window?.firstResponder, fr === self {
        // 포커스 컬러로 세팅하여, 자신의 테두리 영역을 그린다.
        NSColor.keyboardFocusIndicatorColor.set()
        NSBezierPath.setDefaultLineWidth(4.0)
        NSBezierPath.strokeRect(bounds)
    }
}

그리고 제 1 응답자가 되었을 때 포커스링을 그리기 위해서는 뷰가 응답자 상태에 진입하거나 빠져나올 때마다 뷰를 새로 그리도록 갱신해야 한다.

override var acceptsFirstResponder: Bool { return true }

override func resignFirstResponder() -> Bool {
    NSLog("Resigning...")
    setNeedsDisplay(bounds)
    return true
}

override func becomeFirstResponder() -> Bool {
    NSLog("Becoming...")
    setNeedsDisplay(bounds)
    return true
}

참고: 10.7 라이언부터는 AppKit에서 포커스링을 그리는 API가 변경되었으므로, 이 글을 끝까지 읽어보세요.

하지만 이 방법은 텍스트 필드에서 볼 수 있는 예쁜(?) 포커스링을 그리는 것이 아니라 그냥 투박한 파란 사각형을 그릴 뿐이다.  그래서 포커스링을 그리는 방법은 이래야 한다고 설명하기도 하더라만….

if let fr = window?.firstResponder, fr === self,
NSGraphicsContext.currentContextDrawingToScreen {
    NSGraphicsContext.saveGraphicsState()
    NSSetFocusRingStyle(.only)
    NSBezierPath.fill(bounds)
    NSGraphicsContext.restoreGraphicsState()
}

하지만 이것도 기대한 것처럼 근사한 포커스링을 그리지 않는다.

뭐가 문제인가? 앞서 말했지만 이런 코코아 교재들이 너무 오래전에 작성된 것이 많아서 그런거다. OSX 10.7(라이언)이후부터는 컨트롤의 포커스링은 컨트롤의 외부에 그려지므로 뷰 내부에서 draw() 메소드로 그릴 수 없다. 이제부터는 AppKit이 자동으로 관리하게끔 API가 변경되었다. 그래서 어쩌라는 말이냐면… 모르겠다.  일단 NSView클래스 레퍼런스에서 포커스링을 그리는 부분을 한 번 찾아보자.

예전  API – 포커스링 타입 프로퍼티

NSViewfocusRingType 프로퍼티는 포커스링의 표시 타입을 결정한다. 이 값은 enum으로 구성된 다음의 값을 갖는다. 참고

  • default – NSView와 NSCell의 기본 포커스
  • none – 포커스 링 없음
  • exterior – 표준 아쿠아 포커스링

이 값을 .none으로 설정하면 시스템은 포커스링을 그리지 않는다. 이는 자신이 직접 포커스링을 그리려고 하는 경우에 설정하는데, 예를 들어 뷰의 백그라운드 색으로 포커스 상태를 표시하려하거나, 포커스링을 그리는데 충분한 공간이 없을 때 사용한다.

이 값을 바꾼다고해서 뷰가 실제로 포커스링을 그리지 않는다. 포커스링은 draw(:) 메소드 내에서 직접 그려야 한다. (해당 뷰가 제1응답자가 되었는지 확인해야 한다.)

정확한 포커스링의 리드로잉을 위해서 AppKit은 자동적으로 새로 그려야하는 뷰를 그리면서 해당 영역을 그린다. 예를 들어 focusRingType.none이 아닌 값을 설정된 뷰가 제 1 응답자가 된 경우에 자동으로 뷰를 새로 그리려고 한다. 만약 커스텀 뷰를 만들고 이 뷰가 제 1 응답자가 될 수 있지만, 별도의 포커스링을 그리지 않는다면 불필요한 리드로잉을 줄이기위해 해당 속성을 .none으로 설정한다.

OSX 10.7 에서의 변경

포스트의 서두에서 언급된 내용은 Snow Leopard 기준이며, 라이언 (10.7)이후부터는 새로운 방법으로 포커스링을 그리도록 API가 변경되었다. (참고: 릴리즈노트)

이전 방법

  1. draw(:) 내에서 현재 뷰가 제 1 응답자인지 확인한다.
  2. NSSetFocusRingStyle(:)함수를 호출하여 포커스링 스타일을 설정한다.
  3. NSBesizerPath.fill(self.bounds)를 호출하여 포커스링을 그린다.

변경된 방법

  1. 포커스링의 리드로잉은 AppKit이 알아서 결정한다.
  2. 뷰는 AppKit이 포커스링을 위한 자동 리드로잉을 할 것인지 여부를 알려주어야 한다. 이는 focusRingType 프로퍼티를 오버라이드 하여 결정한다.

결국 뷰는 앱킷에게 “어디에”, “어떻게” 포커스링을 그리면 되는 것인지만 말해주면 그 정보를 가지고 알아서 그리겠다는 것같다.  새롭게 추가된 세 가지 API는 다음과 같다.

  • func drawFocusRingMask()
  • var focusRingMaskBounds: NSRect { get }
  • func noteFocusRingMaskChanged()

AppKit은 자동으로 focusRingMaskBoundsdrawFocusRingMask를 호출한다. 따라서 우리가 수정해야 하는 부분도 이 두 곳이다.

drawFocusRingMask에 대한 NSView의 기본 구현은 아무일도 하지 않는 것이며, focusRingMaskBounds는 빈 사각형 (zero 사각형)을 리턴하는 것이다. 따라서 이 두 메소드를 오버라이드하여 포커스링의 템플릿을 만들어주도록 해야 한다. 방법은 간단하다. 포커스링의 경계는 결국 뷰 자체의 경계와 동일하고, 그 영역을 채우라는 명령으로 모든 준비가 끝난다.

override var focusRingMaskBounds: NSRect {
    return bounds
}

override func drawFocusRingMask() {
    NSBezierPath.fill(bounds)
}

앱킷은 자동으로 이들 메소드를 적절한 시점에 호출하고 포커스링을 그리게 된다. 따라서 *firstResponder() 메소드들에서 포커스 링을 그리기 위해 직접 setNeedsDisplay(:)를 호출해야 할 필요가 없다. 제1응답자가 바뀌는 것을 프레임워크가 추적해서 자동으로 포커스링을 그려주는 처리를 하게 된다.

포커스링을 직접 그려야하는 경우보다 훨씬 더 코드가 깔끔해진다.


  1. 물론 대부분의 책은 엄청 오래된 것일 대부분이라, 이렇게 Swift코드로 적혀있을리가 없다. 이 글의 모든 코드는 Swift3으로 코딩한 것이다.