Get the NSRange for the visible text after scroll in UITextView - ios

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.

Related

I couldn't scroll my textview automatically

hi I have an extension in the following code but it could not run for me :(
Actually I want to scroll my textView with a entered speed but I don't know how can I do this
extension UITextView {
func simple_scrollToBottom() {
let textCount: Int = text.count
guard textCount >= 1 else { return }
scrollRangeToVisible(NSRange(location: textCount - 1, length: 1))
}
}
I used it as self.akor_goster.simple_scrollToBottom() in viewdidload
First, you would not want to call this from viewDidLoad()...
At that point, auto-layout has not finished arranging everything in the view, frame and text view content is not yet ready.
Also, you don't want to start an animation yet, because the view has not yet appeared.
So, if you want this to happen automatically, put the call in viewDidAppear().
Second, you cannot control the scrolling speed when calling scrollRangeToVisible() -- but you can calculate the offset and animate that change with your desired speed.
Try this extension:
extension UITextView {
func timedScrollToBottom(duration d: Double) {
let y = contentSize.height - frame.size.height
// if y is less than zero, all the text fits, so no scrolling needed
guard y > 0 else {
return
}
// Duration is in seconds
UIView.animate(withDuration: d, animations: {
self.contentOffset.y = y
})
}
}
and call it like this:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.akor_goster.timedScrollToBottom(duration: 2.0)
}

how to scroll till particular position using xctest

I am trying to swipe up to a particular element in my app, if i use "swipeup" it is going to bottom of the view, which i don't want
Here is my code :
XCUIElement *staticText = [[[tablesQuery2 childrenMatchingType:XCUIElementTypeCell] elementBoundByIndex:2] childrenMatchingType:XCUIElementTypeStaticText].element;
[staticText swipeUp];
Here is my app screen before using swipe up
Here is my app screen after using swipe up
If you know which element you want to select and the height of each picker item, you can make an extension of XCUIElement to select the right value.
/// Move up/down the picker options until the given `selectionPosition` is reached.
func changePickerSelection(pickerWheel: XCUIElement, selectionPosition: UInt) {
// Select the new value
var valueSelected = false
while !valueSelected {
// Get the picker wheel's current position
if let pickerValue = pickerWheel.value {
let currentPosition = UInt(getPickerState(String(pickerValue)).currentPosition)
switch currentPosition.compared(to: selectionPosition) {
case .GreaterThan:
pickerWheel.selectPreviousOption()
case .LessThan:
pickerWheel.selectNextOption()
case .Equal:
valueSelected = true
}
}
}
}
/// Extend XCUIElement to contain methods for moving to the next/previous value of a picker.
extension XCUIElement {
/// Scrolls a picker wheel up by one option.
func selectNextOption() {
let startCoord = self.coordinateWithNormalizedOffset(CGVector(dx: 0.5, dy: 0.5))
let endCoord = startCoord.coordinateWithOffset(CGVector(dx: 0.0, dy: 30.0)) // 30pts = height of picker item
endCoord.tap()
}
/// Scrolls a picker wheel down by one option.
func selectPreviousOption() {
let startCoord = self.coordinateWithNormalizedOffset(CGVector(dx: 0.5, dy: 0.5))
let endCoord = startCoord.coordinateWithOffset(CGVector(dx: 0.0, dy: -30.0))
endCoord.tap()
}
}
let pickerWheel = app.pickerWheels.element(boundBy: 0)
changePickerSelection(pickerWheel, selectionPosition: 2)

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++
}
}
}
}

How to detect touch on NSTextAttachment

