19

I have a non-scrollable UITextView with it's layoutManager maximumNumberOfLines set to 9, which works fine, but, I cannot seem to find a method in NSLayoutManager that restricts the text to not go beyond the frame of the UITextView.

Take for example in this screenshot, the cursor is on the 9th line (the 1st line is clipped at top of screenshot, so disregard that). If the user continues to type new characters, spaces, or hit the return key, the cursor continues off screen and the UITextView's string continues to get longer.

enter image description here

I don't want to limit the amount of characters of the UITextView, due to foreign characters being different sizes.

I've been trying to fix this for several weeks; I'd greatly appreciate any help.

CustomTextView.h

#import <UIKit/UIKit.h>

@interface CustomTextView : UITextView <NSLayoutManagerDelegate>

@end

CustomTextView.m

#import "CustomTextView.h"

@implementation CustomTextView

- (id)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self)
    {
        self.backgroundColor = [UIColor clearColor];
        self.font = [UIFont systemFontOfSize:21.0];
        self.dataDetectorTypes = UIDataDetectorTypeAll;
        self.layoutManager.delegate = self;
        self.tintColor = [UIColor companyBlue];
        [self setLinkTextAttributes:@{NSForegroundColorAttributeName:[UIColor companyBlue]}];
        self.scrollEnabled = NO;
        self.textContainerInset = UIEdgeInsetsMake(8.5, 0, 0, 0);
        self.textContainer.maximumNumberOfLines = 9;
    }
    return self;
}

- (CGFloat)layoutManager:(NSLayoutManager *)layoutManager lineSpacingAfterGlyphAtIndex:(NSUInteger)glyphIndex withProposedLineFragmentRect:(CGRect)rect
{
    return 4.9;
}

@end

Update, still not resolved

