本文是Apple Documentation Archive View Programming Guide的学习笔记。

概要

Introduction to View Programming Guide for Cocoa

窗格(Window)对象是Cocoa应用用来管理其屏幕区域的主要对象,而视图(View)对象则是归窗格对象管理的,负责绘制窗格图像的辅助对象。视图对象辅助绘制一个矩形区域的二维像素数组。

参考:

Cocoa Drawing Guide

视图是什么

What Are Views?

除了前面介绍的绘制功能以外,如果用户在视图区域内出发了操作,那么所属视图必须进行相应。NSView这个抽象类是Cocoa中所有视图控件的基本类,而为了能够相应用户操作,NSView又是从NSResponder派生出来的。视图与视图之间可以嵌套,所以一个窗口通常拥有一整颗视图树。

Cocoa预制了很多NSView派生出来的视图。大概可以分为几类:陈设相关、文字相关、以及用户交互相关。

陈设相关的视图通常是为了在视觉上分割帧框,以达到排布和陈设子控件的目的。常见的例子有NSBox、NSTabView、NSSplitView等等。

NSScrollView也是一种比较复杂的,陈设相关的视图。它需要与NSScroller配置,达到协调其他视图,实现页面滚动的效果。NSScrollView的内容是由另外一个视图:NSClipView来处理的。NSRulerView则显示相应的标尺来配合NSScrollView。

文本相关的视图顾名思义主要是用来显示和处理文本。NSTextView是常用的文本相关的视图。NSTextView是NSText的派生类,NSText才是从NSView派生出来的。Cocoa的文本显示相当复杂,可以参考Cocoa Text Architecture Guide

前面说到,视图不光显示内容,还要处理用户输入。因此一些视图是跟用户输入相关的,允许用户改变控件包含的信息,这类视图往往被叫做控件。控件可以作为cell的容器。所谓cell,是用户交互过程中产生的视觉效果,用于交互展示。一个cell可以被多个控件拥有,比如一个NSTextFieldCell的实例可以被一个NSTextField的实例拥有,也可以同时被一个NSTextFieldCell的实例拥有。是否拥有cell是普通视图和控件的重大差别。

控件可以关联一个目标对象。比如一个按键控件可以管理一个对象,当这个按键被点击的时候,按键会往目标对象发送点击消息。如果不指定目标对象,那么事件就会被发给默认的响应链。控件的基本类型是NSControl,其派生类的例子包括:NSImageView、NSTableView、NSOutlineView。

macOS的默认的图形环境是Quartz。大部分视图都是使用Quartz画出来的,但是Cocoa也允许一些视图使用其他图形环境,比如OpenGL。NSOpenGLView允许一个应用使用OpenGL来画图。只需要从NSOpenGLView派生,并自定义drawRect:即可。需要注意的是,NSOpenGLView不支持子视图。

视图的几何属性

View Geometry

既然视图负责描绘一个帧框,那么它就得知道这个帧框的在屏幕坐标系统中的位置和大小。屏幕坐标的原点(0.0,0.0)坐落在左下角,跟数学上的笛卡尔坐标系一致。每个视图定义自己的坐标系统,视图的所有的绘制都是在这个坐标系下的。

Cocoa用的Quartz图形系统是设计成设备无关的,意味着Quartz的1个单位直接相当于屏幕上的一个像素。通常情况下,程序员不需要操心这个,这都是Quartz和NSView自动帮助管理的。

一个视图其实带有两个位置和大小不同的矩形,一个是视图的帧框,帧框在某个视图的上一级视图的坐标系中定义的;另一个矩形则定义了视图的画界,是在视图自己的坐标系定义的。画界中存放是视图自身的内容,画界中的内容可以被平移,拉伸和旋转;而帧框定义了视图的显示区域,画界中的内容显示的时候不能超出帧框之外。帧框可以被移动,缩放以及旋转。画界上能在帧框中显示的区域可以通过visibleRect获取。

