Unrestricted View Replication


Bryce Pauken

 •  •  • 

Showing multiple copies of a single UIView instance is surprisingly difficult, and probably requires more time travel than you would expect.

Table of Contents

Background

I was recently working on animating a custom Slack emoji, not unlike this one:

I’m handier in Xcode than in Photoshop, so I built the animation using iOS APIs.

But then I was left with a problem: it would be nice to preview this animation in a few different contexts before actually converting it to a .gif. Is the confetti still visible when shrunk down? Are the colors too bright for light mode?

I was picturing a screen like this, with a full emoji preview, plus smaller copies in the sizes used by different Slack contexts, with two sets over different-colored backgrounds:

I could create five instances of the emoji animation, but that’s not quite the same - for one, the confetti is randomized, and it’s so much nicer for it to be in sync across all the different displays.

What I really want is to take a single UIView instance and display it multiple times with arbitrary positions and sizes, leading to the overall question of this article:

… how on earth do you do that?

(Yes, We’re Overengineering)

To be clear - we are absolutely about to overengineer this given the background above.

It’s not unreasonable to just accept out-of-sync confetti, and if we really cared, updating the confetti implementation to use a fixed seed for randomization wouldn’t be too tricky. Building the animation with a lower-level technology than UIKit and Core Animation might also give us some better options here.

In our particular use case, the best approach might even be to build a pipeline that renders our animation to a .gif, then resizes it, then shows the resulting .gifs — that actually has the benefit of us seeing the final product.

But figuring out how to show arbitrary copies of a view is still an intriguing task. I’m a huge fan of problems that sound simple, yet are surprisingly hard to find solutions for, and this is definitely one of them. The emoji use case is how I ran into this question, but we’re not diving into this for the sake of synchronizing confetti — we’re diving in because it turns out to be a super interesting problem.

Cheating: _UIPortalView

Let’s get this one out of the way first — this task is super easy if you’re willing to use private APIs.

_UIPortalView is a private UIView subclass that allows for sharing a view instance across multiple processes - @avaidyam wrote about it and the matching private CAPortalLayer class here.

Unsurprisingly, it allows for displaying an existing view from the same process as well.

This makes our overall task incredibly easy; you can either add a dumped _UIPortalView header to your app, or use a function like this one to handle the private API access in a nice way:

/**
 Returns a new `_UIPortalView` instance
 that replicates the given `view`.
 */
func createPortalView(of view: UIView) -> UIView {
    // Get a reference to the `_UIPortalView` class
    let portalViewClass = NSClassFromString("_UIPortalView")
                          as! UIView.Type
    
    // Create a `_UIPortalView` instance
    let portalView = portalViewClass.init()
    
    // Set the source of the portal view
    portalView.perform(
        NSSelectorFromString("setSourceView:"),
        with: view)

    return portalView
}

Using the above function, it becomes fairly straightforward to put more copies of our emoji on the screen, with the ability to position and scale as needed:

// Create a portal view of our original emoji view
let portalView = createPortalView(of: originalView)

// Position the portal to the right of the original view
portalView.center.y = originalView.center.y
portalView.center.x = originalView.center.x + 100

// Scale the portal view
portalView.transform = CGAffineTransform(scaleX: 0.5, y: 0.5)

// Add the portal view to the view hierarchy
view.addSubview(portalView)

This works perfectly, and passes every test you’d want for our solution — it’s fast, simple, and gives you full control over size and positioning.

But — it’s a private API. Despite the usefulness of this class, you wouldn’t be able to use it in an actual production app without breaking Apple’s rules.

Its usage works fine for my case, but it feels like it leaves the question unanswered. If the goal is to find the best general way to replicate a view, we’ll have to take a look at what we can do with public classes.

CAReplicatorLayer

If you know your CALayer subclasses, your mind likely jumped to CAReplicatorLayer as soon as you started reading this post.

Replicator Layers are fairly powerful — they allow for showing multiple copies of a view with a recursive transformation applied to each one. Replicator layers can be nested, and they can even provide a delay between animations occurring on each replicated instance, making it an easy way to make some nice effects.

With a basic CAReplicatorLayer setup, we can recreate the _UIPortalView example above, and adding extra emojis requires changing only a single character:

The setup for the above view is as follows — we create the layer, tell it to display its contents five times, tell it how to transform each instance, and we’re done:

// Create a replicator layer
let replicatorLayer = CAReplicatorLayer()

// Add our emoji view as the content to replicate
replicatorLayer.addSublayer(originalView.layer)

// Set the replicator layer size equal to our emoji view
let emojiSize = originalView.frame.size
replicatorLayer.frame.size.width = emojiSize.width
replicatorLayer.frame.size.height = emojiSize.height