klcjr89
  • 5,862
  • 10
  • 58
  • 91
  • 1
    What I would do is just load a UIWebView that pulls a .html file from your Xcode files that has a that has javascript limiting this since it's a fairly simple javascript function. Then use javascript injection to query the input. Also set your UIWebView background color to transparent via javascript and objective-c so that it doesn't look like a website is loaded but just a UITextView (even though it isn't a UITextView)... of course this isn't an "answer" so I put it in the comments of this question. – Albert Renshaw Feb 19 '14 at 19:02
  • UITextView, especially in iOS 7 has many known bugs. You should consider using [PSPDFTextView](http://petersteinberger.com/blog/2014/fixing-uitextview-on-ios-7/) and see if that fixes it for you. – Holly Feb 21 '14 at 19:18
  • I do not believe my issues are related to being bugs, as the same issues I'm experiencing would go back to iOS 5 and 6. – klcjr89 Feb 21 '14 at 19:19
  • Just thought I'd add this is still unresolved. – klcjr89 Feb 25 '14 at 17:26
  • If you do not want to limit the amount of characters this is going to be pretty hard to do. Is there a specific reason to make it non-scrollable? The problem is the combination of non-scrollable and unlimited characters. You will have to put a limit somewhere else it will never work. You cannot put indefinite items in a predefined space. – ophychius Feb 26 '14 at 09:47
  • Possible duplicate of [Text scrolls outside of the UITextView box boundary](https://stackoverflow.com/questions/7607907/text-scrolls-outside-of-the-uitextview-box-boundary) – Najam Mar 08 '18 at 06:42

7 Answers7

8

Here is a better answer I think. Whenever the shouldChangeTextInRange delegate method is called we call our doesFit:string:range function to see whether the resulting text height exceeds the view height. If it does we return NO to prevent the change from taking place.

-(BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text {
    FLOG(@" called");

    // allow deletes
    if (text.length == 0)
        return YES;

    // Check if the text exceeds the size of the UITextView
    return [self doesFit:textView string:text range:range];

}
- (float)doesFit:(UITextView*)textView string:(NSString *)myString range:(NSRange) range;
{
    // Get the textView frame
    float viewHeight = textView.frame.size.height;
    float width = textView.textContainer.size.width;

    NSMutableAttributedString *atrs = [[NSMutableAttributedString alloc] initWithAttributedString: textView.textStorage];
    [atrs replaceCharactersInRange:range withString:myString];

    NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:atrs];
    NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize: CGSizeMake(width, FLT_MAX)];
    NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];

    [layoutManager addTextContainer:textContainer];
    [textStorage addLayoutManager:layoutManager];
    float textHeight = [layoutManager
            usedRectForTextContainer:textContainer].size.height;
    FLOG(@" viewHeight = %f", viewHeight);
    FLOG(@" textHeight = %f", textHeight);

    if (textHeight >= viewHeight - 1) {
        FLOG(@" textHeight >= viewHeight - 1");
        return NO;
    } else
        return YES;
}

EDIT OK you will also need to add some checks if you change the format of the text. In my case the user can change the font or make it bold, change paragraph style, etc.. So now any of these changes could also cause the text to exceed the textView borders.

So first you need to make sure you are registering these changes with the textViews undoManager. See below for an example (I just copy the whole attributedString so I can put it back if undo is called).

// This is in my UITextView subclass but could be anywhere

// This gets called to undo any formatting changes 
- (void)setMyAttributedString:(NSAttributedString*) atstr {
    self.attributedText = atstr;
    self.selectedRange = _undoSelection;
}
// Before we make any format changes save the attributed string with undoManager
// Also save the current selection (maybe should save this with undoManager as well using a custom object containing selection and attributedString)
- (void)formatText:(id)sender {
    //LOG(@"formatText: called");
    NSAttributedString *atstr = [[NSAttributedString alloc] initWithAttributedString:self.textStorage];
    [[self undoManager] registerUndoWithTarget:self
                               selector:@selector(setMyAttributedString:)
                                 object:atstr];
    // Remember selection
    _undoSelection = self.selectedRange;

   // Add text formatting attributes
   ...
   // Now tell the delegate that something changed
   [self.delegate textViewDidChange:self];
}

Now check the size in the delegate and undo if it does not fit.

-(void)textViewDidChange:(UITextView *)textView {
    FLOG(@" called");
    if ([self isTooBig:textView]) {
        FLOG(@" text is too big so undo it!");
        @try {
            [[textView undoManager] undo];
        }
        @catch (NSException *exception) {
            FLOG(@" exception undoing things %@", exception);
        }
    }
}
Duncan Groenewald
  • 8,496
  • 6
  • 41
  • 76
  • Duncan I could not get your code to fix my issue; also, Im not allowing the user to change the formatting of the text at all. Is there a way I can get this sample app to you showing what I'm wanting to accomplish? Thank you in advance kind sir. – klcjr89 Feb 28 '14 at 00:31
  • zip it and email it to me at duncan.groenewald@ossh.com.au – Duncan Groenewald Feb 28 '14 at 02:58
  • I vote your answer because it's very good! Also consider `inset` even better! `(textHeight < viewHeight-1-textView.textContainerInset.top-textView.textContainerInset.bottom) ` – Codus Sep 14 '17 at 05:41
3

boundingRectWithSize:options:attributes:context: is not recommended for textviews, because it does not take various attributes of the textview (such as padding), and thus return an incorrect or imprecise value.

To determine the textview's text size, use the layout manager's usedRectForTextContainer: with the textview's text container to get a precise rectangle required for the text, taking into account all required layout constraints and textview quirks.

CGRect rect = [self.textView.layoutManager usedRectForTextContainer:self.textView.textContainer];

I would recommend doing this in processEditingForTextStorage:edited:range:changeInLength:invalidatedRange:, after calling the super implementation. This would mean replacing the textview's layout manager by providing your own text container and setting its layout manager to your subclass' instance. This way you can commit the changes from the textview made by the user, check if the rect is still acceptable and undo if not.

Léo Natan
  • 56,823
  • 9
  • 150
  • 195
  • I'm not sure how to implement this – klcjr89 Feb 21 '14 at 19:33
  • 1
    @troop231 `processEditingForTextStorage:edited:range:changeInLength:invalidatedRange:` is called when changes are committed to the text storage. What you want to implement is check if these changes are acceptable, and if not reverse them. To catch `processEditingForTextStorage:edited:range:changeInLength:invalidatedRange:` you need to subclass `NSLayoutManager`. `UITextView` has a new initializer method: `initWithFrame:textContainer:` that you need to use. – Léo Natan Feb 21 '14 at 19:43
  • I've created a subclass of NSLayoutManager, but don't know how to set my CustomTextView class to use this. – klcjr89 Feb 21 '14 at 19:49
  • 1
    @troop231 See here how to use custom layout manager: http://stackoverflow.com/a/20916091/983912 – Léo Natan Feb 21 '14 at 19:51
  • So I need a custom NSTextStorage too? – klcjr89 Feb 21 '14 at 19:54
  • 1
    @troop231 No, just your own instance so you can set its layout manager to your custom one. – Léo Natan Feb 21 '14 at 19:59
  • let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/48103/discussion-between-troop231-and-leo-natan) – klcjr89 Feb 21 '14 at 20:01
  • "boundingRectWithSize:options:attributes:context: is not recommended for textviews, because it does not take various attributes of the textview (such as padding), and thus return an incorrect or imprecise value" Says who? As long as you're passing the correct `size` it should be fine. If your text view has padding, deduct those values from the width of the size you pass into the method. – Stephen Paul Jan 31 '18 at 01:51
  • Good luck with that. From my experience, that never works correctly. – Léo Natan Jan 31 '18 at 07:05
2

