You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1155 lines
36 KiB

//
// SlackTextViewController
// https://github.com/slackhq/SlackTextViewController
//
// Copyright 2014-2016 Slack Technologies, Inc.
// Licence: MIT-Licence
//
#import "SLKTextView.h"
#import "SLKTextView+SLKAdditions.h"
#import "SLKUIConstants.h"
NSString * const SLKTextViewTextWillChangeNotification = @"SLKTextViewTextWillChangeNotification";
NSString * const SLKTextViewContentSizeDidChangeNotification = @"SLKTextViewContentSizeDidChangeNotification";
NSString * const SLKTextViewSelectedRangeDidChangeNotification = @"SLKTextViewSelectedRangeDidChangeNotification";
NSString * const SLKTextViewDidPasteItemNotification = @"SLKTextViewDidPasteItemNotification";
NSString * const SLKTextViewDidShakeNotification = @"SLKTextViewDidShakeNotification";
NSString * const SLKTextViewPastedItemContentType = @"SLKTextViewPastedItemContentType";
NSString * const SLKTextViewPastedItemMediaType = @"SLKTextViewPastedItemMediaType";
NSString * const SLKTextViewPastedItemData = @"SLKTextViewPastedItemData";
static NSString *const SLKTextViewGenericFormattingSelectorPrefix = @"slk_format_";
@interface SLKTextView ()
// The label used as placeholder
@property (nonatomic, strong) UILabel *placeholderLabel;
// The initial font point size, used for dynamic type calculations
@property (nonatomic) CGFloat initialFontSize;
// Used for moving the caret up/down
@property (nonatomic) UITextLayoutDirection verticalMoveDirection;
@property (nonatomic) CGRect verticalMoveStartCaretRect;
@property (nonatomic) CGRect verticalMoveLastCaretRect;
// Used for detecting if the scroll indicator was previously flashed
@property (nonatomic) BOOL didFlashScrollIndicators;
@property (nonatomic, strong) NSMutableArray *registeredFormattingTitles;
@property (nonatomic, strong) NSMutableArray *registeredFormattingSymbols;
@property (nonatomic, getter=isFormatting) BOOL formatting;
// The keyboard commands available for external keyboards
@property (nonatomic, strong) NSMutableDictionary *registeredKeyCommands;
@property (nonatomic, strong) NSMutableDictionary *registeredKeyCallbacks;
@end
@implementation SLKTextView
@dynamic delegate;
#pragma mark - Initialization
- (instancetype)initWithFrame:(CGRect)frame textContainer:(NSTextContainer *)textContainer
{
if (self = [super initWithFrame:frame textContainer:textContainer]) {
[self slk_commonInit];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)coder
{
if (self = [super initWithCoder:coder]) {
[self slk_commonInit];
}
return self;
}
- (void)slk_commonInit
{
_pastableMediaTypes = SLKPastableMediaTypeNone;
_dynamicTypeEnabled = YES;
self.undoManagerEnabled = YES;
self.editable = YES;
self.selectable = YES;
self.scrollEnabled = YES;
self.scrollsToTop = NO;
self.directionalLockEnabled = YES;
self.dataDetectorTypes = UIDataDetectorTypeNone;
[self slk_registerNotifications];
[self addObserver:self forKeyPath:NSStringFromSelector(@selector(contentSize)) options:NSKeyValueObservingOptionNew context:NULL];
}
#pragma mark - UIView Overrides
- (CGSize)intrinsicContentSize
{
CGFloat height = self.font.lineHeight;
height += self.textContainerInset.top + self.textContainerInset.bottom;
return CGSizeMake(UIViewNoIntrinsicMetric, height);
}
+ (BOOL)requiresConstraintBasedLayout
{
return YES;
}
- (void)layoutIfNeeded
{
if (!self.window) {
return;
}
[super layoutIfNeeded];
}
- (void)layoutSubviews
{
[super layoutSubviews];
self.placeholderLabel.hidden = [self slk_shouldHidePlaceholder];
if (!self.placeholderLabel.hidden) {
[UIView performWithoutAnimation:^{
self.placeholderLabel.frame = [self slk_placeholderRectThatFits:self.bounds];
[self sendSubviewToBack:self.placeholderLabel];
}];
}
}
#pragma mark - Getters
- (UILabel *)placeholderLabel
{
if (!_placeholderLabel) {
_placeholderLabel = [UILabel new];
_placeholderLabel.clipsToBounds = NO;
_placeholderLabel.numberOfLines = 1;
_placeholderLabel.autoresizesSubviews = NO;
_placeholderLabel.font = self.font;
_placeholderLabel.backgroundColor = [UIColor clearColor];
_placeholderLabel.textColor = [UIColor lightGrayColor];
_placeholderLabel.hidden = YES;
_placeholderLabel.isAccessibilityElement = NO;
[self addSubview:_placeholderLabel];
}
return _placeholderLabel;
}
- (NSString *)placeholder
{
return self.placeholderLabel.text;
}
- (UIColor *)placeholderColor
{
return self.placeholderLabel.textColor;
}
- (UIFont *)placeholderFont
{
return self.placeholderLabel.font;
}
- (NSUInteger)numberOfLines
{
CGSize contentSize = self.contentSize;
CGFloat contentHeight = contentSize.height;
contentHeight -= self.textContainerInset.top + self.textContainerInset.bottom;
NSUInteger lines = fabs(contentHeight/self.font.lineHeight);
// This helps preventing the content's height to be larger that the bounds' height
// Avoiding this way to have unnecessary scrolling in the text view when there is only 1 line of content
if (lines == 1 && contentSize.height > self.bounds.size.height) {
contentSize.height = self.bounds.size.height;
self.contentSize = contentSize;
}
// Let's fallback to the minimum line count
if (lines == 0) {
lines = 1;
}
return lines;
}
- (NSUInteger)maxNumberOfLines
{
NSUInteger numberOfLines = _maxNumberOfLines;
if (SLK_IS_LANDSCAPE) {
if ((SLK_IS_IPHONE4 || SLK_IS_IPHONE5)) {
numberOfLines = 2.0; // 2 lines max on smaller iPhones
}
else if (SLK_IS_IPHONE) {
numberOfLines /= 2.0; // Half size on larger iPhone
}
}
if (self.isDynamicTypeEnabled) {
NSString *contentSizeCategory = [[UIApplication sharedApplication] preferredContentSizeCategory];
CGFloat pointSizeDifference = SLKPointSizeDifferenceForCategory(contentSizeCategory);
CGFloat factor = pointSizeDifference/self.initialFontSize;
if (fabs(factor) > 0.75) {
factor = 0.75;
}
numberOfLines -= floorf(numberOfLines * factor); // Calculates a dynamic number of lines depending of the user preferred font size
}
return numberOfLines;
}
- (BOOL)isTypingSuggestionEnabled
{
return (self.autocorrectionType == UITextAutocorrectionTypeNo) ? NO : YES;
}
- (BOOL)isFormattingEnabled
{
return (self.registeredFormattingSymbols.count > 0) ? YES : NO;
}
// Returns only a supported pasted item
- (id)slk_pastedItem
{
NSString *contentType = [self slk_pasteboardContentType];
NSData *data = [[UIPasteboard generalPasteboard] dataForPasteboardType:contentType];
if (data && [data isKindOfClass:[NSData class]])
{
SLKPastableMediaType mediaType = SLKPastableMediaTypeFromNSString(contentType);
NSDictionary *userInfo = @{SLKTextViewPastedItemContentType: contentType,
SLKTextViewPastedItemMediaType: @(mediaType),
SLKTextViewPastedItemData: data};
return userInfo;
}
if ([[UIPasteboard generalPasteboard] URL]) {
return [[[UIPasteboard generalPasteboard] URL] absoluteString];
}
if ([[UIPasteboard generalPasteboard] string]) {
return [[UIPasteboard generalPasteboard] string];
}
return nil;
}
// Checks if any supported media found in the general pasteboard
- (BOOL)slk_isPasteboardItemSupported
{
if ([self slk_pasteboardContentType].length > 0) {
return YES;
}
return NO;
}
- (NSString *)slk_pasteboardContentType
{
NSArray *pasteboardTypes = [[UIPasteboard generalPasteboard] pasteboardTypes];
NSMutableArray *subpredicates = [NSMutableArray new];
for (NSString *type in [self slk_supportedMediaTypes]) {
[subpredicates addObject:[NSPredicate predicateWithFormat:@"SELF == %@", type]];
}
return [[pasteboardTypes filteredArrayUsingPredicate:[NSCompoundPredicate orPredicateWithSubpredicates:subpredicates]] firstObject];
}
- (NSArray *)slk_supportedMediaTypes
{
if (self.pastableMediaTypes == SLKPastableMediaTypeNone) {
return nil;
}
NSMutableArray *types = [NSMutableArray new];
if (self.pastableMediaTypes & SLKPastableMediaTypePNG) {
[types addObject:NSStringFromSLKPastableMediaType(SLKPastableMediaTypePNG)];
}
if (self.pastableMediaTypes & SLKPastableMediaTypeJPEG) {
[types addObject:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeJPEG)];
}
if (self.pastableMediaTypes & SLKPastableMediaTypeTIFF) {
[types addObject:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeTIFF)];
}
if (self.pastableMediaTypes & SLKPastableMediaTypeGIF) {
[types addObject:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeGIF)];
}
if (self.pastableMediaTypes & SLKPastableMediaTypeMOV) {
[types addObject:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeMOV)];
}
if (self.pastableMediaTypes & SLKPastableMediaTypePassbook) {
[types addObject:NSStringFromSLKPastableMediaType(SLKPastableMediaTypePassbook)];
}
if (self.pastableMediaTypes & SLKPastableMediaTypeImages) {
[types addObject:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeImages)];
}
return types;
}
NSString *NSStringFromSLKPastableMediaType(SLKPastableMediaType type)
{
if (type == SLKPastableMediaTypePNG) {
return @"public.png";
}
if (type == SLKPastableMediaTypeJPEG) {
return @"public.jpeg";
}
if (type == SLKPastableMediaTypeTIFF) {
return @"public.tiff";
}
if (type == SLKPastableMediaTypeGIF) {
return @"com.compuserve.gif";
}
if (type == SLKPastableMediaTypeMOV) {
return @"com.apple.quicktime";
}
if (type == SLKPastableMediaTypePassbook) {
return @"com.apple.pkpass";
}
if (type == SLKPastableMediaTypeImages) {
return @"com.apple.uikit.image";
}
return nil;
}
SLKPastableMediaType SLKPastableMediaTypeFromNSString(NSString *string)
{
if ([string isEqualToString:NSStringFromSLKPastableMediaType(SLKPastableMediaTypePNG)]) {
return SLKPastableMediaTypePNG;
}
if ([string isEqualToString:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeJPEG)]) {
return SLKPastableMediaTypeJPEG;
}
if ([string isEqualToString:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeTIFF)]) {
return SLKPastableMediaTypeTIFF;
}
if ([string isEqualToString:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeGIF)]) {
return SLKPastableMediaTypeGIF;
}
if ([string isEqualToString:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeMOV)]) {
return SLKPastableMediaTypeMOV;
}
if ([string isEqualToString:NSStringFromSLKPastableMediaType(SLKPastableMediaTypePassbook)]) {
return SLKPastableMediaTypePassbook;
}
if ([string isEqualToString:NSStringFromSLKPastableMediaType(SLKPastableMediaTypeImages)]) {
return SLKPastableMediaTypeImages;
}
return SLKPastableMediaTypeNone;
}
- (BOOL)isExpanding
{
if (self.numberOfLines >= self.maxNumberOfLines) {
return YES;
}
return NO;
}
- (BOOL)slk_shouldHidePlaceholder
{
if (self.placeholder.length == 0 || self.text.length > 0) {
return YES;
}
return NO;
}
- (CGRect)slk_placeholderRectThatFits:(CGRect)bounds
{
CGFloat padding = self.textContainer.lineFragmentPadding;
CGRect rect = CGRectZero;
rect.size.height = [self.placeholderLabel sizeThatFits:bounds.size].height;
rect.size.width = self.textContainer.size.width - padding*2.0;
rect.origin = UIEdgeInsetsInsetRect(bounds, self.textContainerInset).origin;
rect.origin.x += padding;
return rect;
}
#pragma mark - Setters
- (void)setPlaceholder:(NSString *)placeholder
{
self.placeholderLabel.text = placeholder;
self.accessibilityLabel = placeholder;
[self setNeedsLayout];
}
- (void)setAttributedPlaceholder:(NSAttributedString *)attributedPlaceholder
{
self.placeholderLabel.attributedText = attributedPlaceholder;
self.accessibilityLabel = attributedPlaceholder.string;
[self setNeedsLayout];
}
- (void)setPlaceholderColor:(UIColor *)color
{
self.placeholderLabel.textColor = color;
}
- (void)setPlaceholderNumberOfLines:(NSInteger)numberOfLines
{
self.placeholderLabel.numberOfLines = numberOfLines;
[self setNeedsLayout];
}
- (void)setPlaceholderFont:(UIFont *)placeholderFont
{
if (!placeholderFont) {
self.placeholderLabel.font = self.font;
}
else {
self.placeholderLabel.font = placeholderFont;
}
}
- (void)setUndoManagerEnabled:(BOOL)enabled
{
if (self.undoManagerEnabled == enabled) {
return;
}
self.undoManager.levelsOfUndo = 10;
[self.undoManager removeAllActions];
[self.undoManager setActionIsDiscardable:YES];
_undoManagerEnabled = enabled;
}
- (void)setTypingSuggestionEnabled:(BOOL)enabled
{
if (self.isTypingSuggestionEnabled == enabled) {
return;
}
self.autocorrectionType = enabled ? UITextAutocorrectionTypeDefault : UITextAutocorrectionTypeNo;
self.spellCheckingType = enabled ? UITextSpellCheckingTypeDefault : UITextSpellCheckingTypeNo;
//NOTE: this cause to resign textview responder which we don't want to
//[self refreshFirstResponder];
}
- (void)setContentOffset:(CGPoint)contentOffset
{
// At times during a layout pass, the content offset's x value may change.
// Since we only care about vertical offset, let's override its horizontal value to avoid other layout issues.
[super setContentOffset:CGPointMake(0.0, contentOffset.y)];
}
#pragma mark - UITextView Overrides
- (void)setSelectedRange:(NSRange)selectedRange
{
[super setSelectedRange:selectedRange];
[[NSNotificationCenter defaultCenter] postNotificationName:SLKTextViewSelectedRangeDidChangeNotification object:self userInfo:nil];
}
- (void)setSelectedTextRange:(UITextRange *)selectedTextRange
{
[super setSelectedTextRange:selectedTextRange];
[[NSNotificationCenter defaultCenter] postNotificationName:SLKTextViewSelectedRangeDidChangeNotification object:self userInfo:nil];
}
- (void)setText:(NSString *)text
{
// Registers for undo management
[self slk_prepareForUndo:@"Text Set"];
if (text) {
[self setAttributedText:[self slk_defaultAttributedStringForText:text]];
}
else {
[self setAttributedText:nil];
}
[[NSNotificationCenter defaultCenter] postNotificationName:UITextViewTextDidChangeNotification object:self];
}
- (NSString *)text
{
return self.attributedText.string;
}
- (void)setAttributedText:(NSAttributedString *)attributedText
{
// Registers for undo management
[self slk_prepareForUndo:@"Attributed Text Set"];
[super setAttributedText:attributedText];
[[NSNotificationCenter defaultCenter] postNotificationName:UITextViewTextDidChangeNotification object:self];
}
- (void)setFont:(UIFont *)font
{
NSString *contentSizeCategory = [[UIApplication sharedApplication] preferredContentSizeCategory];
[self setFontName:font.fontName pointSize:font.pointSize withContentSizeCategory:contentSizeCategory];
self.initialFontSize = font.pointSize;
}
- (void)setFontName:(NSString *)fontName pointSize:(CGFloat)pointSize withContentSizeCategory:(NSString *)contentSizeCategory
{
if (self.isDynamicTypeEnabled) {
pointSize += SLKPointSizeDifferenceForCategory(contentSizeCategory);
}
UIFont *dynamicFont = [UIFont fontWithName:fontName size:pointSize];
[super setFont:dynamicFont];
// Updates the placeholder font too
self.placeholderLabel.font = dynamicFont;
}
- (void)setDynamicTypeEnabled:(BOOL)dynamicTypeEnabled
{
if (self.isDynamicTypeEnabled == dynamicTypeEnabled) {
return;
}
_dynamicTypeEnabled = dynamicTypeEnabled;
NSString *contentSizeCategory = [[UIApplication sharedApplication] preferredContentSizeCategory];
[self setFontName:self.font.fontName pointSize:self.initialFontSize withContentSizeCategory:contentSizeCategory];
}
- (void)setTextAlignment:(NSTextAlignment)textAlignment
{
[super setTextAlignment:textAlignment];
// Updates the placeholder text alignment too
self.placeholderLabel.textAlignment = textAlignment;
}
#pragma mark - UITextInput Overrides
#ifdef __IPHONE_9_0
- (void)beginFloatingCursorAtPoint:(CGPoint)point
{
[super beginFloatingCursorAtPoint:point];
_trackpadEnabled = YES;
}
- (void)updateFloatingCursorAtPoint:(CGPoint)point
{
[super updateFloatingCursorAtPoint:point];
}
- (void)endFloatingCursor
{
[super endFloatingCursor];
_trackpadEnabled = NO;
// We still need to notify a selection change in the textview after the trackpad is disabled
if (self.delegate && [self.delegate respondsToSelector:@selector(textViewDidChangeSelection:)]) {
[self.delegate textViewDidChangeSelection:self];
}
[[NSNotificationCenter defaultCenter] postNotificationName:SLKTextViewSelectedRangeDidChangeNotification object:self userInfo:nil];
}
#endif
#pragma mark - UIResponder Overrides
- (BOOL)canBecomeFirstResponder
{
[self slk_addCustomMenuControllerItems];
return [super canBecomeFirstResponder];
}
- (BOOL)becomeFirstResponder
{
return [super becomeFirstResponder];
}
- (BOOL)canResignFirstResponder
{
// Removes undo/redo items
if (self.undoManagerEnabled) {
[self.undoManager removeAllActions];
}
return [super canResignFirstResponder];
}
- (BOOL)resignFirstResponder
{
return [super resignFirstResponder];
}
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
{
if (self.isFormatting) {
NSString *title = [self slk_formattingTitleFromSelector:action];
NSString *symbol = [self slk_formattingSymbolWithTitle:title];
if (symbol.length > 0) {
if (self.delegate && [self.delegate respondsToSelector:@selector(textView:shouldOfferFormattingForSymbol:)]) {
return [self.delegate textView:self shouldOfferFormattingForSymbol:symbol];
}
else {
return YES;
}
}
return NO;
}
if (action == @selector(delete:)) {
return NO;
}
if (action == @selector(slk_presentFormattingMenu:)) {
return self.selectedRange.length > 0 ? YES : NO;
}
if (action == @selector(paste:) && [self slk_isPasteboardItemSupported]) {
return YES;
}
if (self.undoManagerEnabled) {
if (action == @selector(slk_undo:)) {
if (self.undoManager.undoActionIsDiscardable) {
return NO;
}
return [self.undoManager canUndo];
}
if (action == @selector(slk_redo:)) {
if (self.undoManager.redoActionIsDiscardable) {
return NO;
}
return [self.undoManager canRedo];
}
}
return [super canPerformAction:action withSender:sender];
}
- (void)paste:(id)sender
{
id pastedItem = [self slk_pastedItem];
if ([pastedItem isKindOfClass:[NSDictionary class]]) {
[[NSNotificationCenter defaultCenter] postNotificationName:SLKTextViewDidPasteItemNotification object:nil userInfo:pastedItem];
}
else if ([pastedItem isKindOfClass:[NSString class]]) {
// Respect the delegate yo!
if (self.delegate && [self.delegate respondsToSelector:@selector(textView:shouldChangeTextInRange:replacementText:)]) {
if (![self.delegate textView:self shouldChangeTextInRange:self.selectedRange replacementText:pastedItem]) {
return;
}
}
// Inserting the text fixes a UITextView bug whitch automatically scrolls to the bottom
// and beyond scroll content size sometimes when the text is too long
[self slk_insertTextAtCaretRange:pastedItem];
}
}
#pragma mark - NSObject Overrides
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel
{
if ([super methodSignatureForSelector:sel]) {
return [super methodSignatureForSelector:sel];
}
return [super methodSignatureForSelector:@selector(slk_format:)];
}
- (void)forwardInvocation:(NSInvocation *)invocation
{
NSString *title = [self slk_formattingTitleFromSelector:[invocation selector]];
if (title.length > 0) {
[self slk_format:title];
}
else {
[super forwardInvocation:invocation];
}
}
#pragma mark - Custom Actions
- (void)slk_flashScrollIndicatorsIfNeeded
{
if (self.numberOfLines == self.maxNumberOfLines+1) {
if (!_didFlashScrollIndicators) {
_didFlashScrollIndicators = YES;
[super flashScrollIndicators];
}
}
else if (_didFlashScrollIndicators) {
_didFlashScrollIndicators = NO;
}
}
- (void)refreshFirstResponder
{
if (!self.isFirstResponder) {
return;
}
_didNotResignFirstResponder = YES;
[self resignFirstResponder];
_didNotResignFirstResponder = NO;
[self becomeFirstResponder];
}
- (void)refreshInputViews
{
_didNotResignFirstResponder = YES;
[super reloadInputViews];
_didNotResignFirstResponder = NO;
}
- (void)slk_addCustomMenuControllerItems
{
UIMenuItem *undo = [[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"Undo", nil) action:@selector(slk_undo:)];
UIMenuItem *redo = [[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"Redo", nil) action:@selector(slk_redo:)];
NSMutableArray *items = [NSMutableArray arrayWithObjects:undo, redo, nil];
if (self.registeredFormattingTitles.count > 0) {
UIMenuItem *format = [[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"Format", nil) action:@selector(slk_presentFormattingMenu:)];
[items addObject:format];
}
[[UIMenuController sharedMenuController] setMenuItems:items];
}
- (void)slk_undo:(id)sender
{
[self.undoManager undo];
}
- (void)slk_redo:(id)sender
{
[self.undoManager redo];
}
- (void)slk_presentFormattingMenu:(id)sender
{
NSMutableArray *items = [NSMutableArray arrayWithCapacity:self.registeredFormattingTitles.count];
for (NSString *name in self.registeredFormattingTitles) {
NSString *sel = [NSString stringWithFormat:@"%@%@", SLKTextViewGenericFormattingSelectorPrefix, name];
UIMenuItem *item = [[UIMenuItem alloc] initWithTitle:name action:NSSelectorFromString(sel)];
[items addObject:item];
}
self.formatting = YES;
UIMenuController *menu = [UIMenuController sharedMenuController];
[menu setMenuItems:items];
NSLayoutManager *manager = self.layoutManager;
CGRect targetRect = [manager boundingRectForGlyphRange:self.selectedRange inTextContainer:self.textContainer];
[menu setTargetRect:targetRect inView:self];
[menu setMenuVisible:YES animated:YES];
}
- (NSString *)slk_formattingTitleFromSelector:(SEL)selector
{
NSString *selectorString = NSStringFromSelector(selector);
NSRange match = [selectorString rangeOfString:SLKTextViewGenericFormattingSelectorPrefix];
if (match.location != NSNotFound) {
return [selectorString substringFromIndex:SLKTextViewGenericFormattingSelectorPrefix.length];
}
return nil;
}
- (NSString *)slk_formattingSymbolWithTitle:(NSString *)title
{
NSUInteger idx = [self.registeredFormattingTitles indexOfObject:title];
if (idx <= self.registeredFormattingSymbols.count -1) {
return self.registeredFormattingSymbols[idx];
}
return nil;
}
- (void)slk_format:(NSString *)titles
{
NSString *symbol = [self slk_formattingSymbolWithTitle:titles];
if (symbol.length > 0) {
NSRange selection = self.selectedRange;
NSRange range = [self slk_insertText:symbol inRange:NSMakeRange(selection.location, 0)];
range.location += selection.length;
range.length = 0;
// The default behavior is to add a closure
BOOL addClosure = YES;
if (self.delegate && [self.delegate respondsToSelector:@selector(textView:shouldInsertSuffixForFormattingWithSymbol:prefixRange:)]) {
addClosure = [self.delegate textView:self shouldInsertSuffixForFormattingWithSymbol:symbol prefixRange:selection];
}
if (addClosure) {
self.selectedRange = [self slk_insertText:symbol inRange:range];
}
}
}
#pragma mark - Markdown Formatting
- (void)registerMarkdownFormattingSymbol:(NSString *)symbol withTitle:(NSString *)title
{
if (!symbol || !title) {
return;
}
if (!_registeredFormattingTitles) {
_registeredFormattingTitles = [NSMutableArray new];
_registeredFormattingSymbols = [NSMutableArray new];
}
// Adds the symbol if not contained already
if (![self.registeredSymbols containsObject:symbol]) {
[self.registeredFormattingTitles addObject:title];
[self.registeredFormattingSymbols addObject:symbol];
}
}
- (NSArray *)registeredSymbols
{
return self.registeredFormattingSymbols;
}
#pragma mark - Notification Events
- (void)slk_didBeginEditing:(NSNotification *)notification
{
if (![notification.object isEqual:self]) {
return;
}
// Do something
}
- (void)slk_didChangeText:(NSNotification *)notification
{
if (![notification.object isEqual:self]) {
return;
}
if (self.placeholderLabel.hidden != [self slk_shouldHidePlaceholder]) {
[self setNeedsLayout];
}
[self slk_flashScrollIndicatorsIfNeeded];
}
- (void)slk_didEndEditing:(NSNotification *)notification
{
if (![notification.object isEqual:self]) {
return;
}
// Do something
}
- (void)slk_didChangeTextInputMode:(NSNotification *)notification
{
// Do something
}
- (void)slk_didChangeContentSizeCategory:(NSNotification *)notification
{
if (!self.isDynamicTypeEnabled) {
return;
}
NSString *contentSizeCategory = notification.userInfo[UIContentSizeCategoryNewValueKey];
[self setFontName:self.font.fontName pointSize:self.initialFontSize withContentSizeCategory:contentSizeCategory];
NSString *text = [self.text copy];
// Reloads the content size of the text view
[self setText:@" "];
[self setText:text];
}
- (void)slk_willShowMenuController:(NSNotification *)notification
{
// Do something
}
- (void)slk_didHideMenuController:(NSNotification *)notification
{
self.formatting = NO;
[self slk_addCustomMenuControllerItems];
}
#pragma mark - KVO Listener
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
if ([object isEqual:self] && [keyPath isEqualToString:NSStringFromSelector(@selector(contentSize))]) {
[[NSNotificationCenter defaultCenter] postNotificationName:SLKTextViewContentSizeDidChangeNotification object:self userInfo:nil];
}
else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
#pragma mark - Motion Events
- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event
{
if (event.type == UIEventTypeMotion && event.subtype == UIEventSubtypeMotionShake) {
[[NSNotificationCenter defaultCenter] postNotificationName:SLKTextViewDidShakeNotification object:self];
}
}
#pragma mark - External Keyboard Support
typedef void (^SLKKeyCommandHandler)(UIKeyCommand *keyCommand);
- (void)observeKeyInput:(NSString *)input modifiers:(UIKeyModifierFlags)modifiers title:(NSString *_Nullable)title completion:(void (^)(UIKeyCommand *keyCommand))completion
{
NSAssert([input isKindOfClass:[NSString class]], @"You must provide a string with one or more characters corresponding to the keys to observe.");
NSAssert(completion != nil, @"You must provide a non-nil completion block.");
if (!input || !completion) {
return;
}
UIKeyCommand *keyCommand = [UIKeyCommand keyCommandWithInput:input modifierFlags:modifiers action:@selector(didDetectKeyCommand:)];
#ifdef __IPHONE_9_0
if ([UIKeyCommand respondsToSelector:@selector(keyCommandWithInput:modifierFlags:action:discoverabilityTitle:)] ) {
keyCommand.discoverabilityTitle = title;
}
#endif
if (!_registeredKeyCommands) {
_registeredKeyCommands = [NSMutableDictionary new];
_registeredKeyCallbacks = [NSMutableDictionary new];
}
NSString *key = [self keyForKeyCommand:keyCommand];
self.registeredKeyCommands[key] = keyCommand;
self.registeredKeyCallbacks[key] = completion;
}
- (void)didDetectKeyCommand:(UIKeyCommand *)keyCommand
{
NSString *key = [self keyForKeyCommand:keyCommand];
SLKKeyCommandHandler completion = self.registeredKeyCallbacks[key];
if (completion) {
completion(keyCommand);
}
}
- (NSString *)keyForKeyCommand:(UIKeyCommand *)keyCommand
{
return [NSString stringWithFormat:@"%@_%ld", keyCommand.input, (long)keyCommand.modifierFlags];
}
- (NSArray *)keyCommands
{
if (self.registeredKeyCommands) {
return [self.registeredKeyCommands allValues];
}
return nil;
}
#pragma mark Up/Down Cursor Movement
- (void)didPressArrowKey:(UIKeyCommand *)keyCommand
{
if (![keyCommand isKindOfClass:[UIKeyCommand class]] || self.text.length == 0 || self.numberOfLines < 2) {
return;
}
if ([keyCommand.input isEqualToString:UIKeyInputUpArrow]) {
[self slk_moveCursorTodirection:UITextLayoutDirectionUp];
}
else if ([keyCommand.input isEqualToString:UIKeyInputDownArrow]) {
[self slk_moveCursorTodirection:UITextLayoutDirectionDown];
}
}
- (void)slk_moveCursorTodirection:(UITextLayoutDirection)direction
{
UITextPosition *start = (direction == UITextLayoutDirectionUp) ? self.selectedTextRange.start : self.selectedTextRange.end;
if ([self slk_isNewVerticalMovementForPosition:start inDirection:direction]) {
self.verticalMoveDirection = direction;
self.verticalMoveStartCaretRect = [self caretRectForPosition:start];
}
if (start) {
UITextPosition *end = [self slk_closestPositionToPosition:start inDirection:direction];
if (end) {
self.verticalMoveLastCaretRect = [self caretRectForPosition:end];
self.selectedTextRange = [self textRangeFromPosition:end toPosition:end];
[self slk_scrollToCaretPositonAnimated:NO];
}
}
}
// Based on code from Ruben Cabaco
// https://gist.github.com/rcabaco/6765778
- (UITextPosition *)slk_closestPositionToPosition:(UITextPosition *)position inDirection:(UITextLayoutDirection)direction
{
// Only up/down are implemented. No real need for left/right since that is native to UITextInput.
NSParameterAssert(direction == UITextLayoutDirectionUp || direction == UITextLayoutDirectionDown);
// Translate the vertical direction to a horizontal direction.
UITextLayoutDirection lookupDirection = (direction == UITextLayoutDirectionUp) ? UITextLayoutDirectionLeft : UITextLayoutDirectionRight;
// Walk one character at a time in `lookupDirection` until the next line is reached.
UITextPosition *checkPosition = position;
UITextPosition *closestPosition = position;
CGRect startingCaretRect = [self caretRectForPosition:position];
CGRect nextLineCaretRect = CGRectZero;
BOOL isInNextLine = NO;
while (YES) {
UITextPosition *nextPosition = [self positionFromPosition:checkPosition inDirection:lookupDirection offset:1];
// End of line.
if (!nextPosition || [self comparePosition:checkPosition toPosition:nextPosition] == NSOrderedSame) {
break;
}
checkPosition = nextPosition;
CGRect checkRect = [self caretRectForPosition:checkPosition];
if (CGRectGetMidY(startingCaretRect) != CGRectGetMidY(checkRect)) {
// While on the next line stop just above/below the starting position.
if (lookupDirection == UITextLayoutDirectionLeft && CGRectGetMidX(checkRect) <= CGRectGetMidX(self.verticalMoveStartCaretRect)) {
closestPosition = checkPosition;
break;
}
if (lookupDirection == UITextLayoutDirectionRight && CGRectGetMidX(checkRect) >= CGRectGetMidX(self.verticalMoveStartCaretRect)) {
closestPosition = checkPosition;
break;
}
// But don't skip lines.
if (isInNextLine && CGRectGetMidY(checkRect) != CGRectGetMidY(nextLineCaretRect)) {
break;
}
isInNextLine = YES;
nextLineCaretRect = checkRect;
closestPosition = checkPosition;
}
}
return closestPosition;
}
- (BOOL)slk_isNewVerticalMovementForPosition:(UITextPosition *)position inDirection:(UITextLayoutDirection)direction
{
CGRect caretRect = [self caretRectForPosition:position];
BOOL noPreviousStartPosition = CGRectEqualToRect(self.verticalMoveStartCaretRect, CGRectZero);
BOOL caretMovedSinceLastPosition = !CGRectEqualToRect(caretRect, self.verticalMoveLastCaretRect);
BOOL directionChanged = self.verticalMoveDirection != direction;
BOOL newMovement = noPreviousStartPosition || caretMovedSinceLastPosition || directionChanged;
return newMovement;
}
#pragma mark - NSNotificationCenter registration
- (void)slk_registerNotifications
{
[self slk_unregisterNotifications];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(slk_didBeginEditing:) name:UITextViewTextDidBeginEditingNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(slk_didChangeText:) name:UITextViewTextDidChangeNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(slk_didEndEditing:) name:UITextViewTextDidEndEditingNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(slk_didChangeTextInputMode:) name:UITextInputCurrentInputModeDidChangeNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(slk_didChangeContentSizeCategory:) name:UIContentSizeCategoryDidChangeNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(slk_willShowMenuController:) name:UIMenuControllerWillShowMenuNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(slk_didHideMenuController:) name:UIMenuControllerDidHideMenuNotification object:nil];
}
- (void)slk_unregisterNotifications
{
[[NSNotificationCenter defaultCenter] removeObserver:self name:UITextViewTextDidBeginEditingNotification object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self name:UITextViewTextDidChangeNotification object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self name:UITextViewTextDidEndEditingNotification object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self name:UITextInputCurrentInputModeDidChangeNotification object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self name:UIContentSizeCategoryDidChangeNotification object:nil];
}
#pragma mark - Lifeterm
- (void)dealloc
{
[self slk_unregisterNotifications];
[self removeObserver:self forKeyPath:NSStringFromSelector(@selector(contentSize))];
}
@end