NSSearchField and NSSegmentedControl inside an NSToolbar - swift

The setup:
I'm writing an document-based app targeting OS X 10.11 using storyboards. The main window has an NSToolbar with a 3-segment NSSegmentedControl. When the segmented control is clicked, it should toggle the collapsed state of an NSSplitViewItem in a horizontal or vertical NSSplitView. The behavior I'm trying to achieve is the same as in Xcode 7 where a segmented control in the toolbar shows/hides the Navigator/Debug Area/Utilities views.
Currently the segmented control sends an action to the first responder. The action method is implemented by an NSSplitViewController subclass, that then toggles it's NSSplitViewItem's collapsed state.
The problem:
The issue is that the toolbar also contains an NSSearchField. If the NSSearchField has focus, or even if the segmented control itself has focus, clicking on the NSSegmentedControl with the cursor does not result in the action method correctly making it's way up the responder chain to the NSSplitViewController subclass.
Attempted solutions:
Previously I worked around this issue using notifications instead of target/action, but it ended up being too convoluted in the end. Another idea is to send the message to the window controller, which would then pass it to it's content view controller, which would pass it to the vertical split view controller that would then send the message (if needed) to the horizontal split view controller. While I know this would work, it also seemed like an ugly solution having to add code to 2 additional files that simply passed a message along, I thought this was what using the responder chain avoided.
Any insights would be grealy appreciated.
Final solution:
I realized that wiring the segmented control's action up to the first responder only makes sense if the key view context is important. In this case the segmented control should toggle the collapsed state of split view items in multiple nested split views, regardless of what the key view is.
Define an enumeration to represent areas of the split view:
enum SplitViewArea : Int {
// The raw values must match the order of the segmented control
case left, top, right
}
Define a protocol to communicate that a split view area should be toggled:
protocol SplitViewTogglable {
func toggleSplitViewItem(matching area: SplitViewArea)
}
Implement the segmented control action method in the window controller:
#IBAction func segmentedControlSelectionStateDidChange(_ sender: Any) {
guard let segmentedControl = sender as? NSSegmentedControl else { return }
guard let area = SplitViewArea(rawValue: segmentedControl.selectedSegment) else { return }
guard let togglable = contentViewController as? SplitViewTogglable else { return }
togglable.toggleSplitViewItem(matching: area)
}
Implement the SplitViewTogglable protocol's method in the NSSplitViewController subclass:
func toggleSplitViewItem(matching area: SplitViewArea) {
switch area {
case .left:
leftSplitViewItem.isCollapsed = !leftSplitViewItem.isCollapsed
case .top:
// Nested NSSplitViewController that adopts SplitViewTogglable
if let togglable = centerSplitViewItem.viewController as? SplitViewTogglable {
togglable.toggleSplitViewItem(matching: area)
}
case .right:
rightSplitViewItem.isCollapsed = !rightSplitViewItem.isCollapsed
}
}

Is the NSSplitViewController set as the contentViewController of the window?
As part of the responder chain search for an action target, the window will consider its contentViewController as a supplemental target if it responds to the action selector.
When the search field has key focus the responder chain does not go through the normal content area, and instead goes through the toolbar to the window. So the only way the NSSplitViewController could be a part of that search is to be the contentViewController.

Related

Swift macOS SegmentedControl Action not getting called

