UITextInput.characterRange(at:) is off by a few pixels - ios

After adding a tap recognizer to my UITextView subclass, I'm attempting to get the character that is being tapped:
var textRecognizer: UITapGestureRecognizer!
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
textContainer.lineFragmentPadding = 0
textContainerInset = .zero
textRecognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped))
textRecognizer.numberOfTapsRequired = 1
addGestureRecognizer(textRecognizer)
}
#objc func textTapped(recognizer: UITapGestureRecognizer) {
let location = recognizer.location(in: self)
if let cRange = characterRange(at: location) {
let cPosition = offset(from: beginningOfDocument, to: cRange.start)
let cChar = text[Range(NSRange(location: cPosition, length: 1), in: text)!]
print(cChar)
}
}
Problem is that if my attributedText is "Hello world\nWelcome to Stack Overflow" and I tap on the left part of a letter, like the left side of letter f, then characterRange(at: location) returns the previous letter r instead of returning f.

From my perspective, characterRange(at:) is buggy:
If you give it a point on the left half of character at index n, it returns range (n-1, n)
If you give it a point on the right half of character at index n, it returns range (n, n+1)
If you give it a point on the left half of character at index beginningOfDocument, it returns nil
If you give it a point on the right half of character at index endOfDocument, it returns (endOfDocument, endOfDocument+1)
The discrepancy of the behaviors at the extremities of the textInput demonstrate that there is a bug somewhere.
It behaves like a sort of "cursor position at point" function, which makes it unreliable to determine which character is actually at this point: is it the character before the cursor or the character after the cursor?
closestPosition(to:) suffers from the exact same issue.
A working alternative is layoutManager.characterIndex(for:in:fractionOfDistanceBetweenInsertionPoints:). Credit to vacawama:
#objc func textTapped(recognizer: UITapGestureRecognizer) {
var location = recognizer.location(in: self)
location.x -= textContainerInset.left
location.y -= textContainerInset.top
let cPosition = layoutManager.characterIndex(for: location, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
let cChar = text[Range(NSRange(location: cPosition, length: 1), in: text)!]
print(cChar)
}

Related

Make image clickable in UITextView

I am trying to create a notetaking app where you can click on images as well as edit text normally.
Currently, I have placed a tap gesture recognizer over the UITextView which calls:
#IBAction func onImageTap(_ sender: UITapGestureRecognizer) {
if sender.state == .ended {
let textView = sender.view as! UITextView
let layoutManager = textView.layoutManager
// location of tap in textView coordinates
var location = sender.location(in: textView)
location.x -= textView.textContainerInset.left;
location.y -= textView.textContainerInset.top;
// character index at tap location
let characterIndex = layoutManager.characterIndex(for: location, in: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
// if index is valid
if characterIndex < textView.textStorage.length {
// check if the tap location has the image attribute
let attributeValue = textView.attributedText.attribute(NSAttributedString.Key.fileType, at: characterIndex, effectiveRange: nil) as? String
// if location does have custom attribute, extract the url of the PDF and perform segue to display PDF
if let value = attributeValue {
if value == "PDF" {
storedFileName = textView.attributedText.attribute(NSAttributedString.Key.fileName, at: characterIndex, effectiveRange: nil) as? String
performSegue(withIdentifier: "displayPDF", sender: textView)
}
} else {
textView.isEditable = true
textView.becomeFirstResponder()
}
}
}
}
This makes all of the images clickable. However, then I stop being able to click anywhere else in the UITextView to edit the text.
What do I do so that the images remain clickable but, if I click anywhere else in the UITextView, I am able to start editing the text?
Assuming you have added the image to the UITextView as an NSAttachment, as of iOS 9.0 you can check whether or not a given NSRange contains an attachment:
For instance, if the image was located at the 25th character:
let characterIndex = 25
let range = NSRange(location: characterIndex, length: 1)
// Using an IBOutlet in the current view controller called 'textView'
if textView.attributedText.containsAttachments(in: range) {
// Do some activity
}

Get the NSRange for the visible text after scroll in UITextView

I'm trying to save the location of scrolled text in a UITextView so that I can return to that location upon loading the ViewController again. I have very long strings, so I want the user to be able to scroll to a specific location and then return to that location later.
I'm using the UITextView. scrollRangeToVisible function to automatically scroll the text view, but I don't know how to get the NSRange of the text that the user is seeing. Is this the best way to go about this? I tried using the setContentOffset function but that didn't seem to do anything.
Any help is appreciated. Thanks!
Here's a little extension on UITextView that uses its characterRange(at:) function instead. It also adds a computed property to return the currently visible text:
extension UITextView {
private var firstVisibleCharacterPosition: UITextPosition? {
// ⚠️ For some reason `characterRange(at:)` returns nil for points with a low y value.
guard let scrolledPosition = characterRange(at: contentOffset)?.start else {
return beginningOfDocument
}
return scrolledPosition
}
private var lastVisibleCharacterPosition: UITextPosition? {
return characterRange(at: bounds.max)?.end
}
/// The range of the text that is currently displayed within the text view's bounds.
var visibleTextRange: UITextRange? {
guard
let first = firstVisibleCharacterPosition,
let last = lastVisibleCharacterPosition else {
return nil
}
return textRange(from: first, to: last)
}
/// The string that is currently displayed within the text view's bounds.
var visibleText: String? {
guard let visibleTextRange = visibleTextRange else {
return nil
}
return text(in: visibleTextRange)
}
}
I used these shorthand properties in the code above:
extension CGRect {
var min: CGPoint {
return .init(x: minX, y: minY)
}
var max: CGPoint {
return .init(x: maxX, y: maxY)
}
}
I haven't tested this thoroughly but I believe the following should work. The APIs you need are documented in the UITextInput protocol, which UITextView adopts.
You first need to get the UITextPosition that corresponds to a given point inside the view. You'd then convert this value into a UTF-16 character offset. For example, here I print the visible text range (in terms of UTF-16 code units) of a textView every time the view is scrolled:
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let topLeft = CGPoint(x: textView.bounds.minX, y: textView.bounds.minY)
let bottomRight = CGPoint(x: textView.bounds.maxX, y: textView.bounds.maxY)
guard let topLeftTextPosition = textView.closestPosition(to: topLeft),
let bottomRightTextPosition = textView.closestPosition(to: bottomRight)
else {
return
}
let charOffset = textView.offset(from: textView.beginningOfDocument, to: topLeftTextPosition)
let length = textView.offset(from: topLeftTextPosition, to: bottomRightTextPosition)
let visibleRange = NSRange(location: charOffset, length: length)
print("Visible range: \(visibleRange)")
}
In my tests, UITextView tended to count lines that were barely included in the visible range (e.g. by only one pixel), so the reported visible range tended to be one or two lines larger than what a human user would say. You may have to experiment with the exact CGPoint you pass into closesPosition(to:) to get the results you want.

Activate/deactivate code

Trying to get the code so when if whiteDotDist < centerRadius - whiteDotRadius is executed all the code below it is active, and when the code below it is executed it becomes inactive again until the if whiteDotDist < centerRadius - whiteDotRadius is executed again. Sort of like a loop, so you have to keep going back and fourth from center to smallDot. Hard to explain over computer. Update it is giving me error 'Binary operator '<' cannot be applied to operands of type 'CGFloat' and 'Double'
#IBAction func handlePan(recognizer:UIPanGestureRecognizer) {
let translation = recognizer.translation(in: self.view)
if let view = recognizer.view {
view.center = CGPoint(x:view.center.x + translation.x,
y:view.center.y + translation.y)
}
recognizer.setTranslation(CGPoint.zero, in: self.view)
let centerRadius = 37.5
let whiteDotRadius = 23.5
let whiteDotDist = hypot(center.center.x - whiteDot.center.x, center.center.y - whiteDot.center.y - whiteDot.center.y)
if whiteDotDist < centerRadius - whiteDotRadius {
resetTimer() }
if (whiteDot.frame.contains(smallDot.frame) && smallDot.image != nil) {
addOne += 1
score.text = "\(addOne)"
resetTimer()
timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(SecondViewController.startTimer), userInfo: nil, repeats: true)
smallDot.center = spawnRandomPosition()
}
}
}
Check to make sure:
All of the views have the same parent (so that they are all in the same coordinate system)
The Frames are tight around the circles. Change the background color of center, whiteDot, and smallDot to Red and post a picture
Even if you do that, your code checks if the bounding rects are inside each other, so it may look like the smallDot is outside the whiteDot (if it were in the corner), but the bounding frame is enclosed by whiteDot's frame.
If you want to check that the circles (not bounding boxes) are inside each other, get the distance between centers and make sure that that is within the (outer radius - smaller dot radius).
pseudo-code
let centerRadius = 100 // set this to radius of center circle
let whiteDotRadius = 10 // set this to whiteDot radius
let whiteDotDist = hypotf(center.center.x - whiteDot.center.x, center.center.y - whiteDot.center.y)
if whiteDotDist < centerRadius - whiteDotRadius {
// whiteDot is inside center
}

Method for word determination within UITextView.text and modifying word with prefix using Swift 2.2, iOS 9.3 & Xcode 7?

