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] = []
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:
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)
}
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
}
}
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
}
...
}
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)
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.