Use Case:
This is a data entry scenario where the user needs to modify the data in three columns of a view based NSTableView; qty, item number and price. The objective is to keep the users hands on the keyboard and diminish the need for mouse'ing around the table to perform edits.
Detailed UI:
When tabbing within a row, the next column should become selected and editable which is (can be) the default behavior of a NSTableView. However, when in the last column (price) is being edited and tabbing out, it should wrap to the next available row, column 0 (qty) and allow the user to continue editing.
Likewise, backtab should be supported within a row, and when in the first column (qty) and backtabbing it should wrap up the the prior row, last column (price). If they are on row 0, column 0, it should wrap down to the last row, last column.
So essentially the user should be able to navigate and edit any row, column with tab or backtab presses.
While there are several solutions, using keyDown, keyUp, notifications etc a lot of it is older ObjC Code and not very Swifty.
Typically questions should include some kind of code but since I have a solution that works, I am including it as an answer so hopefully it will help a future reader.
Question:
Can you suggest a simpler solution of how to navigate a view based tableview based on the above parameters.
First is to subClass a view based NSTableView set set it up like this
class ItemTableView: NSTableView, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate {
I have a tableView dataSource called transactionArray which holds the items to be displayed within the tableView. I also include the delegate methods
func numberOfRows(in tableView: NSTableView) -> Int
and
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView?
within the class so it's self contained.
Here's the function to handle tab navigation
// handle tab and backtab in an editable view based tableView subclass
// will also scroll the edited cell into view when tabbing into view that are outside the viewable area
//ref https://developer.apple.com/documentation/appkit/nscontroltexteditingdelegate/1428898-control
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
print(#function)
//let whichControl = control //this is the tableView textField where the event
// happened. In this case it will only be the
// NSTableCellView located within this tableView
let whichSelector = commandSelector //this is the event; return, tab etc
//these are the keypresses we are interested in, tab, backtab, return/enter.
let tabSelector = #selector( insertTab(_:) )
//let returnSelector = #selector( insertNewline(_:) ) //use this if you need
//custom return/enter handling
let backtabSelector = #selector( insertBacktab(_:) )
//if the user hits tab, need to determine where they are. If it's in the last
// column, need to see if there is another row and if so, move to next
// row, col 0 and go into edit. If it's a backtab in the first column, need
// to wrap back to the last row, last col and edit
if whichSelector == tabSelector {
let row = self.row(for: textView)
let col = self.column(for: textView)
let lastCol = self.tableColumns.count - 1
if col == lastCol { //we tabbed forward in the last column
let lastRow = self.transactionArray.count - 1
var rowToEdit: Int!
if row < lastRow { //if we are above the last row, go to the next row
rowToEdit = row + 1
} else { //if we are at the last row, last col, tab around to the first row, first col
rowToEdit = 0
}
self.editColumn(0, row: rowToEdit, with: nil, select: true)
self.scrollRowToVisible(rowToEdit)
return true //tell the OS we handled the key binding
} else {
self.scrollColumnToVisible(col + 1)
}
} else if whichSelector == backtabSelector {
let row = self.row(for: textView)
let col = self.column(for: textView)
if col == 0 { //we tabbed backward in the first column
let lastCol = self.tableColumns.count - 1
var rowToEdit: Int!
if row > 0 { //and we are after row zero, back up a row and edit the last col
rowToEdit = row - 1
} else { // we are in row 0, col 0 so wrap forward to the last col, last row
rowToEdit = self.transactionArray.count - 1
}
self.editColumn(lastCol, row: rowToEdit, with: nil, select: true)
self.scrollRowToVisible(rowToEdit)
self.scrollColumnToVisible(lastCol)
return true
} else {
self.scrollColumnToVisible(col - 1)
}
}
return false //let the OS handle the key binding
}
Related
I have been spending too much time on this. I would greatly appreciate your help.
I have a picker view in my iOS app page and I have the following code to change the color of the text of the selected row. However, for some reasons, the row #1 reacts very strangely. I have added a button to the accessory view of the text field that brings up the picker view. When I tap the button, the input view switches to number pad. And if I tap the button again, it would switch back to the picker view. Here is the issue. If I select row #1 of my picker view, the color of the row #1 text changes to blue (as I intended it to be). However, if I tap the button to switch to the number pad mode, and then tap the button to switch back to the picker view, the color of the selected text in row #1 changes back to black.
Please shed some light on me. Thanks.
func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView {
var title = UILabel()
if let view = view {
title = view as! UILabel
}
title.font = UIFont.systemFont(ofSize: 20, weight: UIFont.Weight.medium)
title.textColor = row == pickerView.selectedRow(inComponent: component) ? .systemBlue : .black
title.text = pickerViewData[row]
title.textAlignment = .center
return title
}
and the switch button function is below... only the row #1 is not working as expected... it's really strange... all the other rows change color exactly as intended... even if I use titleForRow along with attributedTitleForRow, I get the same result. Is this a Swift bug..? I tried to search for "picker view row #1 issue", but there doesn't seem to be many questions.
func switchInputView() {
pickerViewStatus = !pickerViewStatus
resetInputView()
pickerView.reloadAllComponents()
}
func resetInputView() {
switch pickerViewStatus {
case true:
activeTextField.inputView = pickerView
case false:
activeTextField.inputView = nil
activeTextField.keyboardType = .numberPad
activeTextField.becomeFirstResponder()
}
addToolbarForPickerView()
activeTextField.reloadInputViews()
}
I looked for posts for issues with all 3 functions listed in the title, but my problem is not discussed anywhere. Frankly, my problem is such that it defies logic IMHO.
On selection of a row in my view-based NSTableView, I want to show a "Save" button in my last (aka "Action") column. So on viewDidLoad() I created my orphan button. Then on tableViewSelectionDidChange() I remove it from the superview and add it to my column's NSTableCellView. When there's no row selected, there is no button to show. Simple. Below code works perfectly.
func tableViewSelectionDidChange(_ notification: Notification) {
let selectedRow = tableView.selectedRow
btnSave!.removeFromSuperview()
if selectedRow > -1,
let rowView = rowView(selectedRow),
let actionView = rowView.view(atColumn: Column.action.hashValue) as? NSTableCellView {
actionView.addSubview(btnSave!)
}
}
Wait. Did I say Perfect? It works as long as I use mouse to change the selected row, including selecting below the last row in table which removes selection and any button in previous selected row. However, when I use the keyboard Up/Down keys to change the selection, it does not work.
First I thought the function will get called only if I use mouse to change row selection. However, that's not true. That's true for tableViewSelectionIsChanging as per docs and as per facts. But for tableViewSelectionDidChange docs don't say it will only work when mouse is used and the facts bear that out. I put print statements and function does get called. I stepped through debugger as well. The mind boggling part is - the method to remove button from superview works, but the one to add button as subview does not work - and only if I use keyboard. How is it possible that the same exact code executes but I get two different outcomes?
Adding remaining functions
I use this to get selected row
private func rowView(_ rowIndex: Int, _ make: Bool = false) -> NSTableRowView? {
return tableView.rowView(atRow: rowIndex, makeIfNecessary: make)
}
I call this in viewDidLoad to create my orphan button
private func createButton() {
btnSave = NSButton(frame: NSRect(x: 10, y: 0, width: 22, height: 16))
btnSave?.title = "S"
btnSave?.setButtonType(.momentaryPushIn)
btnSave?.bezelStyle = .roundRect
}
I'm implementing the rename/editing function in an NSOutlineView. The basic implementation is like:
#objc func renameAction() {
let row = outlineView.clickedRow
let rowView = outlineView.view(
atColumn: 0,
row: row,
makeIfNecessary: false) as! NSTableCellView
rowView.textField!.isEditable = true
rowView.window?.makeFirstResponder(rowView.textField!)
}
A mouseDown: is handled in the NSOutlineView so that the NSTextField can quit editing mode when clicking on the row that is being edited.
The idea of using a custom delegate is from this question
override func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
let localLocation = self.convert(
event.locationInWindow,
from: nil)
let clickedRow = self.row(at: localLocation)
#if DEBUG
print(#file, #line, clickedRow)
#endif
if clickedRow != -1 {
self.extendedDelegate?.didClickRow(clickedRow)
}
}
In the delegate:
func didClickRow(_ row: Int) {
//... get the textField
textField.isEditable = false
textField.resignFirstResponder()
textField.window?.makeFirstResponder(
textField.window?.contentView
)
}
The strange thing is I can right click on an NSTableCellView(including both an icon and an NSTextField) to get the context menu before a left click on ANY row. After the left click, I can only get the context menu by right clicking anywhere outside the NSTableCellView (of any row, no matter it's the clicked one or not) but inside the row (the blank areas surrouding the NSTableCellView).
Screenshots
Before any left clicking, right clicking on the NSTableCellView gets the menu:
After a left clicking on any row, right click doesn't work "on" all the NSTableCellViews, but still works on the blank area of the row.
// No menu upon clicking on the NSTableCellView. But the menu will appear when clicking on the blank areas of the row.
Update:
I found that if the overriding mouseDown: is commented out, (as expected) there's no issue. But that will throw the ability to end editing by clicking on the current row.
I have a UITableView that presents a timeline of data on tvOS. It is dynamically updated via an NSFetchedResultsController.
When the table is updated, new cells are added at the top. However: the previously selected cell remains focused, but the behaviour I need is for the focus to shift to the 'newest' (i.e. topmost) cell after the data update.
How can I achieve this?
Not sure what your code looks like, but you can create a property to keep track of whether the table is actively updating.
var tableViewIsUpdating = false
You can use this to determine if you want the normal focus before or to return the first row of your table view:
override var preferredFocusedView: UIView?{
get {
if updating {
return self.tableView.cellForRowAtIndexPath(NSIndexPath(forRow: 0, inSection: 0))
}
return super.preferredFocusedView
}
}
Then in your NSFetchedResultsControllerDelegate set if it's updating or not:
func controllerWillChangeContent(controller: NSFetchedResultsController){
self.tableViewIsUpdating = true
}
func controllerDidChangeContent(controller: NSFetchedResultsController) {
self.setNeedsFocusUpdate()
self.updateFocusIfNeeded()
self.updating = false
}
You can find more information here on how to update focus programmatically.
I'd like to be able to fix the position of certain rows in a UITableView as the user scrolls.
Specifically, I have a table whereby certain rows are "headers" for the rows that follow, and I'd like the header to stay at the top of the screen as the user scrolls up. It would then move out of the way when the user scrolls far enough that the next header row would take its place.
A similar example would be the Any.DO app. The "Today", "Tommorrow" and "Later" table rows are always visible on the screen.
Does anyone have any suggestions about how this could be implemented?
I'm currently thinking of follow the TableDidScroll delegate and positioning my own cell in the appropriate place in front of the table view. The problem is that at other times I'd really like these cells to be real table cells so that they can be, for example, reordered by the user.
Thanks,
Tim
I've been playing about with this and I've come up with a simple solution.
First, we add a single UITableViewCell property to the controller. This should be initialize such that looks exactly like the row cells that we'll use to create the false section headers.
Next, we intercept scrolling of the table view
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
// Add some logic here to determine the section header. For example, use
// indexPathsForVisibleRows to get the visible index paths, from which you
// should be able to get the table view row that corresponds to the current
// section header. How this works will be implementation dependent.
//
// If the current section header has changed since the pervious scroll request
// (because a new one should now be at the top of the screen) then you should
// update the contents.
IndexPath *indexPathOfCurrentHeaderCell = ... // Depends on implementation
UITableViewCell *headerCell = [self.tableView cellForRowAtIndexPath:indexPathOfCurrentHeaderCell];
// If it exists then it's on screen. Hide our false header
if (headerCell)
self.cellHeader.hidden = true;
// If it doesn't exist (not on screen) or if it's partially scrolled off the top,
// position our false header at the top of the screen
if (!headerCell || headerCell.frame.origin.y < self.tableView.contentOffset.y )
{
self.cellHeader.hidden = NO;
self.cellHeader.frame = CGRectMake(0, self.tableView.contentOffset.y, self.cellHeader.frame.size.width, self.cellHeader.frame.size.height);
}
// Make sure it's on top of all other cells
[self.tableView bringSubviewToFront:self.cellHeader];
}
Finally, we need to intercept actions on that cell and do the right thing...
That's the default behavior for section headers in plain UITableView instances.
If you want to create a custom header, implement the tableView:viewForHeaderInSection: method in your table view delegate and return the view for your header.
Although you will have to manage sections and rows instead of just rows.
Swift 5 solution
var header: UIView?
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(indexPath: indexPath) as UITableViewCell
header = cell.contentView
return cell
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let headerCell = tableView.cellForRow(at: IndexPath(row: 0, section: 0))
guard headerCell == nil || (headerCell!.frame.origin.y < self.tableView.contentOffset.y + headerCell!.frame.height/2) else {
header?.isHidden = true
return
}
guard let hdr = header else { return }
hdr.isHidden = false
hdr.frame = CGRect(x: 0, y: tableView.contentOffset.y, width: hdr.frame.size.width, height: hdr.frame.size.height)
if !tableView.subviews.contains(hdr) {
tableView.addSubview(hdr)
}
tableView.bringSubviewToFront(hdr)
}