Core Animation, CALayer, and UIView Animations

Understand the relationship between UIView and its backing CALayer, how UIView's block-based animation API and Core Animation's implicit and explicit animations relate to one another, how transforms and timing functions shape motion, and where animation and offscreen rendering costs come from.

Everything that moves, fades, or transforms on an iOS screen ultimately runs through Core Animation, the compositing and animation system that sits directly beneath UIKit. Most day-to-day animation code in an Objective-C app never talks to Core Animation directly — it calls +[UIView animateWithDuration:animations:] and lets UIKit handle the rest — but understanding what's actually happening underneath that convenience API is what lets you reason correctly about why some animations are buttery smooth and others visibly stutter, why changing a layer's property sometimes animates automatically and sometimes doesn't, and why a seemingly innocent cornerRadius plus a shadow can quietly tank a list's scroll performance. This lesson works from the ground up: Core Animation itself and the CALayer that actually backs and renders every UIView, the UIView animation API most code uses directly, CGAffineTransform/CATransform3D for scaling, rotating, and translating, the distinction between implicit and explicit animation, timing functions for controlling an animation's pacing, the choice between animating a view's frame directly versus animating Auto Layout constraint constants, general animation performance, and the offscreen-rendering triggers that are one of the most common, hard-to-notice sources of scroll and animation jank in a real app.

Core Animation: The Compositing System Beneath UIKit

Core Animation is the framework responsible for actually compositing everything you see on an iOS screen — taking the tree of visual layers that make up your interface and combining them into the final image sent to the display, and, where applicable, animating changes to that tree smoothly over time. It's a separate framework from UIKit (its classes are prefixed CA, not UI), and it operates conceptually one level below UIKit's UIView-based world: UIKit is largely a layer of Objective-C convenience — view controllers, event handling, Auto Layout, and a simpler, view-oriented API — built on top of Core Animation's more primitive, performance-oriented layer tree.

The reason this two-layer design matters practically is performance: many of the operations Core Animation performs — moving a layer, changing its opacity, applying a transform — can be handed off to the GPU and executed on a separate rendering/compositor process or thread, largely independent of whatever your app's main thread is doing at that exact moment. An animation that's purely a Core Animation-level transform or opacity change can keep running smoothly even if your main thread briefly gets busy with other work, which is a large part of why so much of UIKit's own animation API is built to funnel through Core Animation rather than reimplementing per-frame animation logic itself.

Every UIView animation you write, and every explicit CABasicAnimation you construct by hand, ultimately becomes something Core Animation executes against a layer tree — understanding that layer tree, starting with CALayer itself, is the foundation the rest of this lesson builds on.

CALayer: The Object That Actually Renders

Every UIView is backed by a CALayer, accessible through the view's layer property, and it is genuinely the layer — not the view itself — that handles the actual drawing, compositing, and (in most cases) animation. UIView is best understood as a relatively thin Objective-C wrapper around its backing layer: it adds touch handling, participation in the responder chain, Auto Layout integration, and a simpler, higher-level API surface, but a great many of the geometric and visual properties you set on a view — frame, bounds, center, backgroundColor, alpha (which maps to the layer's opacity) — are, under the hood, actually being read from or written through to that view's backing CALayer.

This view/layer relationship is precisely why so much fine-grained visual control lives on view.layer rather than on the view itself: properties like cornerRadius, borderWidth, borderColor, shadowColor/shadowOpacity/shadowRadius/shadowOffset, and shouldRasterize exist on CALayer, not on UIView, because they're purely rendering concerns Core Animation owns — UIView doesn't duplicate every one of its layer's properties with its own version, so you reach through .layer for anything UIKit hasn't chosen to expose a matching view-level property for.

You can also work with CALayer more directly than through a UIView at all — creating standalone CALayer (or subclass, like CAShapeLayer or CAGradientLayer) instances and adding them as sublayers of a view's layer with -addSublayer:, useful for purely decorative or generated content (a custom gradient, a hand-drawn shape) that doesn't need any of UIView's touch-handling or Auto Layout machinery. This is a genuinely lower-level tool than working with views, and most everyday Objective-C UI code stays at the UIView level, reaching into .layer only for the specific handful of layer-only properties it needs, rather than building custom layer hierarchies from scratch.

UIView Animations: The Block-Based API

