Created
May 14, 2025 16:44
-
-
Save salabaha/d42343fff2a091794ce329a1d5627f1b to your computer and use it in GitHub Desktop.
TextKit 2 implementation of shimmer gradient animation
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
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