Recreating iMessage's Sticker Peel-Off Effect
Tags: ios, swift, programming, mobile
Published: 2023-06-21 14:04:18

Since the release of iOS 10, iMessage has grown from a simple text messaging app into a lively platform filled with various features. Among these, the introduction of expressive stickers has been a major highlight.

True to form, Apple extended its celebrated eye for detail into this feature as well. The seemingly straightforward act of applying a sticker onto a message transcends the usual translate-and-scale animations seen on other platforms. Instead, Apple applies the "sticker" metaphor literally, as the chosen item deforms to mimic the physical action of peeling off an actual sticker.

In this article, we'll reconstruct this particular effect from scratch, leveraging basic trigonometry and some lesser-known features of UIKit.

## The End Result

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mlfhavbjzjleu5i2zjgy.gif)

By the end of this article, we will achieve the peel-off effect seen here. Take note of the image's seamless, non-linear bending and the vibrant shadow.

## Bending Images

UIKit does not provide any public APIs that enable us to apply arbitrary non-affine transformations to views. There is a private API, `CAMeshTransform` (see this [excellent exploration by Bartosz Ciechanowski](https://ciechanow.ski/mesh-transforms/)), but using private APIs is strongly discouraged, as it may lead to Apple rejecting your app upon submission to the App Store.

Given these constraints, we'll opt for the next best alternative, which involves separating an image into several independent layers and manually arranging them to create the illusion of non-uniform transformation.

From this point forward, assume that all provided code exists within the context of a UIView-derived class (for instance, StickerEffectView). To begin, we'll define the array that will hold the image segments:

```
let containerLayer = CALayer()
var segmentLayers: [CALayer] = []
```

And define the parameters that will affect the presentation:

```
let inset: CGFloat = 20.0
let elevation: CGFloat = 60.0
```

Here, `inset` defines to the size of the "edge" that extends beyond our sticker image, allowing us to add a shadow effect later; `elevation` impacts the perceived height at which the sticker will appear to float when we apply the effect.

Next, we need to arrange the segment layers in a way that assigns each one a portion of the image:

```
func rebuildLayers() {
    if bounds.isEmpty {
        return
    }
    
    let segmentCount = 20
    let boundingSize = CGSize(width: bounds.width + inset * 2.0, height: bounds.height + inset * 2.0)
    let segmentHeight = boundingSize.height / CGFloat(segmentCount)
    
    for i in 0 ..< segmentCount {
        if segmentLayers.count <= i {
            let segmentLayer = CALayer()
            
            segmentLayer.anchorPoint = CGPoint()
            
            let segmentFrame = CGRect(origin: CGPoint(x: 0, y: CGFloat(i) * segmentHeight), size: CGSize(width: boundingSize.width, height: segmentHeight))
            let segmentContentsRect = CGRect(origin: CGPoint(x: segmentFrame.minX / boundingSize.width, y: segmentFrame.minY / boundingSize.height), size: CGSize(width: segmentFrame.width / boundingSize.width, height: segmentFrame.height / boundingSize.height))
            
            segmentLayer.contentsRect = segmentContentsRect
            
            containerLayer.addSublayer(segmentLayer)
            segmentLayers.append(segmentLayer)
        }
    }
}
```

In this function, we divide the image into `segmentCount` vertical parts. Each segment is assigned with the exact same image content. To enable them to display only their respective parts, we'll utilize the somewhat obscure `contentsRect` property of `CALayer`. In essence, this property lets us "crop" the image using the GPU, removing the need for doing that on the CPU.

Lastly, we will specify the function that updates the segments' positions and rotations to align with our peel-off wave animation:

```
func updateLayers(fraction: CGFloat, reverse: Bool) {
    let segmentContents = contentImage?.cgImage
    
    for i in 0 ..< segmentLayers.count {
        let segmentLayer = segmentLayers[i]
        
        segmentLayer.contents = segmentContents
        
        let topFraction: CGFloat = CGFloat(i) / CGFloat(segmentLayers.count)
        let bottomFraction: CGFloat = CGFloat(i + 1) / CGFloat(segmentLayers.count)
        
        let topZ = elevation * valueAt(fraction: fraction, t: topFraction, reverse: reverse)
        let bottomZ = elevation * valueAt(fraction: fraction, t: bottomFraction, reverse: reverse)
        
        let topY = -inset + topFraction * (bounds.height + inset * 2.0)
        let bottomY = -inset + bottomFraction * (bounds.height + inset * 2.0)
        
        let dy = bottomY - topY
        let dz = bottomZ - topZ
        let angle = -atan2(dy, dz) + .pi * 0.5
        
        segmentLayer.zPosition = topZ
        segmentLayer.transform = CATransform3DMakeRotation(angle, 1.0, 0.0, 0.0)
        
        let segmentHeight: CGFloat = sqrt(dy * dy + dz * dz)
        
        segmentLayer.position = CGPoint(x: -inset, y: topY)
        
        segmentLayer.bounds = CGRect(origin: CGPoint(), size: CGSize(width: bounds.width + inset * 2.0, height: segmentHeight))
    }
}
```

In this function, `fraction` represents the animation's progress (ranging from 0.0 to 1.0), and `reverse` indicates whether it's a "peel-off" (reverse: false) or a "put-back" (reverse: true) phase. For each layer, the function calculates the locations of its top and bottom edges and positions the layer in a way that accommodates the bending effect.

The `updateLayers` function calls the `valueAt` function, which we have yet to define. In mathematical terms, `valueAt` defines a scalar field (i.e., it returns a floating point number at any given location) that generates the wave effect:

```
func valueAt(fraction: CGFloat, t: CGFloat, reverse: Bool) -> CGFloat {
    let windowSize: CGFloat = 0.8
    
    let effectiveT: CGFloat
    let windowStartOffset: CGFloat
    let windowEndOffset: CGFloat
    if reverse {
        effectiveT = 1.0 - t
        windowStartOffset = 1.0
        windowEndOffset = -windowSize
    } else {
        effectiveT = t
        windowStartOffset = -windowSize
        windowEndOffset = 1.0
    }
    
    let windowPosition = (1.0 - fraction) * windowStartOffset + fraction * windowEndOffset
    let windowT = max(0.0, min(windowSize, effectiveT - windowPosition)) / windowSize
    let localT = 1.0 - windowFunction(t: windowT)
    
    return localT
}
```

In this function, `windowSize` determines the portion of the total image height that will be included in the animatable area. Any area before the window is assigned a value of 1.0 (indicating "peeled-off"), and any area after the window is 0.0 (indicating a stationary position).

The `windowFunction` is a separate function that allows us to  experiment with the shape of the wave. For the sake of simplicity, we'll initially set it as a linear function:

```
func windowFunction(t: CGFloat) -> CGFloat {
    return t
}
```

## Making it Smoother

If we execute the code we've written so far, we observe the following result:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/eh9aupg6doej302jm8lw.gif)

While it looks acceptable, the animation appears a bit harsh and mechanical. We're aiming for a shape that's more along these lines:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nnu5b2r95b0pumo20crp.jpg)

Thankfully, we can easily accomplish this by updating our window function to utilize a Bézier curve instead of a straight line:

```
func windowFunction(t: CGFloat) -> CGFloat {
    return evaluateBezier(0.5, 0.0, 0.5, 1.0, t)
}
```

With this straightforward modification, the animation becomes significantly smoother and more appealing:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o8gvep71n1vnqo7hwstx.gif)

