SlackTextViewControllerを読んだ

2014-11-18#ios

UI周りの理解を深めるため、Slackが公開しているhttps://github.com/slackhq/SlackTextViewControllerを読む。コミット番号は9fcf06ac6f7004e4aacb6536b375d1cb03f08289だ。

全部はさすがに読みきれないので、以下の気になるポイントに集中してコードを読んでいくことにする。

TL;DR

初期化から表示まで

とりあえず、初期化から表示までの流れを先に抑えておく。

SlackTextViewController.m:115

- (instancetype)initWithCoder:(NSCoder *)decoder
{
    NSAssert([self class] != [SLKTextViewController class], @"Oops! You must subclass SLKTextViewController.");
    
    if (self = [super initWithCoder:decoder])
    {
        UITableViewStyle tableViewStyle = [[self class] tableViewStyleForCoder:decoder];
        UICollectionViewLayout *collectionViewLayout = [[self class] collectionViewLayoutForCoder:decoder];
        
        if ([collectionViewLayout isKindOfClass:[UICollectionViewLayout class]]) {
            [self collectionViewWithLayout:collectionViewLayout];
        }
        else if (tableViewStyle == UITableViewStylePlain || tableViewStyle == UITableViewStyleGrouped) {
            [self tableViewWithStyle:tableViewStyle];
        }
        else {
            return nil;
        }
        
        [self commonInit];
    }
    return self;
}

SlackTextViewController.m:160

次に、ViewControllerがself.viewを初期化する際に呼ばれるloadViewを読む。

- (void)loadView
{
    [super loadView];
        
    [self.view addSubview:self.scrollViewProxy];
    [self.view addSubview:self.autoCompletionView];
    [self.view addSubview:self.typingIndicatorView];
    [self.view addSubview:self.textInputbar];
}

SlackTextViewController.m:165

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    [UIView performWithoutAnimation:^{
        [self reloadTextView];
        [self setupViewConstraints];
    }];
}

SlackTextViewController.m:1681