You will need to do this yourself. Basically it would work like this:

  1. In your UITextViewDelegate's textView:shouldChangeTextInRange:replacementText: method find the size of your current text (NSString sizeWithFont:constrainedToSize: for example).
  2. If the size is larger than you allow return FALSE, otherwise return TRUE.
  3. Provide your own feedback to the user if they type something larger than you allow.

EDIT: Since sizeWithFont: is deprecated use boundingRectWithSize:options:attributes:context:

Example:

NSString *string = @"Hello World"; 

UIFont *font = [UIFont fontWithName:@"Helvetica-BoldOblique" size:21];

CGSize constraint = CGSizeMake(300,NSUIntegerMax);

NSDictionary *attributes = @{NSFontAttributeName: font};

CGRect rect = [string boundingRectWithSize:constraint 
                                   options:(NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading)  
                                attributes:attributes 
                                   context:nil];
Patrick Tescher
  • 3,387
  • 1
  • 18
  • 31
  • Can you provide an example? I believe that sizeWithFont method is deprecated. This also has to account for new line breaks and the built in text wrapping of the text view. – klcjr89 Feb 19 '14 at 21:58
  • See my update in my question above to see if I'm on the right track? Thank you – klcjr89 Feb 19 '14 at 23:56
  • 2
    `boundingRectWithSize:...` is a bad idea in textviews. – Léo Natan Feb 21 '14 at 18:58
1

You can check the size of the bounding rectangle and if it is too big call the undo manager to undo the last action. Could be a paste operation or enter in text or new line character.

Here is a quick hack that checks if the height of the text is too close to the height of the textView. Also checks that the textView rect contains the text rect. You might need to fiddle with this some more to suit your needs.

-(void)textViewDidChange:(UITextView *)textView {
    if ([self isTooBig:textView]) {
        FLOG(@" too big so undo");
        [[textView undoManager] undo];
    }
}
/** Checks if the frame of the selection is bigger than the frame of the textView
 */
- (bool)isTooBig:(UITextView *)textView {
    FLOG(@" called");

    // Get the rect for the full range
    CGRect rect = [textView.layoutManager usedRectForTextContainer:textView.textContainer];

    // Now convert to textView coordinates
    CGRect rectRange = [textView convertRect:rect fromView:textView.textInputView];
    // Now convert to contentView coordinates
    CGRect rectText = [self.contentView convertRect:rectRange fromView:textView];

    // Get the textView frame
    CGRect rectTextView = textView.frame;

    // Check the height
    if (rectText.size.height > rectTextView.size.height - 16) {
        FLOG(@" rectText height too close to rectTextView");
        return YES;
    }

    // Find the intersection of the two (in the same coordinate space)
    if (CGRectContainsRect(rectTextView, rectText)) {
        FLOG(@" rectTextView contains rectText");
        return NO;
    } else
        return YES;
}

ANOTHER OPTION - here we check the size and if its too big prevent any new characters being typed in except if its a deletion. Not pretty as this also prevents filling a line at the top if the height is exceeded.

bool _isFull;

