SwiftUI:是否存在修饰符以突出显示Text()视图的子字符串?

时间:2019-12-20 13:52:30

标签: swift swiftui

我在屏幕上有一些文字:

Text("someText1")

可以在不创建很多文本项的情况下突出显示/选择部分文本

我的意思是

Text("som") + Text("eTex").foregroundColor(.red) + Text("t1")

对我来说不是解决办法

最好具有某种修饰符,以某种方式突出显示文本的一部分。类似于:

Text("someText1").modifier(.highlight(text:"eTex"))

有可能吗? (我的意思是不创建很多视图)

4 个答案:

答案 0 :(得分:2)

免责声明:我真的不愿意发布我的答案,因为我确信必须有很多方法更聪明,更好的方法(我不知道使用UIKit视图可以使用包装器) TextKit)和更强大的方法,但是...我认为这是一个有趣的练习,也许有人真的可以从中受益。

所以我们开始:

我将使用一个视图代替一个修饰符,该视图包含一个字符串(用于渲染),另一个视图用于容纳我们的“匹配”文本。

struct HighlightedText: View {
    let text: String
    let matching: String

    init(_ text: String, matching: String) {
        self.text = text
        self.matching = matching
    }

    var body: some View {
        let tagged = text.replacingOccurrences(of: self.matching, with: "<SPLIT>>\(self.matching)<SPLIT>")
        let split = tagged.components(separatedBy: "<SPLIT>")
        return split.reduce(Text("")) { (a, b) -> Text in
            guard !b.hasPrefix(">") else {
                return a + Text(b.dropFirst()).foregroundColor(.red)
            }
            return a + Text(b)
        }
    }
}

我猜代码很不言自明,但简而言之:

  1. 找到所有匹配项
  2. 用硬编码的“标签”替换它们(用另一个硬编码的字符标记比赛的开始)
  3. 拆分标签
  4. 减少组件并返回符合风格的版本

现在,我们可以将其与以下内容一起使用:

struct ContentView: View {
    @State var matching: String = "ll"
    var body: some View {
        VStack {
            TextField("Matching term", text: self.$matching)
            HighlightedText("Hello to all in this hall", matching: self.matching)
            .font(.largeTitle)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

这是一个(cr脚的)gif,在实际操作中展示了它:

enter image description here

https://imgur.com/sDpr0Ul

最后,如果您想知道我如何在Xcode之外运行SwiftUI,here is a gist是我为在Mac上的SwiftUI中快速进行原型制作而设计的

答案 1 :(得分:2)

我非常喜欢@Alladinian的简单解决方案,但是我需要一个不区分大小写的解决方案,例如以便快速高亮显示输入的字符。

这是我使用正则表达式进行的修改:

struct HighlightedText: View {
    let text: String
    let matching: String
    let caseInsensitiv: Bool

    init(_ text: String, matching: String, caseInsensitiv: Bool = false) {
        self.text = text
        self.matching = matching
        self.caseInsensitiv = caseInsensitiv
    }

    var body: some View {
        guard  let regex = try? NSRegularExpression(pattern: NSRegularExpression.escapedPattern(for: matching).trimmingCharacters(in: .whitespacesAndNewlines).folding(options: .regularExpression, locale: .current), options: caseInsensitiv ? .caseInsensitive : .init()) else {
            return Text(text)
        }

        let range = NSRange(location: 0, length: text.count)
        let matches = regex.matches(in: text, options: .withTransparentBounds, range: range)

        return text.enumerated().map { (char) -> Text in
            guard matches.filter( {
                $0.range.contains(char.offset)
            }).count == 0 else {
                return Text( String(char.element) ).foregroundColor(.red)
            }
            return Text( String(char.element) )

        }.reduce(Text("")) { (a, b) -> Text in
            return a + b
        }
    }
}

示例:

struct ContentView: View {
    @State var matching: String = "he"
    var body: some View {
        VStack {
            TextField("Matching term", text: self.$matching)
                .autocapitalization(.none)
            HighlightedText("Hello to all in this hall", matching: self.matching, caseInsensitiv: true)
            .font(.largeTitle)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

答案 2 :(得分:1)

创建文本后,您将无法再将其备份。您的示例创建了本地化问题。 someText1实际上不是要打印的字符串。这是字符串的本地化关键字。默认的本地化字符串恰好是键,因此可以正常工作。本地化时,您搜索eTex的尝试将悄然中断。因此,这不是一个好的通用接口。

即便如此,构建解决方案还是很有启发性的,并且可能对特定情况有用。

基本目标是将样式视为应用于范围的属性。这正是NSAttributedString给我们的,包括合并和拆分范围以管理多个重叠属性的能力。 NSAttributedString并不是特别对Swift友好的,因此从头开始重新实现它可能会有一些价值,但是,我只是将其隐藏为实现细节。

因此,TextStyle将是NSAttributedString.Key和一个将Text转换为另一个Text的函数。

public struct TextStyle {
    // This type is opaque because it exposes NSAttributedString details and
    // requires unique keys. It can be extended by public static methods.

    // Properties are internal to be accessed by StyledText
    internal let key: NSAttributedString.Key
    internal let apply: (Text) -> Text

    private init(key: NSAttributedString.Key, apply: @escaping (Text) -> Text) {
        self.key = key
        self.apply = apply
    }
}

TextStyle是不透明的。为了构造它,我们公开了一些扩展,例如:

// Public methods for building styles
public extension TextStyle {
    static func foregroundColor(_ color: Color) -> TextStyle {
        TextStyle(key: .init("TextStyleForegroundColor"), apply: { $0.foregroundColor(color) })
    }

    static func bold() -> TextStyle {
        TextStyle(key: .init("TextStyleBold"), apply: { $0.bold() })
    }
}

在这里值得注意的是,NSAttributedString仅仅是“由超出范围的属性注释的字符串”。它不是“样式字符串”。我们可以组成所需的任何属性键和值。因此,这些故意不同于可可用于格式化的属性。

接下来,我们创建StyledText本身。我首先关注这种类型的“模型”部分(以后我们将其设为“视图”)。

public struct StyledText {
    // This is a value type. Don't be tempted to use NSMutableAttributedString here unless
    // you also implement copy-on-write.
    private var attributedString: NSAttributedString

    private init(attributedString: NSAttributedString) {
        self.attributedString = attributedString
    }

    public func style<S>(_ style: TextStyle,
                         ranges: (String) -> S) -> StyledText
        where S: Sequence, S.Element == Range<String.Index>
    {

        // Remember this is a value type. If you want to avoid this copy,
        // then you need to implement copy-on-write.
        let newAttributedString = NSMutableAttributedString(attributedString: attributedString)

        for range in ranges(attributedString.string) {
            let nsRange = NSRange(range, in: attributedString.string)
            newAttributedString.addAttribute(style.key, value: style, range: nsRange)
        }

        return StyledText(attributedString: newAttributedString)
    }
}

它只是NSAttributedString的包装,并且是通过将TextStyles应用于范围来创建新的StyledTexts的方法。重要事项:

  • 调用style不会更改现有对象。如果这样做的话,您将无法执行return StyledText("text").apply(.bold())之类的事情。您会得到一个错误消息,该值是不可变的。

  • 范围是棘手的事情。 NSAttributedString使用NSRange,并且具有与String不同的索引概念。 NSAttributedStrings的长度可以与基础String的长度不同,因为它们的字符组成不同。

  • 即使两个字符串看上去完全相同,也无法安全地从一个字符串中提取String.Index并将其应用于另一个字符串。这就是为什么此系统采用闭合来创建范围而不是采用范围本身的原因。 attributedString.string与传入的字符串不完全相同。如果调用者想要传递Range<String.Index>,则至关重要的是,它们必须使用与TextStyle使用的字符串完全相同的字符串来构造。使用闭包最容易确保这一点,并且避免了很多极端情况。

默认的style接口可处理一系列范围,以提高灵活性。但是在大多数情况下,您可能只会传递一个范围,因此为此提供一种便捷的方法非常好,并且在需要整个字符串的情况下也很方便:

public extension StyledText {
    // A convenience extension to apply to a single range.
    func style(_ style: TextStyle,
               range: (String) -> Range<String.Index> = { $0.startIndex..<$0.endIndex }) -> StyledText {
        self.style(style, ranges: { [range($0)] })
    }
}

现在,用于创建StyledText的公共接口:

extension StyledText {
    public init(verbatim content: String, styles: [TextStyle] = []) {
        let attributes = styles.reduce(into: [:]) { result, style in
            result[style.key] = style
        }
        attributedString = NSMutableAttributedString(string: content, attributes: attributes)
    }
}

在此处注意verbatim。此StyledText不支持本地化。可以想象,有了这项工作,它可能会实现,但是还需要更多的思考。

最后,毕竟,我们可以通过为具有相同属性的每个子字符串创建一个Text,将所有样式应用于该Text,然后使用{{1 }}。为方便起见,文本直接暴露出来,因此您可以将其与标准视图组合。

+

就是这样。使用它看起来像这样:

extension StyledText: View {
    public var body: some View { text() }

    public func text() -> Text {
        var text: Text = Text(verbatim: "")
        attributedString
            .enumerateAttributes(in: NSRange(location: 0, length: attributedString.length),
                                 options: [])
            { (attributes, range, _) in
                let string = attributedString.attributedSubstring(from: range).string
                let modifiers = attributes.values.map { $0 as! TextStyle }
                text = text + modifiers.reduce(Text(verbatim: string)) { segment, style in
                    style.apply(segment)
                }
        }
        return text
    }
}

Image of text with red highlighting and bold

Gist

您还可以只将UILabel包装在UIViewRepresentable中,然后使用// An internal convenience extension that could be defined outside this pacakge. // This wouldn't be a general-purpose way to highlight, but shows how a caller could create // their own extensions extension TextStyle { static func highlight() -> TextStyle { .foregroundColor(.red) } } struct ContentView: View { var body: some View { StyledText(verbatim: "?‍?‍?someText1") .style(.highlight(), ranges: { [$0.range(of: "eTex")!, $0.range(of: "1")!] }) .style(.bold()) } } 。但这将是欺骗。 :D

答案 3 :(得分:0)

Rob解决方案的一种略微惯用的变体,可以使用现有的NSAttributedString密钥(如果您已经具有生成NSAttributedStrings的代码,则很有用)。这只是处理font和foregroundColor,但是您可以添加其他字体。

问题:我想向某些文本元素添加链接,但是我不能这样做,因为一旦用轻敲手势修改了文本(或用链接替换了),则该文本将不再与其他文本组合价值观。有没有惯用的方法解决这个问题?

extension NSAttributedString.Key {
    func apply(_ value: Any, to text: Text) -> Text {
        switch self {
        case .font:
            return text.font(Font(value as! UIFont))
        case .foregroundColor:
            return text.foregroundColor(Color(value as! UIColor))
        default:
            return text
        }
    }
}

public struct TextAttribute {
    let key: NSAttributedString.Key
    let value: Any
}

public struct AttributedText {
    // This is a value type. Don't be tempted to use NSMutableAttributedString here unless
    // you also implement copy-on-write.
    private var attributedString: NSAttributedString
    
    public init(attributedString: NSAttributedString) {
        self.attributedString = attributedString
    }
    
    public func style<S>(_ style: TextAttribute,
                         ranges: (String) -> S) -> AttributedText
    where S: Sequence, S.Element == Range<String.Index>
    {
        
        // Remember this is a value type. If you want to avoid this copy,
        // then you need to implement copy-on-write.
        let newAttributedString = NSMutableAttributedString(attributedString: attributedString)
        
        for range in ranges(attributedString.string) {
            let nsRange = NSRange(range, in: attributedString.string)
            newAttributedString.addAttribute(style.key, value: style, range: nsRange)
        }
        
        return AttributedText(attributedString: newAttributedString)
    }
}

public extension AttributedText {
    // A convenience extension to apply to a single range.
    func style(_ style: TextAttribute,
               range: (String) -> Range<String.Index> = { $0.startIndex..<$0.endIndex }) -> AttributedText {
        self.style(style, ranges: { [range($0)] })
    }
}

extension AttributedText {
    public init(verbatim content: String, styles: [TextAttribute] = []) {
        let attributes = styles.reduce(into: [:]) { result, style in
            result[style.key] = style
        }
        attributedString = NSMutableAttributedString(string: content, attributes: attributes)
    }
}

extension AttributedText: View {
    public var body: some View { text() }
    
    public func text() -> Text {
        var text: Text = Text(verbatim: "")
        attributedString
            .enumerateAttributes(in: NSRange(location: 0, length: attributedString.length),
                                 options: [])
            { (attributes, range, _) in
                let string = attributedString.attributedSubstring(from: range).string
                text = text + attributes.reduce(Text(verbatim: string)) { segment, attribute in
                    return attribute.0.apply(attribute.1, to: segment)
                }
            }
        return text
    }
}

public extension Font {
    init(_ font: UIFont) {
        switch font {
        case UIFont.preferredFont(forTextStyle: .largeTitle):
            self = .largeTitle
        case UIFont.preferredFont(forTextStyle: .title1):
            self = .title
        case UIFont.preferredFont(forTextStyle: .title2):
            self = .title2
        case UIFont.preferredFont(forTextStyle: .title3):
            self = .title3
        case UIFont.preferredFont(forTextStyle: .headline):
            self = .headline
        case UIFont.preferredFont(forTextStyle: .subheadline):
            self = .subheadline
        case UIFont.preferredFont(forTextStyle: .callout):
            self = .callout
        case UIFont.preferredFont(forTextStyle: .caption1):
            self = .caption
        case UIFont.preferredFont(forTextStyle: .caption2):
            self = .caption2
        case UIFont.preferredFont(forTextStyle: .footnote):
            self = .footnote
        default:
            self = .body
        }
    }
}