NSClickGestureRecognizer.location(in:) and hitTest() - macos

I have the following view:
which has a custom class based on NSVisualEffectView, and contains an image view, a label (NSTextField) and a popup button. isFlipped of the custom view is always false.
The custom view also contains a NSClickGestureRecognizer which uses a delegate. The delegate implements just one method:
func gestureRecognizerShouldBegin(_ gestureRecognizer: NSGestureRecognizer) -> Bool {
let thePoint = gestureRecognizer.location(in: view)
if let theView = view.hitTest(thePoint) {
return !theView.handlesMouseEvents
}
else {
return true
}
}
If I click in the middle of the popup menu, location(in:) returns the value (182, 16) for instance. This seems correct for me for a non-flipped view. But hitTest() returns the background view for that point as result and not the popup button.
What am I doing wrong?
If I use the manually flipped point (y := height - y) for hit-testing I get the popup button as result. But I don't want to use that approach because it seems ugly to me.
If seems to work if I use the window's content view for hit-testing. But I would still like to know why the approach shown does not work.

The parameter point of hitTest(_:) is
A point that is in the coordinate system of the view’s superview, not of the view itself.
Solution: pass a point in superview coordinates.

Related

NSTableView selected row flickering while scrolling with arrow keys

I have a view based NSTableView and can't figure out how to work around a visual glitch where the currently selected row flickers while scrolling up or down with the arrow keys.
The selected row should appear 'glued' to either the top or bottom of the view, depending on scroll direction. The Finder shows this correct behavior in list view but a regular table view seems to not behave this way out of the box. I'm confused as to why that is and see no obvious way to circumvent it. Can anybody point me to possible causes / solutions?
Edit No. 1
A cell based NSTableView behaves in the desired way by default, so this is presumably a bug specific to the view based implementation. I don't want to use a cell based table for unrelated reasons though.
Edit No. 2
I've tried making the table view's parent view layer backed, as well as intercepting the up / down arrow keystrokes to do my own scrolling, but so far I haven't been able to eliminate the flickering.
Edit No. 3
I've created a small sample project that reproduces the issue.
It looks like the selection changes and the old and new selected rows are redrawn. Next the selected row is animated up/down. Disabling scroll animation fixes the issue. Scroll animation can be disabled by subclassing NSClipView and overriding scroll(to:).
override func scroll(to newOrigin: NSPoint) {
setBoundsOrigin(newOrigin)
}
It might have some side effects.
Edit
Copied from zrzka's solution, with some adjustments. Scroll animation is temporarily disabled when using the up arrow or down arrow key.
class TableView: NSTableView {
override func keyDown(with event: NSEvent) {
if let clipView = enclosingScrollView?.contentView as? ClipView,
(125...126).contains(event.keyCode) && // down arrow and up arrow
event.modifierFlags.intersection([.option, .shift]).isEmpty {
clipView.isScrollAnimationEnabled = false
super.keyDown(with: event)
clipView.isScrollAnimationEnabled = true
}
else {
super.keyDown(with: event)
}
}
}
class ClipView: NSClipView {
var isScrollAnimationEnabled: Bool = true
override func scroll(to newOrigin: NSPoint) {
if isScrollAnimationEnabled {
super.scroll(to: newOrigin)
} else {
setBoundsOrigin(newOrigin)
documentView?.enclosingScrollView?.flashScrollers()
}
}
}
Did you try change the view ?
scrollView.wantsLayer = true
If you used Interface Builder:
Select the scroll view
Open the View Effects Inspector (or press Cmd-Opt-8)
In the table, find the row for your scroll view and check the box.

How to align a toolbar (or its items) with the leading edge of a split view controller's child?

In iOS, a toolbar can be added to any view. In macOS however, it seems only possible to add a toolbar to a window.
I'm working on an app with a split view controller with a toolbar but the toolbar's items only have a meaning with respect to the right view controller's context.
E.g. let's say I have a text editor of some sort, where the left pane shows all documents (like in the Notes app) and the right pane shows the actual text which can be edited. The formatting buttons only affect the text in the right pane. Thus, it seems very intuitive to place the toolbar within that right pane instead of stretching it over the full width of the window.
Is there some way to achieve this?
(Or is there a good UX reason why this would be a bad practice?)
I've noticed how Apple solved this problem in terms of UX in their Notes app: They still use a full-width toolbar but align the button items that are only related to the right pane with the leading edge of that pane.
So in case, there is no way to place a toolbar in a view controller, how can I align the toolbar items with the leading edge of the right view controller as seen in the screenshot above?
Edit:
According to TimTwoToes' answer and the posts linked by Willeke in the comments, it seems to be possible to use Auto Layout for constraining a toolbar item with the split view's child view. This solution would work if there was a fixed toolbar layout. However, Apple encourages (for a good reason) to let users customize your app's toolbar.
Thus, I cannot add constraints to a fixed item in the toolbar. Instead, a viable solution seems to be to use a leading flexible space and adjust its size accordingly.
Initial Notes
It turns out this is tricky because there are many things that need to be considered:
Auto Layout doesn't seem to work properly with toolbar items. (I've read a few posts mentioning that Apple has classified this as a bug.)
Normally, the user can customize your app's toolbar (add and remove items). We should not deprive the user of that option.
Thus, simply constraining a particular toolbar item with the split view or a layout guide is not an option (because the item might be at a different position than expected or not there at all).
After hours of "hacking", I've finally found a reliable way to achieve the desired behavior that doesn't use any internal / undocumented methods. Here's how it looks:
How To
Instead of a standard NSToolbarFlexibleSpaceItem create an NSToolbarItem with a custom view. This will serve as your flexible, resizing space. You can do that in code or in Interface Builder:
Create outlets/properties for your toolbar and your flexible space (inside the respective NSWindowController):
#IBOutlet weak var toolbar: NSToolbar!
#IBOutlet weak var tabSpace: NSToolbarItem!
Create a method inside the same window controller that adjusts the space width:
private func adjustTabSpaceWidth() {
for item in toolbar.items {
if item == tabSpace {
guard
let origin = item.view?.frame.origin,
let originInWindowCoordinates = item.view?.convert(origin, to: nil),
let leftPane = splitViewController?.splitViewItems.first?.viewController.view
else {
return
}
let leftPaneWidth = leftPane.frame.size.width
let tabWidth = max(leftPaneWidth - originInWindowCoordinates.x, MainWindowController.minTabSpaceWidth)
item.set(width: tabWidth)
}
}
}
Define the set(width:) method in an extension on NSToolbarItem as follows:
private extension NSToolbarItem {
func set(width: CGFloat) {
minSize = .init(width: width, height: minSize.height)
maxSize = .init(width: width, height: maxSize.height)
}
}
Make your window controller conform to NSSplitViewDelegate and assign it to your split view's delegate property.1 Implement the following NSSplitViewDelegate protocol method in your window controller:
override func splitViewDidResizeSubviews(_ notification: Notification) {
adjustTabSpaceWidth()
}
This will yield the desired resizing behavior. (The user will still be able to remove the space completely or reposition it, but he can always add it back to the front.)
1 Note:
If you're using an NSSplitViewController, the system automatically assigns that controller to its split view's delegate property and you cannot change that. As a consequence, you need to subclass NSSplitViewController, override its splitViewDidResizeSubviews() method and notify the window controller from there. Your can achieve that with the following code:
protocol SplitViewControllerDelegate: class {
func splitViewControllerDidResize(_ splitViewController: SplitViewController)
}
class SplitViewController: NSSplitViewController {
weak var delegate: SplitViewControllerDelegate?
override func splitViewDidResizeSubviews(_ notification: Notification) {
delegate?.splitViewControllerDidResize(self)
}
}
Don't forget to assign your window controller as the split view controller's delegate:
override func windowDidLoad() {
super.windowDidLoad()
splitViewController?.delegate = self
}
and to implement the respective delegate method:
extension MainWindowController: SplitViewControllerDelegate {
func splitViewControllerDidResize(_ splitViewController: SplitViewController) {
adjustTabSpaceWidth()
}
}
There is no native way to achieve a "local" toolbar. You would have to create the control yourself, but I believe it would be simpel to make.
Aligning the toolbar items using autolayout is described here. Align with custom toolbar item described by Mischa.
The macOS way is to use the Toolbar solution and make them context sensitive. In this instance the text attribute buttons would enable when the right pane has the focus and disable when it looses the focus.

Xcode XIB: how to implement a ScrollView or PageControl to swipe sub-views?