What is the best way to detect when user taps on NSTextAttachment on iOS?
I think that one of the ways would be checking for the character on carret's position whether it is NSAttachmentCharacter, but it just doesn't seem right.
I've also tried UITextViewDelegate method: -(BOOL)textView:(UITextView *)textView shouldInteractWithTextAttachment:(NSTextAttachment *)textAttachment inRange:(NSRange)characterRange but it's not invoked when textView.editable=YES
Josh's answer is almost perfect. However, if you tap in the whitespace of your UITextView past the end of the input, glyphIndex(for:in:fractionOfDistanceThroughGlyph) will return the final glyph in the string. If this is your attachment, it will incorrectly evaluate to true.
Apple's docs say: If no glyph is under point, the nearest glyph is returned, where nearest is defined according to the requirements of selection by mouse. Clients who wish to determine whether the the point actually lies within the bounds of the glyph returned should follow this with a call to boundingRect(forGlyphRange:in:) and test whether the point falls in the rectangle returned by that method.
So, here is a tweaked version (Swift 5, XCode 10.2) that performs an additional check on the bounds of the detected glyph. I believe some of the characterIndex tests are now superfluous but they don't hurt anything.
One caveat: glyphs appear to extend to the height of the line containing them. If you have a tall portrait image attachment next to a landscape image attachment, taps on the whitespace above the landscape image will still evaluate to true.
import UIKit
import UIKit.UIGestureRecognizerSubclass
// Thanks to https://stackoverflow.com/a/52883387/658604
// and https://stackoverflow.com/a/49153247/658604
/// Recognizes a tap on an attachment, on a UITextView.
/// The UITextView normally only informs its delegate of a tap on an attachment if the text view is not editable, or a long tap is used.
/// If you want an editable text view, where you can short cap an attachment, you have a problem.
/// This gesture recognizer can be added to the text view, and will add requirments in order to recognize before any built-in recognizers.
class AttachmentTapGestureRecognizer: UITapGestureRecognizer {
typealias TappedAttachment = (attachment: NSTextAttachment, characterIndex: Int)
private(set) var tappedState: TappedAttachment?
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
tappedState = nil
guard let textView = view as? UITextView else {
state = .failed
return
}
if let touch = touches.first {
tappedState = evaluateTouch(touch, on: textView)
}
if tappedState != nil {
// UITapGestureRecognizer can accurately differentiate discrete taps from scrolling
// Therefore, let the super view evaluate the correct state.
super.touchesBegan(touches, with: event)
} else {
// User didn't initiate a touch (tap or otherwise) on an attachment.
// Force the gesture to fail.
state = .failed
}
}
/// Tests to see if the user has tapped on a text attachment in the target text view.
private func evaluateTouch(_ touch: UITouch, on textView: UITextView) -> TappedAttachment? {
let point = touch.location(in: textView)
let glyphIndex: Int = textView.layoutManager.glyphIndex(for: point, in: textView.textContainer, fractionOfDistanceThroughGlyph: nil)
let glyphRect = textView.layoutManager.boundingRect(forGlyphRange: NSRange(location: glyphIndex, length: 1), in: textView.textContainer)
guard glyphRect.contains(point) else {
return nil
}
let characterIndex: Int = textView.layoutManager.characterIndexForGlyph(at: glyphIndex)
guard characterIndex < textView.textStorage.length else {
return nil
}
guard NSTextAttachment.character == (textView.textStorage.string as NSString).character(at: characterIndex) else {
return nil
}
guard let attachment = textView.textStorage.attribute(.attachment, at: characterIndex, effectiveRange: nil) as? NSTextAttachment else {
return nil
}
return (attachment, characterIndex)
}
}
Apple make this really difficult. As others point out, the delegate method is called, but only when isEditable is false, or when the user does a tap and hold on the attachment. If you want to be informed about a simple tap interaction during editing, forget it.
I went down the touchesBegan: and hitTest: paths, both with problems. The touches methods are called after the UITextView has already handled the interaction, and the hitTest: is too crude, because it messes with the first responder status and so forth.
My solution in the end was gesture recognizers. Apple are using those internally, which explains why touchesBegan: is not really viable in the first place: the gesture recognizers have already handled the event.
I created a new gesture recognizer class for use with a UITextView. It simply checks for the location of the tap, and if it is an attachment, it handles it. I make all the other gesture recognizers subordinate to my one, so we get first look at the events, and the others only come into play if our one fails.
The gesture recognizer class is below, along with an extension for adding it to UITextView. I add it in my UITextView subclass in awakeFromNib, like this. (You needn't use a subclass if you don't have one.)
override func awakeFromNib() {
super.awakeFromNib()
let recognizer = AttachmentTapGestureRecognizer(target: self, action: #selector(handleAttachmentTap(_:)))
add(recognizer)
and I handle the action by calling the existing UITextViewDelegate method textView(_:,shouldInteractWith:,in:,interaction:). You could just as easily put the handling code directly in the action, rather than using the delegate.
#IBAction func handleAttachmentTap(_ sender: AttachmentTapGestureRecognizer) {
let _ = delegate?.textView?(self, shouldInteractWith: sender.attachment!, in: NSRange(location: sender.attachmentCharacterIndex!, length: 1), interaction: .invokeDefaultAction)
}
Here is the main class.
import UIKit
import UIKit.UIGestureRecognizerSubclass
/// Recognizes a tap on an attachment, on a UITextView.
/// The UITextView normally only informs its delegate of a tap on an attachment if the text view is not editable, or a long tap is used.
/// If you want an editable text view, where you can short cap an attachment, you have a problem.
/// This gesture recognizer can be added to the text view, and will add requirments in order to recognize before any built-in recognizers.
class AttachmentTapGestureRecognizer: UIGestureRecognizer {
/// Character index of the attachment just tapped
private(set) var attachmentCharacterIndex: Int?
/// The attachment just tapped
private(set) var attachment: NSTextAttachment?
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
attachmentCharacterIndex = nil
attachment = nil
let textView = view as! UITextView
if touches.count == 1, let touch = touches.first, touch.tapCount == 1 {
let point = touch.location(in: textView)
let glyphIndex: Int? = textView.layoutManager.glyphIndex(for: point, in: textView.textContainer, fractionOfDistanceThroughGlyph: nil)
let index: Int? = textView.layoutManager.characterIndexForGlyph(at: glyphIndex ?? 0)
if let characterIndex = index, characterIndex < textView.textStorage.length {
if NSAttachmentCharacter == (textView.textStorage.string as NSString).character(at: characterIndex) {
attachmentCharacterIndex = characterIndex
attachment = textView.textStorage.attribute(.attachment, at: characterIndex, effectiveRange: nil) as? NSTextAttachment
state = .recognized
} else {
state = .failed
}
}
} else {
state = .failed
}
}
}
extension UITextView {
/// Add an attachment recognizer to a UITTextView
func add(_ attachmentRecognizer: AttachmentTapGestureRecognizer) {
for other in gestureRecognizers ?? [] {
other.require(toFail: attachmentRecognizer)
}
addGestureRecognizer(attachmentRecognizer)
}
}
This same approach could presumably be used for taps on links.
The delegate method does work but ONLY if the attachment has an image in the image attribute AND if editable = NO! So if you have an image pasted in to the attributedString from somewhere else it seems the data ends up being stored in the fileWrapper and next time you put the attributedString back into the textView the image attribute is nil and the layout manager or whatever gets the image from the fileWrapper.
Somewhere in the documents it does mention that there are no methods in NSTextAttachment for persistence of the image attribute.
To test this try copy a photo from the Photo app and paste it into your textView, now if you hold down your finger on it you should see the default menu pop up. Now if you save this rich text, say into a Core Data entity and then retrieve it the image attribute will be nil but the image data will be in attachment.fileWrapper.regularFileContents
Its a pain, and I would love to know the engineers intention. So you have two options it seems.
Create your own custom NSTextAttachment and include methods for archiving the image and other settings (PLEASE SHOW ME HOW TOO WHEN YOU FIGURE THIS ONE OUT)
Every time prior to putting your string back into textView you find all the attachments and recreated the image attribute like so:
attachment.image = [UIImage imageWithData:attachment.fileWrapper.regularFileContents];
Bear in mind the side effect of doing this is invalidating the fileWrapper. I want to resize the image but also keep the original so I don't loose the full resolution. I think the only way of doing this might be to subclass NSTextAttachment.
EDIT:
I figured out how to create the custom NSTextAttachments - here is a link for those interested http://ossh.com.au/design-and-technology/software-development/implementing-rich-text-with-images-on-os-x-and-ios/
EDIT 2: To customise the menu when in Edit Mode see the following Apple documents, the issue is 'touchEnded' never seems to get called so you might have to try using touchesBegan. Careful you don't interfere with the default editing behaviour though.
https://developer.apple.com/library/ios/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/AddingCustomEditMenuItems/AddingCustomEditMenuItems.html
Note that in the code below you would need to add code after // selection management comment to determine which character was touched, check if it is the special text attachment character and
then modify the edit menu or take some other action.
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
UITouch *theTouch = [touches anyObject];
if ([theTouch tapCount] == 2 && [self becomeFirstResponder]) {
// selection management code goes here...
// bring up edit menu.
UIMenuController *theMenu = [UIMenuController sharedMenuController];
CGRect selectionRect = CGRectMake (currentSelection.x, currentSelection.y, SIDE, SIDE);
[theMenu setTargetRect:selectionRect inView:self];
[theMenu setMenuVisible:YES animated:YES];
}
}
Alternately you could add a custom menu by adding the menu item and then modifying the canPerformAction method.
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
LOG(#"canPerformAction: called");
if (action == #selector(viewImage)) {
// Check the selected character is the special text attachment character
return YES;
}
return NO;
}
Here is some addition code but its a bit fussy. Second method just disables the default edit menu if an attachment is detected.
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
FLOG(#"touchesBegan:withEvent: called");
if (self.selectedRange.location != NSNotFound) {
FLOG(#" selected location is %d", self.selectedRange.location);
int ch;
if (self.selectedRange.location >= self.textStorage.length) {
// Get the character at the location
ch = [[[self textStorage] string] characterAtIndex:self.selectedRange.location-1];
} else {
// Get the character at the location
ch = [[[self textStorage] string] characterAtIndex:self.selectedRange.location];
}
if (ch == NSAttachmentCharacter) {
FLOG(#" selected character is %d, a TextAttachment", ch);
} else {
FLOG(#" selected character is %d", ch);
}
}
}
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
FLOG(#"canPerformAction: called");
FLOG(#" selected location is %d", self.selectedRange.location);
FLOG(#" TextAttachment character is %d", NSAttachmentCharacter);
if (self.selectedRange.location != NSNotFound) {
int ch;
if (self.selectedRange.location >= self.textStorage.length) {
// Get the character at the location
ch = [[[self textStorage] string] characterAtIndex:self.selectedRange.location-1];
} else {
// Get the character at the location
ch = [[[self textStorage] string] characterAtIndex:self.selectedRange.location];
}
if (ch == NSAttachmentCharacter) {
FLOG(#" selected character is %d, a TextAttachment", ch);
return NO;
} else {
FLOG(#" selected character is %d", ch);
}
// Check for an attachment
NSTextAttachment *attachment = [[self textStorage] attribute:NSAttachmentAttributeName atIndex:self.selectedRange.location effectiveRange:NULL];
if (attachment) {
FLOG(#" attachment attribute retrieved at location %d", self.selectedRange.location);
return NO;
}
else
FLOG(#" no attachment at location %d", self.selectedRange.location);
}
return [super canPerformAction:action withSender:sender];
}
Swift 3 answer:
func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange) -> Bool {
return true
}
Make sure your textView isEditable = false, isSelectable = true, and isUserInteractionEnabled = true. Duncan's answer did not mention isUserInteractionEnabled, this has to be true, otherwise it wont work.
You can do this programmatically (textView.isEditable = false), or via attributes inspector:
I have altered Drew's gesture recognizer here to subclass UITapGestureRecognizer rather than UIGestureRecognizer.
This offers one advantage in that it only detects discrete taps as opposed to the beginning of a scroll.
import UIKit
import UIKit.UIGestureRecognizerSubclass
// Modified from: https://stackoverflow.com/a/49153247/658604
/// Recognizes a tap on an attachment, on a UITextView.
/// The UITextView normally only informs its delegate of a tap on an attachment if the text view is not editable, or a long tap is used.
/// If you want an editable text view, where you can short cap an attachment, you have a problem.
/// This gesture recognizer can be added to the text view, and will add requirments in order to recognize before any built-in recognizers.
class AttachmentTapGestureRecognizer: UITapGestureRecognizer {
typealias TappedAttachment = (attachment: NSTextAttachment, characterIndex: Int)
private(set) var tappedState: TappedAttachment?
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
tappedState = nil
guard let textView = view as? UITextView else {
state = .failed
return
}
if let touch = touches.first {
tappedState = evaluateTouch(touch, on: textView)
}
if tappedState != nil {
// UITapGestureRecognizer can accurately differentiate discrete taps from scrolling
// Therefore, let the super view evaluate the correct state.
super.touchesBegan(touches, with: event)
} else {
// User didn't initiate a touch (tap or otherwise) on an attachment.
// Force the gesture to fail.
state = .failed
}
}
/// Tests to see if the user has tapped on a text attachment in the target text view.
private func evaluateTouch(_ touch: UITouch, on textView: UITextView) -> TappedAttachment? {
let point = touch.location(in: textView)
let glyphIndex: Int? = textView.layoutManager.glyphIndex(for: point, in: textView.textContainer, fractionOfDistanceThroughGlyph: nil)
let index: Int? = textView.layoutManager.characterIndexForGlyph(at: glyphIndex ?? 0)
guard let characterIndex = index, characterIndex < textView.textStorage.length else {
return nil
}
guard NSTextAttachment.character == (textView.textStorage.string as NSString).character(at: characterIndex) else {
return nil
}
guard let attachment = textView.textStorage.attribute(.attachment, at: characterIndex, effectiveRange: nil) as? NSTextAttachment else {
return nil
}
return (attachment, characterIndex)
}
}
SWIFT 4.2
#objc func myMethodToHandleTap(_ sender: UITapGestureRecognizer) {
let myTextView = sender.view as! UITextView
let layoutManager = myTextView.layoutManager
// 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 characterIndex < myTextView.textStorage.length {
let attributeValue = myTextView.attributedText.attribute(NSAttributedString.Key.attachment, at: characterIndex, effectiveRange: nil) as? NSTextAttachment
if let _ = attributeValue {
print("TAPPED ATTACHMENT")
}
}
}
Use hitTest to get the touch in a subclassed UITextView. This avoids the issue of messing up the standard editing functions. From the position get the character index and then check the character for the attachment.
I found the common way to solve this kind of clicking attachment in attributed string is using UITextView . But the UITextView is so complicated to disable all the menu, the zoom and so on . So I make a simple version of UILabel wrapper to support attachment clicking . It's simple and easy to use&change . the demo is here:
self.attachmentLabel.attributeText = attributeText;
self.attachmentLabel.numberOflines = 2;
self.attachmentLabel.selectBlock = ^(NSInteger attachmentIndex) {
NSLog(#"attachment:%ld called",attachmentIndex);
};
https://github.com/ximmyxiao/TestAttributeStringClick
Hope it will helps:)

How to efficiently find CGRects for visible words in UITextView?

My goal is to mark all visible misspelled words in an UITextView.
The inefficient algorithm is to use the spell checker to find all ranges of misspelled words in the text, convert them to UITextRange objects using positionFromPosition:inDirection:offset etc, then get the graphics rects using the UITextInput method firstRectFromRange.
Thus all the text -> misspelled words-> NSRange collection -> UITextRange collection -> CGRect collection -> evaluate for visibility, draw visible ones
The problem is that this requires that all the text is checked, and all misspelled words are converted to graphics rects.
Thus, I imagine the way to go is to somehow find out what parts of the underlying .text in the UITextView that is visible at the moment.
Thus for range of text visible -> misspelled words-> NSRange collection -> UITextRange collection -> CGRect collection -> evaluate for visibility, draw visible ones
The code in ios - how to find what is the visible range of text in UITextView? might work as a way to bound what parts of the text to check, but still requires that all text is measured, which I imagine could be quite costly.
Any suggestions?
Since UITextView is a subclass of UIScrollView, its bounds property reflects the visible part of its coordinate system. So something like this should work:
- (NSRange)visibleRangeOfTextView:(UITextView *)textView {
CGRect bounds = textView.bounds;
UITextPosition *start = [textView characterRangeAtPoint:bounds.origin].start;
UITextPosition *end = [textView characterRangeAtPoint:CGPointMake(CGRectGetMaxX(bounds), CGRectGetMaxY(bounds))].end;
return NSMakeRange([textView offsetFromPosition:textView.beginningOfDocument toPosition:start],
[textView offsetFromPosition:start toPosition:end]);
}
This assumes a top-to-bottom, left-to-right text layout. If you want to make it work for other layout directions, you will have to work harder. :)
Rob's answer, written in Swift 4. I've added some safety checks.
private func visibleRangeOfTextView(textView: UITextView) -> NSRange {
let bounds = textView.bounds
let origin = CGPoint(x: 10, y: 10)
guard let startCharacterRange = textView.characterRange(at: origin) else {
return NSRange(location: 0, length: 0)
}
let startPosition = startCharacterRange.start
let endPoint = CGPoint(x: bounds.maxX,
y: bounds.maxY)
guard let endCharacterRange = textView.characterRange(at: endPoint) else {
return NSRange(location: 0, length: 0)
}
let endPosition = endCharacterRange.end
let startIndex = textView.offset(from: textView.beginningOfDocument, to: startPosition)
let endIndex = textView.offset(from: startPosition, to: endPosition)
return NSRange(location: startIndex, length: endIndex)
}
Example usage, called from a button tap:
#IBAction func buttonTapped(sender: AnyObject) {
let range = visibleRangeOfTextView(textView: self.textView)
// Note: "as NSString" won't work correctly with Emoji and stuff,
// see also: http://stackoverflow.com/a/24045156/1085556
let nsText = self.textView.text as NSString
let text = nsText.substring(with: range)
NSLog("range: \(range), text = \(text)")
}

Resources