## Adding the Shadow

The shadow effect can be replicated by adding another set of segments that display a blurred version of the sticker:

```
let shadowContainerLayer: CALayer
var shadowSegmentLayers: [CALayer] = []
...
func rebuildLayers() {
    if bounds.isEmpty {
        return
    }
    
    let segmentCount = 20
    let boundingSize = CGSize(width: bounds.width + inset * 2.0, height: bounds.height + inset * 2.0)
    let segmentHeight = boundingSize.height / CGFloat(segmentCount)
    
    for i in 0 ..< segmentCount {
        if segmentLayers.count <= i {
            let segmentLayer = SegmentLayer()
            let shadowSegmentLayer = SegmentLayer()
            
            segmentLayer.anchorPoint = CGPoint()
            shadowSegmentLayer.anchorPoint = CGPoint()
            
            let segmentFrame = CGRect(origin: CGPoint(x: 0, y: CGFloat(i) * segmentHeight), size: CGSize(width: boundingSize.width, height: segmentHeight))
            let segmentContentsRect = CGRect(origin: CGPoint(x: segmentFrame.minX / boundingSize.width, y: segmentFrame.minY / boundingSize.height), size: CGSize(width: segmentFrame.width / boundingSize.width, height: segmentFrame.height / boundingSize.height))
            
            segmentLayer.contentsRect = segmentContentsRect
            shadowSegmentLayer.contentsRect = segmentContentsRect
            
            containerLayer.addSublayer(segmentLayer)
            shadowContainerLayer.addSublayer(shadowSegmentLayer)
            
            segmentLayers.append(segmentLayer)
            shadowSegmentLayers.append(shadowSegmentLayer)
        }
    }
}
...
func updateLayers(fraction: CGFloat, reverse: Bool) {
    let segmentContents = contentImage?.cgImage
    let shadowSegmentContents = shadowContentImage?.cgImage

    for i in 0 ..< segmentLayers.count {
        let segmentLayer = segmentLayers[i]
        let shadowSegmentLayer = shadowSegmentLayers[i]
        
        segmentLayer.contents = segmentContents
        shadowSegmentLayer.contents = shadowSegmentContents
        
        let topFraction: CGFloat = CGFloat(i) / CGFloat(segmentLayers.count)
        let bottomFraction: CGFloat = CGFloat(i + 1) / CGFloat(segmentLayers.count)
        
        let topZ = elevation * valueAt(fraction: fraction, t: topFraction, reverse: reverse)
        let bottomZ = elevation * valueAt(fraction: fraction, t: bottomFraction, reverse: reverse)
        
        let topY = -inset + topFraction * (bounds.height + inset * 2.0)
        let bottomY = -inset + bottomFraction * (bounds.height + inset * 2.0)
        
        let dy = bottomY - topY
        let dz = bottomZ - topZ
        let angle = -atan2(dy, dz) + .pi * 0.5
        
        segmentLayer.zPosition = topZ
        segmentLayer.transform = CATransform3DMakeRotation(angle, 1.0, 0.0, 0.0)
        
        shadowSegmentLayer.zPosition = segmentLayer.zPosition
        shadowSegmentLayer.transform = segmentLayer.transform
        
        let segmentHeight: CGFloat = sqrt(dy * dy + dz * dz)
        
        segmentLayer.position = CGPoint(x: -inset, y: topY)
        shadowSegmentLayer.position = segmentLayer.position
        
        segmentLayer.bounds = CGRect(origin: CGPoint(), size: CGSize(width: bounds.width + inset * 2.0, height: segmentHeight))
        shadowSegmentLayer.bounds = segmentLayer.bounds
    }
}
```

