3

I am trying to display text using UITextView. I added "See More" when displaying long texts. I would like to change the background color when tapping it. I set the background of NSAttributedString, but I can not set round corners and margins well.

Thanks!

What I want to do!

A gray background with a sufficient margin with a rounded corner when tapping the character added to UITextView.

Note: It is already possible to tap a character. This question is about the effect at tapping.

"See More" example

"Cross lines" example

Similar question

NSAttributedString background color and rounded corners

How to set NSString's background cornerRadius on iOS7

  • Once "See More" tapped, u can see full text. Need to show, remaining text with Background Color ? – McDonal_11 Feb 20 '18 at 04:14
  • @McDonal_11 This is the effect to recognize the tap. That is, when you tap (touchesBegan) it becomes a gray background, and when you release it it disappears (touchesEnded). "See More" is an example. This question is about the effect when tapping UITextView characters.m How to achieve this using TextKit? –  Feb 20 '18 at 04:53
  • Did u use TapGesture for TextView ? or, how u r trying ? – McDonal_11 Feb 20 '18 at 05:02
  • @McDonal_11 Thanks for your comment. I use hittest to recognize taps. I can get the tap position and determine if the target character is tapped. The thing I can not do is to add an effect of gray background (rounded corner, size with margin) when tapping its target character. Attached image is "See More" and "Link" on Facebook iOS app Screenshot when tapping. An example in which a gray background is properly set even when a line break occurs. –  Feb 20 '18 at 05:33
  • I have crossed with Gray color level. Trying for Rounded corners . – McDonal_11 Feb 20 '18 at 09:16
  • I have updated my answer. pls check it. – McDonal_11 Feb 20 '18 at 13:31
  • @Cœur Yes. It is not scrollable / editable. –  Mar 29 '18 at 10:10

2 Answers2

1

Adding Background Color with rounded corners in UITextView's Text. This answer will give some ideas for your Question.

Logic:

In UITextView, I have added UITapGestureRecognizer, which detects user's tap action Character by Character. If user, taps on any one of Character in subString, new UIView will be created and triggering Timer. When timer gets end, created UIView will be removed from UITextView.

With the help of, myTextView.position, we can get subString's CGRect. That is frame for Created UIView. Size (WIDTH) for each words in subString, can get from SizeAtrributes.

@IBOutlet weak var challengeTextVw: UITextView!
let fullText = "We Love Swift and Swift attributed text "
var myString = NSMutableAttributedString ()
let subString = " Swift attributed text "
var subStringSizesArr = [CGFloat]()
var myRange = NSRange()
var myWholeRange = NSRange()
let fontSize : CGFloat = 25
var timerTxt = Timer()
let delay = 3.0


override func viewDidLoad() {
    super.viewDidLoad()

    myString = NSMutableAttributedString(string: fullText)
    myRange = (fullText as! NSString).range(of: subString)
    myWholeRange = (fullText as! NSString).range(of: fullText)
    let substringSeperatorArr = subString.components(separatedBy: " ")

    print(substringSeperatorArr)
    print(substringSeperatorArr.count)
    var strConcat = " "

    for str in 0..<substringSeperatorArr.count
    {

        strConcat = strConcat + substringSeperatorArr[str] + " "
        let textSize = (strConcat as! NSString).size(withAttributes: [NSAttributedStringKey.font : UIFont.systemFont(ofSize: fontSize)])
        print("strConcatstrConcat   ", strConcat)

        if str != 0 && str != (substringSeperatorArr.count - 2)
        {
             print("times")
            subStringSizesArr.append(textSize.width)
        }

    }
    let myCustomAttribute = [NSAttributedStringKey.init("MyCustomAttributeName") : "some value", NSAttributedStringKey.foregroundColor : UIColor.orange] as [NSAttributedStringKey : Any]
    let fontAtrib = [NSAttributedStringKey.font : UIFont.systemFont(ofSize: fontSize)]
    myString.addAttributes(myCustomAttribute, range: myRange)
    myString.addAttributes(fontAtrib, range: myWholeRange)

    challengeTextVw.attributedText = myString
    let tap = UITapGestureRecognizer(target: self, action: #selector(myMethodToHandleTap))
    tap.delegate = self

    challengeTextVw.addGestureRecognizer(tap)

    challengeTextVw.isEditable = false
    challengeTextVw.isSelectable = false
}


@objc func myMethodToHandleTap(_ sender: UITapGestureRecognizer) {

    let myTextView = sender.view as! UITextView
    let layoutManager = myTextView.layoutManager

    let numberOfGlyphs = layoutManager.numberOfGlyphs
    var numberOfLines = 0
    var index = 0
    var lineRange:NSRange = NSRange()

    while (index < numberOfGlyphs) {

        layoutManager.lineFragmentRect(forGlyphAt: index, effectiveRange: &lineRange)
        index = NSMaxRange(lineRange);
        numberOfLines = numberOfLines + 1

    }

    print("noLin  ", numberOfLines)

    // location of tap in myTextView coordinates and taking the inset into account
    var location = sender.location(in: myTextView)
    location.x -= myTextView.textContainerInset.left;
    location.y -= myTextView.textContainerInset.top;

    // character index at tap location
    let characterIndex = layoutManager.characterIndex(for: location, in: myTextView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)

    // if index is valid then do something.
    if characterIndex < myTextView.textStorage.length
    {
        // print the character index
        print("character index: \(characterIndex)")

        // print the character at the index
        let myRangee = NSRange(location: characterIndex, length: 1)
        let substring = (myTextView.attributedText.string as NSString).substring(with: myRangee)
        print("character at index: \(substring)")

        // check if the tap location has a certain attribute
        let attributeName = NSAttributedStringKey.init("MyCustomAttributeName")

        let attributeValue = myTextView.attributedText.attribute(attributeName, at: characterIndex, effectiveRange: nil) as? String

        if let value = attributeValue
        {
            print("You tapped on \(attributeName) and the value is: \(value)")
            print("\n\n ererereerer")


            timerTxt = Timer.scheduledTimer(timeInterval: delay, target: self, selector: #selector(delayedAction), userInfo: nil, repeats: false)

            myTextView.layoutManager.ensureLayout(for: myTextView.textContainer)

            // text position of the range.location
            let start = myTextView.position(from: myTextView.beginningOfDocument, offset: myRange.location)!
            // text position of the end of the range
            let end = myTextView.position(from: start, offset: myRange.length)!

            // text range of the range
            let tRange = myTextView.textRange(from: start, to: end)

            // here it is!
            let rect = myTextView.firstRect(for: tRange!)   //firstRectForRange(tRange)
            var secondViewWidthIndex = Int()
            for count in 0..<subStringSizesArr.count
            {
                if rect.width > subStringSizesArr[count]
                {
                    secondViewWidthIndex = count
                }
            }

            let backHideVw = UIView()
            backHideVw.frame.origin.x = rect.origin.x
            backHideVw.frame.origin.y = rect.origin.y + 1
            backHideVw.frame.size.height = rect.height
            backHideVw.frame.size.width = rect.width

            backHideVw.backgroundColor = UIColor.brown
            backHideVw.layer.cornerRadius = 2
            backHideVw.tag = 10
            myTextView.addSubview(backHideVw)
            myTextView.sendSubview(toBack: backHideVw)


            if numberOfLines > 1
            {
                let secondView = UIView()
                secondView.frame.origin.x = 0
                secondView.frame.origin.y = backHideVw.frame.origin.y + backHideVw.frame.size.height
                secondView.frame.size.height = backHideVw.frame.size.height
                secondView.frame.size.width = (subStringSizesArr.last! - subStringSizesArr[secondViewWidthIndex]) + 2
                secondView.backgroundColor = UIColor.brown
                secondView.layer.cornerRadius = 2
                secondView.tag = 20
                backHideVw.frame.size.width = subStringSizesArr[secondViewWidthIndex]

                myTextView.addSubview(secondView)
                print("secondView.framesecondView.frame    ", secondView.frame)

                myTextView.sendSubview(toBack: secondView)
            }

            print("rectrect    ", rect)

        }

    }

}

@objc func delayedAction()
{

    for subVws in challengeTextVw.subviews
    {
        if (String(describing: subVws).range(of:"UIView") != nil)
        {
            if (subVws as! UIView).tag == 10 || (subVws as! UIView).tag == 20
            {
                subVws.removeFromSuperview()
            }
        }
    }

}

All attempts tried, by increasing Font Size.

Attempt 1

enter image description here

Attempt 2

enter image description here

Attempt 3

enter image description here

McDonal_11
  • 3,935
  • 6
  • 24
  • 55
  • Since I keep showing the background until I release it, I will control it with a touch action instead of a timer. Using UIView is one solution. As an alternative solution, I am trying to realize this by using subclass of NSLayoutManager. That idea came from [this question](https://stackoverflow.com/questions/21857408/how-to-set-nsstrings-background-cornerradius-on-ios7) . How about this? I also opened a new question related to [this](https://stackoverflow.com/questions/48897211/how-to-set-the-background-using-nslayoutmanager-subclass-in-uitextview). –  Feb 21 '18 at 02:44
0

If the question is about acquiring the range of a tapped character, you should use layoutManager.characterIndex(for:in:fractionOfDistanceBetweenInsertionPoints:):

private var selectGestureRecognizer: UITapGestureRecognizer!

required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
    selectGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped))
    addGestureRecognizer(selectGestureRecognizer)
}

@objc func textTapped(recognizer: UITapGestureRecognizer) {
    guard recognizer == selectGestureRecognizer else {
        return
    }
    var location = recognizer.location(in: self)
    // https://stackoverflow.com/a/25466154/1033581
    location.x -= textContainerInset.left
    location.y -= textContainerInset.top
    let charIndex = layoutManager.characterIndex(for: location, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
    let range = NSRange(location: charIndex, length: 1)

    // do something with the tapped range
}

You can also use a different UIGestureRecognizer depending on your needs.

If your question is about how to draw a custom background for a range of text, you may use layoutManager.boundingRect(forGlyphRange:in:):

func layoutBackground(range: NSRange) -> CALayer {
    let rect = boundingRectForCharacterRange(range)
    let backgroundLayer = CALayer()
    backgroundLayer.frame = rect
    backgroundLayer.cornerRadius = 5
    backgroundLayer.backgroundColor = UIColor.black.cgColor.copy(alpha: 0.2)
    layer.insertSublayer(backgroundLayer, at: 0)
    return backgroundLayer
}

/// CGRect of substring.
func boundingRectForCharacterRange(_ range: NSRange) -> CGRect {
    let layoutManager = self.layoutManager
    var glyphRange = NSRange()
    // Convert the range for glyphs.
    layoutManager.characterRange(forGlyphRange: range, actualGlyphRange: &glyphRange)
    var glyphRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
    // https://stackoverflow.com/a/28332722/1033581
    glyphRect.origin.x += textContainerInset.left
    glyphRect.origin.y += textContainerInset.top
    return glyphRect
}

Notes:

  • Additionally, you should keep a reference to layers created that way, so that you can remove or reframe them.
  • layoutManager.boundingRect(forGlyphRange:in:) may not give you what you want when the range spans on multiple lines, so consider splitting the range when appropriate.
Cœur
  • 37,241
  • 25
  • 195
  • 267