AppKit의 스크롤뷰, NSScrollView 사용법

iOS에서 화면에 표시할 수 있는 뷰 영역보다 큰 콘텐츠를 표시하기 위해서는 UIScrollView를 사용해서 스크롤 및 확대/축소에 대한 지원을 손쉽게 구현할 수 있음을 우리는 알고 있다. 그렇다면 macOS 환경에서 스크롤뷰는 어떤식으로 구현될 수 있고, 또 그만큼 손쉽게 사용할 수 있을까? 비슷한 스크롤 서비스를 제공한다는 공통점에도 불구하고 데스크톱 환경은 우리가 생각하는 것보다 훨씬 더 복잡하고 많은 요소가 관계하고 있으며 따라서 데스크톱 환경에서의 스크롤은 쉽지 않을지 모른다. 이번 포스팅에서는 AppKit에서 제공하는 스크롤 뷰인 NSScrollView에 대해 알아보자

iOS와 macOS의 차이점

iOS에서 큰 면적의 콘텐츠의 일부를 스크롤 하여 표시하는 기능은 UIScrollView를 통해서 구현한다. UIScrollView는 단일 클래스로 스크롤 서비스를 제공하며, 확대축소를 위해서 델리게이트를 요구하는 것 외에는 간단한 속성값 설정만으로 사용이 가능하며, 스크롤 동작에 필요한 거의 모든  UI구성 요소를 내장하고 있다.

반면 macOS에서는 스크롤 서비스를 구현하는 클래스로 NSScrollView가 있다.  AppKit과 UIKit의 UI 컴포넌트들이 단순히 접두어만 다른 것이 아니라 내부 구현에 차이가 많은 것은 사실이지만, 그 중에서도 스크롤 뷰는 두 플랫폼 간의 차이가 제법 큰 편이다.  일단 NSScrollView에 의한 스크롤 기능 지원은 어떻게 이루어지는지 알아보자

macOS의 스크롤 뷰 구성 요소

macOS의 스크롤 서비스는 NSScrollView에 의해서 제공되며, 이 클래스는 스크롤을 구현하기 위해 필요한 구성요소들을 한 데 결합해주는 중심 역할을 한다. macOS에서의 스크롤 영역과 관련된 각 세부 기능은 다음과 같이 정리할 수 있다.

  • 도큐먼트 뷰 : 임의의 NSView 혹은 그 서브 클래스로 실제로 스크롤을 적용받는 콘텐츠가 된다. 스크롤 동작에 관여하지는 않으며, 대신에 콘텐츠의 구성과 표시에 대한 책임을 담당한다.
  • 콘텐트 뷰 : 스크롤 뷰는 도큐먼트 뷰를 NSClipView 인스턴스로 감싸서 그 일부 영역만을 마스킹 & 클리핑하여 표시한다. 이 조합을 콘텐트 뷰라 하며, 콘텐트 뷰는 bounds 의 영역값을 조정하여 스크롤에 따른 표시 영역을 조정한다.
  • 스크롤러 뷰 : 스크롤 뷰의 우측 혹은 아래쪽에 붙는 스크롤바(스크롤러)에 해당하는 뷰.
  • 룰러 뷰 : 스크롤 뷰의 상단 혹은 왼쪽에 붙일 수 있는 눈금자 뷰. 윈도 운영체제에서는 개별 애플리케이션이 별도의 눈금자를 제공하는 경우만 존재했지만, macOS에서는 시스템 수준에서 눈금자에 대한 UI와 기능을 제공하고 있다.

전통적인 아쿠아 UI로부터 macOS의 UI 디자인이 대대적으로 개편되면서 특히 스크롤러 같은 경우에는 별도의 뷰로 표시되기 보다는 스크롤 뷰 내부에 필요한 경우에만 오버레이되었다가 사라지는 식으로 제공되고 있고, 최근의 UI에서는 눈금자를 쓰는 경우도 거의 존재하지 않는다.

따라서 macOS에서 스크롤 뷰를 쓰기 위해 준비해야하는 설치 절차는 생각보다 더 간단하다.

  1. 도큐멘트 뷰가 될 뷰를 준비한다.
  2. 표시 영역에 맞게 스크롤 뷰를 생성해둔다.
  3. 스크롤뷰의 documentView를 준비한 뷰로 설정한다.
  4. 스크롤뷰를 부모 뷰에 붙인다.

