taler-ios

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

commit 6f77a160025f5e529d0573925ea1335bf050a1dd
parent 4553bd29281d6ee974128c96aca9423e5d555b48
Author: Marc Stibane <marc@taler.net>
Date:   Fri,  4 Jul 2025 10:45:53 +0200

TruncationDetectingText

Diffstat:
ATalerWallet1/Views/HelperViews/TruncationDetectingText.swift | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 214 insertions(+), 0 deletions(-)

diff --git a/TalerWallet1/Views/HelperViews/TruncationDetectingText.swift b/TalerWallet1/Views/HelperViews/TruncationDetectingText.swift @@ -0,0 +1,214 @@ +/* MIT License + * Copyright (c) 2025 by Fatbobman(东坡肘子) + * X: @fatbobman + * Mastodon: @fatbobman@mastodon.social + * GitHub: @fatbobman + * Blog: https://fatbobman.com + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * @author Marc Stibane + * added strikethrough, more compact modes + */ +import SwiftUI + +//@available(iOS 16.0, *) +struct TruncationDetectingText: View { + // MARK: - Properties + + private let content: String + private let maxLines: Int? + private let layoutMode: LayoutMode + private let strikeColor: Color? + + @State private var actualSize: CGSize = .zero + @State private var naturalHeightSize: CGSize = .zero + @State private var naturalWidthSize: CGSize = .zero + + /// Indicates whether the text content is truncated under current constraints + private var isContentTruncated: Bool? { + guard actualSize != .zero, + naturalWidthSize != .zero, + naturalHeightSize != .zero + else { + return nil + } + + switch maxLines { + case .none: + return false + case 1: + // For single line: check if natural width exceeds actual width + return naturalWidthSize.width > actualSize.width + default: + // For multiple lines: check if natural height exceeds actual height + return naturalHeightSize.height > actualSize.height + } + } + + // MARK: - Initialization + + /// Creates a truncation-detecting text view + /// - Parameters: + /// - content: The text content to display + /// - maxLines: Maximum number of lines (nil for unlimited) + /// - layoutMode: The layout mode this text belongs to + init(_ content: String, maxLines: Int?, layoutMode: LayoutMode, strikeColor: Color? = nil) { + self.content = content + self.layoutMode = layoutMode + + if let maxLines = maxLines, maxLines <= 0 { + fatalError("maxLines cannot be less than or equal to 0") + } + + self.maxLines = maxLines + self.strikeColor = strikeColor + } + + // MARK: - Body + + var body: some View { + constrainedText + .measureSize($actualSize) + .background { + naturalHeightText + .measureSize($naturalHeightSize) + .hidden() + } + .background { + naturalWidthText + .measureSize($naturalWidthSize) + .hidden() + } + .overlay { + if let isContentTruncated = isContentTruncated { + Color.clear + .preference( + key: LayoutTruncationStatus.self, + value: [layoutMode: isContentTruncated]) + } + } +#if DEBUG + .task(id: isContentTruncated) { + if let isContentTruncated = isContentTruncated { + print("Layout \(layoutMode.description) - Content truncated: \(isContentTruncated)") + } + } +#endif + } + + // MARK: - Private Views + + @ViewBuilder + private var constrainedText: some View { + switch maxLines { + case .none: + Text(content) + .strikethrough(strikeColor != nil, color: strikeColor) + default: + Text(content) + .strikethrough(strikeColor != nil, color: strikeColor) +// .lineLimit(maxLines ?? 1, reservesSpace: true) + .lineLimit(maxLines ?? 1) + } + } + + /// Text with natural height (allows wrapping within given width) + private var naturalHeightText: some View { + Text(content) + .strikethrough(strikeColor != nil, color: strikeColor) + .fixedSize(horizontal: false, vertical: true) + } + + /// Text with natural width (single line, no wrapping) + private var naturalWidthText: some View { + Text(content) + .strikethrough(strikeColor != nil, color: strikeColor) + .fixedSize(horizontal: true, vertical: false) + } +} + +// MARK: - Layout Mode + +/// Represents different layout modes for amount rows +enum LayoutMode: Int, Hashable, Identifiable, CaseIterable { + case compact1 // Single line horizontal layout + case compact2 // Single line horizontal layout + case compact3 // Single line horizontal layout + case compact4 // Single line horizontal layout + case standard // Two-line horizontal layout + case extended // Vertical layout + + var id: Self { self } +} + +// MARK: - Layout Mode Extensions + +extension LayoutMode: Comparable { + static func < (lhs: LayoutMode, rhs: LayoutMode) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +extension LayoutMode: CustomStringConvertible { + var description: String { + switch self { + case .compact1: "compact1" + case .compact2: "compact2" + case .compact3: "compact3" + case .compact4: "compact3" + case .standard: "standard" + case .extended: "extended" + } + } +} + +// MARK: - Preference Key + +/// Preference key for collecting truncation status from multiple layout modes +struct LayoutTruncationStatus: PreferenceKey { + static var defaultValue: [LayoutMode: Bool] = [:] + + static func reduce( + value: inout [LayoutMode: Bool], + nextValue: () -> [LayoutMode: Bool]) + { + value.merge(nextValue(), uniquingKeysWith: { _, new in new }) + } +} + +// MARK: - View Extensions + +extension View { + /// Measures the size of a view and binds it to the provided binding + /// - Parameter size: A binding to store the measured size + /// - Returns: The view with size measurement capability + func measureSize(_ size: Binding<CGSize>) -> some View { + background( + GeometryReader { geometry in + let currentSize = geometry.size + Color.clear + .task(id: currentSize) { + size.wrappedValue = currentSize + } + }) + } +}