视图的帧框可以在初始化的时候通过initWithFrame:指定,并通过frame获取。视图的画界默认和帧框相同大小,但是在视图初始化之后可以立即修改。画界可以通过bounds获取。

视图的坐标系统可以被转换、缩放、翻面以及旋转。通过setBounds:调整画界就可以对视图的坐标进行调整。上述方法会同时设置画界坐标的原点和缩放,如果想分开设置,可以使用setBoundsOrigin:setBoundsSize:。注意,当视图的画界遭到上述方法修改之后,就不会跟着帧框改变了。

其他修改画界坐标的方法有translateOriginToPoint: scaleUnitSquareToSize:

翻转画界坐标系,需要从NSView派生并自定义isFlipped,让其返回YES。旋转画界坐标则可以使用setBoundsRotation:rotateByAngle:。注意,直接旋转画界坐标系不是一个非常高效的操作,可以通过drawRect: 方法来达到类似的效果,具体参考Coordinate Systems and Transforms

认识和了解视图层叠

Working with the View Hierarchy

因为一个视图中可以裹挟其他子视图,所以这些视图合在一起是按照层叠的方式呈现的。一个视图可以有多个子视图(subview),但只能够有一个父视图(superview)。视图只有在一个窗口中才能被显示;对于一个一个窗口而言,其内容视图就是一个视图层级的顶层视图。

以层叠方式组织视图有诸多好处:

  • 方便使用简单的视图来合成负责的视图,增加视图的可服用性
  • 每个子视图的坐标系统是相对于其父视图的坐标系统的。当一个视图的坐标系统发生改变,其所有子系统的显示都会跟着改变。但是由于每个视图都在自己的坐标系统里面绘制,所以绘制的内容不需要改变,只是输出方式变了。
  • 采用层叠方式组织,有利于事件的处理。当一个视图接收到一个不知道该怎么处理的交互事件的时候,它需要做的就是把事件转给父视图。
  • 层叠意味着每个视图只负责绘制窗口的特定区域。当某块区域需要重新绘制时,很容易找到负责的视图。
  • 层叠方式下,很容易新增视图或者移除既有视图。

视图的superview方法返回其父视图(顶层视图返回nil),subviews放回其子视图们。视图的window方法返回视图所属的窗口,如果不存在此窗口,则返回nil。另外,isDescendantOf: 可以判断一个视图是否是另一个视图的后继;ancestorSharedWithView: 找到两个视图之间的共同前序。opaqueAncestor返回其最近的前序视图,能够完整包含当前视图的输出。

视同initWithFrame:初始化的NSView没有和窗口对应起来,需要在目标视图上通过addSubview:才能把新建的视图添加为目标视图的子节点。addSubview:positioned:relativeTo:可以在插入的时候指定新增视图在子视图们中的位置。replaceSubview:with: 可以替换视图层叠中的某个视图。

虽然一个视图有若干个子视图,但是绘制的时候Cocoa并不严格保证这些子视图的先后次序。Cocoa只保证父子视图之间的先后次序。

removeFromSuperview可以从层叠中移除一个视图。removeFromSuperviewWithoutNeedingDisplay功能类似,但是并不会触发父视图的重新绘制。

当一个视图加入层叠的时候,会触发viewWillMoveToSuperview: viewWillMoveToWindow:。自定义这些方法可以从中获取父视图或者窗口相关的信息。

视图的层叠可以看成是Cocoa的集合对象(collection object)。当视图加入层叠时,其内存会被持有;当移除时,内存会被释放。Advanced Memory Management Programming Guide

可以用setFrame:来调整视图的帧框的位置和大小(或者使用setFrameOrigin:setFrameSize:来分别调整位置和大小)。帧框大小变了的画,视图的画界也会跟着变,除非之前使用过setBounds来调整过画界。但是对于一个视图而言,要调整它的位置和大小可不是一件简单的工作。它既可能影响到父视图,更能影响到子视图。如果一个视图的autoresizesSubviews返回YES,那么当它的大小调整之后,拥有的子视图们也会自动跟着调整。如果这不是期望的行为,那么可以通过setAutoresizesSubviews:来关闭。

