I am trying to write a category for NSTextField
which will add a new method setAnimatedStringValue
. This method is supposed to nicely fade-out the current text, then set the new text and then fade that in.
Below is my implementation:-
- (void) setAnimatedStringValue:(NSString *)aString {
if ([[self stringValue] isEqualToString:aString]) {
return;
}
NSMutableDictionary *dict = Nil;
NSViewAnimation *fadeOutAnim;
dict = [NSDictionary dictionaryWithObjectsAndKeys:self, NSViewAnimationTargetKey,
NSViewAnimationFadeOutEffect, NSViewAnimationEffectKey, nil];
fadeOutAnim = [[NSViewAnimation alloc] initWithViewAnimations:[NSArray arrayWithObjects:
dict, nil]];
[fadeOutAnim setDuration:2];
[fadeOutAnim setAnimationCurve:NSAnimationEaseOut];
[fadeOutAnim setAnimationBlockingMode:NSAnimationBlocking];
NSViewAnimation *fadeInAnim;
dict = [NSDictionary dictionaryWithObjectsAndKeys:self, NSViewAnimationTargetKey,
NSViewAnimationFadeInEffect, NSViewAnimationEffectKey, nil];
fadeInAnim = [[NSViewAnimation alloc] initWithViewAnimations:[NSArray arrayWithObjects:
dict, nil]];
[fadeInAnim setDuration:3];
[fadeInAnim setAnimationCurve:NSAnimationEaseIn];
[fadeInAnim setAnimationBlockingMode:NSAnimationBlocking];
[fadeOutAnim startAnimation];
[self setStringValue:aString];
[fadeInAnim startAnimation];
}
Needless to say, but the above code does not work at all. The only effect I see is the flickering of a progress bar on the same window. That is possibly because I am blocking the main runloop while trying to "animate" it.
Please suggest what is wrong with the above code.
Additional note:
setAnimatedStringValue
is always invoked by a NSTimer
, which is added to the main NSRunLoop
.
I'll take a stab:
I found a couple problems here. First off, this whole thing is set up to be blocking, so it's going to block the main thread for 5 seconds. This will translate to the user as a SPOD/hang. You probably want this to be non-blocking, but it'll require a little bit of extra machinery to make that happen.
Also, you're using NSAnimationEaseOut
for the fade out effect, which is effected by a known bug where it causes the animation to run backwards. (Google for "NSAnimationEaseOut backwards" and you can see that many have hit this problem.) I used NSAnimationEaseIn
for both curves for this example.
I got this working for a trivial example with non-blocking animations. I'm not going to say that this is the ideal approach (I posted a second answer that arguably better), but it works, and can hopefully serve as a jumping off point for you. Here's the crux of it:
@interface NSTextField (AnimatedSetString)
- (void) setAnimatedStringValue:(NSString *)aString;
@end
@interface SOTextFieldAnimationDelegate : NSObject <NSAnimationDelegate>
- (id)initForSettingString: (NSString*)newString onTextField: (NSTextField*)tf;
@end
@implementation NSTextField (AnimatedSetString)
- (void) setAnimatedStringValue:(NSString *)aString
{
if ([[self stringValue] isEqual: aString])
{
return;
}
[[[SOTextFieldAnimationDelegate alloc] initForSettingString: aString onTextField: self] autorelease];
}
@end
@implementation SOTextFieldAnimationDelegate
{
NSString* _newString;
NSAnimation* _fadeIn;
NSAnimation* _fadeOut;
NSTextField* _tf;
}
- (id)initForSettingString: (NSString*)newString onTextField: (NSTextField*)tf
{
if (self = [super init])
{
_newString = [newString copy];
_tf = [tf retain];
[self retain]; // we'll autorelease ourselves when the animations are done.
_fadeOut = [[NSViewAnimation alloc] initWithViewAnimations: @[ (@{
NSViewAnimationTargetKey : tf ,
NSViewAnimationEffectKey : NSViewAnimationFadeOutEffect})] ];
[_fadeOut setDuration:2];
[_fadeOut setAnimationCurve: NSAnimationEaseIn];
[_fadeOut setAnimationBlockingMode:NSAnimationNonblocking];
_fadeOut.delegate = self;
_fadeIn = [[NSViewAnimation alloc] initWithViewAnimations: @[ (@{
NSViewAnimationTargetKey : tf ,
NSViewAnimationEffectKey : NSViewAnimationFadeInEffect})] ];
[_fadeIn setDuration:3];
[_fadeIn setAnimationCurve:NSAnimationEaseIn];
[_fadeIn setAnimationBlockingMode:NSAnimationNonblocking];
[_fadeOut startAnimation];
}
return self;
}
- (void)dealloc
{
[_newString release];
[_tf release];
[_fadeOut release];
[_fadeIn release];
[super dealloc];
}
- (void)animationDidEnd:(NSAnimation*)animation
{
if (_fadeOut == animation)
{
_fadeOut.delegate = nil;
[_fadeOut release];
_fadeOut = nil;
_tf.hidden = YES;
[_tf setStringValue: _newString];
_fadeIn.delegate = self;
[_fadeIn startAnimation];
}
else
{
_fadeIn.delegate = nil;
[_fadeIn release];
_fadeIn = nil;
[self autorelease];
}
}
@end
It would be really nice if there were block-based API for this... it'd save having to implement this delegate object.
I put the whole project up on GitHub.
I was poking around a bit after posting the previous answer. I'm leaving that answer because it corresponds closely to the code you posted, and uses NSViewAnimation
. I did, however, come up with a considerably more concise, albeit slightly harder to read (owing to block parameter indentation) version that uses NSAnimationContext
instead. Here 'tis:
#import <QuartzCore/QuartzCore.h>
@interface NSTextField (AnimatedSetString)
- (void) setAnimatedStringValue:(NSString *)aString;
@end
@implementation NSTextField (AnimatedSetString)
- (void) setAnimatedStringValue:(NSString *)aString
{
if ([[self stringValue] isEqual: aString])
{
return;
}
[NSAnimationContext runAnimationGroup:^(NSAnimationContext *context) {
[context setDuration: 1.0];
[context setTimingFunction: [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseOut]];
[self.animator setAlphaValue: 0.0];
}
completionHandler:^{
[self setStringValue: aString];
[NSAnimationContext runAnimationGroup:^(NSAnimationContext *context) {
[context setDuration: 1.0];
[context setTimingFunction: [CAMediaTimingFunction functionWithName: kCAMediaTimingFunctionEaseIn]];
[self.animator setAlphaValue: 1.0];
} completionHandler: ^{}];
}];
}
@end
Note: To get access to the CAMediaTimingFunction
class used here for specifying non-default timing functions using this API, you'll need to include QuartzCore.framework
in your project.
Also on GitHub.
For Swift 3, here are two convenient setText()
and setAttributedText()
extension methods that fade over from one text to another:
import Cocoa
extension NSTextField {
func setStringValue(_ newValue: String, animated: Bool = true, interval: TimeInterval = 0.7) {
guard stringValue != newValue else { return }
if animated {
animate(change: { self.stringValue = newValue }, interval: interval)
} else {
stringValue = newValue
}
}
func setAttributedStringValue(_ newValue: NSAttributedString, animated: Bool = true, interval: TimeInterval = 0.7) {
guard attributedStringValue != newValue else { return }
if animated {
animate(change: { self.attributedStringValue = newValue }, interval: interval)
}
else {
attributedStringValue = newValue
}
}
private func animate(change: @escaping () -> Void, interval: TimeInterval) {
NSAnimationContext.runAnimationGroup({ context in
context.duration = interval / 2.0
context.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
animator().alphaValue = 0.0
}, completionHandler: {
change()
NSAnimationContext.runAnimationGroup({ context in
context.duration = interval / 2.0
context.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
self.animator().alphaValue = 1.0
}, completionHandler: {})
})
}
}
Call them as following:
var stringTextField: NSTextField
var attributedStringTextField: NSTextField
...
stringTextField.setStringValue("New Text", animated: true)
...
let attributedString = NSMutableAttributedString(string: "New Attributed Text")
attributedString.addAttribute(...)
attributedStringTextField.setAttributedStringValue(attributedString, animated: true)