-(BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text {
    FLOG(@" called");

    // allow deletes
    if (text.length == 0)
        return YES;

    // Check if the text exceeds the size of the UITextView
    if (_isFull) {
        return NO;
    }

    return YES;
}
-(void)textViewDidChange:(UITextView *)textView {
    FLOG(@" called");
    if ([self isTooBig:textView]) {
        FLOG(@" text is too big!");
        _isFull = YES;
    } else {
        FLOG(@" text is not too big!");
        _isFull = NO;
    }
}

/** Checks if the frame of the selection is bigger than the frame of the textView
 */
- (bool)isTooBig:(UITextView *)textView {
    FLOG(@" called");

    // Get the rect for the full range
    CGRect rect = [textView.layoutManager usedRectForTextContainer:textView.textContainer];

    // Now convert to textView coordinates
    CGRect rectRange = [textView convertRect:rect fromView:textView.textInputView];
    // Now convert to contentView coordinates
    CGRect rectText = [self.contentView convertRect:rectRange fromView:textView];

    // Get the textView frame
    CGRect rectTextView = textView.frame;

    // Check the height
    if (rectText.size.height >= rectTextView.size.height - 10) {
        return YES;
    }

    // Find the intersection of the two (in the same coordinate space)
    if (CGRectContainsRect(rectTextView, rectText)) {
        return NO;
    } else
        return YES;
}
Duncan Groenewald
  • 8,496
  • 6
  • 41
  • 76
  • Hi Duncan, could you tell me what self.contentView is? – klcjr89 Feb 26 '14 at 02:20
  • @troop231 I am assuming the view that contains the text view? – Léo Natan Feb 26 '14 at 03:05
  • 1
    Yes @LeoNatan is correct, just use textView.superView or self.view. I use a scrollable form so the contentView is the view the UI controls are placed on which is itself inside a scrollView. – Duncan Groenewald Feb 26 '14 at 03:13
  • I'm not sure if I can't get this working. Does the 16 value make a big difference? Because after I get to my 8th line and when the text is about to wrap to the 9th line, the method is called when it should be called at the end of the 9th line (By 9th line I mean 1-9, and not 0-9) – klcjr89 Feb 26 '14 at 03:24
  • I just realized calling [[textView undoManager] undo]; undos all of the text entered in the UITextView, is there a better way to only change a little bit and not all of the text? – klcjr89 Feb 26 '14 at 03:29
  • 1
    You will have to fiddle a bit to figure out what works. Set it to 0 and see. I think you need to cater for the difference in letters like a and p where p has a descent - so if the user types an a on the last line it might fit but if they then type a p it won't. So might be best to get the max height of the particular font and font size you are using rather than a hardcoded value. – Duncan Groenewald Feb 26 '14 at 03:31
  • Hey Duncan, wouldn't it be better to check to see if the text fits based on width and not height in the text view, that way the a and p letter sizes you mentioned won't matter. – klcjr89 Feb 26 '14 at 03:36
  • Well what is to then prevent you from hitting ENTER ENTER ENTER many times ? Anyway see the other option which prevents any text entry if the height is exceeded. The user has to delete one line to be able to continue. Lots of edge cases like changing the font size etc.. – Duncan Groenewald Feb 26 '14 at 03:58
  • 1
    The issue is you can only determine the size after the edit has taken place and then you have to undo. Unfortunately it does not seem undo manager will undo one character at a time. Another option is to have a hidden textView that you pass the edits from shouldChangeTextInRange to and then use it to calculate the size. If it's too big you return NO. – Duncan Groenewald Feb 26 '14 at 05:23
  • @troop231 see the new answer - this is much cleaner. Better test all edge cases like paste, changing font, formatting text, etc. – Duncan Groenewald Feb 26 '14 at 09:23
1

I created a test VC. It increases a line counter every time a new line is reached in the UITextView. As I understand you want to limit your text input to no more than 9 lines. I hope this answers your question.

#import "ViewController.h"

@interface ViewController ()

@property IBOutlet UITextView *myTextView;

@property CGRect previousRect;
@property int lineCounter;

@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];

[self.myTextView setDelegate:self];

self.previousRect = CGRectZero;
self.lineCounter = 0;
}

- (void)textViewDidChange:(UITextView *)textView {
UITextPosition* position = textView.endOfDocument;

CGRect currentRect = [textView caretRectForPosition:position];

if (currentRect.origin.y > self.previousRect.origin.y){
    self.lineCounter++;
    if(self.lineCounter > 9) {
        NSLog(@"Reached line 10");
        // do whatever you need to here...
    }
}
self.previousRect = currentRect;

}

@end
sangony
  • 11,636
  • 4
  • 39
  • 55
  • 1
    Haven't been able to get this working yet. Can you confirm that if the user types 2 lines, and then moves the cursor to the start of the first line, and then hits the return key, that the previous 2 lines will go all the way to the bottom of the text view, and then stop after the 2nd line gets to the text view's bottom? – klcjr89 Feb 26 '14 at 03:14
  • I didn't run the gamut of all possible scenarios. My understanding was you needed something to alert you when a certain number of lines was reached. – sangony Feb 26 '14 at 03:21
  • I have several scenarios to solve which is a big headache that I can easily solve by disallowing the user to use the return key, but that's not a good experience at all. – klcjr89 Feb 26 '14 at 03:25
  • Double check that you are setting in your .h file. – sangony Feb 26 '14 at 03:28
  • Well I don't what to say... the code accurately counts every time a new line is used. I tested it a with wrap and return key. Try it out yourself. – sangony Feb 26 '14 at 03:32
  • Haven't had a chance to try yet, been busy all day. – klcjr89 Feb 26 '14 at 23:53
1

There is a new Class in IOS 7 that works hand in hand with UITextviews which is the NSTextContainer Class

It works with UITextview through the Textviews text container property

it has this property called size ...

size Controls the size of the receiver’s bounding rectangle. Default value: CGSizeZero.

@property(nonatomic) CGSize size Discussion This property defines the maximum size for the layout area returned from lineFragmentRectForProposedRect:atIndex:writingDirection:remainingRect:. A value of 0.0 or less means no limitation.

I am still in the process of understanding it and trying it out but I believe it should resolve your issue.

Paulo
  • 1,245
  • 10
  • 8
1

No need to find number of lines. We can get all these things by calculating the cursor position from the textview and according to that we can minimize the UIFont of UITextView according to the height of UITextView.

Here is below link.Please refer this. https://github.com/jayaprada-behera/CustomTextView

Jayaprada
  • 944
  • 8
  • 11