ひtoりgoと

NSView のレイアウト方式いろいろ

macOS 開発

AppKit ってせっかく毎年改善されて使い方も変化しているのにほとんど "AppKit Release Notes" 以外の資料が更新されないから長年追い続けている人しか全体像が見えないのでないかといつも思っていたのだけど、WWDC18 ではついにリリースノートすらなくなってしまった! セッションビデオでの説明はあるものの過去の何本ものビデオを見返すなんて現実的ではないし、これ長い目で見るとよくないのでは...

そんなこともあるので zumuya では Cocoa 関係の情報をちょっとずつまとめるのである。今回はビューをレイアウトする方法について 1 つずつ触れていくことにする。

(2018.7.1:追記)

いつもよりだいぶ遅れてリリースノートが公開されました。

方式 1:手動で配置して autoresizingMask で維持

昔ながらの方法。ある View がリサイズされるたび、その変化量に応じて subviews 各要素の frame を決定するのだけど、そこで考慮されるのが autoresizingMask だ。

autoresizingMask はその名の通り複数指定できる。Interface Builder で指定するのが簡単でわかりやすい。内容は:

//水平位置
.minXMargin, //左端から superview の左端までの距離が可変
.maxXMargin, //右端から superview の右端までの距離が可変

//垂直位置(isFlipped だと逆になるので注意)
.minYMargin, //下端から superview の下端までの距離が可変
.maxYMargin, //上端から superview の上端までの距離が可変

//サイズ
.width, //幅が可変
.height //高さが可変
NSView.AutoresizingMask

最初に理想的な親子の位置関係になるように手動で配置したうえでその関係を維持させるというのが基本の使い方。最近は Sketch のシンボル機能でも使われている方式。

例:上下左右にある 10 pt の余白が親 View をリサイズしても維持される。

let parent = NSView(frame: NSRect(x: 0, y: 0, width: 60, height: 60))
let child = NSView(frame: NSRect(x: 10, y: 10, width: 40, height: 40))
child.autoresizingMask = [.width, .height]
parent.addSubview(child)

parent.setFrameSize(NSSize(width: 50, height: 80))
print(child.frame)
Example
(10.0, 10.0, 30.0, 60.0)
Result

シンプルな方式だけど欠点がある。一度でも superview が小さくなりすぎて最初と同じ関係が維持できなくなると、その後いくら広げても崩壊してしまうのだ。“ステータスバーを表示”みたいなユーザの操作があると View を追加したり取り除いたりするたびに理想的な親子関係を作らないといけないし、昔はこの方法しかなかったから大変だった。

リサイズされるまでの流れにも触れておく:

  1. 何らかの要因でサイズが変更された
  2. autoresizesSubview == true なら次に進む
  3. resizeSubviews(withOldSize: NSSize) が呼ばれる
  4. subviews 各要素の resize(withOldSuperviewSize: NSSize) が順番に呼ばれる
  5. その中で oldSuperviewSizeautoresizingMask をもとにリサイズされる

方式 2:constraints を追加して自動で配置

「最近は Mac にも Auto Layout あるのね」みたいな話をときどき耳にするけど、導入されたのは OS X 10.7 Lion で iOS よりも先であることを強調しておく。それなりに大きな計算能力が必要だという背景もあったのかもしれないけれども。

ある View とある View(同じ場合もある)の座標属性の関係がこうであってほしいという情報、つまり制約を保持する NSConstraint のインスタンスを複数作成しておいて View の constraints に追加すれば、システムがそれぞれの関係を考慮して自動で配置してくれる賢い仕組み。

特に macOS では iOS と異なりユーザがウインドウをぐにぐにと好きなようにリサイズできるのが基本なので autoresizingMask(方式 1)は簡単に崩壊してしまうし、環境設定ウインドウを見ればわかるようにリスト表示主体の iOS よりも複雑な配置をすることが多くてしかも各 View のサイズがローカライズに依存することも考えるとなると頭が痛いのだ。そんなときに重宝するのがこれ。ウインドウの最大サイズまで自動で決めてくれる優れものである。

これも Interface Builder で指定するのが視覚的にわかりやすくて楽なのだけど、方式 1 と同じ内容の例を示しておこう。

let parent = NSView()

let child = NSView()
child.translatesAutoresizingMaskIntoConstraints = false

parent.addConstraints([
    child.leftAnchor.constraint(equalTo: parent.leftAnchor, constant: 10),
    child.rightAnchor.constraint(equalTo: parent.rightAnchor, constant: -10),
    child.topAnchor.constraint(equalTo: parent.topAnchor, constant: 10),
    child.bottomAnchor.constraint(equalTo: parent.bottomAnchor, constant: -10),
])