// Display the contents 5 times
replicatorLayer.instanceCount = 5

// Move each instance to the right
// by `emojiSize.width` points...
let translateTransform = CATransform3DMakeTranslation(
    /* x-axis translation: */ emojiSize.width,
    /* y-axis translation: */ 0,
    /* z-axis translation: */ 0
)

// ... and scale each instance down to
// half of the previous one's size
let scaleTransform = CATransform3DScale(
    /* initial transform: */ translateTransform,
    /* x-axis scale: */ 0.5,
    /* x-axis scale: */ 0.5,
    /* x-axis scale: */ 1
)

// Use our combined transformation on each instance
replicatorLayer.instanceTransform = scaleTransform

// Finally, add our replicator layer to the view hierarchy
view.layer.addSublayer(replicatorLayer)

Unfortunately, we’ve now hit a wall. Even though we have five copies of the same UIView instance displayed on-screen, CAReplicatorLayer is limited by its recursive nature — while it’s easy to show a line of replicated views with a uniformly-decreasing scale, we can’t resize or position our copies with the freedom that our end goal requires.

Absurd Replicator Layouts

There’s one miserable-but-technically-feasible option that we can quickly explore here: the previous section mentions that CAReplicatorLayers can be nested.

This is generally used to create a grid of views, but you can make more complex layouts. A major catch is that you’ll likely need some form of redundant copying and/or masking in order to get things right.

As an example, we can create the top part of our overall layout by adding a nested CAReplicatorLayer. Our outer Replicator Layer scales and translates a copy of our inner Replicator Layer such that we have one more emoji in the correct spot. This creates an unwanted copy beneath our layout, which we then have to mask out.

The complexity of this method grows significantly the more you try to add, since on the next step, we’d now be cloning four views instead of two. It becomes increasingly difficult to fit things into the right spot and hide anything that doesn’t belong, and it’s reasonable to expect performance implications when you set out on an exponentially-growing view cloning path.

There are a few tricks to making this easier — having your innermost Replicator Layer clone your view with instances a huge distance apart can mean fewer visual collisions when your nested Replicator Layers shift things back into place — but overall, things still get messy very quickly. Let’s look into other options instead.

Replicator Layers: About Time

We mentioned earlier that CAReplicatorLayer has an option to create a delay in animations across its replicated instances — this is done through its instanceDelay property. Let’s take a small detour to investigate it a bit.

First, as entertaining as all the confetti on this page is, things will be a bit easier if we switch to a subject that highlights time more directly. We’ll use a representation of a clock, animated to complete a rotation every ten seconds.

We can add that clock to a CAReplicatorLayer, similarly to the examples above. Using an instanceCount of 3, and an instanceTransform that shifts each copy to the right, we get a setup like this:

// Show 3 instances of our clock view
replicatorLayer.instanceCount = 3

// Move each subsequent clock view to the right
replicatorLayer.instanceTransform = CATransform3DMakeTranslation(
    /* x-axis translation: */ clockWidth + clockPadding,
    /* y-axis translation: */ 0,
    /* z-axis translation: */ 0
)

That’s not particularly interesting compared to before — but now, let’s pull in the instanceDelay property. By setting an instanceDelay of 2, each instance of our clock starts its animation two seconds after the previous one.

// Delay each subsequent instance by 2 seconds
replicatorLayer.instanceDelay = 2

It’s important to keep in mind that this is the same view — we’re just seeing each instance of it as if we were looking at it through different points in time.

To keep things clear as we progress, let’s add some additional display info under each replicated instance. We’ll include a label to indicate what relative time each instance is at, along with a graph showing the clock hand’s rotation over time.

The underlying graph is the same across all three clocks — after all, they’re the same view — but because each instance is showing its animation two seconds apart, they end up showing different areas of that graph, indicated by their respective highlighted portions.

Right now, this extra info feels only marginally helpful at best. But things are about to get a bit weird, so having a good visual representation of how instanceDelay affects our views will be helpful.

Let’s Do The Time Warp

While instanceDelay does allow us to play animations that are offset from one another, it can be more helpful to frame it in a different context — it allows us to see copies of our view through different windows of time.

The highlighted areas in the above graphs help show this point: each view is showing a different window of time on the overall graph, with each subsequent window starting two seconds before the previous one.

But to really emphasize this point, let’s shift those windows around. This time, let’s use a negative instanceDelay value:

// Delay each subsequent instance by... -3 seconds?
replicatorLayer.instanceDelay = -3

We have three instances of our clock, just like before — but now each subsequent window is ahead of the previous one.

