mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
237 lines
7.8 KiB
Swift
237 lines
7.8 KiB
Swift
//
|
|
// TextTruncationEffect.swift
|
|
// Yattee
|
|
//
|
|
// Animated text truncation effect with "...more" indicator.
|
|
// Adapted from Balaji Venkatesh's TruncationEffect implementation.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
#if os(macOS)
|
|
import AppKit
|
|
#else
|
|
import UIKit
|
|
#endif
|
|
|
|
extension Text {
|
|
/// Applies an animated truncation effect that shows "...more" when collapsed.
|
|
/// - Parameters:
|
|
/// - length: Number of lines to show when truncated
|
|
/// - isEnabled: Whether truncation is enabled (false = expanded)
|
|
/// - animation: Animation to use for expand/collapse transitions
|
|
@ViewBuilder
|
|
func truncationEffect(length: Int, isEnabled: Bool, animation: Animation) -> some View {
|
|
self.modifier(
|
|
TruncationEffectViewModifier(
|
|
length: length,
|
|
isEnabled: isEnabled,
|
|
animation: animation
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - View Modifier
|
|
|
|
private struct TruncationEffectViewModifier: ViewModifier {
|
|
var length: Int
|
|
var isEnabled: Bool
|
|
var animation: Animation
|
|
|
|
@State private var limitedSize: CGSize = .zero
|
|
@State private var fullSize: CGSize = .zero
|
|
@State private var animatedProgress: CGFloat = 0
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.lineLimit(length)
|
|
.opacity(0)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.onGeometryChange(for: CGSize.self) {
|
|
$0.size
|
|
} action: { newValue in
|
|
limitedSize = newValue
|
|
}
|
|
.frame(height: isExpanded ? fullSize.height : nil)
|
|
.overlay {
|
|
// Full content with animation
|
|
GeometryReader { proxy in
|
|
let contentSize = proxy.size
|
|
|
|
content
|
|
.textRenderer(
|
|
TruncationTextRenderer(
|
|
length: length,
|
|
progress: animatedProgress
|
|
)
|
|
)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.onGeometryChange(for: CGSize.self) {
|
|
$0.size
|
|
} action: { newValue in
|
|
fullSize = newValue
|
|
}
|
|
.frame(
|
|
width: contentSize.width,
|
|
height: contentSize.height,
|
|
alignment: isExpanded ? .leading : .topLeading
|
|
)
|
|
}
|
|
}
|
|
.contentShape(.rect)
|
|
.onChange(of: isEnabled) { _, newValue in
|
|
withAnimation(animation) {
|
|
animatedProgress = !newValue ? 1 : 0
|
|
}
|
|
}
|
|
.onAppear {
|
|
// Set initial value without animation
|
|
animatedProgress = !isEnabled ? 1 : 0
|
|
}
|
|
}
|
|
|
|
var isExpanded: Bool {
|
|
animatedProgress == 1
|
|
}
|
|
}
|
|
|
|
// MARK: - Text Renderer
|
|
|
|
@Animatable
|
|
private struct TruncationTextRenderer: TextRenderer {
|
|
@AnimatableIgnored var length: Int
|
|
var progress: CGFloat
|
|
|
|
/// Minimum number of hidden characters to show "...more" indicator.
|
|
/// If less than this, just show the full text without truncation indicator.
|
|
private let minHiddenCharacters = 10
|
|
|
|
func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {
|
|
let totalLines = layout.count
|
|
let hasExtraLines = totalLines > length
|
|
|
|
// Count characters in lines beyond the limit
|
|
let hiddenCharacterCount: Int = {
|
|
guard hasExtraLines else { return 0 }
|
|
var count = 0
|
|
for index in length..<totalLines {
|
|
count += layout[index].flatMap { $0 }.count
|
|
}
|
|
return count
|
|
}()
|
|
|
|
// If hidden content is too short, don't show "...more" - just draw normally
|
|
let shouldShowMoreIndicator = hasExtraLines && hiddenCharacterCount >= minHiddenCharacters
|
|
|
|
for (index, line) in layout.enumerated() {
|
|
var copyContext = ctx
|
|
if index == length - 1 && shouldShowMoreIndicator {
|
|
drawMoreTextAtEnd(line: line, context: ©Context)
|
|
} else {
|
|
if index < length {
|
|
// Draw all visible lines
|
|
copyContext.draw(line)
|
|
} else if shouldShowMoreIndicator {
|
|
drawLinesWithBlurEffect(index: index, layout: layout, in: ©Context)
|
|
} else {
|
|
// No "...more" indicator - just draw with fade based on progress
|
|
copyContext.opacity = progress
|
|
copyContext.draw(line)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func drawLinesWithBlurEffect(index: Int, layout: Text.Layout, in ctx: inout GraphicsContext) {
|
|
let line = layout[index]
|
|
|
|
let lineIndex = Double(index - length)
|
|
let totalExtraLines = Double(layout.count - length)
|
|
|
|
// Divide the animation progress among all extra lines
|
|
let lineStartProgress = lineIndex / max(1, totalExtraLines)
|
|
let lineEndProgress = (lineIndex + 1) / max(1, totalExtraLines)
|
|
|
|
// Calculate this specific line's progress
|
|
let lineProgress = max(0, min(1, (progress - lineStartProgress) / (lineEndProgress - lineStartProgress)))
|
|
|
|
ctx.opacity = lineProgress
|
|
ctx.addFilter(.blur(radius: blurRadius - (blurRadius * lineProgress)))
|
|
ctx.draw(line)
|
|
}
|
|
|
|
private func drawMoreTextAtEnd(line: Text.Layout.Element, context: inout GraphicsContext) {
|
|
let runs = line.flatMap { $0 }
|
|
let runsCount = runs.count
|
|
let text = " ..." + String(localized: "common.more")
|
|
let textCount = text.count
|
|
|
|
// Draw runs until the text count
|
|
for index in 0..<max(runsCount - textCount, 0) {
|
|
let run = runs[index]
|
|
context.draw(run)
|
|
}
|
|
|
|
// Draw remaining runs with opacity filter
|
|
for index in max(runsCount - textCount, 0)..<runsCount {
|
|
let run = runs[index]
|
|
context.opacity = progress
|
|
context.draw(run)
|
|
}
|
|
|
|
// Draw "...more" text
|
|
let textRunIndex = max(runsCount - textCount, 0)
|
|
var typography: Text.Layout.TypographicBounds
|
|
if !runs.isEmpty {
|
|
typography = runs[textRunIndex].typographicBounds
|
|
} else {
|
|
typography = line.typographicBounds
|
|
}
|
|
|
|
let fontSize: CGFloat = typography.ascent
|
|
let font = platformFont(ofSize: fontSize)
|
|
|
|
let spacing: CGFloat = textWidth(text, font: font) / 2
|
|
|
|
let swiftUIText = Text(text)
|
|
.font(Font(font))
|
|
.foregroundStyle(.secondary)
|
|
|
|
let origin = CGPoint(
|
|
x: typography.rect.minX + spacing,
|
|
y: typography.rect.midY
|
|
)
|
|
|
|
context.opacity = 1 - progress
|
|
context.draw(swiftUIText, at: origin)
|
|
}
|
|
|
|
private var blurRadius: CGFloat {
|
|
5
|
|
}
|
|
|
|
// MARK: - Cross-Platform Font Helpers
|
|
|
|
#if os(macOS)
|
|
private func platformFont(ofSize size: CGFloat) -> NSFont {
|
|
NSFont.systemFont(ofSize: size)
|
|
}
|
|
|
|
private func textWidth(_ text: String, font: NSFont) -> CGFloat {
|
|
let attributes: [NSAttributedString.Key: Any] = [.font: font]
|
|
return (text as NSString).size(withAttributes: attributes).width
|
|
}
|
|
#else
|
|
private func platformFont(ofSize size: CGFloat) -> UIFont {
|
|
UIFont.systemFont(ofSize: size)
|
|
}
|
|
|
|
private func textWidth(_ text: String, font: UIFont) -> CGFloat {
|
|
let attributes: [NSAttributedString.Key: Any] = [.font: font]
|
|
return (text as NSString).size(withAttributes: attributes).width
|
|
}
|
|
#endif
|
|
}
|