I'm building a typical Xcode 6 iOS app.
My goal is:
A screen that has an sub-area that can be swiped to change the content.
For example, the home screen has a logo image, a middle area that I want to be swipeable, and a bottom button.
When the user swipes (or taps) the middle area, the area shows the next (or previous) information, which is a typical UIImage and UILabel caption.
The rest of the screen stays the same, i.e. there is no navigation change.
The code is here. It use the recommendations from the StackOverflow post here.
My question: how can I implement the code below better, while still using an XIB?
My current implementation does work, and uses this approach...
A typical Swift Demo.swift file that is a UIViewController that has:
the page index, min, and max
outlets for the PageControl, UIImageView, and UILabel
actions for the page control change, and the image swipe or tap
A typical Demo.xib file that has:
a typical UIViewController for the entire screen
a UIImageView and UILabel for the changeable image and caption text
a PageControl to indicate what tutorial page the user is viewing
I am seeking better ways to accomplish this; I've read many of Xcode tutorials and so far none seem definitive for Xcode 6, XIBs, and Swift.
Here are some implementations that I've researched that seem promising...
Is there a way to implement a subview area in the XIB?
For example, can Xocde show the XIB with a rectangular area that is intended for the changeable content?
Is there an idiomatic way to write the code for changeable content?
For example, by using a ScrollView, perhaps that contains a UIPageViewController?
Is there a way to make a PageControl XIB object large enough to cover the entire UIImageView and UILabel, so I can skip making the UIImageView respond to gestures.
In my Xcode, the PageControl seems to have an uneditable height that is always 37.
The bounty will be for expert advice.
To make a UIPageViewController swipe-able you should implement the UIPageViewControllerDataSource protocol and provide a view controller for the pageViewController(pageViewController:viewControllerBeforeViewController) -> UIViewController? and the ...viewControllerAfterViewController) methods.
Provide a custom view controller for each page that presents an image and label and takes them as properties so you can provide them from the PageViewController.
My trick it to create a method that instantiates a new view controller in these methods:
// MARK:- UIPageViewControllerDataSource
extension MyPageViewController: UIPageViewControllerDataSource {
func viewControllerWithIndex(var index: Int) -> UIViewController! {
let viewController = storyboard?.instantiateViewControllerWithIdentifier("MyViewController") as! MyViewController // This VC has to be in the storyboard, otherwise just use MyVC()
// Adjust the index to be cyclical, not required
if let count = data?.endIndex {
if count == 1 && index != 0 { return nil }
if index < 0 { index += count }
index %= count
}
viewController.view.tag = index
viewController.record = data?[index]
return viewController
}
func pageViewController(pageViewController: UIPageViewController, viewControllerAfterViewController viewController: UIViewController) -> UIViewController? {
let index = viewController.view?.tag ?? 0
return viewControllerWithIndex(index + 1)
}
func pageViewController(pageViewController: UIPageViewController, viewControllerBeforeViewController viewController: UIViewController) -> UIViewController? {
let index = viewController.view?.tag ?? 0
return viewControllerWithIndex(index - 1)
}
func presentationCountForPageViewController(pageViewController: UIPageViewController) -> Int {
return countAndSetupPageControl()
}
func presentationIndexForPageViewController(pageViewController: UIPageViewController) -> Int {
return viewController?.view.tag ?? 0
}
}
Now for the "sub-area" you will need to implement a ChildViewController. If you're using storyboards you can just drag a Container View and put PageViewController in the embedded view controller, otherwise you need to add the PageViewController.view as a subview and set the frame to the middle.
You can find more info in the apple documentation but basically you MUST call these methods:
addChildViewController(pageViewController)
view.addSubView(pageViewController.view)
pageViewController.view.frame = ... // This is your "sub-area"
pageViewController.didMoveToParentViewController(self)
If you add a height constraint to PageControl you can set it's height to whatever you want.
I don't see a problem with your current implementation. Changing it to use a PageViewController would be quite more work.
If I were you I would add an animation in pageUpdate function so the image would fade in or slide in...
It would only make sense to use a PageViewController if you want to be able to scroll to the next page (as in content moving in the same time your finger is moving onscreen). And you can use a PageViewController or a CollectionView with paging enabled.

NSScrollView always appears on top of other views