Again, this change really highlights that we are not thinking in terms of actions starting after a delay — which would be a bit nonsensical with negative values — but rather, looking at our view through different windows of time.

Now we’re finally ready to look back at our previous problem: CAReplicatorLayer does not give us control of the individual replicated instances of our view.

This remains true; we can’t make an overall change to our clock view without it affecting our other two instances. But what we can do is change our clock view during some arbitrary window of time.

Let’s add an animation to the clock view that changes its position beginning at 3 seconds. We’ll animate it for 1.5 seconds, then autoreverse, such that the whole animation is completed by the 6 second mark:

// Animate the clock's translation
let animation = CABasicAnimation(
    keyPath: "transform.translation.y")

// Move 50 points upward
animation.toValue = -50

// Start 3 seconds from the current time
animation.beginTime = replicatorLayer.convertTime(
    CACurrentMediaTime() + 3,
    from: nil)

// Animate for 1.5 seconds...
animation.duration = 1.5

// ... then reverse back into original position
animation.autoreverses = true

// Keep the animation object on the layer
// even after complete. This keeps
// `CAReplicatorLayer` happy.
animation.isRemovedOnCompletion = false

// Add the animation
clockView.layer.add(animation, forKey: nil)

By displaying our view through three different windows of time, we are able to add an animation during one of the windows to control an individual replicated instance!

We are slightly limited by our current demo’s setup, though. From the left clock’s graph, we can see that if we let the animation keep playing, it would start animating upwards itself. We’d generally want our time windows to be much larger, and further apart accordingly.

Let’s change our instanceDelay from -3 to -333 (normally we’d want even larger, but this helps us fit our labels on screen still!). We can also take this opportunity to switch to a more suitable animation.

Using a CAKeyframeAnimation allows us to take advantage of its .discrete Calculation Mode. With this setup, we can specify exactly which translation values we want for each of our three time windows, with no visible animation or interpolation between.

// Animate the clock's translation
let animation = CAKeyframeAnimation(
    keyPath: "transform.translation.y")

// Move the second instance 50 units up,
// and the third instance 25 units up
animation.values = [0, -50, -25]

// Apply the animation values as-is
// instead of interpolating between them
animation.calculationMode = .discrete

// Animate for 999 seconds
// (3 views x 333 seconds apart)
animation.duration = 999

// Again, keep the animation object
// on the layer even after complete
animation.isRemovedOnCompletion = false

// Add the animation
clockView.layer.add(animation, forKey: nil)

The windows of time shown in our animations are now incredibly short, shown only as a dash on the overall graph. And thanks to the .discrete Calculation Mode, we have a nice squared-off animation graph, showing our unmoving views in an otherwise-impossible CAReplicatorLayout arrangement:

With this method, we can easily reposition or scale a view without the restrictions that CAReplicatorLayer usually imposes on us.

Let’s Do The Time Warp (Again)

We still have one major issue left by the end of the previous section: our three clocks are out of sync! The entire premise of this article came from wanting to show copies of our emoji with confetti pieces in sync across each.

The above strategy works fine if you have a predictable animation with a fixed period — if we used an instanceDelay of 1000 above, the clocks would appear in sync due to their animations looping after an evenly-dividing 10 seconds. But that won’t help us with the non-repeating confetti animation that we started the article with, and isn’t a fully general solution to our overall problem.

This is about as far as I got into the problem on my own, and it looks like we’re fairly low on options — we need instanceDelay in order to have different windows of time in which we can position our views, but instanceDelay also inherently moves our animations out-of-sync.

It turns out @chpwn came up with a solution seven whole years ago, for an entirely different purpose — the ability to apply transformations to different parts of the same view, like in this unbelievable demo:

This tweet shows off the spacetime library, which relies on the same setup described above, but adds one incredible realization to take things a setup forward.

To introduce that additional step, let’s look at the documentation of instanceDelay more closely:

Specifies the delay, in seconds, between replicated copies. Animatable.

CAReplicatorLayer’s instanceDelay property is animatable.

We previously showed the ability to control an individual replicated view’s properties, like its position, by animating them during a particular time window — we can do the same to instanceDelay to control a replicated view’s relative time.

That’s right — we are about to descend into nested time travel!

First, let’s start off easy. There are no replicator layers here; just a clock view that has been set to animate between three different positions, staying at each for two seconds.

Next, let’s wrap the clock in a replicator layer, signified by the dashed outline below. We’ll set it up with an initial configuration that shows two copies of our view. We eventually want this replicator layer to show only a single clock, but this seeing-double setup will help us for the next step.

// Show two instances of the clock
replicatorLayer.instanceCount = 2

