iosobjective-ccalayercaanimationcagradientlayer

iOS skeleton loading animation


I am working on adding skeleton loading to an iOS app. I have a UICollectionView where each cell should display a "loading" state while data is being loaded. To handle this state, a skeleton loading technique is going to be used.

For this case, I am just taking the labels and images, and adding a sublayer to them that is a gray sublayer, masked by the bounds of each label or image. This is working fine.

The problem is that I also want a "sheen" or "shimmer" animation that goes across each view, from left to right and it repeats. But I cannot get the animation to work. It just looks like this, without any animation. The second item is lighter in color because it is disabled, otherwise they would all be the same color. But everything looks fine, it is just that there is supposed to be an animation.

enter image description here

Code

Here is the code that I have. Again, the problem is that the animation is not happening. There is supposed to be a CAGradientLayer that starts to the left, out of view, and animates to the right, until it gets out of view. And then it all repeats.

@interface MyCollectionViewCell ()

/**
 * If this is YES, then the skeleton loading layers and animation need to be shown.
 * If this is NO, then the normal view should be shown. No skeleton loading layers should be shown.
 */
@property (assign, nonatomic, getter=isLoading) BOOL loading;

@end

@implementation MyCollectionViewCell

//... initialization and other logic

/**
 * This finds the direct subviews of the contentView that are either labels or images.
 */
- (NSArray<__kindof UIView *> *)loadableSubviews {
    NSMutableArray<UIView *> *views = [[NSMutableArray alloc] init];
    for (UIView *view in self.contentView.subviews) {
        if ([view isKindOfClass:[UILabel class]]) {
            [views addObject:view];
        } else if ([view isKindOfClass:[UIImageView class]]) {
            [views addObject:view];
        }
    }
    return views;
}

- (void)setLoading:(BOOL)loading {
    self->_loading = loading;
    
    NSArray<__kindof UIView *> *loadingViews = [self loadableSubviews];
    
    if (loading) {
        //display the skeleton loading layers

        UIColor *backgroundColor = [UIColor colorWithRed:(210.0/255.0) green:(210.0/255.0) blue:(210.0/255.0) alpha:1.0];
        UIColor *highlightColor = [UIColor colorWithRed:(235.0/255.0) green:(235.0/255.0) blue:(235.0/255.0) alpha:1.0];
        CALayer *skeletonLayer = [CALayer layer];
        skeletonLayer.backgroundColor = backgroundColor.CGColor;
        skeletonLayer.name = @"skeletonLayer";
        skeletonLayer.anchorPoint = CGPointZero;
        skeletonLayer.frame = UIScreen.mainScreen.bounds;
        
        for (UIView *loadingView in loadingViews) {
            CAGradientLayer *gradientLayer = [[CAGradientLayer alloc] init];
            gradientLayer.colors = @[backgroundColor, highlightColor, backgroundColor];
            gradientLayer.startPoint = CGPointMake(0.0, 0.5);
            gradientLayer.endPoint = CGPointMake(1.0, 0.5);
            gradientLayer.frame = UIScreen.mainScreen.bounds;
            gradientLayer.name = @"skeletonGradient";
            
            loadingView.layer.mask = skeletonLayer;
            [loadingView.layer addSublayer:skeletonLayer];
            [loadingView.layer addSublayer:gradientLayer];
            loadingView.clipsToBounds = YES;
            
            CGFloat width = UIScreen.mainScreen.bounds.size.width;
            
            CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"transform.translation.x"];
            animation.duration = 3.0;

            //TODO: is there maybe something wrong with the y coordinate here?
            animation.fromValue = [NSValue valueWithCGPoint:CGPointMake(-width, gradientLayer.frame.origin.y)];
            animation.toValue = [NSValue valueWithCGPoint:CGPointMake(width, gradientLayer.frame.origin.y)];
            animation.repeatCount = CGFLOAT_MAX;
            animation.autoreverses = NO;
            animation.fillMode = kCAFillModeForwards;
            
            [gradientLayer addAnimation:animation forKey:@"gradientShimmer"];
        }
    } else {
        //remove the skeleton loading layers

        for (UIView *loadingView in loadingViews) {
            for (NSInteger x = loadingView.layer.sublayers.count - 1; x >= 0; x--) {
                CALayer *sublayer = loadingView.layer.sublayers[x];
                if ([sublayer.name isEqualToString:@"skeletonLayer"] || [sublayer.name isEqualToString:@"skeletonGradient"]) {
                    [sublayer removeFromSuperlayer];
                }
            }
        }
    }
}

@end

Debugging

I added a breakpoint after adding the animation to the layer and inspected the gradientLayer in LLDB and it shows the animation is attached to the layer:

(lldb) po gradientLayer
<CAGradientLayer:0x280964380; name = "skeletonGradient"; position = CGPoint (187.5 406); bounds = CGRect (0 0; 375 812); allowsGroupOpacity = YES; name = skeletonGradient; endPoint = CGPoint (1 0.5); startPoint = CGPoint (0 0.5); colors = (
    "UIExtendedSRGBColorSpace 0.823529 0.823529 0.823529 1",
    "UIExtendedSRGBColorSpace 0.921569 0.921569 0.921569 1",
    "UIExtendedSRGBColorSpace 0.823529 0.823529 0.823529 1"
); animations = [gradientShimmer=<CABasicAnimation: 0x2809645e0>]>

But for some reason it does not appear to be running.

Questions

  1. Does anyone know why the animation is not working?
  2. Is there a better way to do any of this?
  3. In particular, is there a better way to remove the loading state? Is there a better way to remove these layers?

Solution

  • You set an array of UIColor objects to the colors property of gradientLayer. Instead you have to use CGColors, see Apple's documentation here:

    An array of CGColorRef objects defining the color of each gradient stop. Animatable.

    see https://developer.apple.com/documentation/quartzcore/cagradientlayer/1462403-colors?language=objc

    So this means that you should change the assignment as follows:

    gradientLayer.colors = @[(id)backgroundColor.CGColor, (id)highlightColor.CGColor, (id)backgroundColor.CGColor];
    

    Test

    In the small animated GIF the gray gradients are not as good as in reality, but you can see that it gives the expected result

    Test