🌟

NSTableViewのselectionでボーダースタイルを自作する

2023/03/12に公開

AppKitでは行のセレクション色などをカスタマイズしたい場合は、NSTableRowViewをサブクラスする必要があります。基本的なことは下記の記事を参考にしてください。
https://qiita.com/usagimaru/items/281bda8e65efede484bb

この記事では、そのサブクラス内でボーダースタイルを自作する方法を紹介します。

前提

  • NSTableView、もしくはそのサブクラスを利用している。
  • 複数選択を許可する

本題

参考記事ではNSBezierPathに対してfillを実行していましたが、他のAPIを使うとカスタムのセレクションスタイルを実装できます。この記事ではborderスタイルを実現するために、NSBezierPath.move(to:)NSBezierPath.line(to:)を使います。

NSTableRowViewのプロパティに対しても解説をしておきます。
選択中かどうかの判断がNSTableRowView.isSelectedで行われるというのはUITableViewCellなどと似たような感じでUIKit Developerにとっても直感的だと思いますが、AppKitではNSTableRowView.isEmphasizedというプロパティを追加で参照する必要があります。これは、ウインドウがアクティブでない場合に行がセカンダリカラー(非アクティブ用カラー)を利用できるように用意されているものです。falseの場合にセカンダリカラーを使うようにします。

When emphasized is true, the view will draw with the alternateSelectedControlColor defined by NSColor. When false it will use the secondarySelectedControlColor defined by NSColor.

(Copyright © 2023 Apple Inc. All rights reserved., https://developer.apple.com/documentation/appkit/nstablerowview/1526258-isemphasized, refer-date: 2023/01/24)

APIドキュメントやヘッダーを読むと上記のように書いてあるのですが、NSColorのalternateSelectedControlColorsecondarySelectedControlColorはmacOS 11.0でdeprecatedにされているので、代わりにselectedContentBackgroundColorunemphasizedSelectedContentBackgroundColorを利用します。

サンプル実装は下記になっています。

class BorderSelectionTableRowView: NSTableRowView {

    // ついでにこのプロパティを.normalで返しておくと、コンテンツ色の反転が起きないようにできます。
    override var interiorBackgroundStyle: NSView.BackgroundStyle {
        return .normal
    }

    override func drawSelection(in dirtyRect: NSRect) {
        if isSelected {

            let strokeColor: NSColor = {
                if isEmphasized {
                    return .selectedContentBackgroundColor
                } else {
                    return .unemphasizedSelectedContentBackgroundColor
                }
            }()

            strokeColor.setStroke()

            let bezierPath = NSBezierPath()

            /*
             The top and bottom line is not drawn when the adjacent row is selected.
             This behavior refines multi selection experience.
             */
            // Top Line
            if !isPreviousRowSelected {
                bezierPath.move(to: .init(x: 0, y: 0))
                bezierPath.line(to: .init(x: bounds.maxX, y: 0))
            }

            // Bottom Line
            if !isNextRowSelected {
                bezierPath.move(to: .init(x: 0, y: bounds.maxY))
                bezierPath.line(to: .init(x: bounds.maxX, y: bounds.maxY))
            }

            // Leading Line
            bezierPath.move(to: .init(x: 0, y: 0))
            bezierPath.line(to: .init(x: 0, y: bounds.maxY))

            // Trailing Line
            bezierPath.move(to: .init(x: bounds.maxX, y: 0))
            bezierPath.line(to: .init(x: bounds.maxX, y: bounds.maxY))

            bezierPath.lineWidth = 3
            bezierPath.stroke()
        }
    }
    

}

少しUIを良くするための工夫として、複数選択時に隣接する行が選択されている場合は連続部分のボーダーを表示しないようにしています。これはNSTableViewのisPreviousRowSelectedisNextRowSelectedを利用すると判別できます(便利ですね!)

一つ前の行が選択されている場合は上のラインを引かないように、一つ下の行が選択されている場合は下のラインを引かないようにします。

あとは太さを指定してNSBezierPath.stroke()を呼べばこのサブクラスは完成です。

カスタムNSTableRowViewの利用

NSTableViewDelegateの場合

func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? {
    let identifier = NSUserInterfaceItemIdentifier(rawValue: "Row")
    var rowView = tableView.makeView(withIdentifier: identifier, owner: self) as? BorderSelectionTableRowView
    if rowView == nil {
        rowView = BorderSelectionTableRowView(frame: NSZeroRect)
        rowView?.identifier = identifier
    }

    return rowView
}

NSOutlineViewDelegateの場合

func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? {
    let identifier = NSUserInterfaceItemIdentifier(rawValue: "Row")
    var rowView = outlineView.makeView(withIdentifier: identifier, owner: self) as? BorderSelectionTableRowView
    if rowView == nil {
        rowView = BorderSelectionTableRowView(frame: NSZeroRect)
        rowView?.identifier = identifier
    }

    return rowView
}
GitHubで編集を提案

Discussion