Description
I am trying to use NSSegmentedControls to transition between Child ViewControllers. The ParentViewController is located in Main.storyboard and the ChildViewControllers are located in Assistant.storyboard. Each ChildViewController has a SegmentedControl divided into 2 Segments and their primary use is to navigate between the ChildViewControllers. So they are set up as momentaryPushIn rather than selectOne. Each ChildViewController uses a Delegate to communicate with the ParentViewController.
So in the ParentViewController I added the ChildViewControllers as following:
/// The View of the ParentViewController configured as NSVisualEffectView
#IBOutlet var visualEffectView: NSVisualEffectView!
var assistantChilds: [NSViewController] {
get { return [NSViewController]() }
set(newValue) {
for child in newValue { self.addChild(child) }
}
}
override func viewDidLoad() {
super.viewDidLoad()
// Do view setup here.
addAssistantViewControllersToChildrenArray()
}
override func viewWillAppear() {
visualEffectView.addSubview(self.children[0].view)
self.children[0].view.frame = self.view.bounds
}
private func addAssistantViewControllersToChildrenArray() -> Void {
let storyboard = NSStoryboard.init(name: "Assistant", bundle: nil)
let exampleChild = storyboard.instantiateController(withIdentifier: "ExampleChild") as! ExampleChildViewController
let exampleSibling = storyboard.instantiateController(withIdentifier: "ExampleSibling") as! ExampleSiblingViewController
exampleChild.navigationDelegate = self
exampleSibling.navigationDelegate = self
assistantChilds = [exampleChild, exampleSibling]
}
So far so good. The ExampleChildViewController has an NSTextField instance. While I am in the scope of the TextField, I can trigger the action of the SegmentedControls. Its navigating forward and backward as it should. But once I leave the scope of the TextField I can still click the Segments, but they are not triggering any action. They should be able to navigate forward and backward even if the TextField is not the current "First Responder" of the application. I think I am missing something out here, I hope anyone can help me with this. I know the problem is not the NSSegmentedControl because I am seeing the same behavior with an NSButton, which is configured as Switch/Checkbox, in the SiblingViewController. I just don't have any idea anymore what I am doing wrong.
It`s my first time asking a question myself here, so I hope the way I am doing is fine for making progress with the solution. Let me know if I can do something better/different or if I need to provide more information about something.
Thanks in advance!
Additional Information
For the sake of completeness:
The ParentViewController itself is embedded in a ContainerView,
which is owned by the RootViewController. I can't imagine this does
matter in any way, but this way we are not missing something out.
I am actually not showing the navigation action, because I want to
keep it as simple as possible. Furthermore the action is not problem,
it does what I want it to do. Correct me if I am wrong with this.
Possible solutions I found while researching, which did not work for me:
Setting window.delegate of the ChildViewControllers to NSApp.windows.first?.delegate
Setting the ChildViewController to becomeFirstResponder in its func viewWillAppear()
visualEffectView.addSubview(self.children[0].view, positioned: NSWindow.OrderingMode.above, relativeTo: nil)
Related problems/topics I found while researching:
Basic segmented control not working
Adding and Removing Child View Controllers
NSSegmentedControl - Odd appearance when placed in blur view
How to set first responder to NSTextView in Swift?
How to use #selector in Swift 2.2 for the first responder
Accessing methods, actions and/or outlets from other controllers with swift
How to use Child View Controllers in Swift 4.0 programmatically
Container View Controllers
issues with container view
Control a NSTabViewController from parent View
How to detect when NSTextField has the focus or is it`s content selected cocoa
SOLUTION
let parentViewControllerInstance = self.parent as! ParentViewController
segmentedControl.target = parentViewControllerInstance
In my case I just had to set the delegate as the target of the sendAction method.
Background
Ok, after hours of reading the AppKit Documentation I am now able to answer my own question.
First, debugging the UI showed that the problem was definitely not in the ViewHierarchy.
So I tried to think about the nature of NSButton and NSSegmentedControl. At some point I noticed that both are subclasses of NSControl.
class NSSegmentedControl : NSControl
class NSButton : NSControl
The AppKit Documentation says:
Discussion
Buttons are a standard control used to initiate actions within your app. You can configure buttons with many different visual styles, but the behavior is the same. When clicked, a button calls the action method of its associated target object. (...) You use the action method to perform your app-specific tasks.
The bold text points to the key of the solution – of its associated target object. Typically I define the action of an control item like this:
button.action = #selector(someFunc(_:))
This causes the NSControl instance to call this:
func sendAction(_ action: Selector?, to target: Any?) -> Bool
Parameter Description from the documentation:
Parameters
theAction
The selector to invoke on the target. If the selector is NULL, no message is sent.
theTarget
The target object to receive the message. If the object is nil, the application searches the responder chain for an object capable of handling the message. For more information on dispatching actions, see the class description for NSActionCell.
In conclusion the NSControl instance, which was firing the action method (in my case the NSSegmentedControl), had no target to send its action to. So it was only able to send its action method across the responder chain - which obviously has been nil while the first responder was located in another view.