No matter what, it seems that my NSScrollViews always appear on top of any other view, even if the other view has been added more recently and should appear on top.
What can I do to get another view (say just a plain NSView) to appear above an NSScrollView?
I finally solved this by setting setWantsLayer to YES (true in this case since I'm using RubyMotion) on the NSScrollView, the NSScrollView's content view and the external view that I want to appear above the NSScrollView.
That solved my problem and allowed other views to appear above the NSScrollView.
So check your xib, and make sure your scrollview is at the topmost of your view hierarchy ("Document Outline"). However, when loaded this doesn't necessarily 100% guarantee that the scrollview will be drawn first. A way to manually force the order you'd like for your views is to programmatically move them around based off tags.
In Interface Builder, in the Attributes Inspector, go to the bottom and find the "View" section. Add a tag to the view, with 0 being the bottom most view you'd like, and going up from there.
Then in your controller, add this method definition & a call to it:
NSComparisonResult compareViews(id firstView, id secondView, void *context) {
NSInteger firstTag = [firstView tag];
NSInteger secondTag = [secondView tag];
if (firstTag == secondTag) {
return NSOrderedSame;
} else {
if (firstTag < secondTag) {
return NSOrderedAscending;
} else {
return NSOrderedDescending;
}
}
}
[self.view sortSubviewsUsingFunction:(NSComparisonResult (*)(id, id, void*))compareViews context:nil];
This will ensure that you have your views in the correct order you'd like

How to put a disclosure triangle's title to the right of the triangle?

In the HIG's example of how to use disclosure triangles, it shows a label directly to the right of the triangle.
However, when I throw one of these onto my view in Interface Builder, the text is centered on top of the triangle. I've searched the NSButton API docs, and poked at everything I can find in IB, but nothing I try will put the text to the right of the triangle. What am I missing?
What I generally do is use 2 buttons: one disclosure button and another button for the label:
While you can use a text field for the label, I prefer using a button and setting the button to call performClick: on the disclosure triangle. This makes for a much larger target area to be able to click on than a tiny triangle. (Users with trackpads will thank you).
To set up the button, change it so it looks like this:
Then set its action:
I'm not sure if there's an actual way to get the button to show both properly (without subclassing I mean), since I've generally just used separate items to give the effect. (I just checked and there is indeed a Carbon disclosure control that has both the triangle and the label built-in).
The Carbon control has the right idea where clicking on the label will automatically trigger the control. In some places (notably the re-written Cocoa Finder), you can see that you don't get that behavior for free (unless you use a button like I've shown). I still have an open bug on that one (rdar://6828042): BugID 6828042: 10.6 (10A335) Finder: Inspector's disclsr. triangle's text label not toggleable". ;-)
Have you tried just using a triangle and using a separate label?
The disclosure triangle widget is drawn by the button's bezel, centered in the available space. To create a disclosure triangle button which also has a title, you just need to subclass NSButtonCell and make sure the bezel is restricted to the left side of the button and that the title avoids the bezel. Then add your button in IB, expand it and set your title, and set the class of the cell. Unfortunately, IB won't know how to display your subclass and will put the triangle in the middle of the button. Just make sure it's big enough.
In Objective-C:
#interface TitledDisclosureTriangleButtonCell : NSButtonCell
#end
#define TRIANGLE_PADDING 15.f
#implementation TitledDisclosureTriangleButtonCell
- (NSRect)titleRectForBounds:(NSRect)theRect
{
NSRect titleRect = [super titleRectForBounds:theRect];
titleRect.origin.x = TRIANGLE_PADDING;
titleRect.size.width = NSWidth(titleRect) - TRIANGLE_PADDING;
return titleRect;
}
- (void)drawBezelWithFrame:(NSRect)frame inView:(NSView *)controlView
{
NSRect bezelFrame = frame;
bezelFrame.size.width = TRIANGLE_PADDING;
[super drawBezelWithFrame:bezelFrame inView:controlView];
}
#end
And in Swift:
let TRIANGLE_PADDING: CGFloat = 15
class TitledDisclosureTriangleButtonCell: NSButtonCell
{
override func titleRectForBounds(theRect: NSRect) -> NSRect {
var titleRect = super.titleRectForBounds(theRect)
titleRect.origin.x = TRIANGLE_PADDING
titleRect.size.width = titleRect.size.width - TRIANGLE_PADDING
return titleRect
}
override func drawBezelWithFrame(frame: NSRect, inView controlView: NSView) {
var bezelFrame = frame
bezelFrame.size.width = TRIANGLE_PADDING
super.drawBezelWithFrame(bezelFrame, inView: controlView)
}
}

Resources