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

Posted

技术标签:

【中文标题】SwiftUI:是不是存在修饰符来突出显示 Text() 视图的子字符串?【英文标题】:SwiftUI: is there exist modifier to highlight substring of Text() view?SwiftUI:是否存在修饰符来突出显示 Text() 视图的子字符串? 【发布时间】:2019-12-20 13:52:30 【问题描述】:

我在屏幕上有一些文字:

Text("someText1")

是否可以在不创建大量文本项的情况下突出显示/选择部分文本

我是说

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

对我来说不是解决方案

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

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

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

【问题讨论】:

【参考方案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 只是“一个由范围内的属性注释的字符串”。它不是“样式化的字符串”。我们可以组成任何我们想要的属性键和值。因此,这些属性故意与 Cocoa 用于格式化的属性不同。

接下来,我们创建 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).compactMap( $0 ) 
            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.Index 并将其应用于另一个字符串,即使这两个字符串看起来相同。这就是为什么该系统采用闭包来创建范围而不是采用范围本身。 attributedString.string 与传入的字符串并不完全相同。如果调用者想要传递Range&lt;String.Index&gt;,那么他们必须使用与 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 直接公开,因此您可以将其与标准视图结合使用。

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
    

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

// 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())
    

Gist

您也可以将 UILabel 包装在 UIViewRepresentable 中,然后使用 attributedText。但这就是作弊。 :D

【讨论】:

这真是太棒了。感谢分享! Rob,我很想听听您对我们如何添加对类似于 NSBackgroundColorAttributeName 的样式的支持的想法。将 .background() 修饰符应用于 Text 的挑战在于返回类型变为 View。您的实现依赖于将样式应用于 Text 将返回 Text 的事实,而应用 .background 修饰符时不会出现这种情况。我不认为我们可以将 View 打包回 Text,但我很想听听您的想法。谢谢! 我可以投票多少次?只有1个? :( “你也可以在 UIViewRepresentable 中包装一个 UILabel,然后使用属性文本。但这就是作弊。 :D” - 我可以问一下为什么会作弊吗?【参考方案2】:

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

所以我们开始:

我将创建一个视图来保存一个字符串(用于渲染)和另一个视图来保存我们的“匹配”文本,而不是一个修饰符。

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)
        
    

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

    查找所有匹配项 用硬编码的“标签”替换它们(用另一个硬编码字符标记匹配的开始) 标签分割 如果我们在比赛中,减少组件并返回一个风格化的版本

现在,我们可以像这样使用它:

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)
    

这是一个(蹩脚的)gif,展示了它的实际效果:

https://imgur.com/sDpr0Ul

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

【讨论】:

如何添加背景颜色(荧光笔效果)而不是只为文本着色?如果我添加背景,它不再被认为是视图? @simibac 很好的观察...据我所知,这在纯 SwiftUI 中可能实际上是不可能的(还)。我想有人不得不依赖 UIKit 并将其包装成一个可表示的对象。 看起来很酷的解决方案!我正在尝试对 TextField() 视图中的文本进行同样的突出显示。你认为这有什么可能吗? @Lars 由于此方法依赖于样式化的Text 连接,而TextField 没有提供由Text 初始化的方法,我看不出它是如何完成的(但我很高兴错了,因为我也想知道它是否真的可以通过任何其他方式) @PeterKreinz 你是对的,它不是。在此示例中,字符串匹配部分是最简单的,但我想有人可以轻松修改它并使用正则表达式搜索/替换。【参考方案3】:

我非常喜欢@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)
    

【讨论】:

【参考方案4】:

Rob 解决方案的一个稍微不那么惯用的变体,它使用现有的 NSAttributedString 键(如果您已经有生成 NSAttributedString 的代码,则很有用)。这只是处理字体和前景颜色,但您可以添加其他。

问题:我想为某些文本元素添加链接,但我不能这样做,因为一旦通过点击手势(或替换为链接)修改了文本,它就不再可以与其他文本组合价值观。有没有一种惯用的方法?

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
        
    

【讨论】:

以上是关于SwiftUI:是不是存在修饰符来突出显示 Text() 视图的子字符串?的主要内容,如果未能解决你的问题,请参考以下文章

隐藏 SwiftUI DisclosureGroup 箭头并删除默认填充

在 SwiftUI 中突出显示语音话语

在 SwiftUI 中关闭按钮突出显示

SwiftUI:使用 WkWebView 突出显示具有不同颜色的文本

以编程方式突出显示 SwiftUI 按钮

如何检测何时单击/点击 MapMarker? [复制]