Using a TextView for user message entry it is editable and selectable during user editing.
One of the buttons below the field, toggles the TextView between editing and hash-tagging mode.
When toggled to tag, the TextView has it's editable & selectable properties disabled, and I have a function to detect taps and returns the character position within the text.
I need to determine the word, if any, tapped on, and modify the word in the UITextView.text by prefixing it with a # unless it already has two hashes it which case it removes the hashes.
I'm using regular expressions for the logic.
I have not been able to find a high level method for determining the word of the character tapped on.
I have searched through the Apple's Dev. Lib. and sites like raywenderlich and Grok Swift, but cannot find the method I am sure must be there.
I could implement by testing if the current charcter is a valid word divider if not then decrement character index and test until the word boundary is determined. At which point, I return to the prior index and test for the # character, in the case it is a #, I would test the next character and in the case it is not a #, I would add the # character to the start of the word.
Is there a function within UIKit, TextKit, or a method of UITextView or NSTextStorage, that will return the word of the character tapped and NSRange of that word?
Also what would be the correct method for adding the # to the TextView's text?
[textView:shouldChangeTextInRange:replacementText or textView.textStorage:replaceCharactersInRange:withString:]
I have worked commercially on PC, PlayStation and GameBoy, but this is the first time developing an app and using the iPhone/Mac platform, so I could really use advice.
for detecting the # you need to call the code inside the delegate shouldChangeCharactersInRange
let stringprocess = stringfordetecting.text
let tok = stringprocess!.componentsSeparatedByString(" ")
for item in tok
{
let demo = String(item)
if demo.hasPrefix("#")
{
let range = (stringfordetecting.text! as NSString).rangeOfString(item)
//add code
}
else
{
//add code
}
for detecting the tapped character index add a guesture to the textview
let tapGesture = UITapGestureRecognizer(target: self, action: "textTapped:")
tapGesture.headline = indexPath
tapGesture.numberOfTapsRequired = 1
textview2.addGestureRecognizer(tapGesture)
func textTapped(recognizer: MyTapGestureRecognizer){
let textView: UITextView = recognizer.view as! UITextView
var layoutManager: NSLayoutManager = textView.layoutManager
var location: CGPoint = recognizer.locationInView(textView)
let position: CGPoint = CGPointMake(location.x, location.y)
location.x -= textview2.textContainerInset.left
location.y -= textview2.textContainerInset.top
var charIndex: Int
charIndex = layoutManager.characterIndexForPoint(location, inTextContainer: textview2.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
if charIndex < textview2.textStorage.length
{
print(charIndex)
}
}
for detecting the tapped character in a textview inside the tapguesture recogniser function
func textTapped(recognizer: MyTapGestureRecognizer){
let textView: UITextView = recognizer.view as! UITextView
var layoutManager: NSLayoutManager = textView.layoutManager
var location: CGPoint = recognizer.locationInView(textView)
let position: CGPoint = CGPointMake(location.x, location.y)
location.x -= cell.messageLabel.textContainerInset.left
location.y -= cell.messageLabel.textContainerInset.top
var charIndex: Int
charIndex = layoutManager.characterIndexForPoint(location, inTextContainer: cell.messageLabel.textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
if charIndex < cell.messageLabel.textStorage.length {
let stringprocess = textview.text
let tok = stringprocess.componentsSeparatedByString(" ")
// let attributedString1 = NSMutableAttributedString(string:stringcheck as String)
for item in tok
{
let demo = String(item)
if demo.hasPrefix("#") {
let range = (stringcheck as NSString).rangeOfString(item)
var i = range.location
while i <= range.location+range.length
{
if i == charIndex
{
print(demo)
}
i++
}
}
}
}

Find correct CGect of substring using TextKit and Touch Location

I have been working on wrapping my head around the TextKit work flow for Swift for a couple of days using some of the great questions and answers here among others.
I'm trying to find the bounding rect of a substring found in a label, if the user touches that perform an action, and if not do nothing. Currently, my bounding rect is off. Any feedback is greatly appreciated! Thank you for your time.
TableView Delegate cellForRowAtIndex:
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier(termsCell, forIndexPath: indexPath) as! TableViewCell
let linkRangeIndex = attributedString.string.rangeOfString("link")
let linkRange = attributedString.string.NSRangeFromRange(linkRangeIndex!)
attributedString.setAttributes(linkAttributes, range: linkRange)
cell.termsLabel.attributedText = attributedString
cell.termsLabel.userInteractionEnabled = true
textContainer.lineFragmentPadding = 0.0
textContainer.lineBreakMode = cell.termsLabel.lineBreakMode
textContainer.maximumNumberOfLines = cell.termsLabel.numberOfLines
let labelSize = cell.termsLabel.bounds.size
textContainer.size = labelSize
let tapGesture = UITapGestureRecognizer(target: self, action: "handleTapOnLabel:")
cell.termsLabel.addGestureRecognizer(tapGesture)
return cell
}
}
Handle Taps on Label Method:
What I'm attempting to do is get the bounding rect box of "link" portion of the main string and check if the users touches are with in that box (then preform an action). However my bounding rect box is off. It fires (a print method for testing) when I click on the right side of "with"
#IBAction func handleTapOnLabel(tapGesture: UITapGestureRecognizer) {
let glyphPointer = NSRangePointer()
let linkRangeIndex = attributedString.string.rangeOfString("link")
let linkRange = attributedString.string.NSRangeFromRange(linkRangeIndex!)
let myString = "A string"
myString.NSRangeFromRange(linkRangeIndex!)
let glyphRange = layoutManager.characterRangeForGlyphRange(linkRange, actualGlyphRange: glyphPointer)
print("glyphRange: \(glyphRange)")
let glyphRect = layoutManager.boundingRectForGlyphRange(glyphRange, inTextContainer: textContainer)
print("glyphRect: \(glyphRect)")
let touchPoint = tapGesture.locationOfTouch(0, inView: cell?.textLabel)
print(touchPoint)
// need range of points for label
if CGRectContainsPoint(glyphRect, locationOfTouchInLabel) {
print("I go home now")
} else {
print("no")
}
}
}

Resources