다음은 번들 내 이미지 파일로 스크롤바에 이미지 뷰를 넣는 과정을 기술한 코드이다. 생각보다 매우 간단하다.

var imageView: NSImageView?
var scrollView: NSScrollView?

override function viewDidLoad() {
  super.viewDidLoad()

  // 이미지를 준비하고, 이미지로부터 이미지 뷰를 만든다. 
  // 이미지뷰는 이미지의 크기를 따라가지 않으므로 수동으로 지정한다.
  if let image = NSImage("image.jpg") {
    imageView = NSImageView(image: image)
    imageView.frame.size = image.size
  }
  // 스크롤뷰는 부모뷰의 크기와 동일하게 설정한다.
  scrollView = NSScrollView(frame: view.frame)
  scrollView?.documentView = imageView
  view.addSubView(scrollView!)

  // scrollView?.documentView = imageView
}

NSScrollView에 프레임 속성을 주어 표시될 크기를 지정하고 documentView를 설정하면 스크롤 뷰는 자동으로 NSClipView 객체를 하나 만들어서 documentView를 감싸게 한다. (이 클립뷰는 NSScrollViewcontentView 속성으로 참조된다.) 따라서 전체 콘텐츠 중에 실제 화면에 노출될 부분을 잘라서 표시하는 것은 내부의 NSClipView에 의해서 구현된다.

UIScrollView와 차이점

UIScrollViewNSScrollView의 차이는 iOS의 스크롤뷰는 단일 클래스에 의해서 제공되지만, macOS의 스크롤뷰는 실제로는 여러 클래스들이 동원된다는데 차이가 있다.   macOS의 스크롤뷰는 룰러나 스크롤러 같은 부속이 필요하다는 점 때문에 이런 식으로 구성되었으며, iOS의 스크롤뷰는 (iOS는 애초에 스크롤러가 동적으로 오버레이되는 식으로 제어되므로) 스크롤러에 대한 제어를 UIKit이 자동으로 처리할 수 있으며, 별도의 룰러를 필요로 하지도 않으므로 간단하게 구성되었을 것이다.

  • UIScrollView에서 콘텐츠는 스크롤뷰 자체의 서브 뷰가 되고, 스크롤뷰는 framebounds 속성을 비교하여 전체 콘텐츠 중에서 어떤 부분을 어떤 크기로 보여줄 것인지 결정하고 그만큼 클리핑하여 표시한다.
  • 반면 NSScrollView는 스크롤 서비스에 필요한 클래스들을 묶어서 래핑하는 클래스이다. 실질적으로 콘텐츠는 고스란히 documentView 속성에 할당되고, 그 위에 NSClipView를 얹어서 필요한 부분을 잘라낸다. 그리고 그 주위로 설정 혹은 필요에 따라서 가로/세로 룰러 뷰나 스크롤러를 붙여준다.
  • UIScrollView는 콘텐츠의 크기 설정을 위해서 contentSize 속성을 반드시 지정해야 했지만, NSScrollView는 그럴 필요가 없다.
  • UIScrollView는 콘텐츠의 확대/축소를 위해서 어떤 콘텐츠가 확대될지를 알려주는 델리게이트가 필요하지만, NSScrollView에서는 별도의 처리가 필요없다. 단, 확대/축소의 애니메이션을 위해서는 NSScrollView는 애니메이터 프록시를 써야 하며, 직접적인 프로퍼티 변경이 줌 인/아웃 효과를 내지는 않는다.

콘텐츠의 확대와 축소

콘텐츠의 확대/축소는 magnification 값을 조정하여 변경할 수 있다.  단, 이 값을 변경하는 것이 iOS처럼 자동으로 줌인/줌아웃 효과를 주지는 않는다. 이 값을 조정하려면 해당 객체의 animator를 사용해야 한다.

보너스 : 뷰의 애니메이터

macOS 10.5 이상에서 NSView는 NSAnimatablePropertyContainer 프로토콜을 따르고 있고, 따라서 animator() 메소드를 이용해서 애니메이션을 위한 프록시 객체를 얻을 수 있다. 이 프록시에 대해서 키-밸류 코딩 기반의 프로퍼티 변경 메시지를 보내면 애니메이터는 기본 애니메이션 설정값을 이용해서 해당 프로퍼티를 애니메이트할 수 있다.

func zoomOut(_ view: NSScrollView) {
  let m = view.magnification
  view.animator().magnification = m / 2
}