+[UIView animateWithDuration:animations:] (and its variants taking a completion block, a delay, and options) is the API the overwhelming majority of everyday animation code in an Objective-C app actually calls. You pass it a duration and a block; inside that block, you simply set the final values you want on one or more views' animatable properties — frame, alpha, backgroundColor, transform, and others — exactly as if you were setting them instantaneously, and UIKit figures out the starting values on its own (whatever they currently are) and animates smoothly from there to what you set, over the given duration, without you writing any per-frame code at all.

The completion-block variant, +animateWithDuration:animations:completion:, adds a block invoked once the animation finishes (with a BOOL finished argument telling you whether it ran to completion or was interrupted), which is where you'd typically chain a follow-up action — remove a view that just faded out, or start a second animation. +animateWithDuration:delay:options:animations:completion: adds a start delay and an options mask (things like UIViewAnimationOptionCurveEaseInOut for a timing curve, or UIViewAnimationOptionRepeat to loop), giving you most of the practical control you need without dropping down to Core Animation's own, more verbose API.

Under the hood, this block-based API is itself implemented in terms of Core Animation — UIKit inspects what changed between the block's start and end state for each animatable property and constructs the appropriate Core Animation animations to actually carry it out. This is worth knowing precisely because it explains why UIView animations and Core Animation aren't really two separate animation systems competing for your attention; the UIView API is a convenience layer over the same underlying mechanism the next section, implicit and explicit animation, describes directly.

Transforms: CGAffineTransform and CATransform3D

A transform describes a geometric operation — scaling, rotating, translating (moving), or some combination of the three — applied to a view or layer without changing its underlying frame/bounds in your model data; the transform is applied visually, on top of the view's normal geometry, at render time. UIView's transform property is a CGAffineTransform, a 2D transform (represented as a 3x2 matrix conceptually, though you almost never construct the matrix by hand) built with convenience functions like CGAffineTransformMakeScale(sx, sy), CGAffineTransformMakeRotation(radians), and CGAffineTransformMakeTranslation(tx, ty), and combined with CGAffineTransformConcat when you need more than one operation applied together.

CALayer's equivalent, transform, is a CATransform3D — a full 3D transform matrix, letting you do things a 2D affine transform cannot, like rotate a layer around its X or Y axis to produce a genuine perspective-style 3D flip, or apply perspective (m34) so that 3D rotation looks properly foreshortened rather than flat. Most everyday UI animation only needs CGAffineTransform's 2D operations through UIView.transform; CATransform3D is reached for specifically when you want a true 3D effect, working at the CALayer level directly.

One property of CGAffineTransformIdentity (and CATransform3DIdentity) worth internalizing is that it represents 'no transform at all' — a view's transform defaults to identity, and animating a view's transform *back* to CGAffineTransformIdentity is the standard, idiomatic way to animate it back to its normal, untransformed appearance, rather than trying to compute and set an inverse transform by hand.

Implicit vs. Explicit Animation

An implicit animation is one you get automatically, without constructing any animation object yourself, simply by changing an animatable property on a CALayer that's already part of an on-screen layer tree — Core Animation notices the change and, by default, smoothly animates the transition from the old value to the new one over its own default duration and timing, using what's called the layer's 'default action' for that property. This is precisely the mechanism +[UIView animateWithDuration:animations:] leans on: the block-based API essentially arranges for the changes you make inside its block to be picked up as animated transitions with the duration and options *you* specified, rather than the layer's own untouched defaults.

An explicit animation, in contrast, means you construct a CABasicAnimation (for a simple from/to value change) or CAKeyframeAnimation (for a sequence of values or a path) object yourself, configure its keyPath (the name of the layer property to animate, as a string, like @"position" or @"opacity"), its fromValue/toValue (or values for a keyframe animation), its duration, and add it to the layer with -addAnimation:forKey:. This gives you finer control than the implicit/UIView path — multiple simultaneous animations on different keyPaths with fully independent timing, animations along a custom CGPath with CAKeyframeAnimation, or animations you want to configure once and reuse across several layers.

