Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

Update RBLClipView to use a display link for animated scrolling #64

Closed
wants to merge 13 commits into from

4 participants

@jwilling

This implements animated scrolling of the clip view when triggered by keyboard presses and explicit scrollRectToVisible:animated: calls.

@jspahrsummers

Can you explain a bit more what this is actually for? Why would a scroll wheel need deceleration?

@jwilling

Sorry, I should have been more clear. This isn't for a trackpad or a scroll wheel. This only kicks into action when the current event is from a keyboard (e.g. arrow down), or if the user requests an animated scrollRectToVisible:animated:.

Rebel/RBLClipView.m
((5 lines not shown))
//
#import "RBLClipView.h"
#import "NSColor+RBLCGColorAdditions.h"
+const CGFloat RBLClipViewDecelerationRate = 0.88;
+
+@interface RBLClipView()
+@property (nonatomic) CVDisplayLinkRef displayLink;
+@property (nonatomic) BOOL animate;
+@property (nonatomic) CGPoint destination;
+@property (nonatomic, readonly, getter = isScrolling) BOOL scrolling;

Please document and add memory management attributes to all of these properties.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((6 lines not shown))
return self;
}
+- (void)dealloc {
+ CVDisplayLinkRelease(_displayLink);
+ [[NSNotificationCenter defaultCenter] removeObserver:self];

Please use dot syntax here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((6 lines not shown))
return self;
}
+- (void)dealloc {
+ CVDisplayLinkRelease(_displayLink);
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+}
+
+
+#pragma mark Display link
+
+static CVReturn RBLScrollingCallback(CVDisplayLinkRef displayLink, const CVTimeStamp* now, const CVTimeStamp* outputTime, CVOptionFlags flagsIn, CVOptionFlags* flagsOut, void* displayLinkContext) {

Asterisks for pointers should be associated with the variable, not the type (e.g., const CVTimeStamp *now).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((10 lines not shown))
+ CVDisplayLinkRelease(_displayLink);
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+}
+
+
+#pragma mark Display link
+
+static CVReturn RBLScrollingCallback(CVDisplayLinkRef displayLink, const CVTimeStamp* now, const CVTimeStamp* outputTime, CVOptionFlags flagsIn, CVOptionFlags* flagsOut, void* displayLinkContext) {
+ __block CVReturn status;
+ @autoreleasepool {
+ RBLClipView *clipView = (__bridge id)displayLinkContext;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ status = [clipView updateOrigin];
+ });
+ }
+ return status;

