iOS 界面开发,一般就 List 和 Detail, 表视图 TableView 和详情界面
Cocoa 的表视图 NSTableView 使用介绍:
五个层级:
NSScrollView → NSClipView → NSTableView → NSTableViewRow → NSTableCellView
NSScrollView, 让 NSTableView 能够滚动,
NSClipView,让 NSTableView 不会超出显示区域。
NSTableView, 就是开发用的表视图,一般操作他的 delegate 和 dataSource
NSTableViewRow, 看到的一条。NSTableView 包含若干行
NSTableCellView,每一行 NSTableViewRow,由一到多个格子 NSTableCellView 组成

NSTableView 的内容,分两种组织方式
cell-based 和 view-based

- cell-based 和 view-based 相同点:
两种方式下都是
设置了 NSTableViewDelegate 的方法 func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView?,
就不会走 NSTableViewDataSource 的方法 func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any?
- cell-based 和 view-based 不同于:
没设置 NSTableViewDelegate 的方法 func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView?,
走 NSTableViewDataSource 的方法 func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any?, cell-based 会直接把数据绑定到视图上显示,view-based 就没有

NSTableView 没有 iOS 的 UITableView 的 Section 的概念
用 NSTableView ,封装带 Section 的表视图,还有 Header
协议代理部分
//MARK: - NSTableViewSectionDataSource
protocol TableSectionDataSource: NSTableViewDataSource {
func numberOfSectionsInTable(tb: NSTableView) -> Int
func table(tb: NSTableView, numberOfRowsInSection section: Int) -> Int
// 辅助方法,系统的 NSTableView 只有一个行号,要转化到 iOS 的第几块的第几个格子
// 其余方法,同 iOS
func tableProxySectionAndRow(tb: NSTableView, transformedBy stdRowIdx: Int) -> (section: Int, row: Int)
}
//MARK: - TableSectionDelegate
protocol TableSectionDelegate: NSTableViewDelegate {
func table(tb: NSTableView, headerForSection section: Int) -> NSView?
}
传入数据
// 两个 section
enum Section: Int, CaseIterable {
case season = 0
case letter
var name:String {
switch self {
case .season:
return "季节"
case .letter:
return "字母"
}
}
}
// 每个 section 的数据
private let seasons:[String] = ["春","夏","秋","冬"]
private let letters:[String] = ["X","Y","Z"]
配置封装的 TableSectionDataSource 与转换
class TableProxy: NSObject, TableSectionDelegate, TableSectionDataSource{
// 配置有几块
func numberOfSectionsInTable(tb: NSTableView) -> Int {
return Section.allCases.count
}
// 配置每一块,有几个格子
func table(tb: NSTableView, numberOfRowsInSection section: Int) -> Int {
var count = 0
// 有 header, 就多一个
if table(tb: tb, headerForSection: section) != nil {
count = 1
}
switch (section) {
case Section.season.rawValue:
count += seasons.count
case Section.letter.rawValue:
count += letters.count
default:
count = 0
}
return count
}
// 拿到系统的行号,转换到代理的第几块的第几个格子
func tableProxySectionAndRow(tb: NSTableView, transformedBy stdRowIdx: Int) -> (section: Int, row: Int) {
if let dataSource = tb.dataSource as? TableSectionDataSource {
// 每一行的格子数,整合在一起
let numberOfSections = dataSource.numberOfSectionsInTable(tb: tb)
var totalSectionInfo = [Int](repeating: 0, count: numberOfSections)
for section in 0..<numberOfSections {
totalSectionInfo[section] = dataSource.table(tb: tb, numberOfRowsInSection: section)
}
// 拿系统的行号,和整合的信息,去计算,转换到代理的第几块的第几个格子
let result = transfrom(fromSystem: stdRowIdx, with: totalSectionInfo)
return (section: result.section, row: result.row)
}
assertionFailure("Invalid datasource")
return (section: 0, row: 0)
}
}
转换方法
拿系统的行号,和整合的信息,去计算,转换到代理的第几块的第几个格子
extension TableProxy{
private func transfrom(fromSystem row: Int, with totalInfo: [Int]) -> (section: Int, row: Int) {
var ceil = 0
for section in 0..<totalInfo.count {
// floor, 每一块 section 的第一个元素, 对应的系统的行号
let floor = ceil
// ceil, 每一块 section 的最后的一个元素 + 1, 对应的系统的行号 + 1
ceil += totalInfo[section]
if row >= floor, row < ceil {
return (section: section, row: row - floor)
}
}
return (section: 0, row: 0)
}
}
配置封装的 TableSectionDelegate
// 配置每一块的头视图
func table(tb: NSTableView, headerForSection section: Int) -> NSView? {
switch (section) {
case Section.season.rawValue, Section.letter.rawValue:
let sectionView = tb.makeView(withIdentifier: .contentFolder, owner: self) as! NSTableCellView
return sectionView
default:
break
}
return nil
}
配置系统的 NSTableViewDataSource 和 NSTableViewDelegate
NSTableViewDataSource
func numberOfRows(in tableView: NSTableView) -> Int {
// 算出系统的行的个数
var total = 0
if let dataSource = tableView.dataSource as? TableSectionDataSource {
// 取出每一块的格子数,总和
for section in 0..<dataSource.numberOfSectionsInTable(tb: tableView) {
total += dataSource.table(tb: tableView, numberOfRowsInSection: section)
}
}
return total
}
NSTableViewDelegate
class TableProxy: NSObject, TableSectionDelegate, TableSectionDataSource{
// 提供展示的视图
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
if let dataSource = tableView.dataSource as? TableSectionDataSource {
// 用系统的行号,拿到代理的第几块的第几个格子
var (section, sectionRow) = dataSource.tableProxySectionAndRow(tb: tableView, transformedBy: row)
if let headerView = table(tb: tableView, headerForSection: section) as? NSTableCellView{
// 每一块的第一个格子,是 header
if sectionRow == 0 {
headerView.textField?.stringValue = Section(rawValue: section)?.name ?? ""
return headerView
} else {
// 让视图的格子号,与模型的索引,同步
sectionRow -= 1
}
}
let cell = tableView.makeView(withIdentifier: .contentFile, owner: self) as! NSTableCellView
// 每一块,去取对应的数据
switch (section) {
case Section.season.rawValue:
cell.textField?.stringValue = seasons[sectionRow]
case Section.letter.rawValue:
cell.textField?.stringValue = letters[sectionRow]
default:
()
}
return cell
}
return nil
}
}
// 提供每一行的高度,头视图 header 一般大一些
func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
var height = CGFloat.zero
if let dataSource = tableView.dataSource as? TableSectionDataSource {
let (section, sectionRow) = dataSource.tableProxySectionAndRow(tb: tableView, transformedBy: row)
// 如果该块有 header, 就是 section 对应的那一块,
// 有 header, 第一个格子,是 cell
if let _ = table(tb: tableView, headerForSection: section), sectionRow == 0{
height = 55
}
else{
height = 40
}
}
return height
}
文件系统,实现一个 ls ,子目录 list
iOS 只能访问自己的沙盒, Cocoa App 比较自由
先设置文件访问权限