A subtlety worth knowing about explicit CABasicAnimation/CAKeyframeAnimation objects: by default, they animate the layer's *presentation* — what's on screen during the animation — without actually changing the layer's real, underlying model value; once the animation finishes, the layer visually snaps back to whatever its model value already was (which is why, absent extra handling like setting fillMode/removedOnCompletion, code that expects an explicitly-animated layer to simply stay at its animated end value can be surprised to see it revert). UIView's block-based animation API and simple implicit animation both avoid this trap by actually updating the model value directly, which is one more reason most day-to-day animation code prefers that higher-level path over hand-built explicit animations.

Timing: Controlling the Pace of an Animation

Without any timing adjustment, an animation could simply interpolate linearly between its start and end values — equal progress in equal time — but a linear pace often looks mechanical and unnatural; real-world motion typically starts and/or ends more gradually than it moves through its middle. A timing function, represented by CAMediaTimingFunction, describes exactly how an animation's progress maps onto elapsed time — not a straight line, but a curve, letting an animation ease in slowly from rest, ease out gently to a stop, or both.

CAMediaTimingFunction provides several standard named curves as convenience constructors — +functionWithName:kCAMediaTimingFunctionEaseIn, kCAMediaTimingFunctionEaseOut, kCAMediaTimingFunctionEaseInEaseOut, and kCAMediaTimingFunctionLinear — each representing a common, well-understood easing shape, and you can also construct a fully custom timing curve from four control points with +functionWithControlPoints:::: if none of the standard curves fit. UIView's block-based API exposes the same idea at a higher level through its options mask — UIViewAnimationOptionCurveEaseIn, ...CurveEaseOut, ...CurveEaseInEaseOut, and ...CurveLinear map directly onto the equivalent Core Animation timing functions underneath.

Getting timing right is a genuinely significant part of an animation feeling polished rather than mechanical — the difference between a linear curve and an ease-in-ease-out curve, applied to the exact same start/end values and duration, is often the single biggest factor in whether an animation reads as 'natural' to a user, even though the underlying values being animated haven't changed at all.

Frame Versus Auto Layout for Animation

For a view laid out with plain frames — no Auto Layout constraints involved — animating movement or resizing is direct: you set view.frame (or view.center, for pure repositioning) to its new value inside a UIView animation block, and UIKit animates from the current frame to the new one smoothly. This is simple, and for a view genuinely managed by hand rather than by constraints, it's the natural approach.

For a view positioned and sized by Auto Layout, directly animating its .frame inside an animation block fights the constraint system: the very next layout pass (triggered by rotation, content change, or simply the system re-running layout at some point) recalculates the view's frame from its active constraints and silently overwrites whatever frame you animated it to, because the constraints, not the frame you set, remain the actual source of truth for that view's geometry. The correct approach for an Auto-Layout-managed view is instead to animate a *constraint's* constant property — change the numeric constant on the relevant NSLayoutConstraint to its new target value, then, inside the UIView animation block, call [self.view layoutIfNeeded], which forces an immediate layout pass using the new constant; UIKit interpolates the resulting frame change smoothly across the animation block's duration exactly as if you'd animated the frame directly, but now the constraint remains the actual source of truth throughout and afterward, so nothing snaps back on the next layout pass.

The practical rule that follows from this is simple to state: for views under Auto Layout, animate constraint constants plus layoutIfNeeded, never .frame/.center directly; for views managed purely by hand with no constraints, animating .frame/.center directly is entirely correct and is in fact the simpler of the two approaches when it applies. Mixing the two on the same view — some constraints active alongside a directly-animated frame — is a reliable way to produce a layout that appears to animate correctly once and then snaps to an unexpected position on the very next layout pass.

Performance and Offscreen Rendering

Animation performance, at its core, is about keeping as much of the work as possible on the compositor — the part of the rendering pipeline that can run independently of, and largely in parallel with, your app's main thread — rather than forcing the main thread to redo expensive layout or drawing work on every single frame of an animation. Animating properties Core Animation can handle purely at the compositing level (opacity, transform, position) is comparatively cheap; forcing repeated Auto Layout passes, expensive custom -drawRect: work, or other main-thread-bound recomputation on every frame of an animation is comparatively expensive, and is exactly what tends to produce visibly dropped frames during an otherwise simple-looking animation.