Cannot modify NSTabViewItem

I may be getting lost in a glass of water as I am not an experienced developer but I cannot seem to be able to implement a simple override to modify the size of an NSTabView item.
I have a Tab View Controller (Style = toolbar)
I have a Tabless Tab View
I have 3 Tab Items. For testing I have only subclassed one of them to the subclass below
I have created a new subclass of NSTabViewItem: MyTabViewItem and subclassed one of the 3 tab Items. The code is:
import Cocoa
class MyTabViewItem: NSTabViewItem {
override func drawLabel(_ shouldTruncateLabel: Bool, in labelRect: NSRect) {
var size = self.sizeOfLabel(false)
size.width = 180
print("Draw!!")
}
override func sizeOfLabel(_ computeMin: Bool) -> NSSize {
var size = super.sizeOfLabel(false)
size.width = 180
print("Draw!!")
return size
}
}
Everything works, except the subclassing. The Tabs appear, they do operate by switching the views and the program runs as it should. Except that it does not resize the Tab Item. The code in the subclass MyTabViewItem is never reached (it never prints Draw!! as it should.
I cannot understand what I am missing here. I have not read of any IB connection to make (and I cannot seem to be able to connect the Tab Items anyways). Please apologise if it isa trivial question but I have searched everywhere and not found anything to help me.
Thank you
You said:
I have a Tabless Tab View
This is your problem. An NSTabView only asks an NSTabViewItem to drawLabel if the NSTabView itself is responsible for drawing the tab bar, but you have a “Tabless” tab view. (“Tabless” is the default style when you drag an NSTabViewController into a storyboard.)
You also said:
I have a Tab View Controller (Style = toolbar)
So you don't even want the tab view to draw a tab bar; you want items in the window toolbar to select tabs (like in Xcode's preference window).
Your ability to customize the toolbar items created for your tabs is limited. You can subclass NSTabViewController and override toolbar:itemForItemIdentifier:willBeInsertedIntoToolbar:, like this:
override func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
let toolbarItem = super.toolbar(toolbar, itemForItemIdentifier: itemIdentifier, willBeInsertedIntoToolbar: flag)
if
let toolbarItem = toolbarItem,
let tabViewItem = tabViewItems.first(where: { ($0.identifier as? String) == itemIdentifier.rawValue })
{
toolbarItem.label = "\(tabViewItem.label) 😀"
}
return toolbarItem
}
But I found that making other changes didn't work well:
Setting toolbarItem.image didn't work well for me.
Setting toolbarItem.view made the item stop receiving clicks.
Note that the minSize and maxSize properties are only used if toolbarItem.view is set.
Your best bet is probably to manage the toolbar yourself, without trying to use NSTabViewController's support.
I have also subclassed the NSTabViewController as follows:
import Cocoa
class MyTabViewController: NSTabViewController {
#IBOutlet weak var TradingTabItem: MyTabViewItem!
override func viewDidLoad() {
super.viewDidLoad()
print("Loaded Tab View")
TradingTabItem.label = "New"
// Do view setup here.
}
}
What happens now is that the tab item in my subclass (the only one of the 3 I subclassed) does change its label string to New. However, even if I have added the item as an IBOutlet here, it still does not change seize (and the overridden sizeOfLabel function is not reached).

How to make embedded view controller part of the responder chain?

