Skip to content

Instantly share code, notes, and snippets.

@salabaha
Created May 14, 2025 16:44
Show Gist options
  • Save salabaha/d42343fff2a091794ce329a1d5627f1b to your computer and use it in GitHub Desktop.
Save salabaha/d42343fff2a091794ce329a1d5627f1b to your computer and use it in GitHub Desktop.
TextKit 2 implementation of shimmer gradient animation
class ShimmerFragment: NSTextLayoutFragment {
/// Phase in -1...+1 indicating center of the narrow gradient band
var phase: CGFloat = 0.5
var gradientRange: NSRange?
override func draw(at point: CGPoint, in context: CGContext) {
// Draw the entire text normally first
super.draw(at: point, in: context)
// Now handle the gradient part
guard let gradientRange = gradientRange,
let textLayoutManager = textLayoutManager,
let textContentManager = textLayoutManager.textContentManager else {
return
}
let attributedString = (textElement!.textContentManager as! NSTextContentStorage).attributedString(for: textElement!)
// Get the text range for the gradient
guard let textRange = textContentManager.textRange(for: gradientRange) else {
return
}
// Save the current graphics state
context.saveGState()
// Helper function to clear the original text
func clearOriginalText(at rect: CGRect, in context: CGContext) {
context.setBlendMode(.clear)
context.fill(rect)
context.setBlendMode(.normal)
}
// Process each text segment in the range
textLayoutManager.enumerateTextSegments(in: textRange, type: .standard, options: []) { (textRange, textSegmentFrame, baselineOffset, textContainer) in
let fragRect = textSegmentFrame
guard !fragRect.isEmpty else { return true }
// Clear the original text
clearOriginalText(at: fragRect, in: context)
// Get the substring for this segment
let range = textContentManager.range(for: textRange!)
let substring = attributedString!.attributedSubstring(from: range!)
if !substring.string.isEmpty {
// Create a shimmer gradient based on the current phase
let shimmerGradient = self.createShimmerGradient(with: self.phase)
// Draw the text with the shimmer gradient
self.drawAttributedStringWithGradient(
substring,
in: context,
rect: fragRect,
gradient: shimmerGradient,
gradientStart: self.calculateGradientStart(),
gradientEnd: self.calculateGradientEnd()
)
}
return true
}
// Restore the graphics state
context.restoreGState()
}
/**
Creates a shimmer gradient based on the current phase value.
- Parameter phase: Current shimmer phase (-1...+1)
- Returns: A CGGradient configured for the shimmer effect
*/
private func createShimmerGradient(with phase: CGFloat) -> CGGradient {
// Define the gradient colors
let baseColor = UIColor.purple//UIColor(white: 0.85, alpha: 1.0)
let shimmerColor = UIColor.blue.withAlphaComponent(0.25) // UIColor(white: 1.0, alpha: 1.0)
// Normalize phase to 0...1 for gradient location calculation
let normalizedPhase = (phase + 1) / 2
// Create gradient with a narrow band of shimmer color
// The width of the shimmer band is controlled by these offsets
let shimmerWidth: CGFloat = 0.9
// Calculate locations for the shimmer band
let leadingEdge = max(0, normalizedPhase - shimmerWidth/2)
let center = normalizedPhase
let trailingEdge = min(1, normalizedPhase + shimmerWidth/2)
// Create colors and locations arrays
let colors = [
baseColor.cgColor,
baseColor.cgColor,
shimmerColor.cgColor,
baseColor.cgColor,
baseColor.cgColor
] as CFArray
let locations: [CGFloat] = [
0.0,
leadingEdge,
center,
trailingEdge,
1.0
]
// Create and return the gradient
let colorSpace = CGColorSpaceCreateDeviceRGB()
return CGGradient(colorsSpace: colorSpace, colors: colors, locations: locations)!
}
/**
Calculates the gradient start point based on the current phase.
- Returns: The normalized start point for the gradient
*/
private func calculateGradientStart() -> CGPoint {
// For a horizontal shimmer effect, use left-to-right gradient
return CGPoint(x: 0, y: 0.5)
}
/**
Calculates the gradient end point based on the current phase.
- Returns: The normalized end point for the gradient
*/
private func calculateGradientEnd() -> CGPoint {
// For a horizontal shimmer effect, use left-to-right gradient
return CGPoint(x: 1, y: 0.5)
}
/**
Draws an NSAttributedString filled with a gradient by using the text as a mask.
- Parameters:
- attributedString: The attributed string to draw
- context: The CGContext to draw in
- rect: The rectangle in which to draw the string
- gradient: The gradient to use for filling the text
- gradientStart: The starting point of the gradient in normalized coordinates (0-1)
- gradientEnd: The ending point of the gradient in normalized coordinates (0-1)
- flipped: Whether the coordinate system is flipped (usually true for UIKit)
*/
func drawAttributedStringWithGradient(_ attributedString: NSAttributedString,
in context: CGContext,
rect: CGRect,
gradient: CGGradient,
gradientStart: CGPoint = CGPoint(x: 0, y: 0),
gradientEnd: CGPoint = CGPoint(x: 1, y: 1),
flipped: Bool = false) {
// Save the current graphics state
context.saveGState()
// Create a temporary bitmap context with the correct scale for high-resolution rendering
let scale = UIScreen.main.scale
let scaledWidth = Int(rect.width * scale)
let scaledHeight = Int(rect.height * scale)
// Use premultiplied last for better alpha handling
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
guard let maskContext = CGContext(
data: nil,
width: scaledWidth,
height: scaledHeight,
bitsPerComponent: 8,
bytesPerRow: 0, // Let Core Graphics calculate the optimal bytes per row
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: bitmapInfo.rawValue
) else {
context.restoreGState()
return
}
// Scale the mask context to match the screen scale
maskContext.scaleBy(x: scale, y: scale)
// Handle coordinate system flipping if needed
if flipped {
maskContext.translateBy(x: 0, y: rect.height)
maskContext.scaleBy(x: 1, y: -1)
}
// Clear the context to transparent
maskContext.clear(CGRect(origin: .zero, size: rect.size))
// Create a modified copy of the attributed string with white text for the mask
let textForMask = NSMutableAttributedString(attributedString: attributedString)
let fullRange = NSRange(location: 0, length: textForMask.length)
// Set text color to white and remove any stroke/shadow attributes that might interfere
textForMask.addAttribute(.foregroundColor, value: UIColor.white, range: fullRange)
textForMask.removeAttribute(.strokeColor, range: fullRange)
textForMask.removeAttribute(.shadow, range: fullRange)
// Draw the text into the mask context
UIGraphicsPushContext(maskContext)
textForMask.draw(in: CGRect(origin: .zero, size: rect.size))
UIGraphicsPopContext()
// Get the mask image
guard let maskImage = maskContext.makeImage() else {
context.restoreGState()
return
}
// Move to the position where we want to draw the text
context.translateBy(x: rect.origin.x, y: rect.origin.y)
// Set up clipping with the mask
context.clip(to: CGRect(origin: .zero, size: rect.size), mask: maskImage)
// Convert normalized gradient points to absolute coordinates
let startPoint = CGPoint(
x: gradientStart.x * rect.width,
y: gradientStart.y * rect.height
)
let endPoint = CGPoint(
x: gradientEnd.x * rect.width,
y: gradientEnd.y * rect.height
)
// Draw the gradient in the clipped region
context.drawLinearGradient(
gradient,
start: startPoint,
end: endPoint,
options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]
)
// Restore the graphics state
context.restoreGState()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment