Created
March 14, 2014 10:42
-
-
Save keicoder/9545512 to your computer and use it in GitHub Desktop.
objective-c : UITextView subclass for supporting string, regex search and highlighting
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//UITextView subclass for supporting string, regex search and highlighting | |
//Created by Ivano Bilenchi on 05/11/13 | |
//ICAppDelegate.h | |
@interface ICAppDelegate : UIResponder <UIApplicationDelegate> | |
@property (strong, nonatomic) UIWindow *window; | |
@end | |
//ICAppDelegate.m | |
#import "ICViewController.h" | |
@implementation ICAppDelegate | |
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions | |
{ | |
self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]]; | |
// Override point for customization after application launch. | |
ICViewController *rootController = [[ICViewController alloc] init]; | |
self.window.rootViewController = rootController; | |
self.window.backgroundColor = [UIColor whiteColor]; | |
[self.window makeKeyAndVisible]; | |
return YES; | |
} | |
@end | |
//ICViewController.m | |
#import "ICTextView.h" | |
@interface ICViewController () | |
{ | |
ICTextView *_textView; | |
UISearchBar *_searchBar; | |
} | |
@end | |
@implementation ICViewController | |
#pragma mark - Self | |
- (void)loadView | |
{ | |
/** | |
* 프레임과 바운즈 차이 보기 | |
* applicationFrame are: 0.000000, 20.000000, 320.000000, 460.000000 | |
* applicationBounds are: 0.000000, 0.000000, 320.000000, 480.000000 | |
CGRect applicationFrame = [[UIScreen mainScreen] applicationFrame]; | |
NSLog (@"applicationFrame are: %f, %f, %f, %f", applicationFrame.origin.x, applicationFrame.origin.y, applicationFrame.size.width, applicationFrame.size.height); | |
CGRect applicationBounds = [[UIScreen mainScreen] bounds]; | |
NSLog (@"applicationBounds are: %f, %f, %f, %f", applicationBounds.origin.x, applicationBounds.origin.y, applicationBounds.size.width, applicationBounds.size.height); | |
**/ | |
CGRect tempFrame = [[UIScreen mainScreen] applicationFrame]; | |
CGFloat statusBarOffset = 20.0; // iOS7 | |
_searchBar = [[UISearchBar alloc] initWithFrame:CGRectMake(0.0, statusBarOffset, tempFrame.size.width, 44.0)]; //x, y, width, height | |
_searchBar.delegate = self; | |
UIView *mainView = [[UIView alloc] initWithFrame:tempFrame]; | |
CGFloat keyboardHeight = 216.0; // lazy | |
CGFloat searchBarHeight = _searchBar.frame.size.height; | |
_textView = [[ICTextView alloc] initWithFrame:tempFrame]; | |
UIEdgeInsets tempInsets = UIEdgeInsetsMake(searchBarHeight, 0.0, keyboardHeight, 0.0); //top, left, bottom, right | |
_textView.contentInset = tempInsets; | |
_textView.scrollIndicatorInsets = tempInsets; | |
[mainView addSubview:_textView]; | |
[mainView addSubview:_searchBar]; | |
self.view = mainView; | |
} | |
- (void)viewDidLoad | |
{ | |
[super viewDidLoad]; | |
_textView.text = [NSString stringWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"ICTextView" ofType:@"h"] encoding:NSUTF8StringEncoding error:NULL]; | |
_textView.font = [UIFont systemFontOfSize:14.0]; | |
[_searchBar becomeFirstResponder]; | |
} | |
#pragma mark - UISearchBarDelegate | |
- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText | |
{ | |
if (!searchText || [searchText isEqualToString:@""]) | |
{ | |
[_textView resetSearch]; | |
return; | |
} | |
[_textView scrollToString:searchText searchOptions:NSRegularExpressionCaseInsensitive]; | |
} | |
- (void)searchBarTextDidEndEditing:(UISearchBar *)searchBar | |
{ | |
[_textView becomeFirstResponder]; | |
} | |
- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar | |
{ | |
[_textView scrollToString:searchBar.text searchOptions:NSRegularExpressionCaseInsensitive]; | |
} | |
- (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar | |
{ | |
searchBar.text = nil; | |
[_textView resetSearch]; | |
} | |
@end | |
//ICViewController.h | |
@interface ICViewController : UIViewController <UISearchBarDelegate> | |
@end | |
//ICTextView.h - 1.0.2 | |
//Methods to account for contentInsets in iOS 7 | |
//Contains workarounds to many known iOS 7 UITextView bugs | |
//Installation | |
//1) just grab the ICTextView.h and ICTextView.m files and put them in project. | |
//2) ICTextView requires the QuartzCore framework. | |
//3) #import "ICTextView.h" and It's ready to go. | |
//Configuration: | |
//See comments in the `#pragma mark - Configuration` section. | |
/** | |
* | |
* Usage: | |
* ------ | |
* | |
* Search: | |
* ------- | |
* Searches can be performed via the `scrollToMatch:searchOptions:range:` and `scrollToString:searchOptions:range:` methods. | |
* `scrollToMatch:` performs regex searches, while `scrollToString:` searches for string literals. | |
* Both search methods are regex-powered, and therefore make use of `NSRegularExpressionOptions`. | |
* The `rangeOfFoundString` property contains the range of the current search match. | |
* You can get the actual string by calling the `foundString` method. | |
* The `resetSearch` method lets you restore the search variables to their starting values, effectively resetting the search. | |
* Calls to `resetSearch` cause the highlights to be deallocated, regardless of the `maxHighlightedMatches` variable. | |
* After this method has been called, ICTextView stops highlighting results until a new search is performed. | |
* Content insets methods: | |
* ----------------------- | |
* The `scrollRangeToVisible:consideringInsets:` and `scrollRectToVisible:animated:consideringInsets:` methods let you scroll | |
* until a certain range or rect is visible, eventually accounting for content insets. | |
* This was the default behavior for `scrollRangeToVisible:` before iOS 7, but it has changed since (possibly because of a bug). | |
* This method calls `scrollRangeToVisible:` in iOS 6.x and below, and has a custom implementation in iOS 7. | |
* The other methods are pretty much self-explanatory. See the `#pragma mark - Misc` section for further info. | |
* iOS 7 UITextView Bugfixes | |
* ------------------------- | |
* Long story short, iOS 7 completely broke `UITextView`. `ICTextView` contains fixes for some very common issues: | |
* | |
* - NSTextContainer bugfix: `UITextView` initialized via `initWithFrame:` had an erratic behavior due to an uninitialized or wrong `NSTextContainer` | |
* - Caret bugfix: the caret didn't consider `contentInset` and often went out of the visible area | |
* - characterRangeAtPoint bugfix: `characterRangeAtPoint:` always returned `nil` | |
* These fixes, combined with the custom methods to account for `contentInset`, should make working with `ICTextView` much more bearable | |
* than working with the standard `UITextView`. | |
**/ | |
#import <UIKit/UIKit.h> | |
@interface ICTextView : UITextView | |
#pragma mark - Configuration | |
// Color of the primary search highlight (default = RGB 150/200/255) | |
@property (strong, nonatomic) UIColor *primaryHighlightColor; | |
// Color of the secondary search highlights (default = RGB 215/240/255) | |
@property (strong, nonatomic) UIColor *secondaryHighlightColor; | |
// Highlight corner radius (default = fontSize * 0.2) | |
@property (nonatomic) CGFloat highlightCornerRadius; | |
// Toggles highlights for search results (default = YES // NO = only scrolls) | |
@property (nonatomic) BOOL highlightSearchResults; | |
// Maximum number of cached highlighted matches (default = 100) | |
// Note 1: setting this too high will impact memory usage | |
// Note 2: this value is indicative. More search results will be highlighted if they are on-screen | |
@property (nonatomic) NSUInteger maxHighlightedMatches; | |
// Delay for the auto-refresh while scrolling feature (default = 0.2 // min = 0.1 // off = 0.0) | |
// Note: decreasing/disabling this may improve performance when self.text is very big | |
@property (nonatomic) NSTimeInterval scrollAutoRefreshDelay; | |
// Range of string found during last search ({0, 0} on init and after resetSearch // {NSNotFound, 0} if not found) | |
@property (nonatomic, readonly) NSRange rangeOfFoundString; | |
#pragma mark - Methods | |
#pragma mark -- Search -- | |
// Returns string found during last search | |
- (NSString *)foundString; | |
// Resets search, starts from top | |
- (void)resetSearch; | |
// Scrolls to regex match (returns YES if found, NO otherwise) | |
- (BOOL)scrollToMatch:(NSString *)pattern; | |
- (BOOL)scrollToMatch:(NSString *)pattern searchOptions:(NSRegularExpressionOptions)options; | |
- (BOOL)scrollToMatch:(NSString *)pattern searchOptions:(NSRegularExpressionOptions)options range:(NSRange)range; | |
// Scrolls to string (returns YES if found, NO otherwise) | |
- (BOOL)scrollToString:(NSString *)stringToFind; | |
- (BOOL)scrollToString:(NSString *)stringToFind searchOptions:(NSRegularExpressionOptions)options; | |
- (BOOL)scrollToString:(NSString *)stringToFind searchOptions:(NSRegularExpressionOptions)options range:(NSRange)range; | |
#pragma mark -- Misc -- | |
// Scrolls to visible range, eventually considering insets | |
- (void)scrollRangeToVisible:(NSRange)range consideringInsets:(BOOL)considerInsets; | |
// Scrolls to visible rect, eventually considering insets | |
- (void)scrollRectToVisible:(CGRect)rect animated:(BOOL)animated consideringInsets:(BOOL)considerInsets; | |
// Returns visible range, with start and end position, eventually considering insets | |
- (NSRange)visibleRangeConsideringInsets:(BOOL)considerInsets; | |
- (NSRange)visibleRangeConsideringInsets:(BOOL)considerInsets startPosition:(UITextPosition *__autoreleasing *)startPosition endPosition:(UITextPosition *__autoreleasing *)endPosition; | |
// Returns visible rect, eventually considering insets | |
- (CGRect)visibleRectConsideringInsets:(BOOL)considerInsets; | |
@end | |
//ICTextView.m - 1.0.2 | |
#import "ICTextView.h" | |
#import <QuartzCore/QuartzCore.h> | |
// Document subview tag | |
enum | |
{ | |
ICTagTextSubview = 181337 | |
}; | |
// Private iVars | |
@interface ICTextView () | |
{ | |
// Highlights | |
NSMutableDictionary *_highlightsByRange; | |
NSMutableArray *_primaryHighlights; | |
NSMutableOrderedSet *_secondaryHighlights; | |
// Work variables | |
NSRegularExpression *_regex; | |
NSTimer *_autoRefreshTimer; | |
NSRange _searchRange; | |
NSUInteger _scanIndex; | |
BOOL _performedNewScroll; | |
BOOL _shouldUpdateScanIndex; | |
// TODO: remove iOS 7 bugfixes when an official fix is available | |
BOOL _appliedCharacterRangeAtPointBugfix; | |
} | |
@end | |
// Search results highlighting supported starting from iOS 5.x | |
static BOOL _highlightingSupported; | |
@implementation ICTextView | |
#pragma mark - Synthesized properties | |
@synthesize primaryHighlightColor = _primaryHighlightColor; | |
@synthesize secondaryHighlightColor = _secondaryHighlightColor; | |
@synthesize highlightCornerRadius = _highlightCornerRadius; | |
@synthesize highlightSearchResults = _highlightSearchResults; | |
@synthesize maxHighlightedMatches = _maxHighlightedMatches; | |
@synthesize scrollAutoRefreshDelay = _scrollAutoRefreshDelay; | |
@synthesize rangeOfFoundString = _rangeOfFoundString; | |
#pragma mark - Class methods | |
+ (void)initialize | |
{ | |
if (self == [ICTextView class]) | |
_highlightingSupported = [self conformsToProtocol:@protocol(UITextInput)]; | |
} | |
#pragma mark - Private methods | |
// Adds highlight at rect (returns highlight UIView) | |
- (UIView *)addHighlightAtRect:(CGRect)frame | |
{ | |
UIView *highlight = [[UIView alloc] initWithFrame:frame]; | |
highlight.layer.cornerRadius = _highlightCornerRadius < 0.0 ? frame.size.height * 0.2 : _highlightCornerRadius; | |
highlight.backgroundColor = _secondaryHighlightColor; | |
[_secondaryHighlights addObject:highlight]; | |
[self insertSubview:highlight belowSubview:[self viewWithTag:ICTagTextSubview]]; | |
return highlight; | |
} | |
// Adds highlight at text range (returns array of highlights for text range) | |
- (NSMutableArray *)addHighlightAtTextRange:(UITextRange *)textRange | |
{ | |
NSMutableArray *highlightsForRange = [[NSMutableArray alloc] init]; | |
// iOS 6.x and newer implementation | |
CGRect previousRect = CGRectZero; | |
NSArray *highlightRects = [self selectionRectsForRange:textRange]; | |
// Merges adjacent rects | |
for (UITextSelectionRect *selectionRect in highlightRects) | |
{ | |
CGRect currentRect = selectionRect.rect; | |
if ((currentRect.origin.y == previousRect.origin.y) && (currentRect.origin.x == CGRectGetMaxX(previousRect)) && (currentRect.size.height == previousRect.size.height)) | |
{ | |
// Adjacent, add to previous rect | |
previousRect = CGRectMake(previousRect.origin.x, previousRect.origin.y, previousRect.size.width + currentRect.size.width, previousRect.size.height); | |
} | |
else | |
{ | |
// Not adjacent, add previous rect to highlights array | |
[highlightsForRange addObject:[self addHighlightAtRect:previousRect]]; | |
previousRect = currentRect; | |
} | |
} | |
// Adds last highlight | |
[highlightsForRange addObject:[self addHighlightAtRect:previousRect]]; | |
return highlightsForRange; | |
} | |
// Highlights occurrences of found string in visible range masked by the user specified range | |
- (void)highlightOccurrencesInMaskedVisibleRange | |
{ | |
// Regex search | |
if (_regex) | |
{ | |
if (_performedNewScroll) | |
{ | |
// Initial data | |
UITextPosition *visibleStartPosition; | |
NSRange visibleRange = [self visibleRangeConsideringInsets:YES startPosition:&visibleStartPosition endPosition:NULL]; | |
// Performs search in masked range | |
NSRange maskedRange = NSIntersectionRange(_searchRange, visibleRange); | |
NSMutableArray *rangeValues = [[NSMutableArray alloc] init]; | |
[_regex enumerateMatchesInString:self.text options:0 range:maskedRange usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags flags, BOOL *stop){ | |
NSValue *rangeValue = [NSValue valueWithRange:match.range]; | |
[rangeValues addObject:rangeValue]; | |
}]; | |
///// ADDS SECONDARY HIGHLIGHTS ///// | |
// Array must have elements | |
if (rangeValues.count) | |
{ | |
// Removes already present highlights | |
NSMutableArray *rangesArray = [rangeValues mutableCopy]; | |
NSMutableIndexSet *indexesToRemove = [[NSMutableIndexSet alloc] init]; | |
[rangeValues enumerateObjectsUsingBlock:^(NSValue *rangeValue, NSUInteger idx, BOOL *stop){ | |
if ([_highlightsByRange objectForKey:rangeValue]) | |
[indexesToRemove addIndex:idx]; | |
}]; | |
[rangesArray removeObjectsAtIndexes:indexesToRemove]; | |
indexesToRemove = nil; | |
// Filtered array must have elements | |
if (rangesArray.count) | |
{ | |
// Gets text range of first result | |
NSValue *firstRangeValue = [rangesArray objectAtIndex:0]; | |
NSRange previousRange = [firstRangeValue rangeValue]; | |
UITextPosition *start = [self positionFromPosition:visibleStartPosition offset:(previousRange.location - visibleRange.location)]; | |
UITextPosition *end = [self positionFromPosition:start offset:previousRange.length]; | |
UITextRange *textRange = [self textRangeFromPosition:start toPosition:end]; | |
// First range | |
[_highlightsByRange setObject:[self addHighlightAtTextRange:textRange] forKey:firstRangeValue]; | |
if (rangesArray.count > 1) | |
{ | |
// Loops through ranges | |
for (NSUInteger idx = 1; idx < rangesArray.count; idx++) | |
{ | |
NSValue *rangeValue = [rangesArray objectAtIndex:idx]; | |
NSRange range = [rangeValue rangeValue]; | |
start = [self positionFromPosition:end offset:range.location - (previousRange.location + previousRange.length)]; | |
end = [self positionFromPosition:start offset:range.length]; | |
textRange = [self textRangeFromPosition:start toPosition:end]; | |
[_highlightsByRange setObject:[self addHighlightAtTextRange:textRange] forKey:rangeValue]; | |
previousRange = range; | |
} | |
} | |
// Memory management | |
NSInteger remaining = _maxHighlightedMatches - _highlightsByRange.count; | |
if (remaining < 0) | |
{ | |
NSInteger tempMin = visibleRange.location - visibleRange.length; | |
NSUInteger min = tempMin > 0 ? tempMin : 0; | |
NSUInteger max = min + 3 * visibleRange.length; | |
// Scans highlighted ranges | |
NSMutableArray *keysToRemove = [[NSMutableArray alloc] init]; | |
[_highlightsByRange enumerateKeysAndObjectsUsingBlock:^(NSValue *rangeValue, NSArray *highlightsForRange, BOOL *stop){ | |
// Removes ranges too far from visible range | |
NSUInteger location = [rangeValue rangeValue].location; | |
if ((location < min || location > max) && location != _rangeOfFoundString.location) | |
{ | |
for (UIView *hl in highlightsForRange) | |
{ | |
[hl removeFromSuperview]; | |
[_secondaryHighlights removeObject:hl]; | |
} | |
[keysToRemove addObject:rangeValue]; | |
} | |
}]; | |
[_highlightsByRange removeObjectsForKeys:keysToRemove]; | |
} | |
} | |
} | |
// Eventually updates _scanIndex to match visible range | |
if (_shouldUpdateScanIndex) | |
_scanIndex = visibleRange.location + (_regex ? visibleRange.length : 0); | |
} | |
// Sets primary highlight | |
[self setPrimaryHighlightAtRange:_rangeOfFoundString]; | |
} | |
} | |
// Convenience method used in init overrides | |
- (void)initialize | |
{ | |
_highlightCornerRadius = -1.0; | |
_highlightsByRange = [[NSMutableDictionary alloc] init]; | |
_highlightSearchResults = YES; | |
_maxHighlightedMatches = 100; | |
_scrollAutoRefreshDelay = 0.2; | |
_primaryHighlights = [[NSMutableArray alloc] init]; | |
_primaryHighlightColor = [UIColor colorWithRed:150.0/255.0 green:200.0/255.0 blue:1.0 alpha:1.0]; | |
_secondaryHighlights = [[NSMutableOrderedSet alloc] init]; | |
_secondaryHighlightColor = [UIColor colorWithRed:215.0/255.0 green:240.0/255.0 blue:1.0 alpha:1.0]; | |
// Detects _UITextContainerView or UIWebDocumentView (subview with text) for highlight placement | |
for (UIView *view in self.subviews) | |
{ | |
if ([view isKindOfClass:NSClassFromString(@"_UITextContainerView")] || [view isKindOfClass:NSClassFromString(@"UIWebDocumentView")]) | |
{ | |
view.tag = ICTagTextSubview; | |
break; | |
} | |
} | |
[[NSNotificationCenter defaultCenter] addObserver:self | |
selector:@selector(textChanged) | |
name:UITextViewTextDidChangeNotification | |
object:self]; | |
} | |
// Initializes highlights | |
- (void)initializeHighlights | |
{ | |
[self initializePrimaryHighlights]; | |
[self initializeSecondaryHighlights]; | |
} | |
// Initializes primary highlights | |
- (void)initializePrimaryHighlights | |
{ | |
// Moves primary highlights to secondary highlights array | |
for (UIView *hl in _primaryHighlights) | |
{ | |
hl.backgroundColor = _secondaryHighlightColor; | |
[_secondaryHighlights addObject:hl]; | |
} | |
[_primaryHighlights removeAllObjects]; | |
} | |
// Initializes secondary highlights | |
- (void)initializeSecondaryHighlights | |
{ | |
// Removes secondary highlights from their superview | |
for (UIView *hl in _secondaryHighlights) | |
[hl removeFromSuperview]; | |
[_secondaryHighlights removeAllObjects]; | |
// Removes all objects in _highlightsByRange, eventually except _rangeOfFoundString (primary) | |
if (_primaryHighlights.count) | |
{ | |
NSValue *rangeValue = [NSValue valueWithRange:_rangeOfFoundString]; | |
NSMutableArray *primaryHighlights = [_highlightsByRange objectForKey:rangeValue]; | |
[_highlightsByRange removeAllObjects]; | |
[_highlightsByRange setObject:primaryHighlights forKey:rangeValue]; | |
} | |
else | |
[_highlightsByRange removeAllObjects]; | |
// Sets _performedNewScroll status in order to refresh the highlights | |
_performedNewScroll = YES; | |
} | |
// TODO: remove iOS 7 characterRangeAtPoint: bugfix when an official fix is available | |
#ifdef __IPHONE_7_0 | |
- (void)characterRangeAtPointBugFix | |
{ | |
[self select:self]; | |
[self setSelectedTextRange:nil]; | |
_appliedCharacterRangeAtPointBugfix = YES; | |
} | |
#endif | |
// Called when scroll animation has ended | |
- (void)scrollEnded | |
{ | |
// Refreshes highlights | |
[self highlightOccurrencesInMaskedVisibleRange]; | |
// Disables auto-refresh timer | |
[_autoRefreshTimer invalidate]; | |
_autoRefreshTimer = nil; | |
// scrollView has finished scrolling | |
_performedNewScroll = NO; | |
} | |
// Sets primary highlight | |
- (void)setPrimaryHighlightAtRange:(NSRange)range | |
{ | |
[self initializePrimaryHighlights]; | |
NSValue *rangeValue = [NSValue valueWithRange:range]; | |
NSMutableArray *highlightsForRange = [_highlightsByRange objectForKey:rangeValue]; | |
for (UIView *hl in highlightsForRange) | |
{ | |
hl.backgroundColor = _primaryHighlightColor; | |
[_primaryHighlights addObject:hl]; | |
[_secondaryHighlights removeObject:hl]; | |
} | |
} | |
// TODO: remove iOS 7 caret bugfix when an official fix is available | |
#ifdef __IPHONE_7_0 | |
- (void)textChanged | |
{ | |
UITextRange *selectedTextRange = self.selectedTextRange; | |
if (selectedTextRange) | |
[self scrollRectToVisible:[self caretRectForPosition:selectedTextRange.end] animated:NO consideringInsets:YES]; | |
} | |
#endif | |
#pragma mark - Overrides | |
// TODO: remove iOS 7 characterRangeAtPoint: bugfix when an official fix is available | |
- (void)awakeFromNib | |
{ | |
[self characterRangeAtPointBugFix]; | |
} | |
// Resets search if editable | |
- (BOOL)becomeFirstResponder | |
{ | |
if (self.editable) | |
[self resetSearch]; | |
// return [super becomeFirstResponder]; | |
return YES; | |
} | |
// TODO: remove iOS 7 caret bugfix when an official fix is available | |
- (void)dealloc | |
{ | |
[[NSNotificationCenter defaultCenter] removeObserver:self]; | |
} | |
// Init overrides for custom initialization | |
- (id)initWithCoder:(NSCoder *)aDecoder | |
{ | |
self = [super initWithCoder:aDecoder]; | |
if (self && _highlightingSupported) | |
[self initialize]; | |
return self; | |
} | |
- (id)initWithFrame:(CGRect)frame | |
{ | |
return [self initWithFrame:frame textContainer:nil]; | |
} | |
// TODO: remove iOS 7 NSTextContainer bugfix when an official fix is available | |
#ifdef __IPHONE_7_0 | |
- (instancetype)initWithFrame:(CGRect)frame textContainer:(NSTextContainer *)textContainer | |
{ | |
NSTextStorage *textStorage = [[NSTextStorage alloc] init]; | |
NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; | |
[textStorage addLayoutManager:layoutManager]; | |
if (!textContainer) | |
textContainer = [[NSTextContainer alloc] initWithSize:frame.size]; | |
textContainer.heightTracksTextView = YES; | |
[layoutManager addTextContainer:textContainer]; | |
self = [super initWithFrame:frame textContainer:textContainer]; | |
if (self && _highlightingSupported) | |
[self initialize]; | |
return self; | |
} | |
#endif | |
// Executed while scrollView is scrolling | |
- (void)setContentOffset:(CGPoint)contentOffset | |
{ | |
[super setContentOffset:contentOffset]; | |
if (_highlightingSupported && _highlightSearchResults) | |
{ | |
// scrollView has scrolled | |
_performedNewScroll = YES; | |
// _shouldUpdateScanIndex check | |
if (!_shouldUpdateScanIndex) | |
_shouldUpdateScanIndex = ([self.panGestureRecognizer velocityInView:self].y != 0.0); | |
// Eventually starts auto-refresh timer | |
if (_regex && _scrollAutoRefreshDelay && !_autoRefreshTimer) | |
{ | |
_autoRefreshTimer = [NSTimer timerWithTimeInterval:_scrollAutoRefreshDelay target:self selector:@selector(highlightOccurrencesInMaskedVisibleRange) userInfo:nil repeats:YES]; | |
[[NSRunLoop mainRunLoop] addTimer:_autoRefreshTimer forMode:UITrackingRunLoopMode]; | |
} | |
// Cancels previous request and performs new one | |
[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(scrollEnded) object:nil]; | |
[self performSelector:@selector(scrollEnded) withObject:nil afterDelay:0.1]; | |
} | |
} | |
// Resets highlights on frame change | |
- (void)setFrame:(CGRect)frame | |
{ | |
if (_highlightingSupported && _highlightsByRange.count) | |
[self initializeHighlights]; | |
[super setFrame:frame]; | |
} | |
// Doesn't allow _scrollAutoRefreshDelay values between 0.0 and 0.1 | |
- (void)setScrollAutoRefreshDelay:(NSTimeInterval)scrollAutoRefreshDelay | |
{ | |
_scrollAutoRefreshDelay = (scrollAutoRefreshDelay > 0.0 && scrollAutoRefreshDelay < 0.1) ? 0.1 : scrollAutoRefreshDelay; | |
} | |
// TODO: remove iOS 7 caret bugfix when an official fix is available | |
- (void)setSelectedTextRange:(UITextRange *)selectedTextRange | |
{ | |
[super setSelectedTextRange:selectedTextRange]; | |
[self scrollRectToVisible:[self caretRectForPosition:selectedTextRange.end] animated:NO consideringInsets:YES]; | |
} | |
// TODO: remove iOS 7 characterRangeAtPoint: bugfix when an official fix is available | |
- (void)setText:(NSString *)text | |
{ | |
[super setText:text]; | |
[self characterRangeAtPointBugFix]; | |
} | |
#pragma mark - Public methods | |
#pragma mark -- Search -- | |
// Returns string found during last search | |
- (NSString *)foundString | |
{ | |
return [self.text substringWithRange:_rangeOfFoundString]; | |
} | |
// Resets search, starts from top | |
- (void)resetSearch | |
{ | |
if (_highlightingSupported) | |
{ | |
[self initializeHighlights]; | |
[_autoRefreshTimer invalidate]; | |
_autoRefreshTimer = nil; | |
} | |
_rangeOfFoundString = NSMakeRange(0,0); | |
_regex = nil; | |
_scanIndex = 0; | |
_searchRange = NSMakeRange(0,0); | |
} | |
#pragma mark ---- Regex search ---- | |
// Scroll to regex match (returns YES if found, NO otherwise) | |
- (BOOL)scrollToMatch:(NSString *)pattern | |
{ | |
return [self scrollToMatch:pattern searchOptions:0 range:NSMakeRange(0, self.text.length)]; | |
} | |
- (BOOL)scrollToMatch:(NSString *)pattern searchOptions:(NSRegularExpressionOptions)options | |
{ | |
return [self scrollToMatch:pattern searchOptions:options range:NSMakeRange(0, self.text.length)]; | |
} | |
- (BOOL)scrollToMatch:(NSString *)pattern searchOptions:(NSRegularExpressionOptions)options range:(NSRange)range | |
{ | |
// Calculates a valid range | |
range = NSIntersectionRange(NSMakeRange(0, self.text.length), range); | |
// Preliminary checks | |
BOOL abort = NO; | |
if (!pattern) | |
{ | |
#if DEBUG | |
NSLog(@"Pattern cannot be nil."); | |
#endif | |
abort = YES; | |
} | |
else if (range.length == 0) | |
{ | |
#if DEBUG | |
NSLog(@"Specified range is out of bounds."); | |
#endif | |
abort = YES; | |
} | |
if (abort) | |
{ | |
[self resetSearch]; | |
return NO; | |
} | |
// Optimization and coherence checks | |
BOOL samePattern = [pattern isEqualToString:_regex.pattern]; | |
BOOL sameOptions = (options == _regex.options); | |
BOOL sameSearchRange = NSEqualRanges(range, _searchRange); | |
// Sets new search range | |
_searchRange = range; | |
// Creates regex | |
NSError *error; | |
_regex = [[NSRegularExpression alloc] initWithPattern:pattern options:options error:&error]; | |
if (error) | |
{ | |
#if DEBUG | |
NSLog(@"Error while creating regex: %@", error); | |
#endif | |
[self resetSearch]; | |
return NO; | |
} | |
// Resets highlights | |
if (_highlightingSupported && _highlightSearchResults) | |
{ | |
[self initializePrimaryHighlights]; | |
if (!(samePattern && sameOptions && sameSearchRange)) | |
[self initializeSecondaryHighlights]; | |
} | |
// Scan index logic | |
if (sameSearchRange && sameOptions) | |
{ | |
// Same search pattern, go to next match | |
if (samePattern) | |
_scanIndex += _rangeOfFoundString.length; | |
// Scan index out of range | |
if (_scanIndex < range.location || _scanIndex >= (range.location + range.length)) | |
_scanIndex = range.location; | |
} | |
else | |
_scanIndex = range.location; | |
// Gets match | |
NSRange matchRange = [_regex rangeOfFirstMatchInString:self.text options:0 range:NSMakeRange(_scanIndex, range.location + range.length - _scanIndex)]; | |
// Match not found | |
if (matchRange.location == NSNotFound) | |
{ | |
_rangeOfFoundString = NSMakeRange(NSNotFound, 0); | |
if (_scanIndex) | |
{ | |
// Starts from top | |
_scanIndex = range.location; | |
return [self scrollToMatch:pattern searchOptions:options range:range]; | |
} | |
_regex = nil; | |
return NO; | |
} | |
// Match found, saves state | |
_rangeOfFoundString = matchRange; | |
_scanIndex = matchRange.location; | |
_shouldUpdateScanIndex = NO; | |
// Adds highlights | |
if (_highlightingSupported && _highlightSearchResults) | |
[self highlightOccurrencesInMaskedVisibleRange]; | |
// Scrolls | |
[self scrollRangeToVisible:matchRange consideringInsets:YES]; | |
return YES; | |
} | |
#pragma mark ---- String search ---- | |
// Scroll to string (returns YES if found, NO otherwise) | |
- (BOOL)scrollToString:(NSString *)stringToFind | |
{ | |
return [self scrollToString:stringToFind searchOptions:0 range:NSMakeRange(0, self.text.length)]; | |
} | |
- (BOOL)scrollToString:(NSString *)stringToFind searchOptions:(NSRegularExpressionOptions)options | |
{ | |
return [self scrollToString:stringToFind searchOptions:options range:NSMakeRange(0, self.text.length)]; | |
} | |
- (BOOL)scrollToString:(NSString *)stringToFind searchOptions:(NSRegularExpressionOptions)options range:(NSRange)range | |
{ | |
// Preliminary check | |
if (!stringToFind) | |
{ | |
#if DEBUG | |
NSLog(@"Search string cannot be nil."); | |
#endif | |
[self resetSearch]; | |
return NO; | |
} | |
// Escapes metacharacters | |
stringToFind = [NSRegularExpression escapedPatternForString:stringToFind]; | |
// These checks allow better automatic search on UITextField or UISearchBar text change | |
if (_regex) | |
{ | |
NSString *lcStringToFind = [stringToFind lowercaseString]; | |
NSString *lcFoundString = [_regex.pattern lowercaseString]; | |
if (!([lcStringToFind hasPrefix:lcFoundString] || [lcFoundString hasPrefix:lcStringToFind])) | |
_scanIndex += _rangeOfFoundString.length; | |
} | |
// Performs search | |
return [self scrollToMatch:stringToFind searchOptions:options range:range]; | |
} | |
#pragma mark -- Misc -- | |
// Scrolls to visible range, eventually considering insets | |
- (void)scrollRangeToVisible:(NSRange)range consideringInsets:(BOOL)considerInsets | |
{ | |
// Calculates rect for range | |
UITextPosition *startPosition = [self positionFromPosition:self.beginningOfDocument offset:range.location]; | |
UITextPosition *endPosition = [self positionFromPosition:startPosition offset:range.length]; | |
UITextRange *textRange = [self textRangeFromPosition:startPosition toPosition:endPosition]; | |
CGRect rect = [self firstRectForRange:textRange]; | |
// Scrolls to visible rect | |
[self scrollRectToVisible:rect animated:YES consideringInsets:YES]; | |
} | |
// Scrolls to visible rect, eventually considering insets | |
- (void)scrollRectToVisible:(CGRect)rect animated:(BOOL)animated consideringInsets:(BOOL)considerInsets | |
{ | |
// Gets bounds and calculates visible rect | |
CGRect bounds = self.bounds; | |
UIEdgeInsets contentInset = self.contentInset; | |
CGRect visibleRect = [self visibleRectConsideringInsets:YES]; | |
// Do not scroll if rect is on screen | |
if (!CGRectContainsRect(visibleRect, rect)) | |
{ | |
CGPoint contentOffset = self.contentOffset; | |
// Calculates new contentOffset | |
if (rect.origin.y < visibleRect.origin.y) | |
// rect precedes bounds, scroll up | |
contentOffset.y = rect.origin.y - contentInset.top; | |
else | |
// rect follows bounds, scroll down | |
contentOffset.y = rect.origin.y + contentInset.bottom + rect.size.height - bounds.size.height; | |
[self setContentOffset:contentOffset animated:animated]; | |
} | |
} | |
// Returns visible range, eventually considering insets | |
- (NSRange)visibleRangeConsideringInsets:(BOOL)considerInsets | |
{ | |
return [self visibleRangeConsideringInsets:considerInsets startPosition:NULL endPosition:NULL]; | |
} | |
// Returns visible range, with start and end position, eventually considering insets | |
- (NSRange)visibleRangeConsideringInsets:(BOOL)considerInsets startPosition:(UITextPosition *__autoreleasing *)startPosition endPosition:(UITextPosition *__autoreleasing *)endPosition | |
{ | |
CGRect visibleRect = [self visibleRectConsideringInsets:considerInsets]; | |
CGPoint startPoint = visibleRect.origin; | |
CGPoint endPoint = CGPointMake(CGRectGetMaxX(visibleRect), CGRectGetMaxY(visibleRect)); | |
UITextPosition *start = [self characterRangeAtPoint:startPoint].start; | |
UITextPosition *end = [self characterRangeAtPoint:endPoint].end; | |
if (startPosition) | |
*startPosition = start; | |
if (endPosition) | |
*endPosition = end; | |
return NSMakeRange([self offsetFromPosition:self.beginningOfDocument toPosition:start], [self offsetFromPosition:start toPosition:end]); | |
} | |
// Returns visible rect, eventually considering insets | |
- (CGRect)visibleRectConsideringInsets:(BOOL)considerInsets | |
{ | |
CGRect bounds = self.bounds; | |
if (considerInsets) | |
{ | |
UIEdgeInsets contentInset = self.contentInset; | |
CGRect visibleRect = self.bounds; | |
visibleRect.origin.x += contentInset.left; | |
visibleRect.origin.y += contentInset.top; | |
visibleRect.size.width -= (contentInset.left + contentInset.right); | |
visibleRect.size.height -= (contentInset.top + contentInset.bottom); | |
return visibleRect; | |
} | |
return bounds; | |
} | |
@end | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment