Cocoa App 开发进一步,表视图 TableView 、文件管理与拖拽

1,161 阅读7分钟

iOS 界面开发,一般就 List 和 Detail, 表视图 TableView 和详情界面

Cocoa 的表视图 NSTableView 使用介绍:

五个层级:

NSScrollView → NSClipView → NSTableView → NSTableViewRow → NSTableCellView

NSScrollView, 让 NSTableView 能够滚动,

NSClipView,让 NSTableView 不会超出显示区域。

NSTableView, 就是开发用的表视图,一般操作他的 delegate 和 dataSource

NSTableViewRow, 看到的一条。NSTableView 包含若干行

NSTableCellView,每一行 NSTableViewRow,由一到多个格子 NSTableCellView 组成

SplitOut_View.jpg


NSTableView 的内容,分两种组织方式

cell-based 和 view-based

Screen Shot 2020-03-15 at 4.24.24 PM.png

  • 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 就没有


Screen Shot 2020-03-15 at 6.11.17 PM.png

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 比较自由

先设置文件访问权限

Screen Shot 2020-03-15 at 5.19.02 PM.png

开发时,可先开所有的权限

拿到文件夹的目录列表

// 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
    }
}


Screen Shot 2020-03-15 at 6.04.43 PM.png

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)
        }
    }

Screen Shot 2020-03-15 at 6.04.51 PM.png

打开 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
            }
            
        }
    }
}

github 链接