I am developing a Mac app using storyboards. I have a window that presents an NSViewController as its contents, which contains a "container view controller" that embeds an NSSplitViewController.
The expected behaviour is for the NSSplitViewController to be part of the responder chain, such that a menu item that triggers the toggleSidebar action on the first responder actually collapses the item of the NSSplitViewController that's marked as a sidebar.
However, this simply does not happen and the menu item remains disabled. So my question is, how can-I get the NSSplitViewController to be part of the responder chain?
I noticed that maybe some of these solutions have worked, but i adapted a more general purpose answer from https://stackoverflow.com/a/30938725/6938357.
I made an extension on NSViewController to look for supplemental targets. Works on NSSplitViewController as well as any general NSViewController with child(ren).
extension NSViewController {
open override func supplementalTarget(forAction action: Selector, sender: Any?) -> Any? {
if let target = super.supplementalTarget(forAction: action, sender: sender) {
return target
}
for child in children {
var target = NSApp.target(forAction: action, to: child, from: sender) as? NSResponder
if target?.responds(to: action) == false {
target = child.supplementalTarget(forAction: action, sender: sender) as? NSResponder
}
if target?.responds(to: action) == true {
return target
}
}
return nil
}
}
If you only want this to search on a single view controller, put this implementation there instead. This extension applies to all NSViewControllers and its subclasses.
Check out the nextReponsder property of NSResponder. This property defines the responder chain. It's normally set automatically to follow the responder change defined by the Cocoa framework, but you can alter it to insert/skip/divert the chain in a different direction.
For example, at some point (don't ask me when), Cocoa started including the window's controller in the responder chain. So that my apps work consistently on all versions of macOS, I'll include code like this my window's controller:
- (void)windowDidLoad
{
// Sent when the controller's window has been loaded from the nib
[super windowDidLoad];
NSWindow* window = self.window;
// Make sure this window controller is in the responder chain
NSResponder* nextResponder = window.nextResponder; // get our window's next responder
if (nextResponder!=self)
{
// running earlier OS X that does not include the window controller in the chain: patch us in
self.nextResponder = nextResponder;
window.nextResponder = self;
}
-windowDidLoad, -viewDidLoad, and -awakeFromNib are all good places to adjust the responder chain so they include, or exclude, whatever objects you want.
I ended up getting this to work (in Swift 4) by adding my view controller to the window delegate. After that, my view controller was part of the responder chain (which made application menu items work in my view controller).
//Step 1: Add NSWindowDelegate to the controller
class MyViewController: NSViewController, NSWindowDelegate{
override func viewDidLoad() {
super.viewDidLoad()
//Step 2: Add the view controller to the window delegate
if let window = NSApp.windows.first{
window.delegate = self
}
}
}
I hope that helps someone else. :)

How to handle iOS 11 large title animation when using multiple container views?

I am making an app at the moment where 1 screen has a segmented control with 3 segments. Initially I had 1 table view and when you change segment I would simply change the data source/cell etc and reload the table. While this works great there is always the problem that when you change segments it will not remember your last scroll position because the table view gets reloaded.
I tried to get around this with storing offset position, rows etc but I could never get it to work like I wanted. Seems especially annoying when you have different cell types for the segments and they are self sizing as well.
I than decided to have a master view controller with the segmented control and 3 container views with their own VC and table view for each segment. I simply hide/show the correct container view when changing segments. This also works great but I have 1 problem with iOS 11 style large headers. Only the 1st container view added as a subview to the ViewControllers view manipulates the collasping/expanding of the title when you scroll.
Therefore when I change to the 2nd or 3rd container view and start scrolling I do not get the large title collapsing animation. How can I get around that?
I tried the following
1) Change Container view zPosition when changing segments
2) Move the container view to the front by calling view.bringSubview(toFront: ...)
3) Looping through the subviews and calling
view.exchangeSubview(at: 0, withSubviewAt: ...)
I believe I could remove all container views and add the one I need again and give them constraints but I wonder if there is a more straight forward solution.
Or if someone has a good solution to remember a tableViews scroll position before reloading it I would appreciate that too.
So I found an answer that seems to work for me, thanks to this great article. https://cocoacasts.com/managing-view-controllers-with-container-view-controllers/
Essentially what I did is
1) Remove the ContainerViews and Segues from the MasterViewController Storyboard.
2) Add a lazy property for each VC of the segmented control in the MasterViewController. They are lazy so that they only get initialised when you actually need them
lazy var viewController1: LibraryViewController = {
let viewController = UIStoryboard.libraryViewController // convenience property to create the VC from Storyboard
// do other set up if required.
return viewController
}()
lazy var viewController2: LibraryViewController = {
let viewController = UIStoryboard.libraryViewController // convenience property to create the VC from Storyboard
// do other set up if required.
return viewController
}()
3) Create an extension of UIViewController with the following 2 methods. I added them in an extension purely for code organisation as they might be reused on other ViewControllers.
extension UIViewController {
func add(asChildViewController viewController: UIViewController) {
// Add Child View Controller
addChildViewController(viewController)
// Add Child View as Subview
view.addSubview(viewController.view)
// Configure Child View
viewController.view.frame = view.bounds
viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
// Notify Child View Controller
viewController.didMove(toParentViewController: self)
}
func remove(asChildViewController viewController: UIViewController) {
// Notify Child View Controller
viewController.willMove(toParentViewController: nil)
// Remove Child View From Superview
viewController.view.removeFromSuperview()
// Notify Child View Controller
viewController.removeFromParentViewController()
}
}
4) Now in my segmented control method that gets called when you change segment I simply add the correct ViewController. The nice thing is that remove/adding them does not actually deallocate them.
func didPressSegmentedControl() {
if segmentedControl.selectedSegmentIndex == 0 {
remove(asChildViewController: viewController2)
add(asChildViewController: viewController1)
} else {
remove(asChildViewController: viewController1)
add(asChildViewController: viewController2)
}
}
5) Make sure you call the method at point 4 in ViewDidLoad so that the correct VC is added when the VC is loaded the 1st time.
func viewDidLoad() {
super.viewDidLoad()
didPressSegmentedControl()
}
This way when we remove a ChildViewController and add another one it will always be the the top VC in the subviews array and I get my nice title collapsing animation.
Another added benefit of this approach is that if you never go to a particular segment that particular VC will never get initialised, because they are lazy properties, which should help with efficiency.
Hope this helps somebody trying to do the same.
This is a horrible issue which I hope will be resolved soon, but there is another fix - although I freely admit that this is a nasty hack.
Basically, the issue only applies to the FIRST container view in the hierarchy. Add another container view to your hierarchy. Set it as hidden, then delete its segue and its target view controller just to be neat. Finally, make sure that this is the first container view in the hierarchy by dragging it to the top of the list.

