I am trying to highlight line by line the text in a UITextView. I want to iterate over each line and highlight that one for the user to see, then I want to remove the highlighting effect in preparation for the next line. I have tried and failed to create a solution and this is my best chance right now.
Here is some of what I have been working on so far, it currently fills the UITextView with "NSBackgroundColor 1101" for some reason and I do not know why that is.
func highlight() {
let str = "This is\n some placeholder\n text\nwith newlines."
var newStr = NSMutableAttributedString(string: "")
var arr:[String] = str.components(separatedBy: "\n")
var attArr:[NSMutableAttributedString] = []
for i in 0..<arr.count {
attArr.append(NSMutableAttributedString(string: arr[i]))
}
for j in 0..<attArr.count {
let range = NSMakeRange(0, attArr[j].length)
attArr[j].addAttribute(.backgroundColor, value: UIColor.yellow, range: range)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5){
for m in 0..<attArr.count {
newStr = NSMutableAttributedString(string: "\(attArr[m])\n")
self.textView.attributedText = newStr
}
}
attArr[j].removeAttribute(.backgroundColor, range: range)
//remove from texview here
}
}
As you can see this algorithm is supposed to strip the textView text and place that into an array by separating each line the new line delimiter.
The next thing done is to create an array filled with the same text but as mutable attributed string to begin adding the highlight attribute.
Each time a highlighted line appears there is a small delay until the next line begins to highlight. If anyone can help or point me in to the right direction to begin implementing this correctly it would help immensely,
Thank you!
So you want this:
You need the text view's contents to always be the full string, with one line highlighted, but your code sets it to just the highlighted line. Your code also schedules all the highlights to happen at the same time (.now() + 0.5) instead of at different times.
Here's what I'd suggest:
Create an array of ranges, one range per line.
Use that array to modify the text view's textStorage by removing and adding the .backgroundColor attribute as needed to highlight and unhighlight lines.
When you highlight line n, schedule the highlighting of line n+1. This has two advantages: it will be easier and more efficient to cancel the animation early if you need to, and it will be easier to make the animation repeat endlessly if you need to.
I created the demo above using this playground:
import UIKit
import PlaygroundSupport
let text = "This is\n some placeholder\n text\nwith newlines."
let textView = UITextView(frame: CGRect(x: 0, y:0, width: 200, height: 100))
textView.backgroundColor = .white
textView.text = text
let textStorage = textView.textStorage
// Use NSString here because textStorage expects the kind of ranges returned by NSString,
// not the kind of ranges returned by String.
let storageString = textStorage.string as NSString
var lineRanges = [NSRange]()
storageString.enumerateSubstrings(in: NSMakeRange(0, storageString.length), options: .byLines, using: { (_, lineRange, _, _) in
lineRanges.append(lineRange)
})
func setBackgroundColor(_ color: UIColor?, forLine line: Int) {
if let color = color {
textStorage.addAttribute(.backgroundColor, value: color, range: lineRanges[line])
} else {
textStorage.removeAttribute(.backgroundColor, range: lineRanges[line])
}
}
func scheduleHighlighting(ofLine line: Int) {
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
if line > 0 { setBackgroundColor(nil, forLine: line - 1) }
guard line < lineRanges.count else { return }
setBackgroundColor(.yellow, forLine: line)
scheduleHighlighting(ofLine: line + 1)
}
}
scheduleHighlighting(ofLine: 0)
PlaygroundPage.current.liveView = textView
Related
I have a UITextView can be moved around the parent view by dragging. My expected functionality is to allow the user to move it freely, but also allow the text to add a \n anytime it goes to the edge of the view. I don't want the text to clip off screen so it should resize accordingly. If it reaches the end, then a newline is added, and so forth.
The issue that I have, is that the method I implemented results in a weird behavior where sometimes, when it adds a newline, it will only allow sometimes 1 - 5 characters before adding another new line, instead of matching the previous line's width. There must be a better way to handle this.
This is where all the magic is happening. (Evil magic...)
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
let newText = (textView.text as NSString).replacingCharacters(in: range, with: text)
guard newText != "\n" else { return false }
self.updateViewFrame(text: newText)
let textWidth = self.textSize(text: text).width
if self.frame.origin.x + self.frame.size.width - (textWidth - 2) / 2 >= self.superview!.bounds.size.width {
if text != "" && text != "\n" {
self.text.insert("\n", at: self.text.index(self.text.endIndex, offsetBy: 0))
}
self.updateViewFrame(text: self.text)
}
return true
}
Additionally, I've noticed that the conditional check to see if we're at the edge, sometimes returns true even when I'm expecting it to return false, eg; on a newline that has one character F, for example. I think it could have something to do with the textSize(..).width function, but I've played around with that and it doesn't seem like it's directly related.
private func textSize(text: String?) -> CGSize {
return text?.size(withAttributes: [.font: self.font!]) ?? .zero
}
Here's a SS of the actual issue as it occurs. Each line should fill to the edge of the display, and only add a new line if it reaches the edge.
I uses the following code to make text strikethrough. But after that the visible area moves to the end of the text. But I don't want to move the visible area. How can I do this?
tvText.isScrollEnabled = false
let rng = tvText.selectedRange
let text = NSMutableAttributedString(attributedString: tvText.attributedText)
let attributes = tvText.attributedText.attributes(at: tvText.selectedRange.lowerBound, effectiveRange: nil)
if attributes.keys.contains(NSAttributedString.Key.strikethroughStyle) {
text.removeAttribute(NSAttributedString.Key.strikethroughStyle, range: tvText.selectedRange)
} else {
text.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: tvText.selectedRange)
}
tvText.attributedText = text
tvText.selectedRange = rng
tvText.isScrollEnabled = true
Many thanks Jens
I am trying to draw boxes around each digit entered by a user in UITextField for which keyboard type is - Number Pad.
To simplify the problem statement I assumed that each of the digits (0 to 9) will have same bounding box for its glyph, which I obtained using below code:
func getGlyphBoundingRect() -> CGRect? {
guard let font = font else {
return nil
}
// As of now taking 8 as base digit
var unichars = [UniChar]("8".utf16)
var glyphs = [CGGlyph](repeating: 0, count: unichars.count)
let gotGlyphs = CTFontGetGlyphsForCharacters(font, &unichars, &glyphs, unichars.count)
if gotGlyphs {
let cgpath = CTFontCreatePathForGlyph(font, glyphs[0], nil)!
let path = UIBezierPath(cgPath: cgpath)
return path.cgPath.boundingBoxOfPath
}
return nil
}
I am drawing each bounding box thus obtained using below code:
func configure() {
guard let boundingRect = getGlyphBoundingRect() else {
return
}
for i in 0..<length { // length denotes number of allowed digits in the box
var box = boundingRect
box.origin.x = (CGFloat(i) * boundingRect.width)
let shapeLayer = CAShapeLayer()
shapeLayer.frame = box
shapeLayer.borderWidth = 1.0
shapeLayer.borderColor = UIColor.orange.cgColor
layer.addSublayer(shapeLayer)
}
}
Now problem is -
If I am entering digits - 8,8,8 in the text field then for first occurrence of digit the bounding box drawn is aligned, however for second occurrence of same digit the bounding box appears a bit offset (by negative x), the offset value (in negative x) increases for subsequent occurrences of same digit.
Here is image for reference -
I tried to solve the problem by setting NSAttributedString.Key.kern to 0, however it did not change the behavior.
Am I missing any important property in X axis from the calculation due to which I am unable to get properly aligned bounding box over each digit? Please suggest.
The key function you need to use is:
protocol UITextInput {
public func firstRect(for range: UITextRange) -> CGRect
}
Here's the solution as a function:
extension UITextField {
func characterRects() -> [CGRect] {
var beginningOfRange = beginningOfDocument
var characterRects = [CGRect]()
while beginningOfRange != endOfDocument {
guard let endOfRange = position(from: beginningOfRange, offset: 1), let textRange = textRange(from: beginningOfRange, to: endOfRange) else { break }
beginningOfRange = endOfRange
var characterRect = firstRect(for: textRange)
characterRect = convert(characterRect, from: textInputView)
characterRects.append(characterRect)
}
return characterRects
}
}
Note that you may need to clip your rects if you're text is too long for the text field. Here's an example of the solution witout clipping:
In an NSAttributedString, a range of letters has a link attribute and a custom color attribute.
In Xcode 7 with Swift 2, it works:
In Xcode 8 with Swift 3, the custom attributed color for the link is always ignored (it should be orange in the screenshot).
Here's the code for testing.
Swift 2, Xcode 7:
import Cocoa
import XCPlayground
let text = "Hey #user!"
let attr = NSMutableAttributedString(string: text)
let range = NSRange(location: 4, length: 5)
attr.addAttribute(NSForegroundColorAttributeName, value: NSColor.orangeColor(), range: range)
attr.addAttribute(NSLinkAttributeName, value: "http://somesite.com/", range: range)
let tf = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 50))
tf.allowsEditingTextAttributes = true
tf.selectable = true
tf.stringValue = text
tf.attributedStringValue = attr
XCPlaygroundPage.currentPage.liveView = tf
Swift 3, Xcode 8:
import Cocoa
import PlaygroundSupport
let text = "Hey #user!"
let attr = NSMutableAttributedString(string: text)
let range = NSRange(location: 4, length: 5)
attr.addAttribute(NSForegroundColorAttributeName, value: NSColor.orange, range: range)
attr.addAttribute(NSLinkAttributeName, value: "http://somesite.com/", range: range)
let tf = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 50))
tf.allowsEditingTextAttributes = true
tf.isSelectable = true
tf.stringValue = text
tf.attributedStringValue = attr
PlaygroundPage.current.liveView = tf
I've sent a bug report to Apple, but in the meantime if someone has an idea for a fix or workaround in Xcode 8, that would be great.
Apple Developer has answered:
Please know that our engineering team has determined that this issue behaves as intended based on the information provided.
And they explain why it worked before but doesn't anymore:
Unfortunately, the previous behavior (attributed string ranges with NSLinkAttributeName rendering in a custom color) was not explicitly supported. It happened to work because NSTextField was only rendering the link when the field editor was present; without the field editor, we fall back to the color specified by NSForegroundColorAttributeName.
Version 10.12 updated NSLayoutManager and NSTextField to render links using the default link appearance, similar to iOS. (see AppKit release notes for 10.12.)
To promote consistency, the intended behavior is for ranges that represent links (specified via NSLinkAttributeName) to be drawn using the default link appearance. So the current behavior is the expected behavior.
(emphasis mine)
This answer is not a fix for the issue of NSLinkAttributeName ignoring custom colors, it's an alternative solution for having colored clickable words in NSAttributedString.
With this workaround we don't use NSLinkAttributeName at all, since it forces a style we don't want.
Instead, we use custom attributes, and we subclass the NSTextField/NSTextView to detect the attributes under the mouse click and act accordingly.
There's several constraints, obviously: you have to be able to subclass the field/view, to override mouseDown, etc, but "it works for me" while waiting for a fix.
When preparing your NSMutableAttributedString, where you would have set an NSLinkAttributeName, set the link as an attribute with a custom key instead:
theAttributedString.addAttribute("CUSTOM", value: theLink, range: theLinkRange)
theAttributedString.addAttribute(NSForegroundColorAttributeName, value: NSColor.orange, range: theLinkRange)
theAttributedString.addAttribute(NSCursorAttributeName, value: NSCursor.arrow(), range: theLinkRange)
The color and content for the link is set. Now we have to make it clickable.
For this, subclass your NSTextView and override mouseDown(with event: NSEvent).
We will get the location of the mouse event in the window, find the character index in the text view at that location, and ask for the attributes of the character at this index in the text view's attributed string.
class MyTextView: NSTextView {
override func mouseDown(with event: NSEvent) {
// the location of the click event in the window
let point = self.convert(event.locationInWindow, from: nil)
// the index of the character in the view at this location
let charIndex = self.characterIndexForInsertion(at: point)
// if we are not outside the string...
if charIndex < super.attributedString().length {
// ask for the attributes of the character at this location
let attributes = super.attributedString().attributes(at: charIndex, effectiveRange: nil)
// if the attributes contain our key, we have our link
if let link = attributes["CUSTOM"] as? String {
// open the link, or send it via delegate/notification
}
}
// cascade the event to super (optional)
super.mouseDown(with: event)
}
}
That's it.
In my case I needed to customize different words with different colors and link types, so instead of passing just the link as a string I pass a struct containing the link and additional meta information, but the idea is the same.
If you have to use an NSTextField instead of an NSTextView, it's a bit trickier to find the click event location. A solution is to create an NSTextView inside the NSTextField and from there use the same technique as before.
class MyTextField: NSTextField {
var referenceView: NSTextView {
let theRect = self.cell!.titleRect(forBounds: self.bounds)
let tv = NSTextView(frame: theRect)
tv.textStorage!.setAttributedString(self.attributedStringValue)
return tv
}
override func mouseDown(with event: NSEvent) {
let point = self.convert(event.locationInWindow, from: nil)
let charIndex = referenceView.textContainer!.textView!.characterIndexForInsertion(at: point)
if charIndex < self.attributedStringValue.length {
let attributes = self.attributedStringValue.attributes(at: charIndex, effectiveRange: nil)
if let link = attributes["CUSTOM"] as? String {
// ...
}
}
super.mouseDown(with: event)
}
}
I have tried the answers in How to disable word-wrap of NSTextView?
for half a day but have had no luck. The answers were a bit scattered and confusing really.
I have this code:
#IBOutlet var display: NSTextView!
func applicationDidFinishLaunching(aNotification: NSNotification) {
// Insert code here to initialize your application
let LargeNumberForText: CGFloat = 1.0e7
display.textContainer!.containerSize = NSMakeSize(LargeNumberForText, LargeNumberForText)
display.textContainer!.widthTracksTextView = false
display.horizontallyResizable = true
display.autoresizingMask = [.ViewWidthSizable, .ViewHeightSizable]
}
and I have this in the .xib:
Did I miss a step?
My issue was in fact Premature line wrapping in NSTextView when tabs are used I corrected the issue by employing both the word-wrap code above, and calling this after changing the text:
func format() {
let numStops = 100000;
let tabInterval = 40;
var tabStop: NSTextTab
//attributes for attributed String of TextView
let paraStyle = NSMutableParagraphStyle()
// This first clears all tab stops, then adds tab stops, at desired intervals...
paraStyle.tabStops = []
for cnt in 1...numStops {
tabStop = NSTextTab(type: .LeftTabStopType, location: CGFloat(tabInterval * cnt))
paraStyle.addTabStop(tabStop)
}
var attrs = [String: AnyObject]()
attrs.updateValue(paraStyle, forKey:NSParagraphStyleAttributeName)
display.textStorage!.addAttributes(attrs, range: NSMakeRange(0, display.textStorage!.string.characters.count))
}
where display is the NSTextView. Subclassing would be more elegant of course.