NSScrollViewのセンタリング(fullSizeContentView, magnification対応付き)

NSScrollView の定番の質問に「コンテンツのセンタリングをしたい」というのがあります。

歴史のあるクラスだけあって Stackoverflow なんかでもちょいちょい転がっている頻出パターンなのですが、fullSizeContentView や magnification まで考慮した現代的な回答が見つからなかったためまとめます。

# 解法
結論だけ先にいうと、大枠では他所の回答と同じく NSClipView のカスタマイズによって解決します。このビューをデフォルトの NSClipView の代わりに使うことで自動的に中身のコンテンツがセンタリングされます。
(サンプル: https://github.com/ryutei/CenteringContentViewSample)

```CenteringClipView.swift
import Cocoa

class CenteringClipView: NSClipView {

    override func constrainBoundsRect(_ proposedBounds: NSRect) -> NSRect {
        let base = super.constrainBoundsRect(proposedBounds)
        
        guard let documentFrame = self.documentView?.frame
        else {
            return base
        }
        
        let frame = self.frame
        let mag = frame.width / proposedBounds.width // #1
        let insets = self.enclosingScrollView!.contentInsets // #2
          // #3
        let deltaX = max(frame.width/mag-documentFrame.width, 0.0)
        let deltaY = max(frame.height/mag-insets.top/mag-documentFrame.height, 0.0)
        
        var ret = base
          // #4
        ret.origin.x -= deltaX/2.0 
        ret.origin.y -= deltaY/2.0

        return ret
    }
}
```

# 解説
まず NSClipView.constrainBoundsRect(_:) は何をするメソッドなのかというと、基本的にはスクロール(や拡大縮小)の変化に対応して「これ以上行くな」という範囲の制約をかけるためのものです。

例えばマウスのホイールを回すとその変化量に応じてビューがスクロールビューの中を移動するわけですが、何も制限がかかっていなければコンテンツはやがて画面から外れて無限遠に流れていくことになります。そこで、フレームやらコンテンツの大きさやらを見て、もう端まで来ていると判断したら境界値でストップさせるよう値を調整するわけです。

引数に渡される proposedRect は取り敢えず何も考えずに変化量を反映させてみた場合の値であり、コンテンツが実際に適切な位置にあるかどうかは考慮されていません。なのでこちらがすることは proposedRect の値を参考にしつつ、都合のいい rect に変形して返してやる作業になります。

で、これで具体的に何を計算するのかというと、NSClipView の bounds を返します。NSScrollView はユーザー定義の documentView に対して、一種の選択領域となる contentView (NSClipView) を動かすことで documentView のどの範囲を表示するか制御しています。bounds が移動するとその分 documentView はオフセットされ、sizeを変えると documentView の倍率が変わることになります。なので、ビューが空いている時のセンタリングをしたければ bounds の origin を余白のサイズだけ移動してやればいいことになります。

NSClipView は NSScrollView によって実際に表示される範囲に合わせて大きさが設定されるので、余白の量は基本的には自身の frame と documentView の frame の差ということになります。これが負の値になったら余白がないということなので、この場合は 0.0 にするのが普通です(#3)

ちなみに、bounds に対するジオメトリの変化は documentView に対して逆向きに作用するという点に注意してください。上でも説明したとおり、NSClipView は documentView の選択領域を定義するという考え方なので、bounds が正の方向に移動するということはドキュメント上の選択領域が原点から反対側の角に向かって移動することに相当します。つまり元々の原点は(下向きが正なら)画面外の左上方向に遠ざかっていくということであり、感覚的には負のオフセットがかかるわけです(#4)。
同様に拡大縮小も bounds が広くなると documentView の大きさは小さくなります。これは広角レンズを使うと一度に写る範囲が広くなる代わり、写るものの大きさが小さくなるのをイメージすると分かりやすいと思います。

## magnificationとfullSizeContentView
さて、原則としてはこれでいいのですが、計算をややこしくするのが magnification と fullSizeContentView の存在です。

### magnification
まず magnification ですが、NSScrollView の magnification が変化すると、NSClipView の bounds はその逆数の倍率補正が入ります。したがってオフセット量も1/倍率にしてやる必要があるわけです。ただし、倍率の影響を受けるのはあくまで NSClipView なので、__bounds の計算は倍率を考慮しなければならない__ が __documentView の frame は特に倍率の影響を受けない__という点には注意する必要があります。したがって倍率補正込みの余白計算を式にすると次のようになります。

* (画面上の)余白 = contentView.frame - documentView.frame * 倍率
* bounds上のオフセット = 余白 / 2 / 倍率

上のコードで言うと #3 のdeltaを計算している辺りです。なおコード上はいちいち倍率をかけたり割ったりするのは無駄なので、オフセットの/倍率は余白側に取り込んでまとめています。

### fullSizeContentView
次に fullSizeContentView です。まずこれは何かというと、iOS や Mac でも Safari なんかがやっている、タイトルバーの下にコンテンツのブラー表示が潜り込むアレです。ビューの構造上はウィンドウ全体がコンテンツの表示領域となり、その上にタイトルバーをオーバーレイするような実装になっています。
NSScrollView、NSClipView ともタイトルバーを含むウィンドウ全領域の大きさを持っている点に注意してください。
![fullSize.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214734/44df1e38-5f46-f817-1886-188257c96f38.png)
これはフルサイズ表示の趣旨を考えれば当たり前で、タイトルバーの下に何かを表示するためにはその下にコンテンツをスクロールできる必要があるわけです。しかし、ストレートにそれをやってしまうとタイトルバーの下にある内容が見えないということになるので、コンテンツがスクロールアウトした場合にのみタイトルバーの下に流れるという特殊な動きをとることになります。
__実はこれはセンタリング計算と同じ理屈です__。要するにタイトルバーの分だけ縦方向の余白を多く取ることで全体を下側にオフセットするというのが fullSizeContentView の仕掛けなわけです。

このタイトルバー(とツールバー)の大きさは NSClipView と NSScrollView が持っている contentInsets で取得できます。真面目に計算するなら四方の insets をちゃんと計算したほうがいいとは思うのですが、現実問題としてタイトルバーの潜り込み以外で insets を使うことはあまりないように思うので、ここでは insets.top のみを対象としています。ただし、NSClipView のデフォルト実装も contentInsets はちゃんと考慮してくれるので、通常は余白計算だけ考慮すれば問題ありません。上のコードで言うと、#3の deltaY の計算で frame から insets.top を引く(つまり画面に見えている大きさを計算している)処理がこれに該当します。

## アニメーション
最後に実装上のトラップについて触れておきます。上のコードの #1 では拡大率を取るのに NSScrollView の magnification を使わず、わざわざ frame と proposedBounds の比で拡大率を計算していますが、実はこれは必然的な理由によるものです。
NSScrollView.animator().setMagnification(_:centeredAt:) によって拡大縮小のアニメーションを行う場合、**proposedBounds で要求される拡大率と NSScrollView がプロパティとして持っている拡大率(magnification)は初期値が異なっており、アニメーションがおかしな挙動を示します**。そのため本当の拡大率を取得するためには必ず proposedBounds の値を参照する必要があるわけです。
アニメーション周りは他にもトラップがあって、contentInsets の取得を enclosingScrollView ではなく NSClipView から直接取得すると、設定されるタイミングが異なるのかアニメーションにガタが出るようです。現状でもすべての場合を網羅できているか正直自信がないのですが、一般的な状況については問題のない挙動になっていると思います。