A related but distinct performance trap is offscreen rendering: certain layer properties cannot be composited directly using the layer's normal content and instead force Core Animation to render that layer into an intermediate, offscreen buffer first, and only then composite that buffer onto the screen — an extra full render pass that is meaningfully more expensive than normal, single-pass compositing. shouldRasterize (deliberately caching a layer's rendered output as a bitmap) is the most explicit example, but several common styling choices trigger the same cost implicitly and easily by accident: a non-zero cornerRadius combined with masksToBounds = YES on a layer whose content or sublayers need to be clipped to the rounded corners, and a shadow (shadowOpacity greater than zero) applied without an explicit shadowPath, since without a path Core Animation has to compute the shadow's shape dynamically from the layer's actual (potentially irregular) content rather than a precomputed, fixed outline.

Why this matters specifically for scroll and animation performance is that offscreen rendering isn't a one-time cost — if it's applied to a view inside a table or collection view cell, that extra offscreen render pass repeats for every single cell, every single time that cell's layer content needs to be redrawn or recomposited, which very much includes continuously during fast scrolling. The practical fixes are correspondingly concrete: set an explicit shadowPath (a fixed CGPath, often just a rounded rectangle matching the layer's bounds) so the shadow's shape is precomputed rather than derived dynamically every frame, and, for corner-radius clipping specifically, prefer clipping only the specific subview or image layer that actually needs rounded corners rather than the whole cell's outer layer whenever that's an option, or accept shouldRasterize deliberately (with rasterizationScale set correctly) only for content that is expensive to render but changes rarely, since rasterizing content that changes every frame defeats the entire point of caching it.

UIView Block Animation and an Explicit CABasicAnimation

UIView's block API is implicit animation with explicit control over duration/options — the simplest path for most everyday animation. An explicit CABasicAnimation animates the presentation only; set the layer's real model value separately if it should persist after the animation ends. CAMediaTimingFunction / UIViewAnimationOptionCurve* controls pacing — linear vs. eased motion — independent of duration.

// UIView block-based animation (implicit, model value actually changes):
[UIView animateWithDuration:0.3
                      delay:0.0
                    options:UIViewAnimationOptionCurveEaseInEaseOut
                 animations:^{
    view.alpha = 0.0;
    view.transform = CGAffineTransformMakeScale(0.8, 0.8);
} completion:^(BOOL finished) {
    [view removeFromSuperview];
}];

// Explicit Core Animation (animates the presentation; model value unaffected unless set separately):
CABasicAnimation *fade = [CABasicAnimation animationWithKeyPath:@"opacity"];
fade.fromValue = @(1.0);
fade.toValue = @(0.0);
fade.duration = 0.3;
fade.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
[view.layer addAnimation:fade forKey:@"fadeOut"];

Code Examples

Because panelView is positioned and sized by Auto Layout, its frame cannot be safely animated directly — the next layout pass would just recompute it from the still-unchanged constraints and undo the animation. Instead, this method updates the constraint's constant to the new target value, then calls layoutIfNeeded inside a UIView animation block; UIKit smoothly interpolates the resulting frame change across the block's duration, and because the constraint itself now reflects the new value, nothing snaps back on a subsequent layout pass.

[self.view layoutIfNeeded]; // forces an immediate layout pass using the new constant

CGAffineTransformMakeScale builds a scale-only transform, and CGAffineTransformRotate applies an additional rotation on top of it, producing one combined transform representing both operations at once. The first animation block applies that combined transform; its completion block then animates the badge back to CGAffineTransformIdentity — the standard way to return a view to its untransformed state — rather than computing and applying some inverse transform by hand. Chaining two animation blocks through a completion handler like this is a common, simple way to build a short multi-step animation without reaching for CAKeyframeAnimation.

Setting shadowOpacity alone, with no shadowPath, forces Core Animation to derive the shadow's shape from the layer's actual rendered content every time it composites, which is exactly the kind of implicit offscreen-render trigger that's expensive inside a scrolling table cell repeated across every row. Supplying an explicit shadowPath — here a simple rounded rectangle matching the card's bounds — gives Core Animation a fixed, precomputed shape to work from instead, avoiding that dynamic per-render computation while still producing a visually correct shadow for this card's known, simple geometry.

Common mistake: Adding a layer shadow (shadowOpacity > 0) to every cell in a scrolling list without also setting shadowPath — this is one of the most common, easy-to-miss sources of scroll stutter in a list with card-style cells.