parent.addSubview(child)

parent.setFrameSize(NSSize(width: 50, height: 80))
parent.needsLayout = true
parent.layoutSubtreeIfNeeded()

print(child.frame)
Example
(10.0, 10.0, 30.0, 60.0)
Result

今回の例は親子だけど兄弟同士でも制約は指定可能だし、縦横比率や「〜以上/以下」みたいな指定もできるので、複数の制約を組み合わせればかなり複雑な条件での配置ができる。

注意点は、一つでも矛盾する制約が存在するだけで動かなくなってしまうことだ。上手く priority(優先度)を指定して共存させる必要がある。それと translatesAutoresizingMaskIntoConstraints が標準で true なので false にセットしておかないと意図しない制約が追加されることがあるから注意。

なかなかクセがあり慣れるまでは大変だけど、一度制約が完成してしまえばその後はシステムに面倒を見てもらえるので楽だ。

ちなみに constraints の変更は NSView.updateConstraints()NSViewController.updateViewConstraints() をオーバーライドしてその中でやるのが行儀がいいようで、それらは制約の変更が必要なときに needsUpdateConstraints = true をすれば効率よく呼ばれる。

方式 3:layout() をオーバーライドして手動で配置

手動で配置するという点では最初の方式に近いのだけど、それを View のリサイズのたびに呼ばれる layout() の中でやる。単純にオーバーライドすればよい。UIKit の世界では昔からおなじみの方式だ。

毎回位置関係を計算しなおすから崩壊を防ぐことができるし、場面によっては方式 2 の制約を考えるよりも楽だったりする。UITableViewCell みたいな効率的な使い回しなど、状況に応じて動的に View を追加/除去したい場合にも対応しやすい。

また同じ例で。

class Parent: NSView
{
    public let child = NSView()

    override func layout()
    {
        if #available(macOS 10.12, *) {
            //no longer need to call super method.
        } else {
            defer { super.layout() }
        }
        let bounds = self.bounds
        var childFrame = bounds; do {
            childFrame.origin.x += 10
            childFrame.origin.y += 10
            childFrame.size.width = max(0, (childFrame.size.width - 20))
            childFrame.size.height = max(0, (childFrame.size.height - 20))
        }

        child.frame = childFrame
        subviews = [child]
    }
}

let parent = Parent()

parent.setFrameSize(NSSize(width: 50, height: 80))
parent.needsLayout = true
parent.layoutSubtreeIfNeeded()

print(parent.child.frame)
Example
(10.0, 10.0, 30.0, 60.0)
Result

独自コントロールを作るときにはこの方式を使用することが多いかも。

ちなみに draw(_:) に対する needsDisplay みたいに layout() に対する needsLayout がある。再反映したくなったら直接 layout() を呼ばずにそちらを呼ぶのが基本だ。

方式 4:NSStackViewNSGridView に配置させる

NSStackViewUIStackView の AppKit 版である。いやいやこれは Mac が先。とはいえ WPF(雑に言うと Windows における AppKit みたいなもの)にはもっと昔から StackViewGrid もある...

複数の View を一直線に等間隔で並べたいときには NSStackView を使っておくと楽だ。自力で自動レイアウトの制約を作ってもいいのだけど、これなら一部の View を表示したり隠したりさせたときに自動で詰めてくれる。macOS 10.12 で追加された NSGridView は名前の通りそのグリッド配置版だ。環境設定画面と相性がいいかも。

どちらも最近は Interface Builder で簡単に扱えるのでどんどん使いましょう。

方式 5:NSCollectionView

NSCollectionViewUICollectionView よりだいぶ前に登場したのだけど、当初は限られた機能しかなくてほとんど使われることもなく放置され、何年も後のアップデートで UICollectionView そっくりの内容に変更されたという経緯を持つ。実はソースコードを UICollectionView と共有しているのではないかという噂もある。

一言で表すと NSTableView の二次元配置版みたいな立ち位置。データの個数分の View を用意していてはとても足りないような大量のデータを流し込んでスクロール表示したいときはこれ。選択状態も管理してくれる。

配置にこだわりたければ NSCollectionViewLayout のサブクラスを作ればそれぞれの表示属性(NSCollectionViewLayoutAttributes)をカスタマイズできるのだが UICollectionViewLayoutAttributes みたいに transform3D を指定することができないのが残念なところ。

Share

リンクも共有もお気軽に。記事を書くモチベーションの向上に役立てます。

© 2005-2021 zumuya