[Swift4/Cocoa] 行の並び替えができるNSTableViewのサンプル

By | 2018年7月1日

複数の要素を並べて表示する NSTableView の Swift によるプログラム的な作成方法と、各行の要素をドラッグ&ドロップで並び替え可能にするためのコードについてです。

以下のような、テキストのみの要素を一列に表示する簡単な NSTableView のサンプルを作ります。

ドラッグ&ドロップで並び替えできるNSTableView

サンプルのソースコード

Cocoa Application のプロジェクトを新規作成し、生成される ViewController.swift を以下のように変更します。

import Cocoa

class ViewController: NSViewController, NSTableViewDelegate, NSTableViewDataSource {

    // 表示する要素
    var tableViewData: [String] = ["Item 1", "Item 2", "Item 3", "Item 4", "Item 5"]

    // セルのUTI
    let DRAG_TYPE = "public.data"
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let tableViewWidth: CGFloat = 400
        
        let tableView = NSTableView(frame: NSRect(x: 0, y: 0, width: tableViewWidth, height: 400))
        tableView.dataSource = self
        tableView.delegate = self
        tableView.registerForDraggedTypes([NSPasteboard.PasteboardType(DRAG_TYPE)])
        
        // 列の設定
        let tableColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("column"))
        tableColumn.width = tableViewWidth
        tableView.addTableColumn(tableColumn)

        // NSTableView を NSClipView に設定
        let scrollContentView = NSClipView(frame: NSRect(x: 0, y: 0, width: 400, height: 400))
        scrollContentView.documentView = tableView

        // NSClipView を NSScrollView に設定
        let scrollView = NSScrollView(frame: NSRect(x: 50, y: 50, width: 400, height: 400))
        scrollView.contentView = scrollContentView
        
        self.view.addSubview(scrollView)
    }
    
    // 行数
    func numberOfRows(in tableView: NSTableView) -> Int {
        return tableViewData.count
    }
    
    // 各行の要素(セル)
    func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? {
        return NSCell(textCell: tableViewData[row])
    }

    // 行の高さ
    func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
        return 40
    }

    // 以下ドラッグ&ドロップのための設定
    
    func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, 
        proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation {
        
        if dropOperation == .above {
            return .move
        }
        return []
    }
    
    func tableView(_ tableView: NSTableView, writeRowsWith rowIndexes: IndexSet, to pboard: NSPasteboard) -> Bool {
        
        // IndexSet の情報を NSPasteboard に保持させる
        let data = NSKeyedArchiver.archivedData(withRootObject: rowIndexes)
        pboard.declareTypes([NSPasteboard.PasteboardType(DRAG_TYPE)], owner: self)
        pboard.setData(data, forType: NSPasteboard.PasteboardType(DRAG_TYPE))
        
        return true
    }
    
    func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool {

        // NSPasteboard から行の情報を取り出す
        let pasteboard = info.draggingPasteboard()
        let pasteboardData = pasteboard.data(forType: NSPasteboard.PasteboardType(DRAG_TYPE))

        if let pasteboardData = pasteboardData {

            if let rowIndexes = NSKeyedUnarchiver.unarchiveObject(with: pasteboardData) as? IndexSet {
                
                tableView.beginUpdates()

                for oldIndex in rowIndexes {
                    
                    print(oldIndex) // 元の位置
                    print(row) // 移動後の位置
                    
                    if oldIndex < row {
                        // 要素を下に移動させる場合
                        tableView.moveRow(at: oldIndex, to: row - 1)
                        
                        let oldElm = tableViewData[oldIndex]
                        
                        tableViewData.remove(at: oldIndex)
                        tableViewData.insert(oldElm, at: row - 1)

                    } else {
                        // 要素を上に移動させる場合
                        if row < tableViewData.count {
                            tableView.moveRow(at: oldIndex, to: row)
                            
                            let oldElm = tableViewData[oldIndex]
                            
                            tableViewData.remove(at: oldIndex)
                            tableViewData.insert(oldElm, at: row)
                        }
                    }
                }
                
                tableView.endUpdates()
                
                return true
            }
            print("failed")
        }
        return false
    }
}

viewDidLoad における処理

NSTableView は単独ではなく NSScrollView および NSClipView と共に動作します。また、1つの NSTableView には複数の列を表示できるため、表示する列の数だけ NSTableColumn を追加します。

今回のサンプルでは一列のみ表示させるため NSTableColumn はひとつだけ追加しています。

基本動作やドラッグ&ドロップによる並べ替え機能を実装するためには NSTableViewDataSource および NSTableViewDelegate の2つのプロトコルが必要です。

ドラッグ&ドロップ可能なデータタイプ(UTI)は registerForDraggedTypes で設定します。このサンプルでは public.data です。

ドラッグ&ドロップによる並び替え処理

並び替えの処理には、以下のメソッドを使用します

ドロップしたときに起こる実際の並び替え処理は tableView(_:acceptDrop:row:dropOperation:) における beginUpdatesendUpdates の間で行われます。


...

for oldIndex in rowIndexes {
    
    print(oldIndex) // 元の位置
    print(row) // 移動後の位置
    
    if oldIndex < row {
        tableView.moveRow(at: oldIndex, to: row - 1)
        
        let oldElm = tableViewData[oldIndex]
        
        tableViewData.remove(at: oldIndex)
        tableViewData.insert(oldElm, at: row - 1)
        
    } else {
        if row < tableViewData.count {
            tableView.moveRow(at: oldIndex, to: row)
            
            let oldElm = tableViewData[oldIndex]
            
            tableViewData.remove(at: oldIndex)
            tableViewData.insert(oldElm, at: row)
        }
    }
}

...

moveRow は、ある番号の要素を指定した場所に移動するアニメーションを行うメソッドです。あくまで移動アニメーションを行うだけであり、実際の要素の並び替えが起こるわけではないので、その処理は別に書く必要があります。

このサンプルでは、実際の配列の要素を削除(remove)と挿入(insert)によって移動させています。要素の移動方向によって挙動が異なるため、場合分けを行っています。

以上、要素の並び替えができる NSTableView についてでした。

[Swift4/Cocoa] 行の並び替えができるNSTableViewのサンプル」への1件のフィードバック

  1. 小田 収

    大変参考になりました。
    Macのプログラミング資料は本当に貴重です。
    最近は書籍もほとんどないし、Appleもかみ砕いた資料はほとんど出さなくなったし。

    返信

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です