开发时,可先开所有的权限
拿到文件夹的目录列表
// src 是一个 URL , 目录地址
let properties: [URLResourceKey] = [ URLResourceKey.localizedNameKey, URLResourceKey.creationDateKey, URLResourceKey.localizedTypeDescriptionKey]
do {
// 一个方法,取出了所有目录,忽略了隐藏文件
let paths = try FileManager.default.contentsOfDirectory(at: src, includingPropertiesForKeys: properties, options: [FileManager.DirectoryEnumerationOptions.skipsHiddenFiles])
var files = [ContentInfo]()
var folders = [ContentInfo]()
// 将 ls 出的结果,分类,
// 分为文件和文件夹 folder
for (idx, url) in paths.enumerated() {
let isDirectory = (try url.resourceValues(forKeys: [.isDirectoryKey])).isDirectory ?? false
if isDirectory{
folders.append((idx, url))
}
else{
files.append((idx, url))
}
}
} catch let error{
print("error: \(error.localizedDescription)")
}
ls 增强, 如果给的是一个文件,ls 出他的同级文件目录
给的是一个文件夹,就 ls 出他的下级目录
准备拖拽出一个文件夹,鼠标准确的移到一个图标上,要留点心。
拖拽出,文件夹下的一个文件,比较轻松
let isDirectory = (try src.resourceValues(forKeys: [.isDirectoryKey])).isDirectory ?? false
if isDirectory == false{
// 是文件,就去拿他的上级目录
src = src.deletingLastPathComponent()
}
// 其余同上,现在操作的,必定是一个文件夹
// let paths =
文件的拖拽,是 Mac 的特色之一
一般通过 NSDraggingDestination 协议,实现。
NSView 本来遵守 NSDraggingDestination
使用继承自 NSView 的视图,先注册 registerForDraggedTypes, 进入拖拽操作的生命周期,
处理拖拽的结果 performDragOperation
class DragView: NSView {
private var dragEntered = false{
didSet{
needsDisplay = true
}
}
required init?(coder: NSCoder) {
super.init(coder: coder)
let fileRegister = NSPasteboard.PasteboardType.fileURL
registerForDraggedTypes([fileRegister])
}
// 拖拽时候的,UI 提示
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
// Drawing code here.
var bgColor = NSColor.windowBackgroundColor
if dragEntered{
bgColor = NSColor.lightGray
}
bgColor.setFill()
dirtyRect.fill()
}
}
extension DragView{
//MARK:- Managing a Dragging Session Before an Image Is Released
// 进入拖拽操作的生命周期
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
dragEntered = true
return NSDragOperation.copy
}
override func draggingEnded(_ sender: NSDraggingInfo) {
dragEntered = false
}
override func draggingExited(_ sender: NSDraggingInfo?) {
dragEntered = false
}
//MARK:- Managing a Dragging Session After an Image Is Released
// 处理拖拽的结果
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
// 从剪贴板里,取路径
if var src = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self], options: nil)?.first as? URL {
// 处理拿到的路径
return true
} catch let error{
print("error: \(error.localizedDescription)")
}
}
return false
}
}

