TruncationDetectingText.swift (8127B)
1 /* MIT License 2 * Copyright (c) 2025 by Fatbobman(东坡肘子) 3 * X: @fatbobman 4 * Mastodon: @fatbobman@mastodon.social 5 * GitHub: @fatbobman 6 * Blog: https://fatbobman.com 7 * 8 * Permission is hereby granted, free of charge, to any person obtaining a copy 9 * of this software and associated documentation files (the "Software"), to deal 10 * in the Software without restriction, including without limitation the rights 11 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 * copies of the Software, and to permit persons to whom the Software is 13 * furnished to do so, subject to the following conditions: 14 * 15 * The above copyright notice and this permission notice shall be included in all 16 * copies or substantial portions of the Software. 17 * 18 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 * SOFTWARE. 25 */ 26 27 /** 28 * @author Marc Stibane 29 * added strikethrough, 30 * simplified LayoutMode to Int, 31 * multiple TruncationStatus PreferenceKey 32 */ 33 import SwiftUI 34 35 //@available(iOS 16.0, *) 36 struct TruncationDetectingText: View { 37 // MARK: - Properties 38 39 private let content: String 40 private let maxLines: Int? 41 private let layout: Int 42 private let index: Int 43 private let strikeColor: Color? 44 45 @StateObject var sizeHolder: SizeHolder 46 47 // MARK: - Initialization 48 49 /// Creates a truncation-detecting text view 50 /// - Parameters: 51 /// - content: The text content to display 52 /// - maxLines: Maximum number of lines (0 or nil for unlimited) 53 /// - layout: The layout this text belongs to 54 /// - strikeColor: either nil, or the color for strikethrough() 55 init(_ content: String, maxLines: Int?, layout: Int, index: Int = 0, strikeColor: Color? = nil) { 56 self.content = content 57 self.layout = layout 58 self.index = index 59 60 self.maxLines = (maxLines ?? 0) > 0 ? maxLines : nil 61 self.strikeColor = strikeColor 62 self._sizeHolder = StateObject(wrappedValue: SizeHolder(maxLine: maxLines)) 63 } 64 65 // MARK: - Body 66 67 var body: some View { 68 constrainedText 69 .measureSize(sizeHolder.binding(keyPath: \.actualSize)) 70 .background { 71 naturalHeightText 72 .measureSize(sizeHolder.binding(keyPath: \.naturalHeightSize)) 73 .hidden() 74 } 75 .background { 76 naturalWidthText 77 .measureSize(sizeHolder.binding(keyPath: \.naturalWidthSize)) 78 .hidden() 79 } 80 .overlay { 81 if let isContentTruncated = sizeHolder.isContentTruncated { 82 let value = [layout: isContentTruncated] 83 switch index { 84 case 0: 85 Color.clear 86 .preference(key: LayoutTruncationStatus0.self, 87 value: value) 88 case 1: 89 Color.clear 90 .preference(key: LayoutTruncationStatus1.self, 91 value: value) 92 default: 93 Color.clear 94 .preference(key: LayoutTruncationStatus2.self, 95 value: value) 96 } 97 } 98 } 99 #if DEBUG2 100 .task(id: sizeHolder.isContentTruncated) { 101 if let isContentTruncated = sizeHolder.isContentTruncated { 102 print("Layout \(layout) - Content truncated: \(isContentTruncated)") 103 } 104 } 105 #endif 106 } 107 108 // MARK: - Private Views 109 110 @ViewBuilder 111 private var constrainedText: some View { 112 switch maxLines { 113 case .none: 114 Text(content) 115 .strikethrough(strikeColor != nil, color: strikeColor) 116 default: 117 Text(content) 118 .strikethrough(strikeColor != nil, color: strikeColor) 119 // .lineLimit(maxLines ?? 1, reservesSpace: true) 120 .lineLimit(maxLines ?? 1) 121 } 122 } 123 124 /// Text with natural height (allows wrapping within given width) 125 private var naturalHeightText: some View { 126 Text(content) 127 .strikethrough(strikeColor != nil, color: strikeColor) 128 .fixedSize(horizontal: false, vertical: true) 129 } 130 131 /// Text with natural width (single line, no wrapping) 132 private var naturalWidthText: some View { 133 Text(content) 134 .strikethrough(strikeColor != nil, color: strikeColor) 135 .fixedSize(horizontal: true, vertical: false) 136 } 137 } 138 139 140 // MARK: - Preference Key 141 142 /// Preference key for collecting truncation status from multiple layout modes 143 struct LayoutTruncationStatus0: PreferenceKey { 144 static let defaultValue: [Int: Bool] = [:] 145 146 static func reduce( 147 value: inout [Int: Bool], 148 nextValue: () -> [Int: Bool]) 149 { 150 value.merge(nextValue(), uniquingKeysWith: { _, new in new }) 151 } 152 } 153 154 struct LayoutTruncationStatus1: PreferenceKey { 155 static let defaultValue: [Int: Bool] = [:] 156 157 static func reduce( 158 value: inout [Int: Bool], 159 nextValue: () -> [Int: Bool]) 160 { 161 value.merge(nextValue(), uniquingKeysWith: { _, new in new }) 162 } 163 } 164 165 struct LayoutTruncationStatus2: PreferenceKey { 166 static let defaultValue: [Int: Bool] = [:] 167 168 static func reduce( 169 value: inout [Int: Bool], 170 nextValue: () -> [Int: Bool]) 171 { 172 value.merge(nextValue(), uniquingKeysWith: { _, new in new }) 173 } 174 } 175 176 // MARK: - View Extensions 177 178 extension View { 179 /// Measures the size of a view and binds it to the provided binding 180 /// - Parameter size: A binding to store the measured size 181 /// - Returns: The view with size measurement capability 182 func measureSize(_ size: Binding<CGSize>) -> some View { 183 background( 184 GeometryReader { geometry in 185 let currentSize = geometry.size 186 Color.clear 187 .task(id: currentSize) { 188 size.wrappedValue = currentSize 189 } 190 }) 191 } 192 } 193 // MARK: - SizeHolder 194 195 @MainActor 196 final class SizeHolder: ObservableObject { 197 var actualSize: CGSize = .zero { 198 didSet { 199 needsUpdate() 200 } 201 } 202 203 var naturalHeightSize: CGSize = .zero { 204 didSet { 205 needsUpdate() 206 } 207 } 208 209 var naturalWidthSize: CGSize = .zero { 210 didSet { 211 needsUpdate() 212 } 213 } 214 215 var maxLines: Int? 216 217 init(maxLine: Int? = nil) { 218 maxLines = maxLine 219 } 220 221 // Indicates whether the text content is truncated under current constraints 222 @Published var isContentTruncated: Bool? 223 224 func needsUpdate() { 225 guard actualSize != .zero, 226 naturalWidthSize != .zero, 227 naturalHeightSize != .zero 228 else { 229 return 230 } 231 232 switch maxLines { 233 case .none: 234 isContentTruncated = false 235 case 1: 236 // For single line: check if natural width exceeds actual width 237 isContentTruncated = naturalWidthSize.width > actualSize.width 238 default: 239 // For multiple lines: check if natural height exceeds actual height 240 isContentTruncated = naturalHeightSize.height > actualSize.height 241 } 242 } 243 244 func binding(keyPath: ReferenceWritableKeyPath<SizeHolder, CGSize>) -> Binding<CGSize> { 245 Binding( 246 get: { self[keyPath: keyPath] }, 247 set: { self[keyPath: keyPath] = $0 }) 248 } 249 }