NSSplitViewControllerで3panesパターンを実装する
UIKitではUISplitViewControllerを使って2もしくは3カラムのUIを実装しますが、iPadで最適なのはあくまでprimary-(supplementary)-secondaryのパターンであって、inspector paneはより拡張性の高いmac app特有のパターンです。
この記事ではNSSplitViewController
を利用して、inspector paneを伴った3panesパターンの実装方法を順をおって解説していきます。
1. プロジェクトの作成
「macOS > App」のテンプレートを選択し、プロジェクトを作成します。
この記事ではStoryboardとAppKitを使うので、Interface>Storyboardと選択して作成してください。
(今はCoreDataもUnitTestもいらないのでオプションは外してください。)
2. コンポーネントの配置
Storyboardを開き、初期配置されているWindow SceneとView Controller Sceneを範囲選択して削除してください。そして、cmd+shift+Lでライブラリを開き、"Window Controller with Sidebar"というコンポーネントをドロップします。ドロップされたWindow Controllerの"Is Initial Controller"のチェックを入れておきます。
3. Toolbarにアイテムを追加して属性を設定
Storyboard上でToolbarをクリックするとパネルが表示されて、配置するToolbarItemを決めることができます。"Default Toolbar Items ... drag in items from above"と書いてあるところに入れたものがアプリの起動時にデフォルトで表示されます。
まずはflexible space以外のアイテムをドロップして削除します。その後、ライブラリからToolbarItemをAllowed Toolbar Itemsに一度ドロップして、そこからDefault Toolbar Itemsにドロップします。Allowed Toolbar ItemsのToolbar Itemをクリックして、インスペクタペーンからImage Nameにsidebar.rightを設定します。
ついでに、Toolbarをクリックした状態で、インスペクタからDisplay > Icon Onlyを設定しておきます。
4. 3つ目のPaneを追加
ライブラリからView Controllerを1つ追加でドロップします。Split View Controller Sceneをクリックしてctrlキーを押しながらドラッグして追加したView Controllerの上で放し、split viewsのコネクションを作成します。
このとき、Split View Controller Sceneの一番右端のNSSplitViewItemはHodling Priorityを260に上げておき、User Can Collapseのチェックを入れておきます。
Holding Priorityはサイズを保持する優先度ですが、例えばウインドウの端を持ってサイズを大きくしたときにサイドバーやインスペクタが大きくなると変なので、コンテンツエリアのみ拡大縮小してほしいです。左右のHolding Priorityは260など同一に設定し、中央だけ低い値に設定しておけばその挙動を実現できます。
5. 適当なビューを配置してAutolayoutを設定
View Controller Sceneの3つに対して適当なビューをそれぞれ載せて、上下左右0ptの制約を付けます。ここではPlain Document Content Text Viewを使い、適当なテキストを設定しておきます。
その後、sidebarとinspectorに該当するpane上のテキストビューには、それぞれ"width >= 250"の幅制約を付け、content areaに該当するpaneのテキストビューには"width >= 300"の幅制約を付けておきます(値はプロジェクトごとによります)
NSSplitViewControllerのドキュメントを読むと、SplitViewの仕組みはあったとしても、子ビューでAutolayout制約を設定する必要があると言っています。
To use a split view controller, you must use Auto Layout for the child views and to support animations that collapse and reveal child views. For example, if you design a layout that contains two views, a content area and an optional sidebar, you employ Auto Layout constraints to specify whether the content area shrinks or remains the same size when the sidebar becomes visible.
(https://developer.apple.com/documentation/appkit/nssplitviewcontroller)
5. コードでアクションを設定
NSSplitViewControllerのサブクラスを作成し、StoryboardのSplit View Controller SceneのCustom Classとして設定しておきます。inspectorのIBOutletを繋いでおきます。
import Cocoa
class SplitViewController: NSSplitViewController {
@IBOutlet weak var inspectorSplitViewItem: NSSplitViewItem!
}
NSWindowControllerのサブクラスを作成し、StoryboardのWindow SceneのCustom Classとして設定しておきます。インスペクタトグル用のToolbar ItemからIBAction func toggleInspectorPane(sender:Any?)を接続しておきます。
アニメーション用のプロキシオブジェクトを経由しつつisCollapsed
プロパティをトグルします。
import Cocoa
class WindowController: NSWindowController {
override func windowDidLoad() {
super.windowDidLoad()
// Implement this method to handle any initialization after your window controller's window has been loaded from its nib file.
}
@IBAction func toggleInspectorPane(_ sender: Any) {
(contentViewController as! SplitViewController).inspectorSplitViewItem.animator().isCollapsed.toggle()
}
}
完成!
これで狙ったインスペクタの挙動が実現できました。サンプルのgifを上げておきます。
概ねこのような形で進め、あとはそれぞれのオブジェクトのプロパティを調節したりなどで見た目などを好きなように変えていきます。
おまけ
以下のコードのように.sidebarTrackingSeparator
を活用すると、サイドバーペーン内にNSToolbarItemを置くことができ、トグルするとタイトルバーの左に位置するような挙動を実現できます。コードで書いてます。(このコード単体では動きません。参考程度にお考えください)
extension NSToolbarItem.Identifier {
static let leadingPaneToggleItem = NSToolbarItem.Identifier("jp.co.raiso.tweetboxproto4.toolbaritem.toggle-leading-pane")
static let trailingPaneToggleItem = NSToolbarItem.Identifier("jp.co.raiso.tweetboxproto4.toolbaritem.toggle-trailing-pane")
}
extension ToolbarDelegate: NSToolbarDelegate {
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return [
.leadingPaneToggleItem,
.sidebarTrackingSeparator,
.flexibleSpace,
.trailingPaneToggleItem
]
}
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return toolbarDefaultItemIdentifiers(toolbar)
}
func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
var toolbarItem: NSToolbarItem?
switch itemIdentifier {
case .leadingPaneToggleItem:
let item = NSToolbarItem(itemIdentifier: itemIdentifier)
item.image = NSImage(systemSymbolName: "sidebar.left", accessibilityDescription: nil)
item.action = #selector(ViewController.toggleLeadingPane(sender:))
toolbarItem = item
case .trailingPaneToggleItem:
let item = NSToolbarItem(itemIdentifier: itemIdentifier)
item.image = NSImage(systemSymbolName: "sidebar.right", accessibilityDescription: nil)
item.action = #selector(ViewController.toggleTrailingPane(sender:))
toolbarItem = item
case .toggleSidebar:
let item = NSToolbarItem(itemIdentifier: .toggleSidebar)
toolbarItem = item
case .sidebarTrackingSeparator: //これ
let item = NSToolbarItem(itemIdentifier: .sidebarTrackingSeparator)
toolbarItem = item
default:
toolbarItem = nil
}
toolbarItem?.isBordered = true
return toolbarItem
}
}
Discussion