DEV Community

Cover image for Recreating iMessage's Sticker Peel-Off Effect
Peter Iakovlev
Peter Iakovlev

Posted on

Recreating iMessage's Sticker Peel-Off Effect

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

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), 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] = []
Enter fullscreen mode Exit fullscreen mode

And define the parameters that will affect the presentation:

let inset: CGFloat = 20.0
let elevation: CGFloat = 60.0
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Making it Smoother

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

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

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)
}
Enter fullscreen mode Exit fullscreen mode

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

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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
    }
...
}
Enter fullscreen mode Exit fullscreen mode

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

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:

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!

Top comments (1)

Collapse
 
hongminpark_82 profile image
hongminpark

Hi! Thank you so much for your code.
How can I apply peel-off effect only once, no reverse and without any loop ?
I want to keep the state after the enlarged state.