NSTableViewのselectionでボーダースタイルを自作する
AppKitでは行のセレクション色などをカスタマイズしたい場合は、NSTableRowViewをサブクラスする必要があります。基本的なことは下記の記事を参考にしてください。
この記事では、そのサブクラス内でボーダースタイルを自作する方法を紹介します。
前提
- 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のalternateSelectedControlColorとsecondarySelectedControlColorはmacOS 11.0でdeprecatedにされているので、代わりにselectedContentBackgroundColorとunemphasizedSelectedContentBackgroundColorを利用します。
サンプル実装は下記になっています。
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のisPreviousRowSelectedとisNextRowSelectedを利用すると判別できます(便利ですね!)
一つ前の行が選択されている場合は上のラインを引かないように、一つ下の行が選択されている場合は下のラインを引かないようにします。
あとは太さを指定して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
}
Discussion