Wire it up, 拖拽一个文件夹,使用 NSTableView 展示目录列表
// 使用控制器,把输入 dragView,和输出 tableView,粘在一起
class ViewController: NSViewController {
@IBOutlet weak var tableView: NSTableView!
@IBOutlet weak var dragView: DragView!
let tbProxy = TableProxy()
override func viewDidLoad() {
super.viewDidLoad()
tableView.delegate = tbProxy
tableView.dataSource = tbProxy
dragView.delegate = self
tableView.reloadData()
}
// 收到拖拽的结果,就刷新下模型和视图
extension ViewController: DragViewProxy{
func dragDone(by view: DragView, get info: ContentBundle) {
tbProxy.update(content: info)
tableView.reloadData()
}
}
TableView 功能加强
现在的传入数据部分,是这样的
一个 Section,
header 是上级的文件夹,
内容是 cells, 分文件夹和文件
enum Section{
case empty
case title(URL)
var name:String {
switch self {
case .empty:
return "啥也没有"
case .title(let headline):
return headline.lastPathComponent
}
}
}
// TableView 的排列,使用原本的编号
typealias ContentInfo = (Int, URL)
typealias ContentDetail = (Int, String)
struct TbData{
var headerTitle = Section.empty
var files = [ContentInfo]()
var dirs = [ContentInfo]()
var title: String{
headerTitle.name
}
var fileNames: [ContentDetail]{
files.map { argv -> ContentDetail in
(argv.0, argv.1.lastPathComponent)
}
}
var dirNames: [ContentDetail]{
dirs.map { argv -> ContentDetail in
(argv.0, argv.1.lastPathComponent)
}
}

打开 Cell 对应的文件
开启文件,使用 NSWorkspace
@IBAction func btnRevealInFinderSelected(_ sender: NSButton) {
// 拿到按钮对应的行号
let row = tableView.row(for: sender)
// 两个地方找,找到了,就打开,完事
for argv in tbProxy.data.files{
let (index, src) = argv
// 因为有 header
if index == row - 1 {
// 打开文件
NSWorkspace.shared.open(src)
return
}
}
for argv in tbProxy.data.dirs{
let (index, src) = argv
// 因为有 header
if index == row - 1{
NSWorkspace.shared.open(src)
return
}
}
}
删除一行 cell
@IBAction func removeRowSelected(_ sender: NSButton) {
// 拿到行号
let row = tableView.row(for: sender)
// 先删模型,再删视图
tableView.beginUpdates()
tbProxy.remove(row: row)
tableView.removeRows(at: [row], withAnimation: NSTableView.AnimationOptions.effectFade)
tableView.endUpdates()
}
删除数据之后,还要做一个同步 sync
模型记录的索引,与表格的索引要保持一致
struct TbData{
mutating
func remove(row index: Int){
var i = 0
var j = 0
var fileNameCount = files.count
// 小于的,不用管
// 大于的,就少了一个元素,要减一
// 等于的,就删去,同时总数也变了,要更新
while i < fileNameCount {
switch files[i].0 {
case ..<(index - 1):
i+=1
case index - 1:
files.remove(at: i)
fileNameCount -= 1
j = i
default:
// (index - 1)...
files[i].0 -= 1
i+=1
}
}
var fileDirCount = dirs.count
while j < fileDirCount {
switch dirs[j].0 {
case ..<(index - 1):
j+=1
case index - 1:
dirs.remove(at: j)
fileDirCount -= 1
default:
// (index - 1)...
dirs[j].0 -= 1
j+=1
}
}
}
}