I'm using an NSOutlineView to display a hierarchy of tracks in a timeline, very similar to what would be seen in video editing packages. As the user expands/collapses items in the outline view, the corresponding objects in a scroll view alongside it appear and disappear.
Unfortunately, the outlineViewItemDidCollapse: and outlineViewItemDidExpand: delegate methods get called before the animation is complete, and calling frameOfCellAtColumn: on the outline view only gets the frame at that instant, while Cocoa is still animating its [dis]appearance.
As a workaround, I'm using performSelector:withObject:afterDelay: with a delay of 100ms (as is this Dynamically resize NSWindow based on NSOutlineView height person with a similar problem), but this isn't perfect (and it's smells bad). The animation isn't always finished inside 100ms, and any longer delay becomes quite noticeable.
-(void)outlineViewItemDidExpand:(NSNotification *)notification
{
// we need to delay the update to allow the outline view expand animation to
// complete. Perhaps 0.1s isn't long enough in all cases, but any longer seems
// too sluggish
[self performSelector:#selector(updateSubviewsForTimeline:) withObject:timelineView afterDelay:0.1];
}
Is there any way I can hook into the outlineViewDidExpand|Collase: methods after the animation is complete? Alternatively, can I somehow get a handle on the animation itself inside my outlineViewDidExpand|Collase: methods so I can install a callback on that?
Related
I'm working on a database application that has a graphical editor that is very similar to Interface Builder. Unlike IB, however, this editor can be flipped from editor mode to live mode, where the user interface is fully operational (buttons can be clicked, text edited, etc.) To do this, the graphical editor uses standard Appkit interface classes -- NSButton, NSTextView, etc. The editor itself is implemented with a custom subclass of NSView. All of the user interface elements are subviews of this custom NSView, new elements are added using the addSubview: method, making the new element the topmost visible element (note -- the views are not layer-backed, just regular views). The user can also use Bring-to-Front and Send-to-Back commands to change the ordering of the subviews. This movie shows two overlapping NSButton elements (for illustration purposes, of course normally you would never overlap them), and how the program can re-arrange the subview to change the Z-order of the user interface elements.
The problem is, this works with every kind of interface element except NSImageView. In the movie before there are two elements, an NSButton and an NSImageView. The NSButton is actually "on top" the whole time, the NSImageView element should appear behind the button, but no matter the order of the subviews, the NSImageView always appears on top.
If there are two overlapping NSImageView objects, the visible stacking order between them is unpredictable, but they will always appear above all other objects, no matter what the order of the subviews is.
A possibly useful clue is that if I implement my own custom view that draws an image directly in it's drawRect: method, that works fine. So that's one possible solution, but I am reluctant because that means re-implementing a large swath of useful features that NSImageView normally takes care of, some of which are quite complex like supporting animated GIF display. Other than this layering/z-order issue, everything else about NSImageView works fine.
Perhaps NSImageView is using layer-backing without my asking for it, so it isn't mixing in properly with my other objects? I can't find any documentation that indicates this. I am not linking against the QuartzCore framework.
Here is the code that adds the NSImageView element as a subview to the graphic editor view.
- (void)objectDidAppearBelow:(NSView *)nextView
{
FormView * formView = [FormWindowController currentFormView]; // get view element will be placed into
NSScrollView * imageContainer = [[NSScrollView alloc] initWithFrame:insideBorderRect];
ImageView * ixView = [[ImageView alloc] initWithFrame:[self insideFormObjectBorder:objectRectangle]];
[ixView setOwnerObject:self];
[imageContainer setDocumentView:ixView];
[imageContainer setAutoresizesSubviews:YES];
[shapeView addSubview:imageContainer Below:nextView];
imageDocumentView = ixView; // save weak reference to image view so it can be manipulated
}
In other places there is nearly identical code for NSButton (in several variations for push buttons, radio buttons, etc.), NSTextView, NSTableView (for lists and matrixes), NSSlider, NSScroller, NSSegmentedControl, even WebView. All the others work correctly with overlapping objects, including WebView, only NSImageView doesn't work as expected.
For my reference this is #429 in the Panorama X issue tracker.
I discussed this problem with Apple engineers at WWDC 2018. It turns out that as I suspected, in some cases Appkit will use layer backing for NSImageView even if you did not ask for it! So the best solution is to switch all views to layer backing (which will happen automatically with Mojave).
In this particular case the NSImageView was inside a NSScrollView, which I didn't mention because I didn't think it was important (my bad). It turns out, this is the case where Appkit thinks using layer backing would be a good idea (to optimize scrolling). So another way to fix this is to subclass NSImageView (which I had already done for other reasons) and add this method (written on the spot by an Apple engineer who prefers to remain uncredited).
+ (BOOL)isCompatibleWithResponsiveScrolling {
if (NSAppKitVersionNumber <= 1561. /* NSAppKitVersionNumber10_13 */) {
return NO;
} else {
return YES;
}
}
I was assured that this is all part of the public, documented API, though the documentation is minimal (surprise surprise). There is some discussion of responsive scrolling in the What's new in OS X 10.9 release notes. The check with NSAppKitVersionNumber is to make sure to turn off this patch when running on Mojave, since everything is layer backed.
I have a cocoa application that has a dozen scrollViews. I love the elasticity, especially in some cases where I'd actually put some kind of "Easter egg" (kinda like the apple logo in the books app. you scroll down, you see an apple logo.)
My problem is, that I need to limit the amount of exposed content beyond the actual content area. When I scroll with the magic mouse, especially, the elasticity causes the whole scroll content to disappear! Until you release the scroll, it moves back in.
Now, I would like to limit the elasticity to a specific margin. how?
NSScrollView manages a view which has a "canvas" bigger than what is/can be display at any one time. So if you want a different behaviour:
Check (void)setHorizontalScrollElasticity: but that doesn't quite do what you want. (you want to allow a fixed amount of elasticity)
Subclass NSScrollView to implement the behaviour you want.
Create your own class from scratch (well... Anything inheriting from NSResponder since you want to handle events).
For example, I once wrote a world-map program but needed the map to loop forever on the horizontal axis. I just manually managed the scrolling with a subclassed NSView. (don't have access to code currently)
Something to ponder about: I understand your reasons but just wanted to mention it. The behaviour should be expected by the user. If it looks like a button, it should act like out. Currently, scrollviews have the elasticity so that when they scroll via momentum (user is no longer touching), it doesn't stop suddenly once it reaches the end... which would be jarring for users.
Example
If subclassing NSScrollview, I would try overriding - (void)scrollWheel:(NSEvent *) and detect what are the bounds of the contentView and cap it at a certain value. Something around the lines of:
- (void)scrollWheel:(NSEvent *)event
{
[super scrollWheel:event];
if (self.contentView.bounds.origin.y > SomeConstant)
/* cap the value */
}
I am trying to create a resize toggle animation on this simple custom TUIScrollView class (from TwUI open source project and very similar to UIScrollView) that I have built. It is called TUILayout and supports horizontal layout as well as vertical, animated insertions and removals and has a more declarative way of supplying data to it's cells that I prefer over delegation. It recycles views similar to TUITableView or UITableView. Anyway if you want to follow along, it's just one class and is here.
https://github.com/mrjjwright/TUILayout.
In the example code, the user clicks the blue button in the lower left and all the rows shrink smoothly to a size where the user can reorder and delete some rows (right click on a row in the example to see this in action), etc... and then the user toggle the rows back out to their original size by clicking the blue button again.
While doing this resize in setObjectHeight:animated I first resize my model objects that represent the rows, recalculate and set the contentSize on the TUIScrollView, cycle in all the new views (say 10 more views will fit in the shrunk view so dequeueReusableView and addSubview gets called 10 times) and finally I animate the frames of all the views to their size and location in layoutSubviews.
The result is that the scrollview correctly shrinks to a size where the scrollbar no longer displays, the views that are on screen animate smoothly down to their reduced size, but the newly added subviews that can now fit in the visibleRect animate in much later as one block of subviews.
So all the newly added subviews lag behind the views that were on the screen and I can't figure out why the animation isn't all happening together. I have tried lots of different combos of things with no luck including CATransactions. I am wondering if it has to with how a CAScrollLayer works or if somebody can help me think through this.
The more general issue is how to smoothly handle resizing animations on scrollviews that recycles their views and I have looked at several other grids out there in the iOS world and have got some inspiration but am looking for more.
Thanks!
I think I might have solved my own issue here (as I was making my bed this morning it hit me). I forced the current runloop to run after cycling in all the necessary subviews and very importantly not setting the contentSize of the scrollview until after the run loop completes and adds the needed subviews for the animation. In order to get the run loop to fire I used the trick from this SO question:
skipLayout = YES;
[[NSRunLoop currentRunLoop] runMode: NSDefaultRunLoopMode beforeDate: [NSDate date]];
skipLayout = NO;
If skipLayout is set TUILayout just returns from layoutSubviews so that the views just added are not immediately removed by the recycling layout code. Forcing the run loop to run made sure that all subviews were on the screen for the animation. After this I performed the resize animated layout. I updated the code on github if anybody is interested. I will leave this question open for a while to gain some further insight.
You see this in iPhone apps like Gilt. The user scrolls a view, and a subview apparently "sticks" to one edges as the rest of the scrollView slides underneath. That is, there is a text box (or whatever) in the scrollView, that as the scrollView hits the top of the view, then "sticks" there as the rest of the view continues to slide.
So, there are several issues. First, one can determine via "scrollViewDidScroll:" (during normal scrolling) when the view of interest is passing (or re-appearing). There is a fair amount of granularity here - the differences between delegate calls can be a hundred of points or more. That said, when you see the view approach the top of the scrollView, you turn on a second copy of the view statically displayed under the scrollView top. I have not coded this, but it seems like it will lack a real "stick" look - the view will first disappear then reappear.
Second, if one does a setContentOffset:animated, one does not get the delegate messages (Gilt does not do this). So, how do you get the callbacks in this case? Do you use KVO on "scroll.layer.presentationLayer.bounds" ?
Well, I found one way to do this. When the user scrolls by flicking and dragging, the UIScrollView gives its delegate a "scrollViewDidScroll:" message. You can look then to see if the scroller has moved the content to where you need to take some action.
When "sticking" the view, remove it from the scrollView, and then add it to the scrollView's superview (with an origin of 0,0). When unsticking, do the converse.
If you use the UIScrollView setContentOffset:animated:, it gets trickier. What I did was to subclass UIScrollView, use a flag to specify it was setContentOffset moving the offset, then start a fast running timer to monitor contentOffset.
I put the method that handles the math and sticking/unsticking the child view into this subclass. It looks pretty good.
Gilt uses a table view to accomplish this. Specifically, in the table view's delegate, these two methods:
– tableView:viewForHeaderInSection:
and – tableView:heightForHeaderInSection:
I'm currently putting the final touches on a project.
A lot (if not all) of the UI logic currently relies on Cocoa Bindings.
Some of the user interface elements (labels, buttons, etc.) have their "Hidden" bindings defined. When certain events are triggered, these elements visibility is toggled.
I'm trying to animate the visibility change (by animating the opacity and maybe even the scale). This could easily be accomplished in a number of ways, either by setting the relevant layer properties, adding the animations to the layer, etc. However, since I'm trying to totally rely on the bindings behavior I "can't" really do this directly.
I tried an implementation using Layer actions, by defining actions for the keys kCAOnOrderIn and kCAOnOrderOut on the relevant elements, but it really didn't work, as the setHidden: is most likely being triggered on the NSView instead of the CALayer -- which makes sense.
So, my question is: how would you animate setHidden: on a NSView, when setHidden: is being invoked by the Cocoa Bindings.
Thank you.
This will fade out an NSView...
[[someView animator] setAlphaValue:0.0f];
Animating setHidden will have no visual effect since it's either on or off. If you want to animate visibility, use setAlpha (or setOpacity on the layer) instead. These take a value between 0.0 and 1.0. If you need the hidden flag to get set for the sake of state information, call -performSelector:withObject:afterDelay passing it a selector that sets the hidden value to whatever you need it to be after the animation has completed. Alternatively you can set up a delegate for explicit animation to be called back when the animation finishes and call setHidden then.
I would suggest taking a look at NSViewAnimation. It takes any NSView and can animate the frame, size or visibility.