Change views inside NSSplitViewController

I'm trying to migrate my Objective-C and now improving Swift knowledge to an application for Mac OS X. Steep learning curve!
I'm trying to load a NSSplitViewController with different views in the "detail view" depending on buttons pressed on the "master view" if you will. Following tutorials and searching for hours has led me to nothing.
I currently have:
import Cocoa
class MainSplitView: NSSplitViewController, BlissWindowDelegate {
var masterViewController: vcMainMenu {
let masterItem = splitViewItems[0] as! NSSplitViewItem
return masterItem.viewController as! vcMainMenu
}
override func viewDidLoad() {
super.viewDidLoad()
masterViewController.delegate = self
}
func userDidSelectFunction(function: String) {
switch function {
case "app":
println("You have selected to load the appointment screen")
case "cust":
println("You have selected to load the customer screen")
case "login":
println("I think I am here and you've clicked login?")
let detailItem = splitViewItems[1] as! NSSplitViewItem
// Trying to load the views here ... but no idea how to
case "admin":
println("You've clicked admin")
default:
println("Nothing here ...")
}
}
}
I'm using BlissWindowDelegate to tell me which button was pressed. I am then trying to load into the splitViewItem[1] various views from a Storyboard. But having no luck. Can anyone point me in the right direction please? Even for a decent reference? Nothing on Google is seeming to help.
Thanks.
Since it sounds like you have a specific set of detail panes that can be shown, using an NSTabViewController is probably best way to accomplish this.
Basically, your NSSplitViewController has two children: the master view controller, and a NSTabViewController. And the tab view controller has its own children for each of the detail panes. Since tab view controller shouldn't present its own tab selection UI (the master pane is doing that), you would set the tabStyle to be .Unspecified. The storyboard would look something like this:
Your MainSplitViewController would also have a reference to the tab view controller, detailController. Then on userDidSelectFunction(), you would set the detailController's selectedTabViewItemIndex to be that of the corresponding detail pane. NSTabViewController will take care of the view transition, including animating between the panes if setup to do so.