在 SwiftUI 文本中获取单词的框架

Get frame of a word in SwiftUI Text

我想找到一个词在句子中的位置,以便为该词设置框架。 像这样例如:

目前是否可以在 SwiftUI 中执行此操作

我写了一些代码,这就是我得到的

import SwiftUI

struct ContentView: View {

    @State var text = "I usually get ----- around nine o'clock every morning"
    @State var rects = [CGRect.zero]

    var body: some View {
        ZStack {
            TextView(text: $text, rects: $rects)
                .overlay(
                    ForEach(0..<self.rects.count, id: \.self) { index in
                        RoundedRectangle(cornerRadius: 6)
                            .frame(width: self.rects[index].size.width, height: self.rects[index].size.height)
                            .position(self.rects[index].origin)
                            .foregroundColor(Color.red)
                    }
            )

        }
    }
}

struct TextView: UIViewRepresentable {

    @Binding var text: String
    @Binding var rects: [CGRect]

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> UITextView {

        let textView = UITextView()
        textView.delegate = context.coordinator
        textView.isEditable = true
        textView.isUserInteractionEnabled = true
        textView.font = UIFont.systemFont(ofSize: 24)
        DispatchQueue.main.async {
            let ranges = self.searchRanges(in: textView.text)
            self.rects = self.viewRects(for: ranges, textView: textView)
        }
        return textView
    }

    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.text = text
    }

    class Coordinator : NSObject, UITextViewDelegate {

        var parent: TextView

        init(_ uiTextView: TextView) {
            self.parent = uiTextView
        }

        func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
            return true
        }

        func textViewDidChange(_ textView: UITextView) {
            self.parent.text = textView.text
        }
    }

    func searchRanges(in text: String) -> [Range<String.Index>] {
        var ranges = [Range<String.Index>]()
        var searchRange = text.startIndex ..< text.endIndex
        var range = text.range(
            of: "-----",
            options: .caseInsensitive,
            range: searchRange,
            locale: nil
        )
        while let findedRange = range {
            ranges.append(findedRange)
            searchRange = findedRange.upperBound ..< text.endIndex
            range = text.range(
                of: "-----",
                options: .caseInsensitive,
                range: searchRange,
                locale: nil
            )
        }

        return ranges
    }

    func viewRects(for rnges: [Range<String.Index>], textView: UITextView) -> [CGRect] {
        var rects = [CGRect]()
        for range in rnges {
            let upperBound = range.upperBound.utf16Offset(in: textView.text)
            let lowerBound = range.lowerBound.utf16Offset(in: textView.text)
            let length = upperBound - lowerBound

            if let start = textView.position(from: textView.beginningOfDocument, offset: lowerBound),
                let end = textView.position(from: start, offset: length),
                let txtRange = textView.textRange(from: start, to: end) {
                var rect = textView.firstRect(for: txtRange)
                rect.origin.x = rect.origin.x
                rect.origin.y = rect.midY
                rects.append(rect)
            }
        }
        return rects
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

UIKit 上的代码与故事板相同

您可以通过拆分文本、遍历文本然后使用 overlay 框出您想要的词来实现此目的。

看这个例子:

struct HighlightView: View {
    var words: [FramableWord] = []

    struct FramableWord: Identifiable {
        let id = UUID()
        let text: String
        let isFramed: Bool
    }

    func frame(word: String, in text: String) -> [FramableWord] {
        return text.split(separator: " ").map(String.init).map {
            FramableWord(text: [=10=], isFramed: [=10=] == word)
        }
    }

    init() {
        words = frame(word: "up", in: "I get up at 9")
    }

    var body: some View {
        HStack(spacing: 2) {
            ForEach(words) { word -> AnyView in
                if word.isFramed {
                    return AnyView(
                        Text(word.text)
                            .padding(2)
                            .overlay(RoundedRectangle(cornerRadius: 2).stroke(Color.blue, lineWidth: 2))
                    )
                }

                return AnyView(Text(word.text))
            }
        }
    }
}

结果:

我们无法直接测试您的代码,因为我们没有 UITextView.firstRect(for:)。但是,有两件事很明显:

  1. 您需要在 .overlay() 中使用带 alignment: .topLeading 的 ZStack。一旦 rects 中有多个值,您的 ForEach 将无法始终作为 .overlay() 的根,并且 SwiftUI 默认从中心开始布局。
  2. 您需要使用 .offset() 而不是 .position()。位置用于框架的中心。

struct ContentView: View {
    var rects: [CGRect] = [
        .zero,
        CGRect(x: 10, y: 10, width: 10, height: 50),
        CGRect(x: 20, y: 20, width: 10, height: 50),
        CGRect(x: 30, y: 30, width: 10, height: 50)
    ]

    func size(for rect: CGRect) -> CGSize {
        CGSize(width: rect.minX, height: rect.minY)
    }

    var body: some View {
        Color.blue.opacity(0.125)
            .frame(width: 200, height: 400)
            .overlay(
                ZStack(alignment: .topLeading) {
                    Color.clear // Maximize the size of the ZStack
                    ForEach(0..<self.rects.count, id: \.self) { idx in
                        Color.red
                            .frame(width: self.rects[idx].width, height: self.rects[idx].height)
                            .offset(self.size(for: self.rects[idx]))
                    }
                }
        )
    }
}

结果:

经过几个小时的努力,我得出了一个解决方案,这就是它的样子

import SwiftUI

struct TextView: View {

    @State var text = ""
    @State var gapText = ""
    @State var rects = [CGRect.zero]
    @State var pattern: String = "-----"

    var body: some View {
        ZStack {
            Representable(text: $text, rects: $rects, pattern: $pattern)
            ForEach(0..<self.rects.count, id: \.self) { index in
                ZStack {
                    Button(action: {

                    }) {
                        Text(self.gapText)
                            .foregroundColor(Color("SMTitle"))
                            .font(.system(size: 30, weight: .medium))
                            .multilineTextAlignment(.center)
                            .padding()
                    }
                    .frame(width: self.rects[index].size.width, height: self.rects[index].size.height)
                    .background(Color.white, alignment: .center)
                    .position(x: self.rects[index].origin.x, y:  self.rects[index].origin.y)
                    .overlay(
                        RoundedRectangle(cornerRadius: 6)
                            .stroke(Color("SMTitle"), lineWidth: 2)
                            .frame(width: self.rects[index].size.width, height: self.rects[index].size.height)
                            .position(x: self.rects[index].origin.x, y:  self.rects[index].origin.y)
                    )
                }

            }
        }
    }
}

struct Representable: UIViewRepresentable {

    @Binding var text: String
    @Binding var rects: [CGRect]
    @Binding var pattern: String

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> UITextView {

        let view = UITextView()
        view.delegate = context.coordinator
        view.isEditable = false
        view.isUserInteractionEnabled = true
        view.textColor = UIColor(red: 0.325, green: 0.207, blue: 0.325, alpha: 1)
        view.font = UIFont.systemFont(ofSize: 30, weight: .medium)
        DispatchQueue.main.async {
            let ranges = self.searchRanges(in: view.text)
            self.rects = self.viewRects(for: ranges, textView: view)
        }
        return view
    }

    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.text = text
    }

    class Coordinator : NSObject, UITextViewDelegate {

        var parent: Representable

        init(_ uiTextView: Representable) {
            self.parent = uiTextView
        }

        func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
            return true
        }

        func textViewDidChange(_ textView: UITextView) {
            self.parent.text = textView.text
        }
    }

    func searchRanges(in text: String) -> [Range<String.Index>] {
        var ranges = [Range<String.Index>]()
        var searchRange = text.startIndex ..< text.endIndex
        var range = text.range(
            of: self.pattern,
            options: .caseInsensitive,
            range: searchRange,
            locale: nil
        )
        while let findedRange = range {
            ranges.append(findedRange)
            searchRange = findedRange.upperBound ..< text.endIndex
            range = text.range(
                of: self.pattern,
                options: .caseInsensitive,
                range: searchRange,
                locale: nil
            )
        }

        return ranges
    }

    func viewRects(for ranges: [Range<String.Index>], textView: UITextView) -> [CGRect] {
        var rects = [CGRect]()
        for range in ranges {
            let upperBound = range.upperBound.utf16Offset(in: textView.text)
            let lowerBound = range.lowerBound.utf16Offset(in: textView.text)
            let length = upperBound - lowerBound

            if let start = textView.position(from: textView.beginningOfDocument, offset: lowerBound),
                let end = textView.position(from: start, offset: length),
                let txtRange = textView.textRange(from: start, to: end) {
                var rect = textView.firstRect(for: txtRange)
                rect.origin.x = rect.midX
                rect.origin.y = rect.midY
                rect.size.height = rect.size.height
                rects.append(rect)
            }
        }
        return rects
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        TextView(text: "I usually get ----- around nine o'clock every morning", gapText: "up")
    }
}

我希望看到更优雅的解决方案