- (void)setupViewConstraints
{
    NSDictionary *views = @{@"scrollView": self.scrollViewProxy,
                            @"autoCompletionView": self.autoCompletionView,
                            @"typingIndicatorView": self.typingIndicatorView,
                            @"textInputbar": self.textInputbar,
                            };
    
    [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[scrollView(0@750)][autoCompletionView(0)][typingIndicatorView(0)]-0@999-[textInputbar(>=0)]|" options:0 metrics:nil views:views]];
    [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[scrollView]|" options:0 metrics:nil views:views]];
    [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[autoCompletionView]|" options:0 metrics:nil views:views]];
    [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[typingIndicatorView]|" options:0 metrics:nil views:views]];
    [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[textInputbar]|" options:0 metrics:nil views:views]];
    
    self.scrollViewHC = [self.view slk_constraintForAttribute:NSLayoutAttributeHeight firstItem:self.scrollViewProxy secondItem:nil];
    self.autoCompletionViewHC = [self.view slk_constraintForAttribute:NSLayoutAttributeHeight firstItem:self.autoCompletionView secondItem:nil];
    self.typingIndicatorViewHC = [self.view slk_constraintForAttribute:NSLayoutAttributeHeight firstItem:self.typingIndicatorView secondItem:nil];
    self.textInputbarHC = [self.view slk_constraintForAttribute:NSLayoutAttributeHeight firstItem:self.textInputbar secondItem:nil];
    self.keyboardHC = [self.view slk_constraintForAttribute:NSLayoutAttributeBottom firstItem:self.view secondItem:self.textInputbar];
    
    self.textInputbarHC.constant = [self minimumInputbarHeight];
    self.scrollViewHC.constant = [self appropriateScrollViewHeight];

    if (self.isEditing) {
        self.textInputbarHC.constant += self.textInputbar.accessoryViewHeight;
    }
}

キーボードの表示/非表示に伴うレイアウトの調整

キーボードはself.textInputbar内のUITextFieldがfirstResponderになったときに表示されるはずだ。キーボードが表示される直前/直後にはそれぞれUIKeyboardWillShowNotification, UIKeyboardDidShowNotificationという通知がポストされる。そこで、この通知を監視するオブザーバーを探す。

SlackTextViewController.m:1719

- (void)registerNotifications
{
    // Keyboard notifications
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(willShowOrHideKeyboard:) name:UIKeyboardWillShowNotification object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(willShowOrHideKeyboard:) name:UIKeyboardWillHideNotification object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didShowOrHideKeyboard:) name:UIKeyboardDidShowNotification object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didShowOrHideKeyboard:) name:UIKeyboardDidHideNotification object:nil];
    
    // ...
}

SlackTextViewController.m:1048

-[willShowOrHideKeyboard:]の中でレイアウトの変更に関わる部分を抽出した。

- (void)willShowOrHideKeyboard:(NSNotification *)notification
{
    // ...
    
    // Updates the height constraints' constants
    self.keyboardHC.constant = [self appropriateKeyboardHeight:notification];
    self.scrollViewHC.constant = [self appropriateScrollViewHeight];
    
    // ...
}

SlackTextViewController.m:412

- (CGFloat)appropriateKeyboardHeight:(NSNotification *)notification
{
    CGFloat keyboardHeight = 0.0;

    CGRect endFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
    
    // ...
    
    // Sets the minimum height of the keyboard
    if (self.isMovingKeyboard) {
        if (!UI_IS_IOS8_AND_HIGHER && UI_IS_LANDSCAPE) {
            keyboardHeight = MIN(CGRectGetWidth([UIScreen mainScreen].bounds), CGRectGetHeight([UIScreen mainScreen].bounds));
            keyboardHeight -= MAX(endFrame.origin.x, endFrame.origin.y);
        }
        else {
            keyboardHeight = CGRectGetHeight([UIScreen mainScreen].bounds);
            keyboardHeight -= endFrame.origin.y;
        }
    }
    else {
        if ([notification.name isEqualToString:UIKeyboardWillShowNotification] || [notification.name isEqualToString:UIKeyboardDidShowNotification]) {
            CGRect convertedRect = [self.view convertRect:endFrame toView:self.view.window];
            keyboardHeight = CGRectGetHeight(convertedRect);
        }
        else {
            keyboardHeight = 0.0;
        }
    }
    
    // ...
    
    return keyboardHeight;
}

Slacktextviewcontroller.m:456

- (CGFloat)appropriateScrollViewHeight
{
    CGFloat height = self.view.bounds.size.height;
    
    height -= self.keyboardHC.constant;
    height -= self.textInputbarHC.constant;
    height -= self.autoCompletionViewHC.constant;
    height -= self.typingIndicatorViewHC.constant;
    
    if (height < 0) return 0;
    else return roundf(height);
}

SlackTextViewController.m:1060

willShowOrHideKeyboard:に戻る。

- (void)willShowOrHideKeyboard:(NSNotification *)notification
{
    // ...
    
    // Updates the height constraints' constants
    self.keyboardHC.constant = [self appropriateKeyboardHeight:notification];
    self.scrollViewHC.constant = [self appropriateScrollViewHeight];
    
    // ...
}

Slacktextviewcontroller.m:1112

-[didShowOrHideKeyboard:]の中でレイアウトの変更に関わる部分を探す。

- (void)didShowOrHideKeyboard:(NSNotification *)notification
{
    // ...

    [self reloadInputAccessoryViewIfNeeded];
    [self updateKeyboardDismissModeIfNeeded];

    // Very important to invalidate this flag after the keyboard is dismissed or presented
    self.movingKeyboard = NO;
}

Slacktextviewcontroller.m:993

- (void)updateKeyboardDismissModeIfNeeded
{
    // Skips if the keyboard panning is disabled
    if (!self.isKeyboardPanningEnabled) {
        return;
    }
    
    UIScrollView *scrollView = self.scrollViewProxy;
    UIScrollViewKeyboardDismissMode dismissMode = scrollView.keyboardDismissMode;
    
    BOOL isPannable = self.textView.inputAccessoryView ? YES : NO;
    
    // Enables the keyboard dismiss mode
    if (dismissMode == UIScrollViewKeyboardDismissModeNone && isPannable) {
        scrollView.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive;
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didChangeKeyboardFrame:) name:SLKInputAccessoryViewKeyboardFrameDidChangeNotification object:nil];
    }
    // Disables the keyboard dismiss mode
    else if (dismissMode == UIScrollViewKeyboardDismissModeInteractive && !isPannable) {
        scrollView.keyboardDismissMode = UIScrollViewKeyboardDismissModeNone;
        [[NSNotificationCenter defaultCenter] removeObserver:self name:SLKInputAccessoryViewKeyboardFrameDidChangeNotification object:nil];
    }
}

SlackTextViewController.m:1150

- (void)didChangeKeyboardFrame:(NSNotification *)notification
{
    // ...
    
    self.keyboardHC.constant = [self appropriateKeyboardHeight:notification];
    self.scrollViewHC.constant = [self appropriateScrollViewHeight];
    
    // ...
    
    [self.view layoutIfNeeded];
}

ここまでのおさらい

ここまで、キーボードの表示/非表示に伴うレイアウトの調整についてどのように実装されているのか調べてきた。キーボードの表示からレイアウトの調整が反映されるまで、おおまかに以下のような流れで処理が進行する。

  1. ユーザーが入力を開始する。
  2. UIKeyboardWillShowNotificationが送信され、オブザーバーによって-[willShowOrHideKeyboard:]が呼ばれる。キーボードの高さとスクロールビューの適切な高さが再計算され、高さの制約上の数値が更新される(ここではまだViewに反映されない)。
  3. UIKeyboardDidShowNotificationが送信され、オブザーバーによって-[didShowOrHideKeyboard:]が呼ばれる。textViewframeの更新時に-[didChangeKeyboardFrame:]を呼ぶようにオブザーバーに登録する。
  4. 何かしらのタイミングtextViewframeが更新され、オブザーバーによって-[didChangeKeyboardFrame:]が呼ばれる。再度、キーボードとスクロールビューの高さが計算され設定される。そして、-[CALayer layoutIfNeeded]によって変更された制約上の値がViewに反映され再描画される。