// Show the second instance 50 points
// to the right of the first
replicatorLayer.instanceTransform
    = CATransform3DMakeTranslation(50, 0, 0)

Our goal is to use this replicator layer to control the relative time of our view by animating the replicator layer’s instanceDelay property. However, instanceDelay doesn’t affect the first instance of our view; it only affects the subsequent copies, which is why we create a copy above.

We want to focus on the second instance of the clock view, so let’s deemphasize the first. We can do this using CAReplicator’s instanceColor and instanceAlphaOffset properties, making our first instance semi-transparent, but our cloned instance fully visible.

// Set the first instance to have a
// 50% transparent color
replicatorLayer.instanceColor = UIColor(
    white: 1,
    alpha: 0.5).cgColor

// Set our subsequent copy to be
// 50 percentage points more opaque than
// our starting color, for a total of 100% opaque
replicatorLayer.instanceAlphaOffset = 0.5

Now for the big jump — we’re going to animate the instanceDelay property of this replicator layer. We’ll do so with a squared-off animation curve, such that the replicated clock experiences no delay at its first station, a two-second delay at its second station, and a four-second delay at its final station.

// Animate the replicator
// layer's 'instanceDelay'
let animation = CAKeyframeAnimation(
    keyPath: "instanceDelay")

// Start with no delay, then 2 second
// delay, then 4 seconds
animation.values = [0, 2, 4]

// Apply the delays as-is
// instead of interpolating between them
animation.calculationMode = .discrete

// Animate for 6 seconds
// (3 positions x 2 seconds apart)
animation.duration = 6

// As before, keep the animation object
// on the layer even after complete
animation.isRemovedOnCompletion = false

// Add the animation to the replicator layer
replicatorLayer.add(
    animation,
    forKey: "instanceDelay")

The original, half-transparent clock behaves just like before, but the replicated instance now appears to play the same animation three times.

The new graphs help show why — each time the clock moves to a new position, the animation we just added causes the replicator layer’s instanceDelay to increase by 2 seconds. The rotation of the replicated clock’s hand is given by the rotation that the original layer would be at (the first graph) minus the instance delay (the second graph), giving the seesaw pattern shown in the final graph. That pattern corresponds to the same animation being shown all three times.

Let’s clean up our view a little bit. We can hide the original instance entirely by setting our instanceColor to start at a completely transparent value. We’ll also remove the instanceTransform so that our replicated clock is not shifted over, and remove the dotted outline while we’re at it.

The end result is that we see one clock again, but the one clock is actually our replicated instance; the primary clock is completely hidden.

// Set the first instance to have a
// completely transparent color
replicatorLayer.instanceColor = UIColor(
    white: 1,
    alpha: 0).cgColor

// Set our subsequent copy to be
// 100 percentage points more opaque than
// our starting color, for a total of 100% opaque
replicatorLayer.instanceAlphaOffset = 1

// Use the default transformation
// to stop shifting over the copy
innerReplicatorLayer.instanceTransform
    = CATransform3DIdentity

We now have one view that appears in different positions across three windows of time, with the animations in sync across those windows. Now we wrap all of the above in a second replicator layer designed to show all three windows of time at once:

// Show 3 instances of the view
outerReplicatorLayer.instanceCount = 3

// Show each subsequent instance
// with a window of time 2 seconds
// ahead of the last
outerReplicatorLayer.instanceDelay = -2

And we’ve done it! Three copies of our view, in an arbitrary layout, perfectly in sync.

Back to Confetti

Now that things are in sync again, we can ditch the clock view. Using the system we set up above, creating our original goal is finally possible.

One tricky part here is translating the above knowledge into something that can work well in an overall view hierarchy. We’re not dealing with actual views here, or even layers, but transformations of a single layer applied via animation.

A nice way to handle this discrepancy is to simply create views anyways; then translate the different views’ properties into something that we can feed into our animation.

Let’s start by creating our layout using empty UIViews where we want to ultimately place the emojis. The actual layout code is not particularly interesting, but the end result looks like this, with borders added around the placement views:

The layout for our emoji display

These five views are exposed via a viewSlots property that we will reference later.

Now we can add our replicator layers, mirroring a similar setup as before. We’ll have an inner replicator layer that we use to manage the relative time of our emoji layer, and an outer replicator layer to do the visible replication:

// Create an inner replicator layer
// that shows one copy of our view
// and hides the original, using the
// opacity tricks from above
let innerReplicatorLayer = CAReplicatorLayer()
innerReplicatorLayer.instanceAlphaOffset = 1.0
innerReplicatorLayer.instanceColor = UIColor(white: 1, alpha: 0.0).cgColor
innerReplicatorLayer.instanceCount = 2