We're now very close to achieving the desired effect:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zozpmz9ortvvzom9cutj.gif)

While the shadow looks good, it would be more effective if it were only rendered beneath the peeled-off portions of the sticker. To fix this, we'll implement a simple gradient mask over the shadow container:

```
let shadowMaskGradient: CAGradientLayer
...
shadowContainerLayer.mask = shadowMaskGradient
...
func updateLayers(fraction: CGFloat, reverse: Bool) {
...
    shadowMaskGradient.colors = (0 ... segmentLayers.count).map { i in
        let t = CGFloat(i) / CGFloat(segmentLayers.count)
        return UIColor(white: 1.0, alpha: valueAt(fraction: fraction, t: t, reverse: reverse)).cgColor
    }
...
}
```

Finally, we can see our peel-off effect in action:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/871ov2bfw4obu2r87b7e.gif)

## Bonus

The iMessage peel-off effect includes a subtle sheen that runs though the sticker during the animation. This can be easily added by incorporating an additional layer of image segments that functions as a mask for the glare. The glare itself can be created with a simple gradient:

![](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2v8u59s6r1ltgmz8n09w.gif)

I won't include the code for the glare here, as it primarily reiterates the setup and update functions we've already discussed. The full version can be found at the link below.

## Conclusion

Visual effects often appear to be magic, until you learn the principles behind them yourself. The iMessage sticker peel-off animation is a visually captivating effect that can add a sense of joy and dynamism in your user interface.

The complete source code provided at https://github.com/petertechstories/sticker-effect contains a number of auxiliary functions that were omitted in this article for clarity and brevity.

Happy coding!