自动调整子视图的一种方式是使用setAutoresizingMask:来设定视图的autoresizing mask属性:

  • NSViewHeightSizable,子视图的高度按比例随父视图的高度变化
  • NSViewWidthSizable,子视图的宽度按比例随父视图宽度变化
  • NSViewMinXMargin,子视图的左边框按比例随父视图的宽度变化而移动
  • NSViewMaxXMargin, 子视图的右边框按比例随父视图的宽度变化而移动
  • NSViewMinYMargin,子视图的上边框按比例随父视图的高度变化而移动。如果父视图的坐标翻转了,那么移动的是子视图的下边框。
  • NSViewMaxYMargin,子视图的下上边框按比例随父视图的高度变化而移动。如果父视图的坐标翻转了,那么移动的是子视图的上边框。

NSView的派生类可以自定义resizeSubviewsWithOldSize: resizeWithOldSuperviewSize:来提供一些独特的行为。

注意,文档中带有图,可以更好地解释autoresizing mast。另外,在最新的的AppKit中,此功能几乎已经被AutoLayout给代替了,所以就不做过多介绍了。

当一个视图的帧框或者画界变化是,会发送NSViewFrameDidChangeNotificationNSViewBoundsDidChangeNotification通知。这两个功能其实可以让父视图订阅子视图的位置和大小变化,以改变自己形状。NSScrollViewNSClipView就使用了这些通知。除此之外,如果不想使用这些通知,可以使用setPostsFrameChangedNotifications:setPostsBoundsChangedNotifications:来关闭它们。

一个视图可以通过 setHidden:把自己隐藏起来。一个被隐藏的视图只是在屏幕上看不见了,然后无法跟用户交互而已。它依然在层叠中,依然受前继视图影响,依然影响后续视图。如果被隐藏的视图是窗口的第一响应者,那么该视图的nextValidKeyView会变成新的第一响应者。isHiddenisHiddenOrHasHiddenAncestor可以用来查询隐藏相关的信息。

由于每个视图拥有自己的坐标系统,所以经常需要在不同的视图间转换坐标,对此AppKit提供了以下方法:

  • convertPoint:fromView:
  • convertRect:fromView:
  • convertSize:fromView:
  • convertPoint:toView:
  • convertRect:toView:
  • convertSize:toView:

上面的方法如果不指定对象视图(即视图为nil),那么默认的对象就是窗口本身。

NSWindow有convertBaseToScreen:convertScreenToBase:,可以在窗口坐标系统和屏幕坐标系统之间转化。

注意,如果坐标轴带有旋转角度的情况下,坐标转化的时候可能会改变视图矩形坐标的大小。

如果想将视图坐标转换成屏幕上基础(Base)像素的坐标,可以使用以下方法:

  • (NSRect)convertRectToBase:(NSRect)aRect;
  • (NSPoint)convertPointToBase:(NSPoint)aPoint;
  • (NSSize)convertSizeToBase:(NSSize)aSize;
  • (NSRect)convertRectFromBase:(NSRect)aRect;
  • (NSPoint)convertPointFromBase:(NSPoint)aPoint;
  • (NSSize)convertSizeFromBase:(NSSize)aSize;

基础像素坐标系跟设备强相关,而实际的屏幕坐标系可能会针对像素坐标系来做一定的缩放。

NSView支持tag返回标签,这有助于在一个很深的层叠中寻找一个视图。viewWithTag: 方法会以深度遍历的方式搜索一个层叠,找到并返回第一个指定标签值的视图。NSView本身的tag永远返回-1,但是其派生类可以自定义这个方法返回新的值。有些派生类还会实现setTag: 方法,用于对每个实例设置不同的标签值,NSControl就是这样一个例子。

其他参考

Cocoa Drawing Guide

(本篇完)