taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

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 }