ここでtextViewframeが更新されるのはどのタイミングか考えてみると、2つ考えられる。

ユーザー名や絵文字の補完

続いて、ユーザー名や絵文字の補完がどのように実装されているのか調べる。ドキュメントによると、補完機能を利用する場合はSlackTextViewControllerのサブクラスは以下のような実装を行う必要がある。

  1. -[SlackTextViewController registerPrefixesForAutoCompletion:]を呼んで自動補完を起動するプレフィックスを登録する。
  2. -[SlackTextViewController canShowAutoCompletion]を実装して、自動補完Viewを表示するかどうかをBOOLで返すようにする。このメソッドはテキストが入力されたとき上で登録したプレフィックスを発見した場合に呼ばれる。自動補完ViewはUITableViewのインスタンスであり、自由にカスタマイズできる。自動補完の候補はこのメソッドの中で用意する。
  3. 自動補完Viewの高さを返すメソッドheightForAutoCompletionViewを実装する。
  4. 自動補完の候補が選択された場合、自動補完Viewの-[UITableViewDelegate tableView:didSelectRowAtIndexPath:]が呼ばれるので、この中で-[SlackTextViewController acceptAutoCompletionWithString:]を呼ぶと選択されたテキストが補完される。

これらのメソッドの実装を見ていくことにする。

SlackTextViewController.m:1279

- (void)registerPrefixesForAutoCompletion:(NSArray *)prefixes
{
    NSMutableArray *array = [NSMutableArray arrayWithArray:self.registeredPrefixes];
    
    for (NSString *prefix in prefixes) {
        // Skips if the prefix is not a valid string
        if (![prefix isKindOfClass:[NSString class]] || prefix.length == 0) {
            continue;
        }
        
        // Adds the prefix if not contained already
        if (![array containsObject:prefix]) {
            [array addObject:prefix];
        }
    }
    
    if (_registeredPrefixes) {
        _registeredPrefixes = nil;
    }
    
    _registeredPrefixes = [[NSArray alloc] initWithArray:array];
}

SlackTextViewController.m:1575

- (void)textViewDidChangeSelection:(SLKTextView *)textView
{
    // The text view must be first responder
    if (![self.textView isFirstResponder]) {
        return;
    }
    
    // Skips if the loupe is visible or if there is a real text selection
    if (textView.isLoupeVisible || self.textView.selectedRange.length > 0) {
        return;
    }
    
    // Process the text at every caret movement
    [self processTextForAutoCompletion];
}

SlackTextViewController.m:1343

- (void)handleProcessedWord:(NSString *)word range:(NSRange)range
{
    // ...
    
    BOOL canShow = [self canShowAutoCompletion];
    
    // Reload the tableview before showing it
    [self.autoCompletionView reloadData];
    [self.autoCompletionView setContentOffset:CGPointZero];
    
    [self showAutoCompletionView:canShow];
}

SlackTextViewController.m:1417

- (void)showAutoCompletionView:(BOOL)show
{
    CGFloat viewHeight = show ? [self heightForAutoCompletionView] : 0.0;
    
    // ...
    
    self.autoCompletionViewHC.constant = viewHeight;
    self.autoCompleting = show;
    
    // Toggles auto-correction if requiered
    [self enableTypingSuggestionIfNeeded];
    
    [self.view slk_animateLayoutIfNeededWithBounce:self.bounces
                                           options:UIViewAnimationOptionCurveEaseInOut|UIViewAnimationOptionLayoutSubviews|UIViewAnimationOptionBeginFromCurrentState
                                        animations:NULL];
}

SlackTextViewController.m:1394

最後に、選択したテキストが補完される部分の実装を見ていく。

- (void)acceptAutoCompletionWithString:(NSString *)string
{
    if (string.length == 0) {
        return;
    }
    
    SLKTextView *textView = self.textView;
    
    NSRange range = NSMakeRange(self.foundPrefixRange.location+1, self.foundWord.length);
    NSRange insertionRange = [textView slk_insertText:string inRange:range];
    
    textView.selectedRange = NSMakeRange(insertionRange.location, 0);
    
    [self cancelAutoCompletion];
    
    [textView slk_scrollToCaretPositonAnimated:NO];
}

UITextView+SLKAdditions.m:90

- (NSRange)slk_insertText:(NSString *)text inRange:(NSRange)range
{
    // ...
    
    // Append the new string at the caret position
    if (range.length == 0)
    {
        NSString *leftString = [self.text substringToIndex:range.location];
        NSString *rightString = [self.text substringFromIndex: range.location];
        
        self.text = [NSString stringWithFormat:@"%@%@%@", leftString, text, rightString];
        
        range.location += [text length];
        return range;
    }
    // Some text is selected, so we replace it with the new text
    else if (range.location != NSNotFound && range.length > 0)
    {
        self.text = [self.text stringByReplacingCharactersInRange:range withString:text];
        
        return NSMakeRange(range.location+[self.text rangeOfString:text].length, text.length);
    }
    
    // No text has been inserted, but still return the caret range
    return self.selectedRange;
}