Scrolling credits
Creating Mac application requires lots of though and effort into creating a good user experience too. Although generally Cocoa API and tools allow us spend more time on this, there are areas that could benefit from additional effort. When developing Startupizer 2.0, one such area I wanted to address was nicer about window with scrolling credits. This blog post demonstrates the solution I chose.
Introduction
Cocoa gives us default about panel out of the box, requiring no coding from the developer. Project templates even come wired to use this panel. The panel works by reading information from application info plist and presenting it in a nice little window. It even includes data from credits file as long as the name of the file is what the system expects. It’s ok, but I always liked applications that included custom about window - although this doesn’t affect behavior, it shows attention to details the developer has put into the app.
Ever since starting work on Startupizer, I wanted to include custom about window with nice scrolling credits. I even envisioned how it would look and behave like but never got around to do it. During finalizing my work on 2.0, I decided it’s about time I do something about it! I first checked on Google - after all, why inventing the hot water? Although I found several solutions, they all seemed to be several years old - they relied on scrolling contents of a NSTextView
through a NSTimer
. Although this is fine per-se, it seemed like hacking to achieve the effect the developer wanted. And I had trouble getting it working so finally I gave up and decided to roll my own using simpler methods available in modern API.
The plan
Thinking about it, I jotted down these requirements:
- I wanted to use Credits.rtf for the source of the credits so that my solution could replace original. It would also decouple the UI from formatting the credits.
- Text should “fade in” at the bottom and “fade out” at the top.
- Once the whole text is out, it should re-appear again at the bottom, repeating in infinite loop.
- Preferrably, the window should use white background (this was purely matter of taste).
Looking at these, the main question was how to get text scrolling. The answer seemed obvious: Core Animation. This would solve not only scrolling, but also fade in and out, simply by strategically placing layers in a view! And to get text scrolling, simply set its layer’s starting position and animate the Y coordinate until the whole layer is moved out of the view. Then repeat the animation.
Creating Core Animation layers
Creating Core Animation based scrolling view is surprisingly simple: add custom NSView
to a window and make it available to code via IBOutlet
. Then make the view layer backed and “inject” our own root layer to it:
- (void)awakeFromNib {
self.creditsView.layer = self.creditsRootLayer;
self.creditsView.wantsLayer = YES;
}
Root layer is plain simple CALayer
with three sublayers: a CATextLayer
that contains the text to scroll and two “fade” layers that will be rendered on top of text layer and will provide a nice gradient that will make the text look like it’s fading in at the bottom and fading out at the top:
- (CALayer *)creditsRootLayer {
if (_creditsRootLayer) return _creditsRootLayer;
_creditsRootLayer = [CALayer layer];
[_creditsRootLayer addSublayer:self.creditsTextLayer];
[_creditsRootLayer addSublayer:self.creditsTopFadeLayer];
[_creditsRootLayer addSublayer:self.creditsBottomFadeLayer];
return _creditsRootLayer;
}
Note that the order of sublayers is important - we want to have text layer rendered below fading gradients (we could also use zPosition
instead). Creating text layer is also straightforward:
- (CATextLayer *)creditsTextLayer {
if (_creditsTextLayer) return _creditsTextLayer;
NSString *path = [[NSBundle mainBundle] pathForResource:@"Credits" ofType:@"rtf"];
NSAttributedString *credits = [[NSAttributedString alloc] initWithPath:path documentAttributes:nil];
CGSize size = [self sizeForAttributedString:credits inWidth:self.creditsView.bounds.size.width];
_creditsTextLayer = [CATextLayer layer];
_creditsTextLayer.wrapped = YES;
_creditsTextLayer.string = credits;
_creditsTextLayer.anchorPoint = CGPointMake(0.0, 0.0);
_creditsTextLayer.frame = CGRectMake(0.0, 0.0, size.width, size.height);
return _creditsTextLayer;
}
There are couple of points to bear in mind: first, we want to have the text wrapped. Secondly, we make the anchor point to bottom-left; this will make code for scrolling animation simpler later on. But perhaps the most important: we make the layer fit the whole string size. For this, we calculate the required height of the string given the width of the parent view. The calculation is performed inside sizeForAttributedString:inWidth:
method, created with the help of this post on Richard Hult’s blog. Basically, it takes desired string width and calculates what the height should be by wrapping the string to the width. I’m not posting the code here, but you can check it in the accompanying project. I also encourage you to read Richard’s blog post as it’s quite informative and offers more than one solution!
Dealing with fade in and out is also straighforward by using CAGradientLayer
. We need two layers, one streched over the top part of the view and having bottom color set to transparent and top to white. The second is similar, just has the colors reversed and is stretched at the bottom of the view. Let’s see how top layer is initialized:
- (CAGradientLayer *)creditsTopFadeLayer {
if (_creditsTopFadeLayer) return _creditsTopFadeLayer;
CGColorRef color1 = kAboutWindowCreditsFadeColor1;
CGColorRef color2 = kAboutWindowCreditsFadeColor2;
CGFloat height = kAboutWindowCreditsFadeHeight;
_creditsTopFadeLayer = [CAGradientLayer layer];
_creditsTopFadeLayer.colors = [NSArray arrayWithObjects:(__bridge id)color1, (__bridge id)color2, nil];
_creditsTopFadeLayer.frame = CGRectMake(0.0, 0.0, self.creditsView.bounds.size.width, height);
return _creditsTopFadeLayer;
}
I chose to use constants for colors and height, so it’s simple to tweak. In real-life project you might want to get these values from elsewhere.
Scrolling credits text
Once layers are initialized, it is time to implement the actual scrolling. We want to automatically start scrolling when the window loads and stop when the window is closed, then restart again when the window is reopened. We do this in showWindow:
and windowWillClose:
methods of our about NSWindowController
subclass. But the actual code is implemented in startCreditsScrollAnimation
and stopCreditsScrollAnimation
methods. Let’s first see how we start scrolling:
- (void)startCreditsScrollAnimation {
CATextLayer *creditsLayer = self.creditsTextLayer;
CGFloat viewHeight = self.creditsView.bounds.size.height;
CGFloat fadeCompensation = self.creditsFadeHeightCompensation;
[self resetCreditsScrollPosition];
[CATransaction begin];
[CATransaction setAnimationDuration:kAboutWindowCreditsAnimationDuration];
[CATransaction setAnimationTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear]];
[CATransaction setCompletionBlock:^{
[self startCreditsScrollAnimation];
}];
creditsLayer.position = CGPointMake(0.0, viewHeight - fadeCompensation);
[CATransaction commit];
}
It’s simple: we first reset the layer, so that we always start at the bottom edge. Then we animate the text layer position to the top of the view. Remember that we previously set the text layer’s anchorPoint
? That is the key for the animation: by setting the anchor point to (0,0), our position coordinate points to layer’s bottom-left corner, so we need to animate it until it hides at the top of the view.
Core Animation uses 250 ms as default time for animations, but that would be a bit too fast for scrolling credits wouldn’t it :) So we need to change animation duration. We also need to be told when the animation stops, so that we can restart it. We could do this by creating a custom CAAnimation
object, setting its delegate to our window controller and assigning animation to the layer. But that would require some more code distributed over several methods. Instead I chose to use CATransaction
- it results in few lines of code and allows us change all parameters we need. Plus it gives us a nice block based hook that is called when animation completes! When that happens, we simply call startCreditsScrollAnimation
again which will reset the layer position and start animation all over again!
Perhaps worth mentioning: note usage of creditsFadeHeightCompensation
: it’s simply a number of points (or pixels) by which the view height is reduced when animating. The only reason for this was that in my experimentation, using slight offset provided smoother results - the text appeared at the bottom immediately after hiding at the top, without any delay. Feel free to change this to see for yourself.
Here’s how we reset scrolling:
- (void)resetCreditsScrollPosition {
CATextLayer *creditsLayer = self.creditsTextLayer;
CGFloat textHeight = creditsLayer.frame.size.height;
CGFloat fadeCompensation = self.creditsFadeHeightCompensation;
[CATransaction begin];
[CATransaction setAnimationDuration:0.0];
creditsLayer.position = CGPointMake(0.0, -textHeight + fadeCompensation);
[CATransaction commit];
}
It’s similar, except here we don’t want to animate, but simply move the text layer to the bottom. The user wouldn’t notice it as the layer is at this point hidden at the top of the view and the starting position moves it below the visible portion of the view at the bottom. Note how we must use actual text height here - remember that we’re moving text layer’s bottom-left corner!
And how do we stop scrolling? We simply reset the scroll position using above method. However there is slight complication here: when stop is called, scroll animation may already be in progress, so although the code would work, the animation would restart again when completion block from startCreditsScrollAnimation
would be called! To compensate for that, we introduce a BOOL
property that prevents restart:
- (void)stopCreditsScrollAnimation {
self.isCreditsAnimationActive = NO;
[self resetCreditsScrollPosition];
}
Check the accompanying project for full code! And that’s it, enjoy your polished about window!
Conclusion
Although using Core Animation tends to result in somewhat verbose code, it’s really straightforward. The hardest part was calculating actual credits size and even that it only a couple of lines (copied from elsewhere :) It also took some time and tweaking before animation was working just right. But other than that, creating custom about window with scrolling credits was really simple and results are well worth the effort IMHO!
Perhaps just few more random notes:
- The image view uses
NSApplicationIcon
, therefore it will automatically pick your custom application icon. - You can play with animation duration, although in my experimentation it became jagged when using too large values.
- It would be cool to let user stop animation when mouse is hovering over the text, especially if you have larger credits text - I leave that as an excercise for the reader. And if you’re feeling adventurous, fork the project at GitHub and let me know of your solution!
- Top and bottom fade layers frames are only calculated once, so they would be misplaced if the scrolling view would resize during runtime. But about windows are usually not resizable, so this shouldn’t be a problem.
- To get white background, I use
BackgroundColorView
class, a subclass ofNSView
. You can set any color you want, but it defaults to white, so I can get away with simply setting the class in IB. - I use OS X 10.7 Lion Auto Layout and ARC!
Update: June 13, 2013
If you want to use this code and have the text looking crisp on retina screens, you need to update it slighlty (at least for OS X, not sure about iOS). Apple recommends using CALayer
delegate and return YES from layer:shouldInheritContentsScale:fromWindow:
. This works, but in my case it beach balled my app forever. It’s likely something specific to my app, but if you also experience this, you can also set the contentsScale
to text layer directly. It probably won’t be as adaptable as delegate approach (it probably won’t work fine when dragged from retina window to normal one and vice versa), but at least it’ll work.