- (void)expandPanel {
    self.panelHeightConstraint.constant = 220;  // update the constraint's constant, not the frame

    [UIView animateWithDuration:0.35
                     animations:^{
        [self.view layoutIfNeeded];  // forces an immediate layout pass using the new constant
    }];
}
- (void)pulseAndSpinBadge:(UIView *)badge {
    CGAffineTransform scaleUp = CGAffineTransformMakeScale(1.3, 1.3);
    CGAffineTransform rotated = CGAffineTransformRotate(scaleUp, M_PI / 8.0);

    [UIView animateWithDuration:0.2 animations:^{
        badge.transform = rotated;
    } completion:^(BOOL finished) {
        [UIView animateWithDuration:0.2 animations:^{
            badge.transform = CGAffineTransformIdentity;  // animate back to "no transform"
        }];
    }];
}
- (void)configureCardAppearance:(UIView *)cardView {
    cardView.layer.shadowColor = [UIColor blackColor].CGColor;
    cardView.layer.shadowOpacity = 0.2;
    cardView.layer.shadowOffset = CGSizeMake(0, 2);
    cardView.layer.shadowRadius = 4;

    // Without an explicit shadowPath, Core Animation must compute the shadow's shape
    // dynamically from the layer's content on every render — forcing offscreen rendering.
    CGPathRef shadowPath =
        CGPathCreateWithRoundedRect(cardView.bounds, 8, 8, NULL);
    cardView.layer.shadowPath = shadowPath;
    CGPathRelease(shadowPath);
}

Animation and Graphics in Real Apps

The everyday polish users notice — a smooth card expansion, a natural-feeling modal presentation, a list that scrolls without a single dropped frame even with rounded, shadowed cards — comes directly from the concepts in this lesson applied carefully and consistently: implicit UIView animation for the common cases, explicit Core Animation only when finer control is genuinely needed, constraint-constant animation rather than frame animation for Auto-Layout-driven screens, and a habitual awareness of offscreen-rendering triggers anywhere a visual effect is applied across many repeated views. Profiling a real app's scroll performance with Instruments' Core Animation tool, watching specifically for the 'Color Offscreen-Rendered Yellow' overlay lighting up cells it shouldn't, is a direct, practical extension of exactly the offscreen-rendering concepts this lesson introduced.

Animating a view's .frame or .center directly when that view is positioned by active Auto Layout constraints.

Update the relevant NSLayoutConstraint's constant to the new target value, then call [view layoutIfNeeded] inside the UIView animation block.

A view under Auto Layout has its constraints as the actual source of truth for its geometry; a directly-animated frame gets silently overwritten and reverted on the next layout pass, since the unchanged constraints simply recompute the old frame.

Applying a layer shadow (shadowOpacity > 0) without an explicit shadowPath to views inside a scrolling table or collection view.

