// // 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..