Created
August 1, 2026 09:24
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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(¤tGlyphViews, &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(¤tGlyphViews, &incomingGlyphViews) | |
| currentGlyphCount = pendingGlyphCount | |
| transitionIsRunning = false | |
| } | |
| private func completeTransition() { | |
| configure(currentGlyphViews, with: [], offset: 0, scale: 1, opacity: 0) | |
| swap(¤tGlyphViews, &incomingGlyphViews) | |
| currentGlyphCount = pendingGlyphCount | |
| transitionIsRunning = false | |
| } | |
| } | |
| #endif |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment