2

I want to find the position of a word in a sentence so that I can set a frame for that word. Like so for example: enter image description here

Is it currently possible to do this in SwiftUI

I wrote some code and this is what I got

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

enter image description here

Same code on UIKit with storyboard

enter image description here

Beslan Tularov
  • 3,111
  • 1
  • 21
  • 34

3 Answers3

4

After working for several hours, I came to a solution and this is how it looks

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")
    }
}

I would like to see a more elegant solution

Beslan Tularov
  • 3,111
  • 1
  • 21
  • 34
  • Did you try my solution? I believe it will work with your existing code if you just replace `Color.blue` with `TextView` in your original code. Your main issue is using `position` which you're adjusting for by adding `midX` and `midY`. You also aren't using `alignment: .topLeading` for your `ZStack`. This basically makes it possible to convert to UIKit coordinates. – arsenius Apr 30 '20 at 00:40
3

You can achieve this by splitting up the text, looping over it, and then using an overlay to frame the words you want.

See this example:

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: $0, isFramed: $0 == 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))
            }
        }
    }
}

Result:

example

milo
  • 936
  • 7
  • 18
1

We can't directly test your code as we don't have UITextView.firstRect(for:). But, two things are evident:

  1. You need to use a ZStack with alignment: .topLeading in your .overlay(). Your ForEach won't work consistently as the root of .overlay() once you have more than one value in rects, and SwiftUI lays out from the center by default.
  2. You need to use .offset() not .position(). Position is used for the center of your frame.

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]))
                    }
                }
        )
    }
}

Result:

Result

arsenius
  • 12,090
  • 7
  • 58
  • 76