Incorrect indentation here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((6 lines not shown))
return self;
}
+- (void)dealloc {
+ CVDisplayLinkRelease(_displayLink);
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+}
+
+
+#pragma mark Display link
+
+static CVReturn RBLScrollingCallback(CVDisplayLinkRef displayLink, const CVTimeStamp* now, const CVTimeStamp* outputTime, CVOptionFlags flagsIn, CVOptionFlags* flagsOut, void* displayLinkContext) {
+ __block CVReturn status;
+ @autoreleasepool {
+ RBLClipView *clipView = (__bridge id)displayLinkContext;
+ dispatch_async(dispatch_get_main_queue(), ^{

If this is asynchronous, status won't be updated before this function returns.

On the other hand, it seems silly to block here.

This is one area I wasn't entirely sure about. What would you like me to do here?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((16 lines not shown))
+
+static CVReturn RBLScrollingCallback(CVDisplayLinkRef displayLink, const CVTimeStamp* now, const CVTimeStamp* outputTime, CVOptionFlags flagsIn, CVOptionFlags* flagsOut, void* displayLinkContext) {
+ __block CVReturn status;
+ @autoreleasepool {
+ RBLClipView *clipView = (__bridge id)displayLinkContext;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ status = [clipView updateOrigin];
+ });
+ }
+ return status;
+}
+
+- (CVDisplayLinkRef)displayLink {
+ if (_displayLink == NULL) {
+ CVDisplayLinkCreateWithActiveCGDisplays(&_displayLink);
+ CVDisplayLinkSetOutputCallback(_displayLink, &RBLScrollingCallback, (__bridge void *)(self));

No parentheses needed around the value being casted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((18 lines not shown))
+ __block CVReturn status;
+ @autoreleasepool {
+ RBLClipView *clipView = (__bridge id)displayLinkContext;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ status = [clipView updateOrigin];
+ });
+ }
+ return status;
+}
+
+- (CVDisplayLinkRef)displayLink {
+ if (_displayLink == NULL) {
+ CVDisplayLinkCreateWithActiveCGDisplays(&_displayLink);
+ CVDisplayLinkSetOutputCallback(_displayLink, &RBLScrollingCallback, (__bridge void *)(self));
+ [self updateCVDisplay];
+ }

Please add a blank line after this. Vertical whitespace is cheap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((25 lines not shown))
+ return status;
+}
+
+- (CVDisplayLinkRef)displayLink {
+ if (_displayLink == NULL) {
+ CVDisplayLinkCreateWithActiveCGDisplays(&_displayLink);
+ CVDisplayLinkSetOutputCallback(_displayLink, &RBLScrollingCallback, (__bridge void *)(self));
+ [self updateCVDisplay];
+ }
+ return _displayLink;
+}
+
+- (void)updateCVDisplay {
+ NSScreen *screen = self.window.screen;
+ if (screen) {
+ NSDictionary* screenDictionary = [[NSScreen mainScreen] deviceDescription];

Style: asterisk, dot syntax

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((26 lines not shown))
+}
+
+- (CVDisplayLinkRef)displayLink {
+ if (_displayLink == NULL) {
+ CVDisplayLinkCreateWithActiveCGDisplays(&_displayLink);
+ CVDisplayLinkSetOutputCallback(_displayLink, &RBLScrollingCallback, (__bridge void *)(self));
+ [self updateCVDisplay];
+ }
+ return _displayLink;
+}
+
+- (void)updateCVDisplay {
+ NSScreen *screen = self.window.screen;
+ if (screen) {
+ NSDictionary* screenDictionary = [[NSScreen mainScreen] deviceDescription];
+ NSNumber *screenID = [screenDictionary objectForKey:@"NSScreenNumber"];

Style: subscripting

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((24 lines not shown))
+ }
+ return status;
+}
+
+- (CVDisplayLinkRef)displayLink {
+ if (_displayLink == NULL) {
+ CVDisplayLinkCreateWithActiveCGDisplays(&_displayLink);
+ CVDisplayLinkSetOutputCallback(_displayLink, &RBLScrollingCallback, (__bridge void *)(self));
+ [self updateCVDisplay];
+ }
+ return _displayLink;
+}
+
+- (void)updateCVDisplay {
+ NSScreen *screen = self.window.screen;
+ if (screen) {

Style: explicit comparison against nil

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((27 lines not shown))
+
+- (CVDisplayLinkRef)displayLink {
+ if (_displayLink == NULL) {
+ CVDisplayLinkCreateWithActiveCGDisplays(&_displayLink);
+ CVDisplayLinkSetOutputCallback(_displayLink, &RBLScrollingCallback, (__bridge void *)(self));
+ [self updateCVDisplay];
+ }
+ return _displayLink;
+}
+
+- (void)updateCVDisplay {
+ NSScreen *screen = self.window.screen;
+ if (screen) {
+ NSDictionary* screenDictionary = [[NSScreen mainScreen] deviceDescription];
+ NSNumber *screenID = [screenDictionary objectForKey:@"NSScreenNumber"];
+ CGDirectDisplayID displayID = [screenID unsignedIntValue];

Style: dot syntax

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((22 lines not shown))
+ status = [clipView updateOrigin];
+ });
+ }
+ return status;
+}
+
+- (CVDisplayLinkRef)displayLink {
+ if (_displayLink == NULL) {
+ CVDisplayLinkCreateWithActiveCGDisplays(&_displayLink);
+ CVDisplayLinkSetOutputCallback(_displayLink, &RBLScrollingCallback, (__bridge void *)(self));
+ [self updateCVDisplay];
+ }
+ return _displayLink;
+}
+
+- (void)updateCVDisplay {

This method should be invoked any time the clip view moves between windows.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((52 lines not shown))
+ if (self.animate && (self.window.currentEvent.type != NSScrollWheel)) {
+ self.destination = newOrigin;
+ [self beginScrolling];
+ } else {
+ [self endScrolling];
+ [super scrollToPoint:newOrigin];
+ }
+}
+
+- (BOOL)scrollRectToVisible:(NSRect)aRect animated:(BOOL)animated {
+ self.animate = animated;
+ return [super scrollRectToVisible:aRect];
+}
+
+- (void)beginScrolling {
+ if (self.isScrolling) {

Style: dot syntax should not use is getters.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((5 lines not shown))
//
#import "RBLClipView.h"
#import "NSColor+RBLCGColorAdditions.h"
+const CGFloat RBLClipViewDecelerationRate = 0.88;
+
+@interface RBLClipView()
+@property (nonatomic) CVDisplayLinkRef displayLink;
+@property (nonatomic) BOOL animate;

Can you please give this property a clearer name?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((5 lines not shown))
//
#import "RBLClipView.h"
#import "NSColor+RBLCGColorAdditions.h"
+const CGFloat RBLClipViewDecelerationRate = 0.88;
+
+@interface RBLClipView()
+@property (nonatomic) CVDisplayLinkRef displayLink;
+@property (nonatomic) BOOL animate;
+@property (nonatomic) CGPoint destination;

Can you please give this property a clearer name?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((41 lines not shown))
+ NSNumber *screenID = [screenDictionary objectForKey:@"NSScreenNumber"];
+ CGDirectDisplayID displayID = [screenID unsignedIntValue];
+ CVDisplayLinkSetCurrentCGDisplay(_displayLink, displayID);
+ } else {
+ CVDisplayLinkSetCurrentCGDisplay(_displayLink, kCGDirectMainDisplay);
+ }
+}
+
+#pragma mark Scrolling
+
+- (void)scrollToPoint:(NSPoint)newOrigin {
+ if (self.animate && (self.window.currentEvent.type != NSScrollWheel)) {
+ self.destination = newOrigin;
+ [self beginScrolling];
+ } else {
+ [self endScrolling];

This will invoke endScrolling if the event came from a scroll wheel. Is that correct behavior?

Yes, because if the display link is still animating a deceleration and the user suddenly scrolls with the mouse/trackpad, we want to immediately stop any further scrolling done by the display link.

Even if we aren't scrolling, it's cheap to call this since we're just doing an early return in endScrolling.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((60 lines not shown))
+
+- (BOOL)scrollRectToVisible:(NSRect)aRect animated:(BOOL)animated {
+ self.animate = animated;
+ return [super scrollRectToVisible:aRect];
+}
+
+- (void)beginScrolling {
+ if (self.isScrolling) {
+ return;
+ }
+
+ CVDisplayLinkStart(self.displayLink);
+}
+
+- (void)endScrolling {
+ if (!self.isScrolling)

Style: braces, method without the is

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((72 lines not shown))
+}
+
+- (void)endScrolling {
+ if (!self.isScrolling)
+ return;
+
+ CVDisplayLinkStop(self.displayLink);
+ self.animate = NO;
+}
+
+- (BOOL)isScrolling {
+ return CVDisplayLinkIsRunning(self.displayLink);
+}
+
+- (CVReturn)updateOrigin {
+ if(self.window == nil) {

Style: space after if

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((80 lines not shown))
+}
+
+- (BOOL)isScrolling {
+ return CVDisplayLinkIsRunning(self.displayLink);
+}
+
+- (CVReturn)updateOrigin {
+ if(self.window == nil) {
+ [self endScrolling];
+ return kCVReturnError;
+ }
+
+ CGPoint o = self.bounds.origin;
+ CGPoint lastOrigin = o;
+ o.x = o.x * RBLClipViewDecelerationRate + self.destination.x * (1 - RBLClipViewDecelerationRate);
+ o.y = o.y * RBLClipViewDecelerationRate + self.destination.y * (1 - RBLClipViewDecelerationRate);

Can you please document the intended animation curve here?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((82 lines not shown))
+- (BOOL)isScrolling {
+ return CVDisplayLinkIsRunning(self.displayLink);
+}
+
+- (CVReturn)updateOrigin {
+ if(self.window == nil) {
+ [self endScrolling];
+ return kCVReturnError;
+ }
+
+ CGPoint o = self.bounds.origin;
+ CGPoint lastOrigin = o;
+ o.x = o.x * RBLClipViewDecelerationRate + self.destination.x * (1 - RBLClipViewDecelerationRate);
+ o.y = o.y * RBLClipViewDecelerationRate + self.destination.y * (1 - RBLClipViewDecelerationRate);
+
+ [self setBoundsOrigin:o];

Style: dot syntax

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((84 lines not shown))
+}
+
+- (CVReturn)updateOrigin {
+ if(self.window == nil) {
+ [self endScrolling];
+ return kCVReturnError;
+ }
+
+ CGPoint o = self.bounds.origin;
+ CGPoint lastOrigin = o;
+ o.x = o.x * RBLClipViewDecelerationRate + self.destination.x * (1 - RBLClipViewDecelerationRate);
+ o.y = o.y * RBLClipViewDecelerationRate + self.destination.y * (1 - RBLClipViewDecelerationRate);
+
+ [self setBoundsOrigin:o];
+
+ if((fabs(o.x - lastOrigin.x) < 0.1) && (fabs(o.y - lastOrigin.y) < 0.1)) {

Style: space after if, parentheses around each part of the && unnecessary

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((86 lines not shown))
+- (CVReturn)updateOrigin {
+ if(self.window == nil) {
+ [self endScrolling];
+ return kCVReturnError;
+ }
+
+ CGPoint o = self.bounds.origin;
+ CGPoint lastOrigin = o;
+ o.x = o.x * RBLClipViewDecelerationRate + self.destination.x * (1 - RBLClipViewDecelerationRate);
+ o.y = o.y * RBLClipViewDecelerationRate + self.destination.y * (1 - RBLClipViewDecelerationRate);
+
+ [self setBoundsOrigin:o];
+
+ if((fabs(o.x - lastOrigin.x) < 0.1) && (fabs(o.y - lastOrigin.y) < 0.1)) {
+ [self endScrolling];
+ [self setBoundsOrigin:self.destination];

Style: dot syntax

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((87 lines not shown))
+ if(self.window == nil) {
+ [self endScrolling];
+ return kCVReturnError;
+ }
+
+ CGPoint o = self.bounds.origin;
+ CGPoint lastOrigin = o;
+ o.x = o.x * RBLClipViewDecelerationRate + self.destination.x * (1 - RBLClipViewDecelerationRate);
+ o.y = o.y * RBLClipViewDecelerationRate + self.destination.y * (1 - RBLClipViewDecelerationRate);
+
+ [self setBoundsOrigin:o];
+
+ if((fabs(o.x - lastOrigin.x) < 0.1) && (fabs(o.y - lastOrigin.y) < 0.1)) {
+ [self endScrolling];
+ [self setBoundsOrigin:self.destination];
+ [(NSScrollView *)self.superview flashScrollers];

This should use -[NSView enclosingScrollView].

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jspahrsummers

Sorry, I should have been more clear. This isn't for a trackpad or a scroll wheel. This only kicks into action when the current event is from a keyboard (e.g. arrow down), or if the user requests an animated scrollRectToVisible:animated:.

Can you please document this in the interface, so it's clear to others as well?


Made a lot of style notes here. It may be helpful to review our Objective-C conventions.

@dannygreg @joshaber @alanjrogers Anyone else want to take a look at this?

@jwilling

Apologies for the style issues, a lot of this was written before the conventions were released, and I did not go back and change them.

@jwilling

I need to look into this some more and figure out exactly when a good time is for the scrolling to be enabled. I'll reopen once I feel it's ready for re-review.

@jwilling jwilling closed this
@jwilling jwilling reopened this
@jwilling

Hopefully I didn't miss anything there. Please let me know if the documentation isn't explicit enough!

Rebel/RBLClipView.m
((5 lines not shown))
//
#import "RBLClipView.h"
#import "NSColor+RBLCGColorAdditions.h"
+const CGFloat RBLClipViewDecelerationRate = 0.88;

This should be static if it's not exported.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((5 lines not shown))
//
#import "RBLClipView.h"
#import "NSColor+RBLCGColorAdditions.h"
+const CGFloat RBLClipViewDecelerationRate = 0.88;
+
+@interface RBLClipView()

Style: RBLClipView ()

Can you please document all the properties in this interface?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((21 lines not shown))
+- (void)dealloc {
+ CVDisplayLinkRelease(_displayLink);
+ [NSNotificationCenter.defaultCenter removeObserver:self];
+}
+
+#pragma mark View Heirarchy
+
+- (void)viewWillMoveToWindow:(NSWindow *)newWindow {
+ if (self.window != nil) {
+ [NSNotificationCenter.defaultCenter removeObserver:self name:NSWindowDidChangeScreenNotification object:self.window];
+ }
+
+ [super viewWillMoveToWindow:newWindow];
+
+ if (newWindow != nil) {
+ [NSNotificationCenter.defaultCenter addObserverForName:NSWindowDidChangeScreenNotification object:newWindow queue:nil usingBlock:^(NSNotification *note) {

The return value of this method needs to be saved if the observer is to be removed later. (removeObserver:name:object: doesn't include block observers by default.)

It'd be easier to just register a selector instead.

Thanks, I'll do that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((33 lines not shown))
+ [super viewWillMoveToWindow:newWindow];
+
+ if (newWindow != nil) {
+ [NSNotificationCenter.defaultCenter addObserverForName:NSWindowDidChangeScreenNotification object:newWindow queue:nil usingBlock:^(NSNotification *note) {
+ [self updateCVDisplay];
+ }];
+ }
+}
+
+#pragma mark Display link
+
+static CVReturn RBLScrollingCallback(CVDisplayLinkRef displayLink, const CVTimeStamp *now, const CVTimeStamp *outputTime, CVOptionFlags flagsIn, CVOptionFlags *flagsOut, void *displayLinkContext) {
+ __block CVReturn status;
+ @autoreleasepool {
+ RBLClipView *clipView = (__bridge id)displayLinkContext;
+ dispatch_async(dispatch_get_main_queue(), ^{

This still has the issue of asynchrony that I mentioned before (status not guaranteed to be set before returning).

So we turn this into sync, or just always return success by default? Not sure what you want me to do here.

Well, it's your implementation, so I thought you might have opinions on which is more correct. I guess returning success would make the most sense, so it doesn't choke the display link with a synchronous dispatch.

I'm also not clear what the error return code even does, since it doesn't seem to be documented.

I'm not entirely sure either. I think returning success will be fine.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((54 lines not shown))
+}
+
+- (CVDisplayLinkRef)displayLink {
+ if (_displayLink == NULL) {
+ CVDisplayLinkCreateWithActiveCGDisplays(&_displayLink);
+ CVDisplayLinkSetOutputCallback(_displayLink, &RBLScrollingCallback, (__bridge void *)self);
+ [self updateCVDisplay];
+ }
+
+ return _displayLink;
+}
+
+- (void)updateCVDisplay {
+ NSScreen *screen = self.window.screen;
+ if (screen == nil) {
+ NSDictionary *screenDictionary = [NSScreen.mainScreen deviceDescription];

Style: dot syntax

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jwilling

One thing I had hoped to fix here was that currently the scrollers are just flashed after the animation completes. What would be ideal is if the scrollers show up constantly through the whole animation. I've attempted to fix this, but I cannot find a way. Either I'm missing something obvious, or this is just a shortcoming with NSScrollView that just can't be fixed.

Rebel/RBLClipView.m
((5 lines not shown))
//
#import "RBLClipView.h"
#import "NSColor+RBLCGColorAdditions.h"
+// The deceleration constant used for the ease-out curve in the animation.
+static const CGFloat RBLClipViewDecelerationRate = 0.88;
+
+@interface RBLClipView ()
+// Used to drive the animation through repeated callbacks.
+// A display link is used instead of a timer so that we don't get dropped frames and tearing.
+@property (nonatomic, assign) CVDisplayLinkRef displayLink;

Can you please document the memory management semantics of this property as well?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Rebel/RBLClipView.m
((21 lines not shown))
+- (void)dealloc {
+ CVDisplayLinkRelease(_displayLink);
+ [NSNotificationCenter.defaultCenter removeObserver:self];
+}
+
+#pragma mark View Heirarchy
+
+- (void)viewWillMoveToWindow:(NSWindow *)newWindow {
+ if (self.window != nil) {
+ [NSNotificationCenter.defaultCenter removeObserver:self name:NSWindowDidChangeScreenNotification object:self.window];
+ }
+
+ [super viewWillMoveToWindow:newWindow];
+
+ if (newWindow != nil) {
+ [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(updateCVDisplay) name:NSWindowDidChangeScreenNotification object:newWindow];

Notification selectors must accept exactly one argument of type NSNotification. Even if this works, it can't be depended upon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jspahrsummers

@github/Mac Can someone else please take a look at this too?

@alanjrogers

I've noticed that we're setting up the CVDisplayLink for the window's display device. We should tear down and recreate the display link in response to a - (void)windowDidChangeScreen:(NSNotification *)notification.

@jspahrsummers

@alanjrogers That does happen, but it uses the notification instead of the method.

@alanjrogers

@jspahrsummers duh, I somehow missed that completely. Carry on, nothing to see here. :hamburger:

@jwilling

:moon:

I've tested this in a couple of apps and it doesn't seem to have any adverse affects, but there's always a possibility I'm missing something big here. I don't want this to break GHfM, either.

@jwilling

Just a gentle bump here. I know you all are quite busy so take your time, but there are a couple things depending on this at the moment and it would be relatively nice to get this merged in when possible.

@jwilling

Actually I take that back for now, I'd welcome some feedback on the state of this PR but I think I'm going to rewrite the deceleration formula to not use the logic from TwUI. I've gotten feedback from at least two people that it scrolls a little bit too slowly, and given the way it's written there's not a good way to change it without changing the feeling of the scroll. As a result, I would like to change the deceleration logic to create an interpolation curve based off of a duration instead of a distance.

@jspahrsummers

This must've slipped off my radar – sorry about that. Just let me know when you're doing with your refactoring and I'll take another look ASAP.

@jwilling

I feel terrible about leaving this open for so long. Finally gave this some thought and concluded it'd just be better to make the deceleration rate a property, with a default that is slightly lower (meaning faster animations).

Thoughts on this are welcome. Also, thanks for your patience on the style issues earlier, @jspahrsummers.

@dannygreg

I have one concern here, TwUI's deceleration is wrong in almost all cases. Scrolling a TUITableView for example, uses all the wrong rates and is way slower than scrolling an NSTableView.

AppKit has also clearly made the decision that it should not do any kind of deceleration when scrolling in cells, particularly when acting from keyborad input. I just spent a lot of time studying how Mail.app reacts to key presses in it's tableview in order to match it up with something I'm working on. It achieves a much more responsive feel by not adding any animation sugar, such as easing, of any kind. It in fact feels much "smoother" as a result.

I vote we leave this up to AppKit.

@jwilling

@dannygreg I understand your reasoning here, but there is a problem with AppKit. I don't know if you've ever set your key repeat rate to high, but there are quite a few people that do have that setting on. If the repeat rate is high and you hold down an arrow key, things start to blow up. I'd encourage you to check it out in any app that uses a standard NSTableView.

Also, setting the deceleration to 1 (or 0) will achieve the exact effect as Mail, if you so desire.

@dannygreg

@jwilling I am aware what you're referring to, we have a fix that I will PR. It has nothing to do with deceleration. The fix is to override "scroll to visible" to not try and scroll things into the middle of the view. Instead you make them scroll the least amount possible. Visibly "pinning" the selection to the top/bottom of the table.

That's what mail.app is doing and what we do in a forthcoming version of GitHub for Mac.

@jwilling

@dannygreg What I meant was that the same effect you're talking about is possible by setting the deceleration in this implementation to 1 or 0.

@jwilling

In any case, if this just doesn't work for you all that's okay, I'll just maintain my own fork of Rebel that has this capability.

@dannygreg

I'm :-1: because it changes the visual appearance when there is another satisfactory fix. I'm really keen to leave stuff like that to AppKit, to avoid that feeling of it not being "quite right" that you get with TwUI and the like.

That's only me though, would be interested to hear @joshaber/@jspahrsummers/@alanjrogers' thoughts.

@jspahrsummers

@dannygreg's point is fair. Any AppKit changes made by Rebel should be consistent with the "default" UX, whatever that means.

@jwilling I'm gonna close this out – sorry about that. Hopefully the code review was helpful, though!

@dannygreg

There is a valid problem with NSTableView's default selection behaviour though. I must remember to PR the fix @mdiep found.

@jwilling

For what it's worth, here is a comparison of the three different ways of scrolling, vanilla NSClipView included. This isn't a NSTableView, it's my own scratch-built table view. The lag is screen-recording induced.

http://appjon.com/drop/scrolling_comparisons.mov

@alanjrogers

@jwilling is that demo code available somewhere?

@jwilling

@alanjrogers This was a weekend project for me but it's turned into something bigger, so I plan on releasing it in (hopefully) the near future. If it would help you out to test things right now, just let me know and I can send it over.

@alanjrogers

@jwilling I was mostly just curious to try it locally, it's hard to judge the 'feel' from the video. But I do agree with @jspahrsummers and @dannygreg comments about Rebel not messing with the default OS X UX. It might be worth filing a radar with your demo app though, as it looks like a valid issue.

@jwilling

@alanjrogers Here is the demo with the custom scrolling behavior. There's no way to toggle it off, but you can compare it against Mail.app, for example. http://appjon.com/drop/files-A89Rx0Zy9S.zip

@dannygreg

It's pretty clear to me: there should be no easing when a scroll is done with anything other than a trackpad. I would reason that they came to the decision because there is no "momentum" unless you are using a trackpad/mouse. The metaphor completely breaks down.

I do think scrollToVisible: should be altered to match Mail/GitHub for Mac though.

@jwilling jwilling referenced this pull request in ButterKit/Butter
Closed

Why aren't all of these enhancements part of Rebel? #15

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Jan 12, 2013
  1. @jwilling
  2. @jwilling

    oops

    jwilling authored
Commits on Jan 15, 2013
  1. @jwilling

    style fixes

    jwilling authored
Commits on Jan 16, 2013
  1. @jwilling
  2. @jwilling

    clearer properties, comments

    jwilling authored
  3. @jwilling

    document in the header

    jwilling authored
  4. @jwilling

    document more properties

    jwilling authored
  5. @jwilling
  6. @jwilling
Commits on Feb 3, 2013
  1. @jwilling

    round the destination origin

    jwilling authored
Commits on Mar 30, 2013
  1. @jwilling
  2. @jwilling
  3. @jwilling

    whoops, formatting

    jwilling authored
This page is out of date. Refresh to see the latest.
Showing with 187 additions and 4 deletions.
  1. +21 −0 Rebel/RBLClipView.h
  2. +166 −4 Rebel/RBLClipView.m
View
21 Rebel/RBLClipView.h
@@ -4,6 +4,7 @@
//
// Created by Justin Spahr-Summers on 2012-09-14.
// Copyright (c) 2012 GitHub. All rights reserved.
+// Update with smooth scrolling by Jonathan Willing, with logic from TwUI.
//
#import <QuartzCore/QuartzCore.h>
@@ -14,6 +15,14 @@
// after the scroll view is initialized. For some reason, scroll bars will
// disappear on 10.7 (but not 10.8) unless hasHorizontalScroller and
// hasVerticalScroller are set _after_ the contentView.
+//
+// RBLClipView performs an ease-out animation with any changes to the origin
+// of the clip view when it originates from a keyboard event. It will also animate with the same
+// deceleration if -scrollRectToVisible:animated: is called with `animation` set to YES.
+// Any other events causing a bounds change will not be animated.
+//
+// An example of when this would fire by default is a key press that triggers an offscreen
+// cell to come into view in a NSTableView.
@interface RBLClipView : NSClipView
// The backing layer for this view.
@@ -24,4 +33,16 @@
// Defaults to NO.
@property (nonatomic, getter = isOpaque) BOOL opaque;
+// Calls -scrollRectToVisible:, optionally animated.
+- (BOOL)scrollRectToVisible:(CGRect)rect animated:(BOOL)animated;
+
+// Any time the origin changes with an animation as discussed above, the deceleration
+// rate will be used to create an ease-out animation.
+//
+// Values should range from [0, 1]. Smaller deceleration rates will provide
+// generally fast animations, whereas larger rates will create lengthy animations.
+//
+// Defaults to 0.78.
+@property (nonatomic, assign) CGFloat decelerationRate;
+
@end
View
170 Rebel/RBLClipView.m
@@ -4,11 +4,31 @@
//
// Created by Justin Spahr-Summers on 2012-09-14.
// Copyright (c) 2012 GitHub. All rights reserved.
+// Update with smooth scrolling by Jonathan Willing, with logic from TwUI.
//
#import "RBLClipView.h"
#import "NSColor+RBLCGColorAdditions.h"
+// The deceleration constant used for the ease-out curve in the animation.
+static const CGFloat RBLClipViewDecelerationRate = 0.78;
+
+@interface RBLClipView ()
+// Used to drive the animation through repeated callbacks.
+// A display link is used instead of a timer so that we don't get dropped frames and tearing.
+// Lazily created when needed, released in dealloc. Stopped automatically when scrolling is not occurring.
+@property (nonatomic, assign) CVDisplayLinkRef displayLink;
+
+// Used to determine whether to animate in `scrollToPoint:`.
+@property (nonatomic, assign) BOOL shouldAnimateOriginChange;
+
+// Used when animating with the display link as the final origin for the animation.
+@property (nonatomic, assign) CGPoint destinationOrigin;
+
+// Return value is whether the display link is currently animating a scroll.
+@property (nonatomic, readonly) BOOL animatingScroll;
+@end
+
@implementation RBLClipView
#pragma mark Properties
@@ -36,17 +56,159 @@ - (void)setOpaque:(BOOL)opaque {
- (id)initWithFrame:(NSRect)frame {
self = [super initWithFrame:frame];
if (self == nil) return nil;
-
+
self.layer = [CAScrollLayer layer];
self.wantsLayer = YES;
-
+
self.layerContentsRedrawPolicy = NSViewLayerContentsRedrawNever;
-
+
// Matches default NSClipView settings.
self.backgroundColor = NSColor.clearColor;
self.opaque = NO;
-
+
+ self.decelerationRate = RBLClipViewDecelerationRate;
+
return self;
}
+- (void)dealloc {
+ CVDisplayLinkRelease(_displayLink);
+ [NSNotificationCenter.defaultCenter removeObserver:self];
+}
+
+#pragma mark View Heirarchy
+
+- (void)viewWillMoveToWindow:(NSWindow *)newWindow {
+ if (self.window != nil) {
+ [NSNotificationCenter.defaultCenter removeObserver:self name:NSWindowDidChangeScreenNotification object:self.window];
+ }
+
+ [super viewWillMoveToWindow:newWindow];
+
+ if (newWindow != nil) {
+ [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(updateCVDisplay:) name:NSWindowDidChangeScreenNotification object:newWindow];
+ }
+}
+
+#pragma mark Display link
+
+static CVReturn RBLScrollingCallback(CVDisplayLinkRef displayLink, const CVTimeStamp *now, const CVTimeStamp *outputTime, CVOptionFlags flagsIn, CVOptionFlags *flagsOut, void *displayLinkContext) {
+ @autoreleasepool {
+ RBLClipView *clipView = (__bridge id)displayLinkContext;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [clipView updateOrigin];
+ });
+ }
+
+ return kCVReturnSuccess;
+}
+
+- (CVDisplayLinkRef)displayLink {
+ if (_displayLink == NULL) {
+ CVDisplayLinkCreateWithActiveCGDisplays(&_displayLink);
+ CVDisplayLinkSetOutputCallback(_displayLink, &RBLScrollingCallback, (__bridge void *)self);
+ [self updateCVDisplay:nil];
+ }
+
+ return _displayLink;
+}
+
+- (void)updateCVDisplay:(NSNotification *)note {
+ NSScreen *screen = self.window.screen;
+ if (screen == nil) {
+ NSDictionary *screenDictionary = NSScreen.mainScreen.deviceDescription;
+ NSNumber *screenID = screenDictionary[@"NSScreenNumber"];
+ CGDirectDisplayID displayID = screenID.unsignedIntValue;
+ CVDisplayLinkSetCurrentCGDisplay(_displayLink, displayID);
+ } else {
+ CVDisplayLinkSetCurrentCGDisplay(_displayLink, kCGDirectMainDisplay);
+ }
+}
+
+#pragma mark Scrolling
+
+- (void)scrollToPoint:(NSPoint)newOrigin {
+ NSEventType type = self.window.currentEvent.type;
+
+ if (self.shouldAnimateOriginChange && type != NSScrollWheel) {
+ // Occurs when `-scrollRectToVisible:animated:` has been called with an animated flag.
+ self.destinationOrigin = newOrigin;
+ [self beginScrolling];
+ } else if (type == NSKeyDown || type == NSKeyUp || type == NSFlagsChanged) {
+ // Occurs if a keyboard press has triggered a origin change. In this case we
+ // want to explicitly enable and begin the animation.
+ self.destinationOrigin = newOrigin;
+ [self beginScrolling];
+ } else {
+ // For all other cases, we do not animate. We call `endScrolling` in case a previous animation
+ // is still in progress, in which case we want to stop the display link from making further
+ // callbacks, which would interfere with normal scrolling.
+ [self endScrolling];
+ [super scrollToPoint:newOrigin];
+ }
+}
+
+- (void)setDestinationOrigin:(CGPoint)origin {
+ // We want to round up to the nearest integral point, since some classes
+ // seem to provide non-integral point values.
+ _destinationOrigin = (CGPoint){ .x = round(origin.x), .y = round(origin.y) };
+}
+
+- (BOOL)scrollRectToVisible:(NSRect)aRect animated:(BOOL)animated {
+ self.shouldAnimateOriginChange = animated;
+ return [super scrollRectToVisible:aRect];
+}
+
+- (void)beginScrolling {
+ if (self.animatingScroll) {
+ return;
+ }
+
+ CVDisplayLinkStart(self.displayLink);
+}
+
+- (void)endScrolling {
+ if (!self.animatingScroll) {
+ return;
+ }
+
+ CVDisplayLinkStop(self.displayLink);
+ self.shouldAnimateOriginChange = NO;
+}
+
+- (BOOL)animatingScroll {
+ return CVDisplayLinkIsRunning(self.displayLink);
+}
+
+// Sanitize the deceleration rate to [0, 1] so nothing unexpected happens.
+- (void)setDecelerationRate:(CGFloat)decelerationRate {
+ if (decelerationRate > 1)
+ decelerationRate = 1;
+ else if (decelerationRate < 0)
+ decelerationRate = 0;
+ _decelerationRate = decelerationRate;
+}
+
+- (void)updateOrigin {
+ if (self.window == nil) {
+ [self endScrolling];
+ return;
+ }
+
+ CGPoint o = self.bounds.origin;
+ CGPoint lastOrigin = o;
+
+ // Calculate the next origin on a basic ease-out curve.
+ o.x = o.x * self.decelerationRate + self.destinationOrigin.x * (1 - self.decelerationRate);
+ o.y = o.y * self.decelerationRate + self.destinationOrigin.y * (1 - self.decelerationRate);
+
+ self.boundsOrigin = o;
+
+ if (fabs(o.x - lastOrigin.x) < 0.1 && fabs(o.y - lastOrigin.y) < 0.1) {
+ [self endScrolling];
+ self.boundsOrigin = self.destinationOrigin;
+ [self.enclosingScrollView flashScrollers];
+ }
+}
+
@end
Something went wrong with that request. Please try again.