I am looking to add checkboxes to NSOutlineview using the correct Apple recommended method - however its not clear from the documentation.
How do I add the behavour to allow users whereby if I click a parent checkbox, then it will select the children, and if I unclick it - it will deselect the children of that item?
edit: I have simplified my question and added image to make it clearer ( hopefully)
My Approach:
I have been using the wonderful answer by Code Different to build an Outline view in my mac app.
https://stackoverflow.com/a/45384599/559760
- I chose to populate the NSoutLine view using a manual process instead of using CocoaBindings.
I added in a stack view including a check box which seems to be the right approach:
My solution involves creating an array to hold the selected items in the viewcontroller and then creating functions for adding and removing
var selectedItems: [Int]?
#objc func cellWasClicked(sender: NSButton) {
let newCheckBoxState = sender.state
let tag = sender.tag
switch newCheckBoxState {
case NSControl.StateValue.on:
print("adding- \(sender.tag)")
case NSControl.StateValue.off:
print("removing- \(sender.tag)")
default:
print("unhandled button state \(newCheckBoxState)")
}
I identify the checkbutton by the tag that was assigned to the checkbox
In the interest of future Googlers I will repeat things I've written in my other answer. The difference here is this has the extra requirement that a column is editable and I have refined the technique.
The key to NSOutlineView is that you must give an identifier to each row, be it a string, a number or an object that uniquely identifies the row. NSOutlineView calls this the item. Based on this item, you will query your data model to populate the outline.
In this answer we will setup an outline view with 2 columns: an editable Is Selected column and a non-editable Title column.
Interface Builder setup
Select the first column and set its identifier to isSelected
Select the second column and set its identifier to title
Select the cell in the first column and change its identifier to isSelectedCell
Select the cell in the second column and change its identifier to titleCell
Consistency is important here. The cell's identifier should be equal to its column's identifier + Cell.
The cell with a checkbox
The default NSTableCellView contains a non-editable text field. We want a check box so we have to design our own cell.
CheckboxCellView.swift
import Cocoa
/// A set of methods that `CheckboxCelView` use to communicate changes to another object
protocol CheckboxCellViewDelegate {
func checkboxCellView(_ cell: CheckboxCellView, didChangeState state: NSControl.StateValue)
}
class CheckboxCellView: NSTableCellView {
/// The checkbox button
#IBOutlet weak var checkboxButton: NSButton!
/// The item that represent the row in the outline view
/// We may potentially use this cell for multiple outline views so let's make it generic
var item: Any?
/// The delegate of the cell
var delegate: CheckboxCellViewDelegate?
override func awakeFromNib() {
checkboxButton.target = self
checkboxButton.action = #selector(self.didChangeState(_:))
}
/// Notify the delegate that the checkbox's state has changed
#objc private func didChangeState(_ sender: NSObject) {
delegate?.checkboxCellView(self, didChangeState: checkboxButton.state)
}
}
Connecting the outlet
Delete the default text field in the isSelected column
Drag in a checkbox from Object Library
Select the NSTableCellView and change its class to CheckboxCellView
Turn on the Assistant Editor and connect the outlet
The View Controller
And finally the code for the view controller:
import Cocoa
/// A class that represents a row in the outline view. Add as many properties as needed
/// for the columns in your outline view.
class OutlineViewRow {
var title: String
var isSelected: Bool
var children: [OutlineViewRow]
init(title: String, isSelected: Bool, children: [OutlineViewRow] = []) {
self.title = title
self.isSelected = isSelected
self.children = children
}
func setIsSelected(_ isSelected: Bool, recursive: Bool) {
self.isSelected = isSelected
if recursive {
self.children.forEach { $0.setIsSelected(isSelected, recursive: true) }
}
}
}
/// A enum that represents the list of columns in the outline view. Enum is preferred over
/// string literals as they are checked at compile-time. Repeating the same strings over
/// and over again are error-prone. However, you need to make the Column Identifier in
/// Interface Builder with the raw value used here.
enum OutlineViewColumn: String {
case isSelected = "isSelected"
case title = "title"
init?(_ tableColumn: NSTableColumn) {
self.init(rawValue: tableColumn.identifier.rawValue)
}
var cellIdentifier: NSUserInterfaceItemIdentifier {
return NSUserInterfaceItemIdentifier(self.rawValue + "Cell")
}
}
class ViewController: NSViewController {
#IBOutlet weak var outlineView: NSOutlineView!
/// The rows of the outline view
let rows: [OutlineViewRow] = {
var child1 = OutlineViewRow(title: "p1-child1", isSelected: true)
var child2 = OutlineViewRow(title: "p1-child2", isSelected: true)
var child3 = OutlineViewRow(title: "p1-child3", isSelected: true)
let parent1 = OutlineViewRow(title: "parent1", isSelected: true, children: [child1, child2, child3])
child1 = OutlineViewRow(title: "p2-child1", isSelected: true)
child2 = OutlineViewRow(title: "p2-child2", isSelected: true)
child3 = OutlineViewRow(title: "p2-child3", isSelected: true)
let parent2 = OutlineViewRow(title: "parent2", isSelected: true, children: [child1, child2, child3])
child1 = OutlineViewRow(title: "p3-child1", isSelected: true)
child2 = OutlineViewRow(title: "p3-child2", isSelected: true)
child3 = OutlineViewRow(title: "p3-child3", isSelected: true)
let parent3 = OutlineViewRow(title: "parent3", isSelected: true, children: [child1, child2, child3])
child3 = OutlineViewRow(title: "p4-child3", isSelected: true)
child2 = OutlineViewRow(title: "p4-child2", isSelected: true, children: [child3])
child1 = OutlineViewRow(title: "p4-child1", isSelected: true, children: [child2])
let parent4 = OutlineViewRow(title: "parent4", isSelected: true, children: [child1])
return [parent1, parent2, parent3, parent4]
}()
override func viewDidLoad() {
super.viewDidLoad()
outlineView.dataSource = self
outlineView.delegate = self
}
}
extension ViewController: NSOutlineViewDataSource, NSOutlineViewDelegate {
/// Returns how many children a row has. `item == nil` means the root row (not visible)
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
switch item {
case nil: return rows.count
case let row as OutlineViewRow: return row.children.count
default: return 0
}
}
/// Returns the object that represents the row. `NSOutlineView` calls this the `item`
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
switch item {
case nil: return rows[index]
case let row as OutlineViewRow: return row.children[index]
default: return NSNull()
}
}
/// Returns whether the row can be expanded
func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
switch item {
case nil: return !rows.isEmpty
case let row as OutlineViewRow: return !row.children.isEmpty
default: return false
}
}
/// Returns the view that display the content for each cell of the outline view
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
guard let item = item as? OutlineViewRow, let column = OutlineViewColumn(tableColumn!) else { return nil }
switch column {
case .isSelected:
let cell = outlineView.makeView(withIdentifier: column.cellIdentifier, owner: self) as! CheckboxCellView
cell.checkboxButton.state = item.isSelected ? .on : .off
cell.delegate = self
cell.item = item
return cell
case .title:
let cell = outlineView.makeView(withIdentifier: column.cellIdentifier, owner: self) as! NSTableCellView
cell.textField?.stringValue = item.title
return cell
}
}
}
extension ViewController: CheckboxCellViewDelegate {
/// A delegate function where we can act on update from the checkbox in the "Is Selected" column
func checkboxCellView(_ cell: CheckboxCellView, didChangeState state: NSControl.StateValue) {
guard let item = cell.item as? OutlineViewRow else { return }
// The row and its children are selected if state == .on
item.setIsSelected(state == .on, recursive: true)
// This is more efficient than calling reload on every child since collapsed children are
// not reloaded. They will be reloaded when they become visible
outlineView.reloadItem(item, reloadChildren: true)
}
}
Result
Related
I want to use #resultbuilder and Combine to create my own reactive and declarative UICollectionView List in UIKit, similiar to what we get with List {} in SwiftUI.
For that, i am using a resultbuilder to create a Snapshot like this:
#resultBuilder
struct SnapshotBuilder {
static func buildBlock(_ components: ListItemGroup...) -> [ListItem] {
return components.flatMap { $0.items }
}
// Support `for-in` loop
static func buildArray(_ components: [ListItemGroup]) -> [ListItem] {
return components.flatMap { $0.items }
}
static func buildFinalResult(_ component: [ListItem]) -> NSDiffableDataSourceSectionSnapshot<ListItem> {
var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<ListItem>()
sectionSnapshot.append(component)
return sectionSnapshot
}
}
I also need to use the following extensions to pass ListItemGroup to SnapshotBuilder and get [ListItem]
struct ListItem: Hashable {
let title: String
let image: UIImage?
var children: [ListItem]
init(_ title: String, children: [ListItem] = []) {
self.title = title
self.image = UIImage(systemName: title)
self.children = children
}
}
protocol ListItemGroup {
var items: [ListItem] { get }
}
extension Array: ListItemGroup where Element == ListItem {
var items: [ListItem] { self }
}
extension ListItem: ListItemGroup {
var items: [ListItem] { [self] }
}
My List Class looks like this:
final class List: UICollectionView {
enum Section {
case main
}
var data: UICollectionViewDiffableDataSource<Section, ListItem>!
private var cancellables = Set<AnyCancellable>()
init(_ items: Published<[String]>.Publisher, style: UICollectionLayoutListConfiguration.Appearance = .insetGrouped, #SnapshotBuilder snapshot: #escaping () -> NSDiffableDataSourceSectionSnapshot<ListItem>) {
super.init(frame: .zero, collectionViewLayout: List.createLayout(style))
configureDataSource()
data.apply(snapshot(), to: .main)
items
.sink { newValue in
let newSnapshot = snapshot()
self.data.apply(newSnapshot, to: .main, animatingDifferences: true)
}
.store(in: &cancellables)
}
required init(coder: NSCoder) {
super.init(coder: coder)!
}
private static func createLayout(_ appearance: UICollectionLayoutListConfiguration.Appearance) -> UICollectionViewLayout {
let layoutConfig = UICollectionLayoutListConfiguration(appearance: appearance)
return UICollectionViewCompositionalLayout.list(using: layoutConfig)
}
private func configureDataSource() {
let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, ListItem> {
(cell, indexPath, item) in
var content = cell.defaultContentConfiguration()
content.image = item.image
content.text = item.title
cell.contentConfiguration = content
}
data = UICollectionViewDiffableDataSource<Section, ListItem>(collectionView: self) {
(collectionView: UICollectionView, indexPath: IndexPath, identifier: ListItem) -> UICollectionViewCell? in
let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier)
cell.accessories = [.disclosureIndicator()]
return cell
}
}
}
And i am using it in my ViewControllers like this:
class DeclarativeViewController: UIViewController {
#Published var testItems: [String] = []
var collectionView: List!
override func viewDidLoad() {
super.viewDidLoad()
navigationController?.navigationBar.sizeToFit()
title = "Settings"
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "plus"), style: .plain, target: self, action: #selector(addItem))
view.backgroundColor = .systemBackground
collectionView = List($testItems) {
for item in self.testItems {
ListItem(item)
}
}
collectionView.frame = view.bounds
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(collectionView)
}
#objc func addItem() {
testItems.append("Item \(testItems.count)")
}
}
As you can see, i initialize my List with the #Published var testItems variable. In my init() func, i setup a subscriber and store them in cancellables, so i can react on changes.
If i add an item to testItems array, the sink callback is exectued to create a new snapshot and apply them to data. It works, but i need to tap the navigation button twice, to see an item on the list. Two questions:
Why this is happen and how can i solve this? (so i only need to tap the button once to see changes in my list)
and how can i improve my code? (currently I always create a new snapshot instead of extending the already created one)
Let me answer both questions by answering your second one.
How can i improve my code? (currently I always create a new snapshot
instead of extending the already created one)
I'm a bit confused about your use of #resultBuilder. Typically one would use a result builder to create a Domain Specific Language (DSL). In this case you could create a DSL for constructing ListItems, but that would imply that you mean to populate a list at compile time, most of your code here seems to focus on updating the list, dynamically, a runtime. So using result builder seems overly complex.
In this case, you're also using a Publisher where you could probably get by using a simple didSet on your controller's property. However, a Publisher would be a really good idea as part of a more complex Model that the Controller was trying to coordinate with its views. I had a version of your code where I replaced the Publisher with didSet but on second glance - imaging the more complex model case, I put the publisher back in.
You've got your publisher's pipeline all tangled up in your result builder - which is odd because, again, publishers are about reacting dynamically to changes at runtime whereas result builders are about making nice DSLs for the syntax sugaring of compile time code.
So I pulled out the DSL, and set up a rich pipeline that makes good use of having a publisher.
Also, when using Combine publishers, it's common to use type erasure to make the actual nature of the publisher more anonymous. So in my rework, I use eraseToAnyPublisher so that List could take it's values from anyone, not just an #Published list of strings.
So List becomes:
final class List: UICollectionView {
enum Section {
case main
}
private var subscriptions = Set<AnyCancellable>()
private var data: UICollectionViewDiffableDataSource<Section, ListItem>!
init(itemPublisher: AnyPublisher<[String], Never>,
style: UICollectionLayoutListConfiguration.Appearance = .insetGrouped) {
super.init(frame: .zero, collectionViewLayout: List.createLayout(style))
configureDataSource()
itemPublisher
.map{ items in items.map { ListItem($0) }}
.map{ listItems in
var newSnapshot = NSDiffableDataSourceSectionSnapshot<ListItem>()
newSnapshot.append(listItems)
return newSnapshot
}
.sink {
newSnapshot in
self.data?.apply(newSnapshot, to: .main, animatingDifferences: true)
}
.store(in: &subscriptions)
}
required init?(coder : NSCoder) {
super.init(coder: coder)
}
private static func createLayout(_ appearance: UICollectionLayoutListConfiguration.Appearance) -> UICollectionViewLayout {
let layoutConfig = UICollectionLayoutListConfiguration(appearance: appearance)
return UICollectionViewCompositionalLayout.list(using: layoutConfig)
}
private func configureDataSource() {
let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, ListItem> {
(cell, indexPath, item) in
var content = cell.defaultContentConfiguration()
content.image = item.image
content.text = item.title
cell.contentConfiguration = content
}
data = UICollectionViewDiffableDataSource<Section, ListItem>(collectionView: self) {
(collectionView: UICollectionView, indexPath: IndexPath, identifier: ListItem) -> UICollectionViewCell? in
let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier)
cell.accessories = [.disclosureIndicator()]
return cell
}
}
}
Note the rich processing pipeline that is set up for itemPublisher and that it comes into the class as AnyPublisher<[String], Never>.
Then your DeclarativeViewController becomes:
class DeclarativeViewController: UIViewController {
#Published var testItems: [String] = []
var collectionView: List!
override func viewDidLoad() {
super.viewDidLoad()
navigationController?.navigationBar.sizeToFit()
title = "Settings"
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "plus"), style: .plain, target: self, action: #selector(addItem))
view.backgroundColor = .systemBackground
collectionView = List(itemPublisher: $testItems.eraseToAnyPublisher())
collectionView.frame = view.bounds
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(collectionView)
}
#objc func addItem() {
testItems.append("Item \(testItems.count)")
}
}
where the testItems model's publisher get erased away to an any publisher.
In my code ListItem stays the same, but all the stuff related to the #resultBuiler is gone. Maybe you could use it if you wanted to create a funciton to build a set of ListItems for the initial set of items in a table (or for a table that has static content) But it didn't seem necessary here.
I use a NSOutlineView that auto saves expanded state. If I manually reload data when dataSource updates, the func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? datasource method is not called anymore and every cell collapses. Any idea why this might happen?
Tried to reloadItem with nil send as param but still no good.
I use this for persisting expanded rows:
func outlineView(_ outlineView: NSOutlineView, persistentObjectForItem item: Any?) -> Any? {
return NSKeyedArchiver.archivedData(withRootObject: item)
}
func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? {
guard let data = object as? Data,
let item = NSKeyedUnarchiver.unarchiveObject(with: data) as? Category else { return nil }
let foundItem = recursiveSearch(for: item, in: viewModel.dataSource.value)
return foundItem
}
And this to reloadData:
viewModel.dataSource.subscribe(onNext: { [weak self] _ in
self?.outlineView.reloadData()
}).disposed(by: disposeBag)
IMHO autosaving is sort of half-baked feature and it doesn't work as expected. In other words, it's implemented in a way that it restores the state when your application launches (just once) and then you're on your own.
Implement your own one utilizing outlineViewItemDidExpand(_:) & outlineViewItemDidCollapse(_:) (especially when we're reloading, ...).
Couple of tricks you can use if you do not want to implement custom autosaving. But I wouldn't rely on them.
First trick - tell the NSOutlineView to reload persistent state
NSOutlineView inherits from the NSTableView and the autosaveName property documentation says:
If you change the value of this property to a new name, the table reads in any saved information and sets the order and width of this table view’s columns to match. Setting the name to nil removes any previously stored state from the user defaults.
What is inaccurate here - setting it to nil doesn't remove previously stored expanded items state for NSOutlineView. We can use it to force the NSOutlineView to reload expanded items state:
class ViewController: NSViewController, NSOutlineViewDelegate, NSOutlineViewDataSource {
#IBOutlet var outlineView: NSOutlineView!
// It's for testing, to demonstrate the persistent state reloading
private var doNotLoad = true
override func viewDidAppear() {
super.viewDidAppear()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.doNotLoad = false
let autosaveName = self.outlineView.autosaveName
self.outlineView.autosaveName = nil
self.outlineView.reloadData()
self.outlineView.autosaveName = autosaveName
}
}
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
if (doNotLoad) {
return 0
}
return item == nil ? data.count : (item as! Node).children.count
}
}
If you'd like to comply with the documentation, do not use nil and set some fake name. But I would expect that once the bug is fixed, the persistent state will be removed if we change the autosaveName or if we set it set to nil.
Second trick - load & expand yourself
Imagine you have the following Node class:
class Node {
let id: Int
let children: [Node]
// ...
}
And your data source implements:
func outlineView(_ outlineView: NSOutlineView, persistentObjectForItem item: Any?) -> Any? {
(item as! Node).id
}
func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? {
guard let id = object as? Int else { return nil }
return data.firstNode { $0.id == id }
}
The firstNode is not related to this question, but here's the implementation (because it's mentioned in the code):
extension Array where Self.Element == Node {
// Search for a node (recursively) until a matching element is found
func firstNode(where predicate: (Element) throws -> Bool) rethrows -> Element? {
for element in self {
if try predicate(element) {
return element
}
if let matched = try element.children.firstNode(where: predicate) {
return matched
}
}
return nil
}
}
Then you can reloadData & expand all the items by yourself:
outlineView.reloadData()
if outlineView.autosaveExpandedItems,
let autosaveName = outlineView.autosaveName,
let persistentObjects = UserDefaults.standard.array(forKey: "NSOutlineView Items \(autosaveName)"),
let itemIds = persistentObjects as? [Int] {
itemIds.forEach {
let item = outlineView.dataSource?.outlineView?(self.outlineView, itemForPersistentObject: $0)
self.outlineView.expandItem(item)
}
}
So I've created an NSOutlineView to display the file & directory list in a hierarchical way. I'm building a BitTorrent client (stating so the class names make sense).
As you can see, this is pretty much how the outline view looks:
The problem is associated with the Name column. In the name column, for each row, I have a checkbox and a text field side by side. This will help you get a clearer idea:
Now, I use bindings to get the value for each textfield. However, since there are 2 views (checkbox and textfield) that needs to bound to the same NSTableCellView, I'm returning a struct, from the data source, containing 2 values: a string for the text field (which holds the file/directory name), and a boolean for enabling/disabling the checkbox.
To handle the outline view (especially its data), I've set its class to TorrentContent, which is defined as below:
import Cocoa
struct Name {
let value: String
let enabled: Bool
}
class TorrentContent: NSOutlineView, NSOutlineViewDelegate, NSOutlineViewDataSource {
var content: [TorrentContentItem]
required init?(coder: NSCoder) {
let srcDir = TorrentContentItem("src")
let mainJava = TorrentContentItem("main.java")
let mainCpp = TorrentContentItem("main.cpp")
srcDir.children.append(mainJava)
srcDir.children.append(mainCpp)
content = [srcDir]
super.init(coder: coder)
delegate = self
dataSource = self
}
func outlineView(_: NSOutlineView, isItemExpandable item: Any) -> Bool {
if let _item = item as? TorrentContentItem {
if _item.children.count > 0 {
return true
} else {
return false
}
} else {
return false
}
}
func outlineView(_: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
if item == nil {
return content.count
} else {
if let _item = item as? TorrentContentItem {
return _item.children.count
}
}
return 0
}
func outlineView(_: NSOutlineView, child: Int, ofItem item: Any?) -> Any {
if item != nil {
if let _item = item as? TorrentContentItem {
return _item.children[child]
}
}
return content[child]
}
func outlineView(_: NSOutlineView, objectValueFor col: NSTableColumn?, byItem item: Any?) -> Any? {
if item != nil, col != nil {
if let _item = item as? TorrentContentItem {
switch col!.title {
case "Name":
return Name(value: _item.name, enabled: false)
default:
return nil
}
}
}
return nil
}
}
I've hard-coded the data so it'll be easier for you to understand what's going on.
Focusing only on the name column, here's the part of the above code which deals with that:
func outlineView(_: NSOutlineView, objectValueFor col: NSTableColumn?, byItem item: Any?) -> Any? {
if item != nil, col != nil {
if let _item = item as? TorrentContentItem {
switch col!.title {
case "Name":
return Name(value: _item.name, enabled: false)
default:
return nil
}
}
}
return nil
}
As you can see, it returns the Name struct, which contains values for both the views. I've hard-coded the enabled value to false just for testing purposes.
Now to bind that to the textfield's value property, I've done this:
My logic is that, since objectValue is an instance of the Name struct, objectValue.value should be the value of the Name struct's instance, which is a string.
I want to bind the enabled property of the checkbox in a similar way. However, none of the bindings work. They cause the app to crash. This is what XCode shows me after it crashes everytime I attempt to view the outline view during runtime:
Only got "(lldb)" in the console.
What am I doing wrong, and how do I achieve what I want? That is, setting the property values of multiple views from the data source class.
Cocoa Bindings uses Key Value Observing (KVO) and the observed object must be KVO compatible. See Using Key-Value Observing in Swift.
You can only use key-value observing with classes that inherit from NSObject.
Mark properties that you want to observe through key-value observing with both the #objc attribute and the dynamic modifier.
Solution A: Return a KVO compatble object from outlineView(_:objectValueFor:byItem:)
Solution B: Don't use Cocoa Bindings. Create a subclass of NSTableCellView and add a enabledCheckbox outlet. Set the values in outlineView(_:viewFor:item:).
I am having trouble creating a NSOutlineView in Xcode 8 (Swift 3). I have a plist file with some information that I would like to present in an OutlineView. The plist file looks as following (example):
Root Dictionary *(1 item)
Harry Watson Dictionary *(5 items)*
name String Harry Watson
age Int 99
birthplace String Westminster
birthdate Date 01/01/1000
hobbies Array *(2 items)*
item 0 String Tennis
item 1 String Piano
The OutlineView should look pretty similar, like follow:
name Harry Watson
age 99
birthplace Westminster
birthdate 01/01/1000
> hobbies ... (<- this should be expandable)
I already searched for NSOutlineView tutorials on Google, but everything I found was raywenderlich.com, so I read a bit but in my opinion it isn't that easy.
So I am wondering whether you could help me with the exact example above and give me some code examples, especially regarding this function:
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {}
I am not sure what to write in there.
If you have any questions, let me know.
Thanks in advance and kind regards
I find Ray Wenderlitch's tutorials vary wildly in quality. The in-jokes, the verbosity, the step-by-step handholding that assumes you know nothing about Swift is just too nauseating to me. Here's a skinny tutorial which covers the basics of populating an outline view, manually and via Cocoa Bindings.
The key to understand NSOutlineView is that you must give each row a unique identifier, be it a string, a number or an object that represents the row. NSOutlineView calls it the item. Based on this item, you will query your data model to fill the outline view with data.
This answer presents 3 approaches:
Manual: doing everything yourself in the most basic way. It's a great introduction to learn how to interact with NSOutlineView but I don't recommend this for production code.
Streamlined: the outline view is still manually populated, but the approach is more elegant. This is what I use for my own production code.
Cocoa Binding: some magicky stuff left over from the golden days of Mac OS X. While very convenient, it's not the way of the future. Consider this an advanced topic
1. Populate the outline view manually
Interface Builder Setup
We will use a very simple NSOutlineView with just two columns: Key and Value.
Select the first column and change its identifier to keyColumn. Then select the second column and change its identifier to valueColumn:
Set the identifier for the cell to outlineViewCell. You only need to do it once.
Code
Copy and paste the following to your ViewController.swift:
// Data model
struct Person {
var name: String
var age: Int
var birthPlace: String
var birthDate: Date
var hobbies: [String]
}
class ViewController: NSViewController {
#IBOutlet weak var outlineView: NSOutlineView!
// I assume you know how load it from a plist so I will skip
// that code and use a constant for simplicity
let person = Person(name: "Harry Watson", age: 99, birthPlace: "Westminster",
birthDate: DateComponents(calendar: .current, year: 1985, month: 1, day: 1).date!,
hobbies: ["Tennis", "Piano"])
let keys = ["name", "age", "birthPlace", "birthDate", "hobbies"]
override func viewDidLoad() {
super.viewDidLoad()
outlineView.dataSource = self
outlineView.delegate = self
}
}
extension ViewController: NSOutlineViewDataSource, NSOutlineViewDelegate {
// You must give each row a unique identifier, referred to as `item` by the outline view
// * For top-level rows, we use the values in the `keys` array
// * For the hobbies sub-rows, we label them as ("hobbies", 0), ("hobbies", 1), ...
// The integer is the index in the hobbies array
//
// item == nil means it's the "root" row of the outline view, which is not visible
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
if item == nil {
return keys[index]
} else if let item = item as? String, item == "hobbies" {
return ("hobbies", index)
} else {
return 0
}
}
// Tell how many children each row has:
// * The root row has 5 children: name, age, birthPlace, birthDate, hobbies
// * The hobbies row has how ever many hobbies there are
// * The other rows have no children
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
if item == nil {
return keys.count
} else if let item = item as? String, item == "hobbies" {
return person.hobbies.count
} else {
return 0
}
}
// Tell whether the row is expandable. The only expandable row is the Hobbies row
func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
if let item = item as? String, item == "hobbies" {
return true
} else {
return false
}
}
// Set the text for each row
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
guard let columnIdentifier = tableColumn?.identifier.rawValue else {
return nil
}
var text = ""
// Recall that `item` is the row identiffier
switch (columnIdentifier, item) {
case ("keyColumn", let item as String):
switch item {
case "name":
text = "Name"
case "age":
text = "Age"
case "birthPlace":
text = "Birth Place"
case "birthDate":
text = "Birth Date"
case "hobbies":
text = "Hobbies"
default:
break
}
case ("keyColumn", _):
// Remember that we identified the hobby sub-rows differently
if let (key, index) = item as? (String, Int), key == "hobbies" {
text = person.hobbies[index]
}
case ("valueColumn", let item as String):
switch item {
case "name":
text = person.name
case "age":
text = "\(person.age)"
case "birthPlace":
text = person.birthPlace
case "birthDate":
text = "\(person.birthDate)"
default:
break
}
default:
text = ""
}
let cellIdentifier = NSUserInterfaceItemIdentifier("outlineViewCell")
let cell = outlineView.makeView(withIdentifier: cellIdentifier, owner: self) as! NSTableCellView
cell.textField!.stringValue = text
return cell
}
}
Result
2. A more streamlined approach
Set up your Storyboard as in #1. Then copy and paste the following code to your View Controller:
import Cocoa
/// The data Model
struct Person {
var name: String
var age: Int
var birthPlace: String
var birthDate: Date
var hobbies: [String]
}
/// Representation of a row in the outline view
struct OutlineViewRow {
var key: String
var value: Any?
var children = [OutlineViewRow]()
static func rowsFrom( person: Person) -> [OutlineViewRow] {
let hobbiesChildren = person.hobbies.map { OutlineViewRow(key: $0) }
return [
OutlineViewRow(key: "Age", value: person.age),
OutlineViewRow(key: "Birth Place", value: person.birthPlace),
OutlineViewRow(key: "Birth Date", value: person.birthDate),
OutlineViewRow(key: "Hobbies", children: hobbiesChildren)
]
}
}
/// A listing of all available columns in the outline view.
///
/// Since repeating string literals is error prone, we define them in a single location here.
/// The literals must match the column identifiers in the Story Board
enum OutlineViewColumn: String {
case key = "keyColumn"
case value = "valueColumn"
init?(tableColumn: NSTableColumn) {
self.init(rawValue: tableColumn.identifier.rawValue)
}
}
class ViewController: NSViewController {
#IBOutlet weak var outlineView: NSOutlineView!
let person = Person(name: "Harry Watson", age: 99, birthPlace: "Westminster",
birthDate: DateComponents(calendar: .current, year: 1985, month: 1, day: 1).date!,
hobbies: ["Tennis", "Piano"])
var rows = [OutlineViewRow]()
override func viewDidLoad() {
super.viewDidLoad()
self.rows = OutlineViewRow.rowsFrom(person: self.person)
outlineView.dataSource = self
outlineView.delegate = self
}
}
extension ViewController: NSOutlineViewDataSource, NSOutlineViewDelegate {
/// Return the item representing each row
/// If item == nil, it is the root of the outline view and is invisible
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
switch item {
case nil:
return self.rows[index]
case let row as OutlineViewRow:
return row.children[index]
default:
return NSNull()
}
}
/// Return the number of children for each row
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
switch item {
case nil:
return self.rows.count
case let row as OutlineViewRow:
return row.children.count
default:
return 0
}
}
/// Determine if the row is expandable
func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
switch item {
case let row as OutlineViewRow:
return !row.children.isEmpty
default:
return false
}
}
/// Return content of the cell
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
guard let row = item as? OutlineViewRow,
let column = OutlineViewColumn(tableColumn: tableColumn!)
else {
fatalError("Invalid row and column combination")
}
let text: String
switch column {
case .key:
text = row.key
case .value:
text = row.value == nil ? "" : "\(row.value!)"
}
let identifier = NSUserInterfaceItemIdentifier("outlineViewCell")
let view = outlineView.makeView(withIdentifier: identifier, owner: self) as! NSTableCellView
view.textField?.stringValue = text
return view
}
}
3. Using Cocoa Bindings
Another way to populate the outline view is using Cocoa Bindings, which can significantly reduce the amount of code you need to write. However, consider Cocoa Bindings an advanced topic. When it works, it's like magic, but when it doesn't, it can be very hard to fix. Cocoa Bindings are not available on iOS.
Code
For this example, let's up the ante by having the NSOutlineView showing details of multiple persons.
// Data Model
struct Person {
var name: String
var age: Int
var birthPlace: String
var birthDate: Date
var hobbies: [String]
}
// A wrapper object that represents a row in the Outline View
// Since Cocoa Binding relies on the Objective-C runtime, we need to mark this
// class with #objcMembers for dynamic dispatch
#objcMembers class OutlineViewRow: NSObject {
var key: String // content of the Key column
var value: Any? // content of the Value column
var children: [OutlineViewRow] // set to an empty array if the row has no children
init(key: String, value: Any?, children: [OutlineViewRow]) {
self.key = key
self.value = value
self.children = children
}
convenience init(person: Person) {
let hobbies = person.hobbies.map { OutlineViewRow(key: $0, value: nil, children: []) }
let children = [
OutlineViewRow(key: "Age", value: person.age, children: []),
OutlineViewRow(key: "Birth Place", value: person.birthPlace, children: []),
OutlineViewRow(key: "Birth Date", value: person.birthDate, children: []),
OutlineViewRow(key: "Hobbies", value: nil, children: hobbies)
]
self.init(key: person.name, value: nil, children: children)
}
}
class ViewController: NSViewController {
let people = [
Person(name: "Harry Watson", age: 99, birthPlace: "Westminster",
birthDate: DateComponents(calendar: .current, year: 1985, month: 1, day: 1).date!,
hobbies: ["Tennis", "Piano"]),
Person(name: "Shelock Holmes", age: 164, birthPlace: "London",
birthDate: DateComponents(calendar: .current, year: 1854, month: 1, day: 1).date!,
hobbies: ["Violin", "Chemistry"])
]
#objc lazy var rows = people.map { OutlineViewRow(person: $0) }
override func viewDidLoad() {
super.viewDidLoad()
}
}
Interface Builder setup
In your storyboard:
Add a Tree Controller from the Object Library
Select the Tree Controller and open the Attributes Inspector (Cmd + Opt + 4). Set its Children key path to children.
Open the Bindings inspector (Cmd + Opt + 7) and set up bindings for the IB objects as follow.
| IB Object | Property | Bind To | Controller Key | Model Key Path |
|-----------------|--------------------|-----------------|-----------------|-------------------|
| Tree Controller | Controller Content | View Controller | | self.rows |
| Outline View | Content | Tree Controller | arrangedObjects | |
| Table View Cell | Value | Table Cell View | | objectValue.key |
| (Key column) | | | | |
| Table View Cell | Value | Table Cell View | | objectValue.value |
| (Value column) | | | | |
(don't confuse Table View Cell with Table Cell View. Terrible naming, I know)
Result
You can use a DateFormatter for nicer date output in both approaches but that's not essential for this question.
A clear example and perfect as a start for working with a NSOutlineView.
As I work with a later Swift version, I had to change
switch (columnIdentifier, item)
to
switch (columnIdentifier.rawValue, item).
Interface Builder also did the correct adjustments for setting
let cell = outlineView.make(withIdentifier: "outlineViewCell", owner: self) as! NSTableCellView
to
let cell = outlineView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "outlineViewCell"), owner: self) as! NSTableCellView
I'm working on an NSOutlineView that uses NSView subclasses to generate custom cells in the outline. This I've gotten to work, BUT after the Outline sucks in the data from the model class and displays it correctly, the Outline is released(?) from memory / goes to nil and I haven't figured out a way to get it back.
Here is the MainViewController class
class MainWindowController: NSWindowController, ShareInfoDelegate, NSOutlineViewDelegate, NSOutlineViewDataSource {
override var windowNibName: String {
return "MainWindowController"
}
#IBOutlet var daOutline: NSOutlineView!
// The NSoutline I'm trying to get back to
Some stuff related to the test data (Omitted)
leading us to the NSOutlineViewDataSource stuff
//MARK: - NSOutlineViewDataSource
func outlineView(outlineView: NSOutlineView, child index: Int, ofItem item: AnyObject?) -> AnyObject {
if let item: AnyObject = item {
switch item {
case let work as Work:
return work.movements[index]
case let movement as Movement:
return movement.tracks[index]
default:
let track = item as! Track
return track.credits[index]
}
} else {
if allWorks.count > 0 {
return allWorks[index]
}
}
let q = "patience"
return q
}
func outlineView(outlineView: NSOutlineView, isItemExpandable item: AnyObject) -> Bool {
switch item {
case let work as Work:
return (work.movements.count > 0) ? true : false
case let movement as Movement:
return (movement.tracks.count > 0) ? true : false
case let track as Track:
return (track.credits.count > 0) ? true: false
default:
return false
}
}
func outlineView(outlineView: NSOutlineView, numberOfChildrenOfItem item: AnyObject?) -> Int {
if let item: AnyObject = item {
switch item {
case let work as Work:
return work.movements.count
case let movement as Movement:
return movement.tracks.count
case let track as Track:
return track.credits.count
default:
return 0
}
} else {
return allWorks.count
}
}
func outlineView(daOutline: NSOutlineView, viewForTableColumn theColumn: NSTableColumn?, item: AnyObject) -> NSView? {
switch item {
case let worked as Work:
let cell = daOutline.makeViewWithIdentifier("newTry", owner:self) as! newTry
cell.fourthLabel.stringValue = worked.composer
cell.fourthCell.stringValue = worked.title
return cell
case let moved as Movement:
let cell2 = daOutline.makeViewWithIdentifier("SecondTry", owner:self) as! SecondTry
cell2.roman.stringValue = moved.name!
cell2.details.stringValue = moved.sections!
cell2.track.stringValue = "0"
return cell2
default:
print("probably not")
}
print("not again")
return nil
}
func outlineView(daOutline: NSOutlineView, heightOfRowByItem item: AnyObject) -> CGFloat {
switch item {
case let worked as Work:
return 40
default:
return 24
}
}
And the stuff in WindowDidLoad
override func windowDidLoad() {
super.windowDidLoad()
let nib = NSNib(nibNamed: "newTry", bundle: NSBundle.mainBundle())
daOutline.registerNib(nib!, forIdentifier: "newTry")
let nib2 = NSNib(nibNamed: "SecondTry", bundle: NSBundle.mainBundle())
daOutline.registerNib(nib2!, forIdentifier: "SecondTry")
//give Sender it's Receiver
mailItOut.delegate = receiver
allWorks.append(work1)
allWorks.append(work2)
work1.movements.append(move1)
work1.movements.append(move2)
work1.movements.append(move3)
work1.movements.append(move4)
work2.movements.append(move5)
work2.movements.append(move6)
work2.movements.append(move7)
daOutline.reloadData()
daOutline?.expandItem(work1, expandChildren: false)
daOutline?.expandItem(work2, expandChildren: false)
}
}
And Finally what the newTry NSView class looks like
class newTry: NSView {
var delegate: ShareInfoDelegate?
#IBOutlet weak var fourthCell: NSTextField!
#IBOutlet weak var fourthLabel: NSTextField!
#IBAction func cellAdd(sender: NSTextField) {
var catchIt: String = String()
catchIt = sender.stringValue
if catchIt != "" {
tryAgain = catchIt
whichField = "title"
//Trigger the sender to send message to it's Receiver
mailItOut.sendMessage()
}
}
The cellAdd Action is used to try and get user input from the text cells back into the model. To do this I (AFAIK) need to access the NSOutline (daOutline) and get which row I'm at and put the data from the sender into the appropriate part of the Model class. Which is something that I've managed to get to work in a standard (1 cell / 1 data value) outline. But in this prototype, as far as I can tell, the MainWindowController has released all of its contents and daOutline is nil (bad).
How do I get XCode to bring / reload the completed outline (or never release it) and get daOutline to a non nil state?
For those who come after there appeared to be two problems that led to the NSOutline outlet becoming nil. The first one was that in implementing the delegate protocol "shareInfoDelegate" I was creating a new instance of the MainWindowController, not the one with the data in it. This new instance did NOT have the IBOutlets connected (or much of anything useful about it).
Once I scrapped the Delegate and moved to using NSNotification to update information about the NSView textFields my NSOutline came "back".
The second, more minor, problem was that in the NSView nib file I placed and NSBox to mimic the behavior of a group row (e.g. a gray background). As a side effect the NSBox was inhibiting the normal row select behavior of the outline. Which made it very hard to determine which row was selected. When I deleted the NSBox, row selection became much more easy to determine.
in particular this Question and the answer by Chuck were helpful in sniffing this out.
Why is my NSOutlineView datasource nil?
Thanks Indeed(!)