Background
So, with iOS 6 an UITextView can take an attributedString, which could be useful for Syntax highlighting.
I'm doing some regex patterns in -textView:shouldChangeTextInRange:replacementText: and oftentimes I need to change the color of a word already typed. I see no other options than resetting the attributedText, which takes time.
- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text
{
//A context will allow us to not call -attributedText on the textView, which is slow.
//Keep context up to date
[self.context replaceCharactersInRange:range withAttributedString:[[NSAttributedString alloc] initWithString:text attributes:self.textView.typingAttributes]];
// […]
self.textView.scrollEnabled = FALSE;
[self.context setAttributes:self.defaultStyle range:NSMakeRange(0, self.context.length)];
[self refresh]; //Runs regex-patterns in the context
textView.attributedText = self.context;
self.textView.selectedRange = NSMakeRange(range.location + text.length, 0);
self.textView.scrollEnabled = TRUE;
return FALSE;
}
This runs okayish on the simulator, but on an iPad 3 each -setAttributedText takes a few hundreds of milliseconds.
I filed a bug to Apple, with the request of being able to mutate the attributedText. It got marked as a duplicate, so I cannot see what they're saying about this.
The question
The more specific question:
How can I change the color of certain ranges in a UITextView, with a large multicolored text, with good enough performance to do it in every shouldReplaceText...?
The more broad question:
How do you do syntax highlighting with a UITextView in iOS 6?
I encountered the same problem for my application Zap-Guitar (No-Strings-Attached) where I allow users to type/paste/edit their own songs and the app highlights recognized chords.
Yes it is true apple uses an html writer and parser to display the attributed text. A wonderful explanation of behind the scene can be found here: http://www.cocoanetics.com/2012/12/uitextview-caught-with-trousers-down/
The only solution I found for this problem is not to use attributed text which is an overkill for syntax highlighting.
Instead I reverted to the good old UITextView with plain text and added buttons to the text view where highlighted was needed. To compute the buttons frames I used this answer: How to find position or get rect of any word in textview and place buttons over that?
This reduced CPU usage by 30% (give or take).
Here is a handy category:
#implementation UITextView (WithButtons)
- (CGRect)frameForTextRange:(NSRange)range {
UITextPosition *beginning = self.beginningOfDocument;
UITextPosition *start = [self positionFromPosition:beginning offset:range.location];
UITextPosition *end = [self positionFromPosition:start offset:range.length];
UITextRange *textRange = [self textRangeFromPosition:start toPosition:end];
CGRect rect = [self firstRectForRange:textRange];
return [self convertRect:rect fromView:self.textInputView];
}
#end
The attributedText accessors have to round-trip to/from HTML, so it's really non-optimal for a syntax-highlighted text view implementation. On iOS 6, you'll probably want to use CoreText directly.
Related
I was searching many hours and could not find any solution for, what I thought was simple, but as it seems it is not.
I have a tableView and attached a viewController as subclass of tableViewController.
Now I would just like to change the header color of the sections.
Due to the reason that this is not possible in storyboard, I would really like to know with which code I can make it happen.
this is work 100% and you can change your index or header text color
- (void)tableView:(UITableView *)tableView willDisplayHeaderView:(UIView *)view forSection:(NSInteger)section
{
view.tintColor = [UIColor redColor];
// if you have index/header text in your tableview change your index text color
UITableViewHeaderFooterView *headerIndexText = (UITableViewHeaderFooterView *)view;
[headerIndexText.textLabel setTextColor:[UIColor blueColor]];
}
I resently faced some strange issue on IOS 6.0 related to MKAnnotation (part of MKMap Kit)
May be some of you fased the same, or have idea or advise how to skip/avoid/solve it.
Here it is
I need some Pins on my map with callout bubbles (contains titles & subtitles nothing more)
When i pressing on it callout starts. Due to user comfort I've added observer to center pin:
(void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context{
NSString *action = (NSString*)context;
if([[change valueForKey:#"new"] intValue] == 1 && [action isEqualToString:#"GMAP_ANNOTATION_SELECTED"]) {
if ([((MKAnnotationView*) object).annotation isKindOfClass:[CustomPlacemark class]]) {
CustomPlacemark *place = ((MKAnnotationView*) object).annotation;
[mapView setCenterCoordinate:place.coordinate animated:YES];
}
}
}
On ios 5.1 or above everything looks & works fine (pin centering after press, callout bubbles shows above centered horizontally), but on IOS 6.0 behavior is strage, pin centering as well, but callout bubble not, even if it's width enough to fill screen horizontally it could appear partially beyond screen.
Haven't found any solution or options to manually setup appearance behavior. Have any advices?
Thx for further answers, and sorry for my English.
Well, Ive searched in several places and although some people allegedly have found fixes it doesn't seem to apply to my case.
I'm trying to procedurally set the line height of a few UItextviews like this :
UITextView *lab = [LocalTexts objectAtIndex:j];
NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init];
paragraphStyle.lineHeightMultiple = 50.0f;
paragraphStyle.maximumLineHeight = 50.0f;
paragraphStyle.minimumLineHeight = 50.0f;
NSString *string = lab.text;
NSDictionary *ats = #{
NSFontAttributeName : [UIFont fontWithName:#"DIN Medium" size:16.0f],
NSParagraphStyleAttributeName : paragraphStyle,
};
lab.attributedText = [[NSAttributedString alloc] initWithString:string attributes:ats];
Strange thing is that if I disable the NSFontAttributeName assignment, the line height will work, also, if I set the Paragraph style to have a certain paragraph height, that always works too, so the NSParagraphStyleAttribute IS NOT being fully ignored. I dont know if it is a bug or I'm actually doing something wrong.
I tried implementing it as pure CORE TEXT, but it is a bit too complex for the current scope of the project.
Hope someone can point me in the right direction. Thanks.
This is a known bug in NSHTMLWriter which is used by UITextView to convert your attributed string to HTML: http://www.cocoanetics.com/2012/12/radar-uitextview-ignores-minimummaximum-line-height-in-attributed-string/
You can use UITextView replacement we have in DTCoreText to render this text correctly: https://github.com/Cocoanetics/DTCoreText
iOS 6 has been updated to use UITextView for rich text editing (a UITextView now earns an attributedText property —which is stupidly non mutable—). Here is a question asked on iOS 6 Apple forum under NDA, that can be made public since iOS 6 is now public...
In a UITextView, I can undo any font change but cannot undo a replacement in a copy of the view's attributed string. When using this code...
- (void) replace: (NSAttributedString*) old with: (NSAttributedString*) new
{
1. [[myView.undoManager prepareWithInvocationTarget:self] replace:new with:old];
2. old=new;
}
... undoing is working well.
But if I add a line to get the result visible in my view, the undoManager do not fire the "replace:with:" method as it should...
- (void) replace: (NSAttributedString*) old with: (NSAttributedString*) new
{
1. [[myView.undoManager prepareWithInvocationTarget:self] replace:new with:old];
2. old=new;
3. myView.attributedText=[[NSAttributedString alloc] initWithAttributedString:old];
}
Any idea? I have the same problem with any of the replacement methods, using a range or not, for MutableAttributedString I tried to use on line "2"...
Umm, wow I really didn't expect this to work! I couldn't find a solution so I just started trying anything and everything...
- (void)applyAttributesToSelection:(NSDictionary*)attributes {
UITextView *textView = self.contentCell.textView;
NSRange selectedRange = textView.selectedRange;
UITextRange *selectedTextRange = textView.selectedTextRange;
NSAttributedString *selectedText = [textView.textStorage attributedSubstringFromRange:selectedRange];
[textView.undoManager beginUndoGrouping];
[textView replaceRange:selectedTextRange withText:selectedText.string];
[textView.textStorage addAttributes:attributes range:selectedRange];
[textView.undoManager endUndoGrouping];
[textView setTypingAttributes:attributes];
}
Mac Catalyst (iOS14)
UITextView has undoManager that will manage undo and redo for free without any additional code.
But replacing its attributedText will reset the undoManager (Updating text and its attributes in textStorage not work for me too). I found that undo and redo will works normally when formatting text without replacing attributedText but by standard edit actions (Right click on highlighting text > Font > Bold (Mac Catalyst)).
So to fix this :
You need to set the allowsEditingTextAttributes of UITextView to be true, this will make UITextView support undo and redo of attributedText.
self.textView.allowsEditingTextAttributes = true
If you want to change the text of attributedText, use replace(_:withText:) of UITextInput, or insertText(_:) and deleteBackward() of UIKeyInput that UITextView conforming to.
self.textView.replace(self.textView.selectedTextRange!, withText: "test")
If you want to change attributes of text, use updateTextAttributes(conversionHandler:) of UITextView instead.
self.textView.updateTextAttributes { _ in
let font = UIFont.boldSystemFont(ofSize: 17)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
]
return attributes
}
For changing text and its attributes in specific range, modify the selectedRange of UITextView.
To implement undo and redo buttons, check this answer : https://stackoverflow.com/a/50530040/8637708
I have tested with Mac Catalyst, it should work on iOS and iPadOS too.
The Undo Manager is reset after setting its 'text' or 'attributedText' property, this is why it does not work. Whether this behavior is a bug or by design I don't know.
However, you can use the UITextInput protocol method instead.
(void)replaceRange:(UITextRange *)range withText:(NSString *)text
This works.
All my research so far seems to indicate it is not possible to do this accurately. The only two options available to me at the outset were:
a) Using a Layout manager for the CATextLayer - not available on iOS as of 4.0
b) Use sizeWithFont:constrainedToSize:lineBreakMode: and adjust the frame of the CATextLayer according to the size returned here.
Option (b), being the simplest approach, should work. After all, it works perfectly with UILabels. But when I applied the same frame calculation to CATextLayer, the frame was always turning out to be a bit bigger than expected or needed.
As it turns out, the line-spacing in CATextLayers and UILabels (for the same font and size) is different. As a result, sizeWithFont (whose line-spacing calculations would match with that of UILabels) does not return the expected size for CATextLayers.
This is further proven by printing the same text using a UILabel, as against a CATextLayer and comparing the results. The text in the first line overlaps perfectly (it being the same font), but the line-spacing in CATextLayer is just a little shorter than in UILabel. (Sorry I can't upload a screenshot right now as the ones I already have contain confidential data, and I presently don't have the time to make a sample project to get clean screenshots. I'll upload them later for posterity, when I have the time)
This is a weird difference, but I thought it would be possible to adjust the spacing in the CATextLayer by specifying the appropriate attribute for the NSAttributedString I use there, but that does not seem to be the case. Looking into CFStringAttributes.h I can't find a single attribute that could be related to line-spacing.
Bottomline:
So it seems like it's not possible to use CATextLayer on iOS in a scenario where the layer is required to fit to its text. Am I right on this or am I missing something?
P.S:
The reason I wanted to use CATextLayer and NSAttributedString's is because the string to be displayed is to be colored differently at different points. I guess I'd just have to go back to drawing the strings by hand as always....of course there's always the option of hacking the results from sizeWithFont to get the proper line-height.
Abusing the 'code' tags a little to make the post more readable.
I'm not able to tag the post with 'CATextLayer' - surprisingly no such tags exist at the moment. If someone with enough reputation bumps into this post, please tag it accordingly.
Try this:
- (CGFloat)boundingHeightForWidth:(CGFloat)inWidth withAttributedString:(NSAttributedString *)attributedString {
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString( (CFMutableAttributedStringRef) attributedString);
CGSize suggestedSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRangeMake(0, 0), NULL, CGSizeMake(inWidth, CGFLOAT_MAX), NULL);
CFRelease(framesetter);
return suggestedSize.height;
}
You'll have to convert your NSString to NSAttributedString. In-case of CATextLayer, you can use following CATextLayer subclass method:
- (NSAttributedString *)attributedString {
// If string is an attributed string
if ([self.string isKindOfClass:[NSAttributedString class]]) {
return self.string;
}
// Collect required parameters, and construct an attributed string
NSString *string = self.string;
CGColorRef color = self.foregroundColor;
CTFontRef theFont = self.font;
CTTextAlignment alignment;
if ([self.alignmentMode isEqualToString:kCAAlignmentLeft]) {
alignment = kCTLeftTextAlignment;
} else if ([self.alignmentMode isEqualToString:kCAAlignmentRight]) {
alignment = kCTRightTextAlignment;
} else if ([self.alignmentMode isEqualToString:kCAAlignmentCenter]) {
alignment = kCTCenterTextAlignment;
} else if ([self.alignmentMode isEqualToString:kCAAlignmentJustified]) {
alignment = kCTJustifiedTextAlignment;
} else if ([self.alignmentMode isEqualToString:kCAAlignmentNatural]) {
alignment = kCTNaturalTextAlignment;
}
// Process the information to get an attributed string
CFMutableAttributedStringRef attrString = CFAttributedStringCreateMutable(kCFAllocatorDefault, 0);
if (string != nil)
CFAttributedStringReplaceString (attrString, CFRangeMake(0, 0), (CFStringRef)string);
CFAttributedStringSetAttribute(attrString, CFRangeMake(0, CFAttributedStringGetLength(attrString)), kCTForegroundColorAttributeName, color);
CFAttributedStringSetAttribute(attrString, CFRangeMake(0, CFAttributedStringGetLength(attrString)), kCTFontAttributeName, theFont);
CTParagraphStyleSetting settings[] = {kCTParagraphStyleSpecifierAlignment, sizeof(alignment), &alignment};
CTParagraphStyleRef paragraphStyle = CTParagraphStyleCreate(settings, sizeof(settings) / sizeof(settings[0]));
CFAttributedStringSetAttribute(attrString, CFRangeMake(0, CFAttributedStringGetLength(attrString)), kCTParagraphStyleAttributeName, paragraphStyle);
CFRelease(paragraphStyle);
NSMutableAttributedString *ret = (NSMutableAttributedString *)attrString;
return [ret autorelease];
}
HTH.
I have a much easier solution, that may or may not work for you.
If you aren't doing anything special with the CATextLayer that you can't do a UILabel, instead make a CALayer and add the layer of the UILabel to the CALayer
UILabel*label = [[UILabel alloc]init];
//Do Stuff to label
CALayer *layer = [CALayer layer];
//Set Size/Position
[layer addSublayer:label.layer];
//Do more stuff to layer
With LabelKit you don't need CATextLayer anymore. No more wrong line spacing and wider characters, all is drawn in the same way as UILabel does, while still animated.
This page gave me enough to create a simple centered horizontally CATextLayer : http://lists.apple.com/archives/quartz-dev/2008/Aug/msg00016.html
- (void)drawInContext:(CGContextRef)ctx {
CGFloat height, fontSize;
height = self.bounds.size.height;
fontSize = self.fontSize;
CGContextSaveGState(ctx);
CGContextTranslateCTM(ctx, 0.0, (fontSize-height)/2.0 * -1.0);
[super drawInContext:ctx];
CGContextRestoreGState(ctx);
}