1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NSOutlineViewの落とし穴...NSTableViewとの違い

Last updated at Posted at 2025-11-16

背景

今までもNSOutlineViewは使ってきました。
今回新たに

  • マウスクリック以外の指示でアイテムの開閉を行う
  • アイテムの開閉状態をアプリ起動時に再現する

という機能を持つアプリを開発しています。
今まではこれらの機能は実装していませんでした。

両方とも、NSOutlineViewに実現するためのメソッドが用意されていました。
余裕だろう...と思っていましたが、意外とハマってしまいました。

NSOutlineViewで表示するデータ

単純な二次元の「構造体」です。

public struct Scene: Codable {
    public var pageNumber = 0
    public var sceneNumber = 0
    public var sceneTitle = "SCENE 1 - 1"
}
public struct Page: Codable {
    public var pageTitle = "PAGE 1"
    public var pageNumber = 0
    public var scenes = [Scene](repeating: Scene(), count: 10)
}

実際の見た目はこんな感じです。
スクリーンショット 2025-11-16 18.24.03.png

マウスクリック以外でアイテムを開閉する

NSOutlineViewにはアイテムを開閉するメソッドが用意されています。

NSOutlineView
open func expandItem(_ item: Any?)

NSTableViewと違って、行番号で指定するのではなく、表示しているそのものを指定するんですね。
確かに、アイテムの開閉状態によって行数や行番号は変わってくるので、アイテムで指定するのは理にかなっています。

引数のitemはどんな型でもよく、今回の場合はPage「構造体」が該当します。
今回はこの「構造体」が悪さをしていました。

さて、この引数のitemですが、

outlineView.expandItem(memory[0])

でよさそうに思えますが、これがダメなんですよね。
NSOutlineViewが内部に抱えているデータはmemoryのコピーです。
expandItem()メソッドの引数itemと、NSOutlineViewの中のインスタンスが同一でなくてはいけないのです。

アイテムの開閉状態を自前で再現する

NSOutlineViewで表示されているテキストを編集した場合、NSTableViewと同じように

outlineView.reloadData()

を実行することで、表示データを更新します。
しかし、NSOutlineViewではreloadData()を実行するとアイテムが全て閉じてしまうんです。
そのため、reloadData()の前に開いているアイテムを確認して覚えておく必要があります。
そのために、こんな便利なメソッドが用意されています。

NSOutlineView
open func isItemExpanded(_ item: Any?) -> Bool

今回の場合はPage構造体の開閉状態を知りたいのですが、これもやはりダメなんです。
引数で渡すとしたら

let result = outlineView.isItemExpanded(memory[0])

というような記述になりますが、前述同様NSOutlineViewが抱えているインスタンスとmemory[0]は同一ではないため、やはり正しく機能しません。

構造体ではなくクラスならいい?

はい、その通りです。
構造体は値型ですから、コピーを作って渡すことになります。
クラスは参照型ですから、NSOutlineViewが抱えているものとmemoryは同一です。
NSTableViewは構造体でもクラスでも問題なく動きます。
NSOutlineViewの場合は、扱うデータモデルはクラスが良さそうです。

じゃ、構造体ではダメなのかというと、そうではありません。
正直なところ、今回のデータモデルはクラスより構造体の方が良いと思っています。
しかし、手間がかかります。

reloadData()の後開閉状態を復元する

クラスで開閉状態を復元する場合

まずは開いているitemを取得します。
expandedItemsというプロパティを作りました。

public var expandedItems: [Any] {
    var result = [Any]()
        
    for i in 0 ..< numberOfRows {
        if let item = item(atRow: i) {
            if isItemExpanded(item) {
                result.append(item)
            }
        }
    }
    return result
}

reloadData()の後でこのように復元します。

for item in expandedItems {
    outlineView.expandItem(item)
}

構造体で開閉状態を復元する場合

構造体の場合、itemではなく開いている行を使って実現します。
まずは下記のように開いている行を取得します。
expandedRowsというプロパティを作りました。

public var expandedRows: [Int] {
    var result = [Int]()
        
    for i in 0 ..< numberOfRows {
        if let item = item(atRow: i) {
            if isItemExpanded(item) {
                result.append(i)
            }
        }
    }
    return result
}

reloadData()の後でこのように復元します。

for row in expandedRows {
    outlineView.expandItem(outlineView.item(atRow: row))
}

NSOutlineViewからrowを使ってitemを取得し、その取得したitemをそのまま引数で渡します。
ソースコードでアイテムを開閉する時もこの方法が使えます。

NSOutlineViewは行番号ではなくアイテムで管理しているので、それを考えると構造体よりクラスの方がスマートなんですね。

ソースコードでアイテムを開閉・選択する

データモデルがクラスの場合

とってもシンプルです!

let page = 8
let scene = 3
let row = outlineView.row(forItem: memory[page].scenes[scene])
//Pageアイテムを開く
outlineView.expandItem(memory[page])
//Sceneアイテムの行を選択する
outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) 
//Sceneアイテムのある場所までスクロールする
outlineView.scrollRowToVisible(row)

データモデルが構造体の場合

少し手間がかかりますが、問題なくできます。

let page = 8
let scene = 3

//Pageアイテムを開く
for row in 0 ..< outlineView.numberOfRows {
    if let item = outlineView.item(atRow: row) as? Page {
        if item.pageNumber == page {
            outlineView.expandItem(item)
        }
    }
}
        
for row in 0 ..< outlineView.numberOfRows {
    if !outlineView.item(atRow: row) is Page {
        let item = outlineView.item(atRow: row) as! Scene
        
        if item.pageNumber == page && item.sceneNumber == scene {
            //Sceneアイテムの行を選択する
            outlineView.scrollRowToVisible(row)
            //Sceneアイテムをのある場所までスクロールする
            outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
        }
    }
}

データモデルがクラスの場合と違って、NSOutlineViewのアイテムとmemoryが同一のものを指しているかを確認しなくてはいけません。

データモデルを構造体にしたいというポリシーがある場合は、多少手間がかかってもこの方が一貫性がありますね。

最後に

なかなかこの答えに辿り着くのには時間がかかりました...
結局はAppleのドキュメントを見て気付いた...というところです。

もし同じように悩んでいる方の助けになればと思い、記事投稿しました。

これと同じく、アプリ起動時に開閉状態を再現する機能もこの同一であることが関わってきます。
これについても後日投稿します。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?