I have a view-based NSTableView in a MacOSX app that structures data nicely. I would like to implement the NSTableView to have row heights which grow with the content of the data entered into one of the NSTextViews. I've subclassed an NSTextView to "grow" with the user text but the issue is that having the field embedded in the TableView causes the field to be clipped.
Does anyone have any suggestions as to how to go about implementing a growing row size?
You need to implement -tableView:heightOfRow: in your table view delegate and return the appropriate height for the row. Furthermore, you need to monitor the text views for changes in their height and call -noteHeightOfRowsWithIndexesChanged: on the table view when any of them changes. To monitor the height of the text views, it should suffice to observe the NSViewFrameDidChangeNotification that they will post.
(If you're using auto layout generally in your UI, I think you will have to leave the text views with translatesAutoresizingMaskIntoConstraints on, place them manually, and set their autoresizing masks as appropriate. Then, you would avoid setting any other constraints on them. This is because you need the frame to be set by the text layout manager but not by auto layout.)
I've managed to implement this in Swift 3 (with the help of this great tip and this and this SO answer):
Make sure the table view cell in the NSTableView has a delegate connection to your subclass/view controller which adopts the NSTextFieldDelegate protocol.
Also give it these constraints to make sure it resizes according to the height of the row:
In the delegate use this code:
var editedString: String? = nil
var textCellForHeight: NSTextFieldCell = NSTextFieldCell.init()
func control(_ control: NSControl, textShouldBeginEditing fieldEditor: NSText) -> Bool {
editedString = fieldEditor.string ?? ""
return true
}
func control(_ control: NSControl, textShouldEndEditing fieldEditor: NSText) -> Bool {
editedString = nil
return true
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(insertNewline(_:)) {
textView.insertNewlineIgnoringFieldEditor(self)
editedString = textView.string ?? ""
//The NSAnimationContext lines get rid of the animation that normally happens when the height changes
NSAnimationContext.beginGrouping()
NSAnimationContext.current().duration = 0
myTableView.noteHeightOfRows(withIndexesChanged: IndexSet.init(integer: selected))
NSAnimationContext.endGrouping()
myTable.needsDisplay = true
return true
}
return false
}
func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
if let temp = editedString { //we know it’s currently being edited if it’s not nil
textCellForHeight.stringValue = temp
//in my case, there was a specific table column that I knew would be edited
//you might need to decide on the column width another way.
let column = myTable.tableColumns[myTable.column(withIdentifier: “TheColumnThatsBeingEdited”)]
let frame: NSRect = NSMakeRect(0, 0, column.width, CGFloat.greatestFiniteMagnitude)
return textCellForHeight.cellSize(forBounds: frame).height
}
return yourStandardHeightCGFloat
}
Related
I setup a trivial view based NSTableView, the view is a simple NSTextField used as a label:
func tableView(_ tv: NSTableView, viewFor tc: NSTableColumn?, row: Int)
-> NSView?
{
let v = (tv.makeView(withIdentifier: viewID, owner: nil) as? NSTextField)
?? NSTextField()
v.isSelectable = false
v.isEditable = false
v.stringValue = data[row] // [String]
v.identifier = viewID
return v
}
and then I enable dragging of the items using this delegate method:
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int)
-> NSPasteboardWriting?
{
return MyPasteboardItem(value: data[row])
}
This works, but when I drag the row, I get an empty representation of the textfield:
(in a different setup things like image views and buttons get drawn, but the NSTextField also ends up white).
I highly suspect this is due to the NSTextField being backed by a TextLayer which doesn't get drawn if the tableview captures an image of the view hierarchy being dragged.
What is a good way to fix this? I considered implementing draw(), but well.
Update: If I do an own NSTextField subclass and override draw(), it indeed starts to work:
final class MyTextField : NSTextField {
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
}
}
Looking at the thing in the view debugger shows that the Layer switches from NSTextLayer to _NSViewBackingLayer when draw is overridden.
But I assume this is not exactly desirable? Is there a better way to accomplish this?
Complete sample: https://gist.github.com/helje5/48728983951ab3362af43b967c554475
Setting drawsBackground=false on the textfield fixed it for me.
If you are using xib file then untick "Draws Background" on the Text Field or in your viewFor: method something like:
v.drawsBackground = false;
I ended up with this in an NSTextField subclass, not sure whether it is a good idea:
override public func draw(_ dirtyRect: NSRect) {
// This switches from the UXLabel being backed by `NSTextLayer` to
// `_NSViewBackingLayer`, which may not be desirable.
// BUT: This enables proper drawing of the Drag&Drop cell.
super.draw(dirtyRect)
}
I have an NSTableView that can swap in different cell views based on data values for the row. When the model changes, I reload the table, and the table's delegate will provide the right table cell view for the new data.
The table uses autolayout for its cell views. All cell views load normally initially. When updating the table after a model change, I get different results depending on whether I call reloadData() or reloadData(forRowIndexes:columnIndexes). When using reloadData(), the cell view is loaded and autolayout works fine. If I use reloadData(forRowIndexes:columnIndexes), autolayout produces completely different, unexpected results.
I created a sample project to demonstrate the problem.
Here is an image of the project setup including constraints set on the table cell views. There are two row templates, one with a blue view (even rows), one with green (odd rows) that should span the table width (minus a bit of padding). A controller supplies the cell views:
class TableController: NSObject {
#IBOutlet weak var tableView: NSTableView!
var colorData = [1, 0, 1, 0]
#IBAction func swapLine(_ sender: Any) {
colorData[1] = (colorData[1] + 1) % 2
// tableView.reloadData()
tableView.reloadData(forRowIndexes: [1], columnIndexes: [0])
}
}
extension TableController: NSTableViewDataSource {
func numberOfRows(in tableView: NSTableView) -> Int {
return colorData.count
}
}
extension TableController: NSTableViewDelegate {
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let cellId = (colorData[row]) % 2 == 0 ? "EvenCell" : "OddCell"
return tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(cellId), owner: self)
}
}
A button in the interface just swaps the data for row 1 and reloads the data. The initial view looks like this (alternating green and blue rects). If you use reloadData(), it looks like this (row 1 changed from blue to green). But, if you use reloadData(withRowIndexes:columnIndexes:), the cell view shrinks to 40 points wide vice 480 as in the others. Here's a grab of the view debugger showing the cell view with the wrong size and showing ambiguous width constraints (this doesn't happen when using reloadData()).
The documentation mentions that the row view is reused with reloadData(forRowIndexes:columnIndexes:), but not with reloadData(), which I've verified. I imagine this reusing of the row view is what's causing the autolayout problems, but I can find no connection. Nothing found at SO, AppKit release notes, WWDC videos, Google searches or from pounding my head on the table. Would be truly grateful for assistance.
Update:
Here's the code for ColorView:
class ColorView: NSView {
#IBInspectable var intrinsicHeight: CGFloat = 20
#IBInspectable var color: NSColor = NSColor.blue
override var intrinsicContentSize: NSSize {
return NSSize(width: NSView.noIntrinsicMetric, height: intrinsicHeight)
}
override func draw(_ dirtyRect: NSRect) {
color.setFill()
dirtyRect.fill()
}
}
I think I've got it working. If I call layoutSubtreeIfNeeded() on the cell just before it is returned (so that all its subviews like the dynamic text are already set), then it seems to work.
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
//...
cell.layoutSubtreeIfNeeded()
return cell
}
I hope that helps.
I ran into the same issue, and noticed the actual auto-layout constraints were missing for the rows that reloadData is called for. My (hacky) solution was to add the constraints that are supposed to be automatically set up for the cell manually as well. Note that in my table view I'm just using one column so I'm able to set the width constraint to equal the row's width instead of relying on the columns specified width.
class CustomRowView: NSTableRowView {
override func addSubview(_ view: NSView) {
super.addSubview(view)
// Add constraints NSTableView is supposed to set up
view.topAnchor.constraint(equalTo: topAnchor).isActive = true
view.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
view.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
view.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 1.0).isActive = true
view.layoutSubtreeIfNeeded()
}
}
I'm trying to create a tray popover app with table very similar to the one Dropbox has in it's popover view.
There is a table of files and when you hover mouse over a table cell, cell will highlight and show additional controls.
I'm not sure if NSTableView is suitable for this at all?
Does anyone have any advice?
This would be an ideal use of NSTableView. Using a view-based NSTableView, you'll be easily able to create the look of those cells (views). The highlight on mouse-over should be accomplishable if you add an NSTrackingArea to the table view (scroll view might be better) with -[NSView addTrackingArea:], which gives you callbacks for -mouseMoved: events. From that method you can use the locationInWindow property on the NSEvent, and then use NSTableView's -rowAtPoint: call to query which row you should change to display the hover event.
As a possible amendment here, I had to do the following to make this work:
highlightedRow: MyCustomRow?
func viewWillAppear(){
configureTableHighlight()
}
func configureTableHighlight() {
let trackingArea = NSTrackingArea(rect: scrollView.frame, options: [.mouseMoved, .activeInKeyWindow, .inVisibleRect], owner: self, userInfo: nil)
scrollView.addTrackingArea(trackingArea)
}
override func mouseMoved(with event: NSEvent) {
let pointInTableView = tableView.convert(event.locationInWindow, to: nil)
let row = tableView.row(at: pointInTableView)
if row == -1 {
highlightedRow?.highlight = false
highlightedRow = nil
return
}
guard let view = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? MyCustomRow else {
return
}
if(highlightedRow == view){
return
}
view.highlight = true;
highlightedRow?.highlight = false
highlightedRow = view;
}
This might depend on where you add the trackingView however.
Additional reference:
mouseover detection in NSTableView's NSCell?
I have a view-based NSTableView and I'm trying to customize the appearance of certain rows.
I understand I need to implement the delegate method mentioned in the title; However I'm not sure about how to do it.
The documentation says:
You can use the delegate method tableView:rowViewForRow: to customize
row views. You typically use Interface Builder to design and lay out
NSTableRowView prototype rows within the table. As with prototype
cells, prototype rows are retrieved programmatically at runtime.
Implementation of NSTableRowView subclasses is entirely optional.
However, unlike cells, there is no NSTableRowView class in interface builder, nor is it clear how to setup a "prototype" row view.
I am trying something like this (Swift 3):
func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView?
{
if (row % 4) == 0 {
// ..................................................
// [ A ] SPECIAL ROW:
if let rowView = tableView.make(withIdentifier: "SpecialRow", owner: self) as? NSTableRowView {
rowView.backgroundColor = NSColor.gray()
return rowView
}
else {
return nil
}
// ^ Always returns nil (Because I don't know how
// to setup the prototype in Interface Builder)
}
else{
// ..................................................
// [ B ] NORMAL ROW (No customization needed)
return nil
}
}
I have similar code working for cells -i.e., -tableView:viewForTableColumn:row:.
OK, so the obvious (?) solution worked:
On Interface Builder, drag and drop a plain-vanilla NSView into the table (it will only accept the drop in a specific column, not as a direct child of the table view).
Go to the Identity Inspector for the just dropped view, and change its Class to "NSTableRowView".
Because just setting the .backgroundColor property as in my code does not work, I instead used this solution and added a box view as a child, and configured that in Interface Builder. I had to setup autolayout constraints between the box and the row view, so that it would stretch to the row view's actual size at runtime.
(Alternatively, I could have played with the wantsLayer property of the row view... )
UPDATE: It turns out the backgroundColor property I was using in my code is defined in NSTableRowView (NSView does not have such property, unlike UIView).
But it also gets overridden by the table view's setting (i.e., alternating rows or not), so instead I should customize it in this method:
func tableView(_ tableView: NSTableView, didAdd rowView: NSTableRowView, forRow row: Int)
{
if (row % 4) == 0 {
rowView.backgroundColor = NSColor.controlAlternatingRowBackgroundColors()[1]
}
else{
rowView.backgroundColor = NSColor.clear()
}
}
...after it was added (and its background color configured by the table view).
(Incidentally, it turns out I do not need a custom row view after all. At least not to customize the background color)
After searching through SO and online, I'm struggling to figure out a concept that I thought would be relatively simple. Essentially, I have a table in an OS X Swift app, with several columns, and it is currently populating data. I am trying to discern how I can set the background color of each "row" (ideally with alternating colors, but I'll start with just one color). My MasterViewController file is like so;
import Cocoa
class MasterViewController: NSViewController {
var minions = [Minion]()
func setupSampleMinion() {
minions = Minion.fetchMinionData()
}
override func viewDidLoad() {
super.viewDidLoad()
// Do view setup here.
}
}
// MARK: - NSTableViewDataSource extension MasterViewController: NSTableViewDataSource {
func numberOfRowsInTableView(aTableView: NSTableView) -> Int {
return self.minions.count
}
func tableView(tableView: NSTableView, viewForTableColumn tableColumn: NSTableColumn?, row: Int) -> NSView? {
// 1
var cellView: NSTableCellView = tableView.makeViewWithIdentifier(tableColumn!.identifier, owner: self) as! NSTableCellView
let minion = self.minions[row]
// 2
if tableColumn!.identifier == "MyColumn" {
// 3
cellView.imageView!.image = NSImage(named: "minion.name!")
cellView.textField!.stringValue = minion.name!
return cellView
}
return cellView
}
}
func tableView(tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? {
let myCustomView = MyRowView()
return myCustomView
}
class MyRowView: NSTableRowView {
override func drawRect(dirtyRect: NSRect) {
super.drawRect(dirtyRect)
self.backgroundColor = NSColor(red: 0.76, green: 0.82, blue: 0.92, alpha: 1)
NSRectFill(dirtyRect)
}
}
// MARK: - NSTableViewDelegate extension MasterViewController: NSTableViewDelegate {
}
While I THINK I have some of the coding right, this does not seem to set the background color if the row in any way. Any thoughts or overall guidance would be most appreciated. Thank you!
If you just want the rows to use the standard alternating colors for rows, there's a simple checkbox in the Attributes inspector for the table view in IB to enable that.
To use a non-standard background color, you want to set the row view's backgroundColor, but not inside of drawRect(). If you change properties of a view that affect how it draws inside of drawRect(), that will probably mark the view as needing display, which will provoke another call to drawRect(), etc.
It should work to just set it in the delegate's tableView(_:didAddRowView:forRow:) method. That's documented in the description of the backgroundColor property.
With regard to your attempt at overriding drawRect(): setting the row view's backgroundColor will presumably affect how the superclass draws. So, setting it after calling through to super is unlikely to help. It definitely won't affect the subsequent NSRectFill() call. That function relies on the fill color set for the current graphics context, which is implicit. You would change that by calling someColor.set().
Buy, anyway, there should be no need to override drawRect() given that you can set the backgroundColor. If you want to achieve some background drawing beyond what's possible by just setting a color, you should override drawBackgroundInRect() and not drawRect(), anyway.
Finally, your implementation of tableView(tableView:rowViewForRow:) should call the table view's makeViewWithIdentifier(_:owner:) method first, before creating a new view. And it should set the identifier on any new view it does create. That allows the table view to maintain a reuse queue of views, to avoid constantly destroying and recreating views.