Set an explicit shadowPath (a fixed CGPath matching the layer's known geometry) whenever you apply a shadow, especially on views repeated across many reused cells.

Without a shadowPath, Core Animation must dynamically derive the shadow's shape from the layer's content on every render, forcing offscreen rendering — repeated across every cell on every scroll frame, this is a common, easy-to-miss source of scroll stutter.

Assuming an explicit CABasicAnimation leaves a layer at its animated end value once the animation finishes, with no further code.

Either update the layer's actual model property to the end value directly (alongside or instead of the explicit animation), or use fillMode/removedOnCompletion deliberately if the presentation should persist.

A CABasicAnimation by default animates only the presentation, not the underlying model value; once it completes and is removed, the layer visually snaps back to whatever its (unchanged) model value already was, unless you handle this explicitly.

Applying cornerRadius and masksToBounds = YES to a large, complex view's outer layer purely to round its corners, without considering the offscreen-rendering cost.

Where possible, clip only the specific subview or content layer that actually needs rounded corners, and reserve shouldRasterize for content that's expensive to render but changes rarely.

cornerRadius combined with masksToBounds can force offscreen rendering depending on what the layer needs to clip; applying it broadly, to every cell in a large scrolling list, multiplies a per-render cost across every row on every frame of scrolling.

What is the relationship between a UIView and its backing CALayer? A. They are unrelated; CALayer is only used for custom drawing B. CALayer is a subclass of UIView C. UIView is a relatively thin wrapper adding touch handling and layout on top of its backing CALayer, which actually performs the rendering and compositing D. UIView performs the rendering directly, and CALayer only stores an animation history

C. UIView is a relatively thin wrapper adding touch handling and layout on top of its backing CALayer, which actually performs the rendering and compositing — Every UIView is backed by a CALayer accessible via .layer; the layer is what actually renders and composites content, while UIView adds touch handling, the responder chain, and Auto Layout on top of it.

What is the key difference between an implicit and an explicit Core Animation animation? A. Implicit animations only work on UIView, never on CALayer directly B. Implicit animation happens automatically when an animatable layer property changes (as UIView's block API leans on); explicit animation means constructing a CABasicAnimation/CAKeyframeAnimation object yourself C. Explicit animations are always faster than implicit ones D. There is no real difference between the two

B. Implicit animation happens automatically when an animatable layer property changes (as UIView's block API leans on); explicit animation means constructing a CABasicAnimation/CAKeyframeAnimation object yourself — Implicit animation is triggered automatically by a property change on an on-screen layer, using its default action; explicit animation is you constructing and adding a CABasicAnimation/CAKeyframeAnimation object directly for finer control.

Why should a view positioned by Auto Layout constraints not be animated by setting its .frame directly? A. UIView animation blocks do not support the frame property at all B. The constraints remain the actual source of truth for the view's geometry, and a subsequent layout pass recomputes and overwrites the directly-set frame C. Setting .frame directly always crashes on a constrained view D. Frame animation is slower than constraint-constant animation in every case

B. The constraints remain the actual source of truth for the view's geometry, and a subsequent layout pass recomputes and overwrites the directly-set frame — A view's active constraints, not its frame, remain the source of truth; the correct approach is to change a constraint's constant and call layoutIfNeeded inside the animation block, so the constraint itself reflects the new state.

What does a CAMediaTimingFunction control? A. How long an animation runs, overriding its duration B. How an animation's progress maps onto elapsed time — its easing curve, such as ease-in or ease-out — rather than a straight linear interpolation C. Whether an animation is implicit or explicit D. Which thread an animation runs on

B. How an animation's progress maps onto elapsed time — its easing curve, such as ease-in or ease-out — rather than a straight linear interpolation — A timing function describes the pacing curve of an animation's progress over time; standard named curves like ease-in-ease-out replace the default straight-line, constant-speed interpolation with more natural-looking motion.

Which of these forces Core Animation to render a layer offscreen before compositing it? A. Animating a layer's opacity from 1.0 to 0.0 B. A shadow (shadowOpacity > 0) with no explicit shadowPath set C. Setting a view's backgroundColor to a solid color D. Using +[UIView animateWithDuration:animations:] instead of an explicit CABasicAnimation

B. A shadow (shadowOpacity > 0) with no explicit shadowPath set — Without an explicit shadowPath, Core Animation must dynamically derive the shadow's shape from the layer's actual content on every render, which forces an extra offscreen render pass — supplying a fixed shadowPath avoids that dynamic computation.

By default, does a CABasicAnimation change a layer's underlying model value once it completes? A. Yes, always, permanently B. No — it animates the presentation only, and the layer reverts to its unchanged model value unless you handle this explicitly C. Only if fillMode is left at its default D. Only when the animation is added with a completion block

B. No — it animates the presentation only, and the layer reverts to its unchanged model value unless you handle this explicitly — An explicit CABasicAnimation animates the presentation layer, not the model value; once removed, the layer visually snaps back to whatever its model value already was, which is why code expecting it to persist at the animated end value needs extra handling.

A card view inside a table cell has rounded corners (cornerRadius + masksToBounds = YES on its outer layer) and a drop shadow (shadowOpacity > 0, no shadowPath set). The list stutters while scrolling. Identify both offscreen-rendering triggers here and describe a fix for each.

The shadow without a shadowPath forces Core Animation to compute the shadow's shape dynamically from the layer's content on every render; the fix is to set an explicit shadowPath (e.g. a rounded rect CGPath matching the card's bounds) so the shape is precomputed instead. The cornerRadius + masksToBounds combination forces offscreen rendering to clip content to the rounded corners; where possible, apply the rounding/clipping only to the specific subview (e.g. an inner content view or image view) that actually needs it rather than the cell's full outer layer, reducing how much content has to be rendered offscreen per cell.

Corner radius + masksToBounds and a shadow without a shadowPath are two independent offscreen-rendering triggers, and they need two independent fixes — think about what each one is forcing Core Animation to compute per render.