// Create an outer replicator layer
// to show one copy of our view
// for each available view slot,
// each through windows of time
// very far apart
let outerReplicatorLayer = CAReplicatorLayer()
outerReplicatorLayer.instanceCount = viewSlots.count
outerReplicatorLayer.instanceDelay
    = Constants.SomeLargeInstanceDelay

// Add our emoji layer to the
// inner replicator, the inner
// replicator to the outer replicator,
// and the outer replicator to our
// overall view
innerReplicatorLayer.addSublayer(emojiView.layer)
outerReplicatorLayer.addSublayer(innerReplicatorLayer)
view.layer.addSublayer(outerReplicatorLayer)

That’s our entire setup as far as the view & layer hierarchy is concerned; now we just need to add the right animations to show our view.

First, we’ll animate the instanceDelay of our inner replicator layer in order to control the relative time of our emoji layer — creating the seesaw-style graph that we saw earlier.

// Compute the total duration
// of all our time windows
let instanceCount = viewSlots.count
let duration = Constants.SomeLargeInstanceDelay
    * CGFloat(instanceCount)

// Create the actual animation,
// similarly to before...
let instanceDelayAnimation = CAKeyframeAnimation(
    keyPath: "instanceDelay"
)
instanceDelayAnimation.calculationMode = .discrete
instanceDelayAnimation.duration = duration

// ... with instanceDelay values of
// `[delay*0, delay*1, delay*2, ...]`
instanceDelayAnimation.values = (0 ... instanceCount).map {
    CGFloat($0) * Constants.SomeLargeInstanceDelay
}

// Finally, add the animation
innerReplicatorLayer.add(
    instanceDelayAnimation,
    forKey: "instanceDelay")

With this setup, we have five copies of the emoji shown on-screen, but we need to use an animation to move each copy around in its own respective window of time.

We can do this by creating an array of CATransform3D values, one for each of our placeholder views. For each of those views, we look at its position and size to derive the right transformation to display a replicated emoji view in the same location.

// Create an array holding our `transform` values.
// For each view placeholder in our background view...
let transformations = emojiBackgroundView.viewSlots.map {
    placeholderView in

    // Get the origin of the slot's placeholder view,
    // represented in our own view's coordinate system
    let relativeOrigin = view.convert(
        placeholderView.frame.origin,
        from: emojiBackgroundView
    )
    
    // Find the correct scale by comparing the
    // size of the placeholder view with the
    // size of our actual emoji view
    let scale = placeholderView.bounds.size.width
        / emojiView.bounds.size.width
    
    // Create a translation transform
    // to move the instance to the correct spot
    let translationTransform
        = CATransform3DMakeTranslation(
            relativeOrigin.x,
            relativeOrigin.y,
            0)
    
    // Add in a scale transform
    // to adjust our instance's size
    let scaleAndTranslationTransform
        = CATransform3DScale(
            translationTransform,
            scale,
            scale,
            1)
    
    // Return the combined transform
    return scaleAndTranslationTransform
}

We now have a transformations array where each element is the CATransform3D value that will move and scale our emoji instances to the correct positions.

We just need to use that array in an animation of our inner replicator layer’s transform property:

// Create the animaion
let transformAnimation = CAKeyframeAnimation(
    keyPath: "transform"
)
transformAnimation.calculationMode = .discrete
transformAnimation.duration = duration
transformAnimation.isRemovedOnCompletion = false
transformAnimation.values = transformations

// ... and add it to
// our replicator layer
innerReplicatorLayer.add(
    transformAnimation,
    forKey: "transform"
)

And with that: our final view!

We had to jump around a lot to get to this point — if you want to see everything in one place, you can download the project for this final screen on GitHub.

Conclusion

Well, it’s definitely not pretty. Working inside of a CAReplicatorLayer means we can’t really treat our replicated instances like they’re real views (they’re not, after all), which complicates things like layout, animations, and more.

But having some method to take a single view instance and show it wherever and however you like, even with some extensive setup required up front, is still super fun. I’ll take this opportunity to link again to the spacetime library which, while initially suited for a different purpose (and not super up-to-date), should let you do a version of this setup out of the box.

I think it’d be possible to make this particular use case a lot more streamlined — maybe a system that gives you proxy view objects that you can position arbitrarily, and that automatically reflects those changes in this replicator layer setup — but there’s a pretty limited area in between “I need a complex arrangement of in-sync replicated views on screen” and “I am unwilling to build my view in a way that makes that possible by default”, so it might not see a ton of use.

But having found myself in that area — I’m glad there are some options.


Say Hello!