Skip to content

Instantly share code, notes, and snippets.

@zhangqifan
Created August 1, 2026 09:24
Show Gist options
  • Select an option

  • Save zhangqifan/5d5301da26c0ee79c1e2ae89c04e25d8 to your computer and use it in GitHub Desktop.

Select an option

Save zhangqifan/5d5301da26c0ee79c1e2ae89c04e25d8 to your computer and use it in GitHub Desktop.
AnimatedGlyphLabel — a single-line UIKit label that transitions text one glyph at a time (CoreText layout, per-glyph spring + gaussian blur stagger). Self-contained, iOS 13+, zero dependencies.
#if os(iOS)
import CoreImage
import CoreImage.CIFilterBuiltins
import CoreText
import UIKit
/// A single-line UIKit label that transitions text one glyph at a time.
///
/// The intrinsic content size follows the current text. Glyphs are laid out
/// individually from the leading edge, which suits short strings such as
/// titles, dates, and counters; scripts that require contextual shaping
/// (for example Arabic) are not supported.
///
/// The implementation relies only on APIs available since iOS 13 so the file
/// can be hosted by any target with an iOS 13 deployment floor.
@MainActor
public final class AnimatedGlyphLabel: UIView {
/// Controls the vertical and three-dimensional direction of a transition.
public enum TransitionDirection {
case forward
case backward
var verticalOffset: CGFloat {
switch self {
case .forward: 12
case .backward: -12
}
}
var rotationAngle: CGFloat {
switch self {
case .forward: -.pi / 12
case .backward: .pi / 12
}
}
}
private struct GlyphLayout {
let character: Character
let bounds: CGRect
let position: CGPoint
}
private final class GlyphView: UIView {
private let label = UILabel()
private let blurredImageView = UIImageView()
var blurredLayer: CALayer {
blurredImageView.layer
}
var sharpLayer: CALayer {
label.layer
}
init(font: UIFont, color: UIColor) {
super.init(frame: .zero)
clipsToBounds = false
backgroundColor = .clear
isUserInteractionEnabled = false
blurredImageView.contentMode = .center
blurredImageView.isUserInteractionEnabled = false
addSubview(blurredImageView)
label.font = font
label.textColor = color
label.backgroundColor = .clear
label.textAlignment = .center
label.isUserInteractionEnabled = false
addSubview(label)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
label.frame = bounds
blurredImageView.bounds = CGRect(origin: .zero, size: blurredImageView.image?.size ?? .zero)
blurredImageView.center = CGPoint(x: bounds.midX, y: bounds.midY)
}
func configure(character: Character, bounds: CGRect, blurredImage: UIImage?) {
label.text = String(character)
blurredImageView.image = blurredImage
self.bounds = bounds
setNeedsLayout()
layoutIfNeeded()
}
func clear() {
label.text = nil
blurredImageView.image = nil
sharpLayer.opacity = 1
blurredLayer.opacity = 0
}
}
private static let animationDuration: TimeInterval = 0.5
private static let minimumGlyphDelay: TimeInterval = 0.014
private static let maximumGlyphDelay: TimeInterval = 0.08
private static let targetGlyphStaggerDuration: TimeInterval = 0.14
private static let transitionScale: CGFloat = 0.92
private static let glyphBlurRadius: CGFloat = 2.25
private static let glyphBlurPadding: CGFloat = 7
/// The currently displayed text.
public private(set) var text: String
private let font: UIFont
private let textColor: UIColor
private let blurContext = CIContext(options: [.cacheIntermediates: true])
private var measuredContentSize: CGSize
private var blurredGlyphCache = [Character: UIImage]()
private var blurredGlyphCacheColor: UIColor?
private var currentGlyphViews = [GlyphView]()
private var incomingGlyphViews = [GlyphView]()
private var currentGlyphCount = 0
private var pendingGlyphCount = 0
private var transitionIsRunning = false
private var animationGeneration = 0
public init(text: String, font: UIFont, textColor: UIColor) {
self.text = text
self.font = font
self.textColor = textColor
measuredContentSize = Self.measuredSize(of: text, font: font)
super.init(frame: .zero)
clipsToBounds = false
backgroundColor = .clear
isAccessibilityElement = true
accessibilityTraits = .staticText
accessibilityLabel = text
let layouts = makeLayouts(for: text)
ensureGlyphViewCapacity(layouts.count)
configure(currentGlyphViews, with: layouts, offset: 0, scale: 1, opacity: 1)
configure(incomingGlyphViews, with: [], offset: 0, scale: 1, opacity: 0)
currentGlyphCount = layouts.count
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override var intrinsicContentSize: CGSize {
measuredContentSize
}
public override func sizeThatFits(_ size: CGSize) -> CGSize {
measuredContentSize
}
/// Updates the displayed text, optionally using the glyph transition.
///
/// The transition is skipped when `animated` is false, animations are
/// globally disabled, or Reduce Motion is enabled.
public func setText(_ newText: String, animated: Bool, direction: TransitionDirection = .forward) {
guard text != newText else { return }
finishPendingAnimation()
let newLayouts = makeLayouts(for: newText)
ensureGlyphViewCapacity(newLayouts.count)
text = newText
accessibilityLabel = newText
pendingGlyphCount = newLayouts.count
updateMeasuredContentSize(for: newText)
let shouldAnimate = animated
&& UIView.areAnimationsEnabled
&& !UIAccessibility.isReduceMotionEnabled
guard shouldAnimate else {
configure(currentGlyphViews, with: [], offset: 0, scale: 1, opacity: 0)
configure(incomingGlyphViews, with: newLayouts, offset: 0, scale: 1, opacity: 1)
swap(&currentGlyphViews, &incomingGlyphViews)
currentGlyphCount = pendingGlyphCount
return
}
let offset = direction.verticalOffset
configure(
incomingGlyphViews,
with: newLayouts,
offset: offset,
scale: Self.transitionScale,
rotationX: direction.rotationAngle,
opacity: 0
)
animationGeneration &+= 1
let currentGeneration = animationGeneration
transitionIsRunning = true
let incomingGlyphDelay = Self.glyphDelay(forGlyphCount: newLayouts.count)
let outgoingGlyphDelay = Self.glyphDelay(forGlyphCount: currentGlyphCount)
for (index, layout) in newLayouts.enumerated() {
addTransition(
to: incomingGlyphViews[index],
position: layout.position,
transform: CATransform3DIdentity,
opacity: 1,
delay: incomingGlyphDelay * TimeInterval(index),
appearing: true
)
}
for index in 0..<currentGlyphCount {
let glyphView = currentGlyphViews[index]
addTransition(
to: glyphView,
position: CGPoint(x: glyphView.layer.position.x, y: glyphView.layer.position.y - offset),
transform: Self.transitionTransform(
scale: Self.transitionScale,
rotationX: -direction.rotationAngle
),
opacity: 0,
delay: outgoingGlyphDelay * TimeInterval(index),
appearing: false
)
}
let incomingDuration = Self.animationDuration
+ incomingGlyphDelay * TimeInterval(max(0, newLayouts.count - 1))
let outgoingDuration = Self.animationDuration
+ outgoingGlyphDelay * TimeInterval(max(0, currentGlyphCount - 1))
let totalDuration = max(incomingDuration, outgoingDuration)
DispatchQueue.main.asyncAfter(deadline: .now() + totalDuration) { [weak self] in
guard let self, animationGeneration == currentGeneration else { return }
completeTransition()
}
}
private func ensureGlyphViewCapacity(_ glyphCount: Int) {
while currentGlyphViews.count < glyphCount {
let glyphView = makeGlyphView()
currentGlyphViews.append(glyphView)
addSubview(glyphView)
}
while incomingGlyphViews.count < glyphCount {
let glyphView = makeGlyphView()
incomingGlyphViews.append(glyphView)
addSubview(glyphView)
}
}
private func updateMeasuredContentSize(for text: String) {
let size = Self.measuredSize(of: text, font: font)
guard size != measuredContentSize else { return }
measuredContentSize = size
invalidateIntrinsicContentSize()
}
private func addTransition(
to glyphView: GlyphView,
position: CGPoint,
transform: CATransform3D,
opacity: Float,
delay: TimeInterval,
appearing: Bool
) {
let layer = glyphView.layer
let positionAnimation = makeSpringAnimation(
keyPath: #keyPath(CALayer.position),
fromValue: NSValue(cgPoint: layer.position),
toValue: NSValue(cgPoint: position)
)
let transformAnimation = makeSpringAnimation(
keyPath: #keyPath(CALayer.transform),
fromValue: NSValue(caTransform3D: layer.transform),
toValue: NSValue(caTransform3D: transform)
)
let opacityAnimation = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
opacityAnimation.fromValue = NSNumber(value: layer.opacity)
opacityAnimation.toValue = NSNumber(value: opacity)
opacityAnimation.duration = 0.24
opacityAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut)
CATransaction.begin()
CATransaction.setDisableActions(true)
layer.position = position
layer.transform = transform
layer.opacity = opacity
CATransaction.commit()
let animation = CAAnimationGroup()
animation.animations = [positionAnimation, transformAnimation, opacityAnimation]
animation.beginTime = layer.convertTime(CACurrentMediaTime(), from: nil) + delay
animation.duration = Self.animationDuration
animation.fillMode = .backwards
layer.add(animation, forKey: "AnimatedGlyphLabel.transition")
addBlurTransition(to: glyphView, delay: delay, appearing: appearing)
}
private func makeSpringAnimation(keyPath: String, fromValue: Any, toValue: Any) -> CASpringAnimation {
let animation = CASpringAnimation(keyPath: keyPath)
animation.fromValue = fromValue
animation.toValue = toValue
animation.mass = 1
animation.stiffness = 260
animation.damping = 20
animation.initialVelocity = 0.35
animation.duration = Self.animationDuration
return animation
}
private static func transitionTransform(scale: CGFloat, rotationX: CGFloat) -> CATransform3D {
guard scale != 1 || rotationX != 0 else { return CATransform3DIdentity }
var transform = CATransform3DIdentity
transform.m34 = -1 / 480
transform = CATransform3DRotate(transform, rotationX, 1, 0, 0)
return CATransform3DScale(transform, scale, scale, 1)
}
private static func glyphDelay(forGlyphCount glyphCount: Int) -> TimeInterval {
guard glyphCount > 1 else { return 0 }
let delay = targetGlyphStaggerDuration / TimeInterval(glyphCount - 1)
return min(maximumGlyphDelay, max(minimumGlyphDelay, delay))
}
private func addBlurTransition(to glyphView: GlyphView, delay: TimeInterval, appearing: Bool) {
let blurredLayer = glyphView.blurredLayer
let animation: CAAnimation
if appearing {
let opacityAnimation = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
opacityAnimation.fromValue = 0.8
opacityAnimation.toValue = 0
opacityAnimation.duration = 0.3
opacityAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut)
animation = opacityAnimation
} else {
let opacityAnimation = CAKeyframeAnimation(keyPath: #keyPath(CALayer.opacity))
opacityAnimation.values = [0, 0.65, 0]
opacityAnimation.keyTimes = [0, 0.45, 1]
opacityAnimation.duration = 0.34
opacityAnimation.timingFunctions = [
CAMediaTimingFunction(name: .easeOut),
CAMediaTimingFunction(name: .easeIn),
]
animation = opacityAnimation
}
CATransaction.begin()
CATransaction.setDisableActions(true)
blurredLayer.opacity = 0
CATransaction.commit()
animation.beginTime = blurredLayer.convertTime(CACurrentMediaTime(), from: nil) + delay
animation.fillMode = .backwards
blurredLayer.add(animation, forKey: "AnimatedGlyphLabel.blurTransition")
guard appearing else { return }
let sharpLayer = glyphView.sharpLayer
let sharpAnimation = CABasicAnimation(keyPath: #keyPath(CALayer.opacity))
sharpAnimation.fromValue = 0
sharpAnimation.toValue = 1
sharpAnimation.duration = 0.22
sharpAnimation.beginTime = sharpLayer.convertTime(CACurrentMediaTime(), from: nil) + delay + 0.07
sharpAnimation.fillMode = .backwards
sharpAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut)
CATransaction.begin()
CATransaction.setDisableActions(true)
sharpLayer.opacity = 1
CATransaction.commit()
sharpLayer.add(sharpAnimation, forKey: "AnimatedGlyphLabel.sharpTransition")
}
private func makeLayouts(for text: String) -> [GlyphLayout] {
let height = ceil(font.lineHeight)
let line = Self.makeLine(for: text, font: font)
var utf16Offset = 0
var layouts = [GlyphLayout]()
layouts.reserveCapacity(text.count)
for character in text {
let glyphText = String(character)
let width = max(1, (glyphText as NSString).size(withAttributes: [.font: font]).width)
let size = CGSize(width: width, height: height)
let originX = CTLineGetOffsetForStringIndex(line, utf16Offset, nil)
layouts.append(
GlyphLayout(
character: character,
bounds: CGRect(origin: .zero, size: size),
position: CGPoint(x: originX + width / 2, y: height / 2)
)
)
utf16Offset += glyphText.utf16.count
}
return layouts
}
private static func measuredSize(of text: String, font: UIFont) -> CGSize {
CGSize(
width: ceil(measuredWidth(of: text, font: font)),
height: ceil(font.lineHeight)
)
}
private static func measuredWidth(of text: String, font: UIFont) -> CGFloat {
CGFloat(CTLineGetTypographicBounds(makeLine(for: text, font: font), nil, nil, nil))
}
private static func makeLine(for text: String, font: UIFont) -> CTLine {
let attributedText = NSAttributedString(
string: text,
attributes: [
.font: font,
.ligature: 0,
]
)
return CTLineCreateWithAttributedString(attributedText)
}
private func makeGlyphView() -> GlyphView {
GlyphView(font: font, color: textColor)
}
private func configure(
_ glyphViews: [GlyphView],
with layouts: [GlyphLayout],
offset: CGFloat,
scale: CGFloat,
rotationX: CGFloat = 0,
opacity: Float
) {
CATransaction.begin()
CATransaction.setDisableActions(true)
for (index, glyphView) in glyphViews.enumerated() {
glyphView.layer.removeAllAnimations()
glyphView.sharpLayer.removeAllAnimations()
glyphView.blurredLayer.removeAllAnimations()
guard index < layouts.count else {
glyphView.clear()
glyphView.layer.opacity = 0
continue
}
let layout = layouts[index]
glyphView.configure(
character: layout.character,
bounds: layout.bounds,
blurredImage: blurredImage(for: layout.character)
)
glyphView.layer.position = CGPoint(x: layout.position.x, y: layout.position.y + offset)
glyphView.layer.transform = Self.transitionTransform(scale: scale, rotationX: rotationX)
glyphView.layer.opacity = opacity
glyphView.sharpLayer.opacity = 1
glyphView.blurredLayer.opacity = 0
}
CATransaction.commit()
}
private func blurredImage(for character: Character) -> UIImage? {
let resolvedTextColor = textColor.resolvedColor(with: traitCollection)
if resolvedTextColor != blurredGlyphCacheColor {
blurredGlyphCache.removeAll()
blurredGlyphCacheColor = resolvedTextColor
}
if let cachedImage = blurredGlyphCache[character] {
return cachedImage
}
let text = String(character)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: resolvedTextColor,
]
let glyphSize = CGSize(
width: max(1, ceil((text as NSString).size(withAttributes: attributes).width)),
height: ceil(font.lineHeight)
)
let imageSize = CGSize(
width: glyphSize.width + Self.glyphBlurPadding * 2,
height: glyphSize.height + Self.glyphBlurPadding * 2
)
let renderer = UIGraphicsImageRenderer(size: imageSize)
let sourceImage = renderer.image { _ in
(text as NSString).draw(
in: CGRect(
x: Self.glyphBlurPadding,
y: Self.glyphBlurPadding,
width: glyphSize.width,
height: glyphSize.height
),
withAttributes: attributes
)
}
guard let cgImage = sourceImage.cgImage else { return nil }
let inputImage = CIImage(cgImage: cgImage)
let blurFilter = CIFilter.gaussianBlur()
blurFilter.inputImage = inputImage
blurFilter.radius = Float(Self.glyphBlurRadius * sourceImage.scale)
guard let outputImage = blurFilter.outputImage,
let outputCGImage = blurContext.createCGImage(outputImage, from: inputImage.extent)
else {
return nil
}
let blurredImage = UIImage(cgImage: outputCGImage, scale: sourceImage.scale, orientation: .up)
blurredGlyphCache[character] = blurredImage
return blurredImage
}
private func finishPendingAnimation() {
animationGeneration &+= 1
guard transitionIsRunning else { return }
CATransaction.begin()
CATransaction.setDisableActions(true)
for glyphView in currentGlyphViews + incomingGlyphViews {
glyphView.layer.removeAllAnimations()
glyphView.sharpLayer.removeAllAnimations()
glyphView.blurredLayer.removeAllAnimations()
}
CATransaction.commit()
configure(currentGlyphViews, with: [], offset: 0, scale: 1, opacity: 0)
swap(&currentGlyphViews, &incomingGlyphViews)
currentGlyphCount = pendingGlyphCount
transitionIsRunning = false
}
private func completeTransition() {
configure(currentGlyphViews, with: [], offset: 0, scale: 1, opacity: 0)
swap(&currentGlyphViews, &incomingGlyphViews)
currentGlyphCount = pendingGlyphCount
transitionIsRunning = false
}
}
#endif
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment