MDCSwipeToChooseを読んだ

前回に引き続き、UI周りのテクニックを学ぶためhttps://github.com/modocache/MDCSwipeToChooseを読む。海外で話題のTinder風のアプリを簡単に開発することができる。

まず使い方を簡単に見ていく。

override func viewDidLoad() {
    let options = MDCSwipeToChooseViewOptions()
    options.delegate = self
    options.likedText = "Like"
    options.likedColor = UIColor.blueColor()
    options.nopeText = "Nope"
    options.nopeColor = UIColor.redColor()
    options.onPan = { state in NSLog("Panning") }

    let swipableView = MDCSwipeToChooseView(frame: view.frame, options: options)
    view.addSubview(swipableview)
}

func viewDidCancelSwipe(view: UIView!) {
    NSLog("Cancel to choose")
}

func view(view: UIView!, wasChoosenWithDirection direction: MDCSwipeDirection) }
    NSLog("Choose to \(direction == .Left ? "Left" : "Right")")
}
  • MDCSwipeToChooseViewOptionsオブジェクトにスワイプするViewの設定をまとめて初期化時に渡している。
    • delegateMDCSwipeToChooseDelegateを実装するオブジェクトである必要がある。
    • likedTextとかnopeTextというのは、右もしくは左にスワイプされるときにView上に表示されるテキストのこと。
    • onPanはスワイプされているときに呼ばれる処理。
  • MDCSwipeToChooseDelegateメソッドとしてviewDidCancelSwipe()view(view:wasChoosenWithDirection:)がある。前者はスワイプを途中でやめたとき、後者はスワイプしてViewをどちらかに選んだときに呼ばれる。

さらに、MDCSwipeToChooseViewだけではなく、UIViewをスワイプできるようにするカテゴリも用意されているため、より柔軟に実装できるようになっている。

今回、重点的に読んでいきたいのは以下のポイントだ。

  • ライブラリの設計。カテゴリも含めた柔軟な実装を可能にするのは優れた設計があるからだと思うので参考にしたい。
  • スワイプに合わせたViewの動き。

ライブラリの設計

このライブラリの作者の書いたiOS UI Component API Designという記事によると、設計において2点考慮されているようだ。

  1. 継承よりカテゴリーによるコンポジションを選ぶ。
  2. デリゲートメソッドやブロックの引数にパラメータオブジェクトを使う。

継承よりカテゴリーによるコンポジション

MDCSwipeToChooseViewに機能を追加したい場合、サブクラスを定義する必要がある。しかし、この方法では別のライブラリが提供するViewのもつ機能を組み込むことができない。そこで、カテゴリーでUIViewに機能を拡張することで、他のライブラリとも組み合わせることができる。

カテゴリーによる拡張の欠点はインスタンス変数を追加することができないことだ。そのため、プロパティをカテゴリーによって拡張する場合は、<objc/runtime.h>objc_setAssociatedObject()を使ったトリッキーな実装が必要になる。

より簡単に実装するには、カスタマイズ用のパラメータを束ねる設定オブジェクトを使うのがよさそう。この設定オブジェクトのプロパティだけは上記のトリッキーな手法で拡張するしかないが、Viewをカスタマイズする変数はすべてのこの設定オブジェクトに隠ぺいする。このライブラリでの設定オブジェクトはMDCSwipeOptionsMDCSwipeToChooseViewOptionsだった。

パラメータオブジェクト

デリゲートメソッドonPanなどのブロックのシグネチャがバージョンアップデートで変更されてしまうと互換性がなくなってしまう。そこで、複数の引数をまとめたパラメータオブジェクトというのを用意し、引数の変更をすべてパラメータオブジェクト内の変更で吸収することで、メソッドシグネチャを変更せずに互換性を保つことができる。このライブラリではMDCPanStateがパラメータオブジェクトの役割を果たしている。

typedef void (^MDCSwipeToChooseOnPanBlock)(MDCPanState *state);

@interface MDCSwipeOptions : NSObject

// ...

@property (nonatomic, copy) MDCSwipeToChooseOnPanBlock onPan;

// ...

@end
@interface MDCPanState : NSObject

@property (nonatomic, strong) UIView *view;
@property (nonatomic, assign) MDCSwipeDirection direction;
@property (nonatomic, assign) CGFloat thresholdRatio;

@end

初期化から表示まで

設計について確認したので、初期化から表示されるまでの流れからソースコードを読んでいく。

MDCSwipeToChooseView.m:44

- (instancetype)initWithFrame:(CGRect)frame options:(MDCSwipeToChooseViewOptions *)options {
    self = [super initWithFrame:frame];
    if (self) {
        _options = options ? options : [MDCSwipeToChooseViewOptions new];
        [self setupView];
        [self constructImageView];
        [self constructLikedView];
        [self constructNopeImageView];
        [self setupSwipeToChoose];
    }
    return self;
}
  • まずこのクラスのスーパークラスUIViewなので、普通の初期化処理をしたあとにセットアップ処理を実行している。
  • いくつかのセットアップ処理を順に見ていく。

MDCSwipeToChooseView.m:59

- (void)setupView {
    self.backgroundColor = [UIColor clearColor];
    self.layer.cornerRadius = 5.f;
    self.layer.borderWidth = 2.f;
    self.layer.borderColor = [UIColor colorWith8BitRed:220.f
                                                 green:220.f
                                                  blue:220.f
                                                 alpha:1.f].CGColor;
}
  • selfの見た目に関する設定をしている。
  • 背景色が透明で、角丸で、枠線の太さと色を指定しているだけのようだ。

MDCSwipeToChooseView.m:69

- (void)constructImageView {
    _imageView = [[UIImageView alloc] initWithFrame:self.bounds];
    _imageView.clipsToBounds = YES;
    [self addSubview:_imageView];
}
  • selfと同じ大きさの_imageViewを作ってサブビューに追加している。
  • -[UIView clipsToBounds]はサブビューを自分のboundsのサイズで切り抜く設定だ。YESを指定すると、サブビューの自分からはみ出た部分は表示されなくなる。

MDCSwipeToChooseView.m:75

- (void)constructLikedView {
    CGRect frame = CGRectMake(MDCSwipeToChooseViewHorizontalPadding,
                              MDCSwipeToChooseViewTopPadding,
                              CGRectGetMidX(_imageView.bounds),
                              MDCSwipeToChooseViewLabelWidth);
    self.likedView = [[UIView alloc] initWithFrame:frame];
    [self.likedView constructBorderedLabelWithText:self.options.likedText
                                             color:self.options.likedColor
                                             angle:self.options.likedRotationAngle];
    self.likedView.alpha = 0.f;
    [self.imageView addSubview:self.likedView];
}
  • likedViewというのは右にスワイプしたときに浮かび上がるテキストのためのビュー。
  • 最初は非表示になっているのでalpha0になっている。
  • -[UIView constructBorderedLabelWithText:color:angle]というメソッドUIView+MDCBorderedLabel.mで定義されている拡張。

UIView+MDCBorderedLabel.m:31

- (void)constructBorderedLabelWithText:(NSString *)text
                                 color:(UIColor *)color
                                 angle:(CGFloat)angle {
    self.layer.borderColor = color.CGColor;
    self.layer.borderWidth = 5.f;
    self.layer.cornerRadius = 10.f;

    UILabel *label = [[UILabel alloc] initWithFrame:self.bounds];
    label.text = [text uppercaseString];
    label.textAlignment = NSTextAlignmentCenter;
    label.font = [UIFont fontWithName:@"HelveticaNeue-CondensedBlack"
                                 size:48.f];
    label.textColor = color;
    [self addSubview:label];

    self.transform = CGAffineTransformRotate(CGAffineTransformIdentity,
                                             MDCDegreesToRadians(angle));
}
  • 角丸や枠線の設定をして、UILabelをサブビューに追加している。
  • -[UIView transform]centerまたはanchorPointを基準とした変換値を表す。これを設定するとその変換が適用される。CGAffineTransformRotate()は回転のためのアフィン変換行列を返す。第1引数に既存のアフィン変換、第2引数に回転角度を指定する。

MDCSwipeToChooseView.m:88

- (void)constructNopeImageView {
    CGFloat width = CGRectGetMidX(self.imageView.bounds);
    CGFloat xOrigin = CGRectGetMaxX(_imageView.bounds) - width - MDCSwipeToChooseViewHorizontalPadding;
    self.nopeView = [[UIImageView alloc] initWithFrame:CGRectMake(xOrigin,
                                                                  MDCSwipeToChooseViewTopPadding,
                                                                  width,
                                                                  MDCSwipeToChooseViewLabelWidth)];
    [self.nopeView constructBorderedLabelWithText:self.options.nopeText
                                            color:self.options.nopeColor
                                            angle:self.options.nopeRotationAngle];
    self.nopeView.alpha = 0.f;
    [self.imageView addSubview:self.nopeView];
}
  • こちらは左にスワイプしたときに浮かび上がるテキストのビュー。
  • _likedViewと大差ない。

MDCSwipeToChooseView.m:102

- (void)setupSwipeToChoose {
    MDCSwipeOptions *options = [MDCSwipeOptions new];
    options.delegate = self.options.delegate;
    options.threshold = self.options.threshold;

    __block UIView *likedImageView = self.likedView;
    __block UIView *nopeImageView = self.nopeView;
    __weak MDCSwipeToChooseView *weakself = self;
    options.onPan = ^(MDCPanState *state) {
        if (state.direction == MDCSwipeDirectionNone) {
            likedImageView.alpha = 0.f;
            nopeImageView.alpha = 0.f;
        } else if (state.direction == MDCSwipeDirectionLeft) {
            likedImageView.alpha = 0.f;
            nopeImageView.alpha = state.thresholdRatio;
        } else if (state.direction == MDCSwipeDirectionRight) {
            likedImageView.alpha = state.thresholdRatio;
            nopeImageView.alpha = 0.f;
        }

        if (weakself.options.onPan) {
            weakself.options.onPan(state);
        }
    };

    [self mdc_swipeToChooseSetup:options];
}
  • MDCSwipeOptionsオブジェクトを生成して、初期化時に渡されたself.optionsのプロパティをコピーしている。onPanブロック内で最後に初期化時に渡されたself.options.onPanが実行されるようになっている。
  • __block属性はブロック内で変更する場合に変数につける必要がある。
  • onPan内の処理を詳しく見ていく。
    • ブロックの引数に渡されるstatedirectionプロパティはMDCSwipeDirection型の値で、None, Left, Rightのいずれかだ。Leftなら`nopeImageViewのアルファ値を変更し表示されるようにしている。逆にRightならlikedImageViewを同様にして表示されるようにしている。
    • statethresholdRatioプロパティはコメントによると、ある閾値にどれだけ近づいているかを表す、0から1までの値だ。1のとき閾値に達したことを意味する。よって、ある閾値に達したときthresholdRatio1になり、likedImageViewまたはnopeImageViewのアルファ値が1になって完全に表示されるようになる。
  • -[UIView mdc_swipeToChooseSetup:]UIView+MDCSwipeToChoose.mで定義されたカテゴリーによって拡張されたメソッドだ。このライブラリはMDCSwipeToChooseViewという専用のクラスだけでなく、UIViewのカテゴリーを提供することでより柔軟に実装できるようになっているが、その中心部分はこのカテゴリー内で実装しているようだ。

UIView+MDCSwipeToChoose.m:38

- (void)mdc_swipeToChooseSetup:(MDCSwipeOptions *)options {
    self.mdc_options = options ? options : [MDCSwipeOptions new];
    self.mdc_viewState = [MDCViewState new];
    self.mdc_viewState.originalCenter = self.center;

    [self mdc_setupPanGestureRecognizer];
}
  • self.mdc_optionsself.mdc_viewStateを初期化している。
  • -[UIView mdc_setupPanGestureRecognizer]でジェスチャーのイベントハンドリングを実装しているのだろう。

スワイプに合わせたViewの動き

これまでMDCSwipeToChooseViewおよびUIView+MDCSwipeToChooseによる拡張部分の初期化について見てきた。これからスワイプに合わせてViewをどのように動かしているのかについて詳細に見ていく。

UIView+MDCSwipeToChoose.m:104

- (void)mdc_setupPanGestureRecognizer {
    SEL action = @selector(mdc_onSwipeToChoosePanGestureRecognizer:);
    UIPanGestureRecognizer *panGestureRecognizer =
    [[UIPanGestureRecognizer alloc] initWithTarget:self
                                            action:action];
    [self addGestureRecognizer:panGestureRecognizer];
}
  • UIPanGestureRecognizerを初期化してUIViewに追加している。パンというジェスチャーはスワイプとかドラッグのことだ。
  • スワイプされると-[UIView mdc_onSwipeToChoosePanGestureRecognizer:]が呼ばれるようだ。

UIView+MDCSwipeToChoose.m:227

- (void)mdc_onSwipeToChoosePanGestureRecognizer:(UIPanGestureRecognizer *)panGestureRecognizer {
    UIView *view = panGestureRecognizer.view;

    if (panGestureRecognizer.state == UIGestureRecognizerStateBegan) {
        self.mdc_viewState.originalCenter = view.center;

        // If the pan gesture originated at the top half of the view, rotate the view
        // away from the center. Otherwise, rotate towards the center.
        if ([panGestureRecognizer locationInView:view].y < view.center.y) {
            self.mdc_viewState.rotationDirection = MDCRotationAwayFromCenter;
        } else {
            self.mdc_viewState.rotationDirection = MDCRotationTowardsCenter;
        }
    } else if (panGestureRecognizer.state == UIGestureRecognizerStateEnded) {
        // Either move the view back to its original position or move it off screen.
        [self mdc_finalizePosition];
    } else {
        // Update the position and transform. Then, notify any listeners of
        // the updates via the pan block.
        CGPoint translation = [panGestureRecognizer translationInView:view];
        view.center = MDCCGPointAdd(self.mdc_viewState.originalCenter, translation);
        [self mdc_rotateForTranslation:translation
                     rotationDirection:self.mdc_viewState.rotationDirection];
        [self mdc_executeOnPanBlockForTranslation:translation];
    }
}
  • スワイプが始まったとき、ユーザーの指の位置がViewの上半分ならMDCRotationAwayFromCenterすなわち1.0、下半分ならMDCRotationTowardsCenterすなわち-1.0self.mdc_viewState.rotationDirectionにセットしている。
  • スワイプが終わったとき、-[UIVIew mdc_finalizePosition]を呼ぶ。ここはあとで詳細に見ることにしてスキップする。
  • スワイプ中
    • -[UIPanGestureRecognizer translationInView:]によって最初に指が触れた点からの移動量を取得している。
    • 取得した移動量をoriginalCenterに加えた値をcenterとすることで、ユーザーの指の位置が常にcenterになるようにViewを移動させているようだ。
    • -[UIView mdc_rotateForTranslation:rotationDirection:]によってViewを回転させているようだ。あとで詳細を見ることにする。
    • -[UIView mdc_executeOnPanBlockForTranslation:]はスワイプの状態からthresholdRatioを計算したりMDCPanStateを生成したりしてself.mdc_options.onPan()の引数に渡して実行している。ここで、Viewの初期化時に指定したonPanのブロックが実行されることになる。

UIView+MDCSwipeToChoose.m:189

後回しにしていた-[UIView mdc_rotateForTranslation:rotationDirection:]を先に見る。

- (void)mdc_rotateForTranslation:(CGPoint)translation
               rotationDirection:(MDCRotationDirection)rotationDirection {
    CGFloat rotation = MDCDegreesToRadians(translation.x/100 * self.mdc_options.rotationFactor);
    self.transform = CGAffineTransformRotate(CGAffineTransformIdentity,
                                             rotationDirection * rotation);
}
  • x軸方向への移動量 / 100に定数倍したものをラジアンに変換して、rotationDirection1.0or-1.0)を掛けた量を回転させている。

UIView+MDCSwipeToChoose.m:114

次に、スワイプが終了したときに呼ばれる-[UIView mdc_finalizePosition]を見ていく。

- (void)mdc_finalizePosition {
    MDCSwipeDirection direction = [self mdc_directionOfExceededThreshold];
    switch (direction) {
        case MDCSwipeDirectionRight:
        case MDCSwipeDirectionLeft: {
            CGPoint translation = MDCCGPointSubtract(self.center,
                                                     self.mdc_viewState.originalCenter);
            [self mdc_exitSuperviewFromTranslation:translation];
            break;
        }
        case MDCSwipeDirectionNone:
            [self mdc_returnToOriginalCenter];
            [self mdc_executeOnPanBlockForTranslation:CGPointZero];
            break;
    }
}
  • -[UIView mdc_directionOfExceededThreshold]である閾値を超えた方向を取得しているようだ。
  • 取得した方向が左か右であれば-[UIView mdc_exitSuperviewFromTranslation:]を呼び、どちらでもなかった場合は-[UIView mdc_returnToOriginalCenter]-[UIView mdc_executeOnPanBlockForTranslation:]を呼んでいる。

UIView+MDCSwipeToChoose.m:215

まず閾値をを超えた方向を取得する部分から見ていく。

- (MDCSwipeDirection)mdc_directionOfExceededThreshold {
    if (self.center.x > self.mdc_viewState.originalCenter.x + self.mdc_options.threshold) {
        return MDCSwipeDirectionRight;
    } else if (self.center.x < self.mdc_viewState.originalCenter.x - self.mdc_options.threshold) {
        return MDCSwipeDirectionLeft;
    } else {
        return MDCSwipeDirectionNone;
    }
}
  • どうやら閾値というのはself.mdc_options.thresholdのことのようだ。デフォルトでは100.0だ。
  • Viewの中心点のx座標がもともとの中心点のx座標から閾値以上移動した場合、右方向ならRight、左方向ならLeftを返している。そうでなければNoneを返している。

UIView+MDCSwipeToChoose.m:146

次に、上記の閾値を超えてどちらかの方向が返ってきた場合に呼ばれる-[UIView mdc_exitSuperviewFromTranslation:]を見る。

- (void)mdc_exitSuperviewFromTranslation:(CGPoint)translation {
    MDCSwipeDirection direction = [self mdc_directionOfExceededThreshold];
    id<MDCSwipeToChooseDelegate> delegate = self.mdc_options.delegate;
    if ([delegate respondsToSelector:@selector(view:shouldBeChosenWithDirection:)]) {
        BOOL should = [delegate view:self shouldBeChosenWithDirection:direction];
        if (!should) {
            return;
        }
    }

    MDCSwipeResult *state = [MDCSwipeResult new];
    state.view = self;
    state.translation = translation;
    state.direction = direction;
    state.onCompletion = ^{
        if ([delegate respondsToSelector:@selector(view:wasChosenWithDirection:)]) {
            [delegate view:self wasChosenWithDirection:direction];
        }
    };
    self.mdc_options.onChosen(state);
}
  • delegateview:shouldBeChosenWithDirection:が実装されていれば、それを呼びNOが返ってきた場合そこで終了する。
  • MDCSwipeResultオブジェクトを初期化してself.mdc_options.onChosen()に渡して実行している。

MDCSwipeOptions.m:33

onChosenは何を参照しているのか確認する。

- (instancetype)init {
    self = [super init];
    if (self) {
        _swipeCancelledAnimationDuration = 0.2;
        _swipeCancelledAnimationOptions = UIViewAnimationOptionCurveEaseOut;
        _swipeAnimationDuration = 0.1;
        _swipeAnimationOptions = UIViewAnimationOptionCurveEaseIn;
        _rotationFactor = 3.f;

        _onChosen = [[self class] exitScreenOnChosenWithDuration:0.1
                                                         options:UIViewAnimationOptionCurveLinear];
    }
    return self;
}
  • _onChosen+[MDCSwipeOptions exitScreenOnChosenWithDuration:options]の返り値を参照している。

MDCSwipeOptions.m:50

+ (MDCSwipeToChooseOnChosenBlock)exitScreenOnChosenWithDuration:(NSTimeInterval)duration
                                                        options:(UIViewAnimationOptions)options {
    return ^(MDCSwipeResult *state) {
        CGRect destination = MDCCGRectExtendedOutOfBounds(state.view.frame,
                                                          state.view.superview.bounds,
                                                          state.translation);
        [UIView animateWithDuration:duration
                              delay:0.0
                            options:options
                         animations:^{
                             state.view.frame = destination;
                         } completion:^(BOOL finished) {
                             if (finished) {
                                 [state.view removeFromSuperview];
                                 state.onCompletion();
                             }
                         }];
    };
}
  • このメソッドはブロックを返しているのであって、ブロックを実行しているわけではない。
  • その内容としては、Viewをスーパービューの外にアニメーションつきで移動させ、完了後にそのViewをスーパービューから削除し、state.onCompletion()を実行するというものだ。

UIView+MDCSwipeToChoose.m:146

いったん-[UIView mdc_exitSuperviewFromTranslation:]に戻ってonCompletionを確認する。

- (void)mdc_exitSuperviewFromTranslation:(CGPoint)translation {
    // ...

    MDCSwipeResult *state = [MDCSwipeResult new];
    state.view = self;
    state.translation = translation;
    state.direction = direction;
    state.onCompletion = ^{
        if ([delegate respondsToSelector:@selector(view:wasChosenWithDirection:)]) {
            [delegate view:self wasChosenWithDirection:direction];
        }
    };
    self.mdc_options.onChosen(state);
}
  • Viewが枠外に消えた後に、onChosen()の引数に渡されたstateonCompletionが実行されるので、ここではdelegateview:wasChosenWithDirection:が呼ばれることになる。

UIView+MDCSwipeToChoose.m:131

続いて、-[UIView mdc_finalizePosition]閾値を超えなかった場合に呼ばれる2つのメソッドのうち、-[UIView mdc_returnToOriginalCenter]を見る。

- (void)mdc_returnToOriginalCenter {
    [UIView animateWithDuration:self.mdc_options.swipeCancelledAnimationDuration
                          delay:0.0
                        options:self.mdc_options.swipeCancelledAnimationOptions
                     animations:^{
                         self.transform = CGAffineTransformIdentity;
                         self.center = self.mdc_viewState.originalCenter;
                     } completion:^(BOOL finished) {
                         id<MDCSwipeToChooseDelegate> delegate = self.mdc_options.delegate;
                         if ([delegate respondsToSelector:@selector(viewDidCancelSwipe:)]) {
                             [delegate viewDidCancelSwipe:self];
                         }
                     }];
}
  • アニメーションつきで回転を打ち消し、もともとの中心点に移動させている。
  • それらが完了したあと、delegateviewDidCancelSwipe:を呼んでいる。

UIView+MDCSwipeToChoose.m:168

もう1つの-[UIView mdc_executeOnPanBlockForTranslation:]を見る。

- (void)mdc_executeOnPanBlockForTranslation:(CGPoint)translation {
    if (self.mdc_options.onPan) {
        CGFloat thresholdRatio = MIN(1.f, fabsf(translation.x)/self.mdc_options.threshold);

        MDCSwipeDirection direction = MDCSwipeDirectionNone;
        if (translation.x > 0.f) {
            direction = MDCSwipeDirectionRight;
        } else if (translation.x < 0.f) {
            direction = MDCSwipeDirectionLeft;
        }

        MDCPanState *state = [MDCPanState new];
        state.view = self;
        state.direction = direction;
        state.thresholdRatio = thresholdRatio;
        self.mdc_options.onPan(state);
    }
}
  • 中心点に戻る際のonPanブロックを実行している。そのために、thresholdRatioを計算しMDCPanStateを初期化している。

SlackTextViewControllerを読んだ

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

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

  • キーボードの表示/非表示に伴うレイアウトの調整。何も工夫しないとキーボードでViewが隠れてしまうはずだ。
  • ユーザー名や絵文字の補完

TL;DR

  • キーボードの表示/非表示の際に送信される通知UIKeyboardWillShowNotification等を使ってレイアウトを調整している。レイアウトはすべてAuto Layout上の制約をプログラムで制御することで調整している。例えば、キーボードの高さに併せてスクロールビューの高さを大きくしたり小さくしたりしている。
  • UITextView上のカーソル位置が変更されるタイミングで、事前に登録されたプレフィックスにマッチするかどうかチェックしている。マッチすれば、補完候補を表示するUITextViewを表示し、それに併せて各Viewのレイアウトを調整している。
  • 全体を通して「キーボード等の状態が変化する」→「各Viewの適切な高さを計算する」→「-[NSLayoutConstraint constant]を更新する」→「-[CALayer layoutIfNeeded]を呼んで再描画する」という流れだった。

初期化から表示まで

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

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;
}
  • NSAssert()は第一引数がtrueであることを表明するために使われる。falseならそこで第二引数のメッセージをログに出力して強制終了する。
  • -[tableViewWithStyle:]がやっていることは主に2つ。
    • _tableViewの初期化。
    • _scrollViewProxyの初期化。これは実際には_tableViewを参照している。また、タップしたらキーボードを閉じる設定をしている。
  • -[commonInit]は名前の通り、他の初期化メソッド内でも呼ばれており、主に以下のようなことを行っている。
    • 各状態プロパティの初期化。
    • 多数のオブザーバーを登録する。

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];
}
  • self.viewを初期化したあと、self.scrollViewProxy, self.autoCompletionView, self.typingIndicatorView, self.textInputbarの4つのサブビューが追加されている。それぞれのサブビューは以下のようなものだ。
    • self.scrollViewProxy: 上でも見たようにself.tableViewのこと。
    • self.autoCompletionView: おそらく補完候補を表示するUITableViewだと思われる。
    • self.typingIndicatorView: 「◯◯が入力中…」のようなメッセージを表示するためのViewで、SLKTextIndicatorViewというカスタムViewとして定義されている。
    • self.textInputbar: テキストの入力フォームや送信ボタンを含むUIToolBarで、これもSLKTextInputbarというカスタムViewとして定義されている。

SlackTextViewController.m:165

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    [UIView performWithoutAnimation:^{
        [self reloadTextView];
        [self setupViewConstraints];
    }];
}
  • -[reloadTextView]はキャッシュされた入力テキストを復旧してTextFieldに表示している。
  • -[SlackTextViewController 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;
    }
}
  • -[UIView addConstraints:]の部分はself.viewのサブビューに対する以下のような制約を追加している。
    • 各サブビューの高さ、およびサブビュー間の垂直方向の余白を設定
    • 各サブビューの水平方向の親Viewとの余白はなし
  • 各サブビュー間に制約が追加された結果、計算された制約の値をプロパティに保持する。このプロパティはレイアウトを調整する際にどんどん更新していくため重要。

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

キーボードは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];
    
    // ...
}
  • UIKeyboardWillShowNotificationがポストされたとき、willShowOrHideKeyboard:が呼ばれるようになっている。また、UIKeyboardDidShowNotificationがポストされたとき、didShowOrHideKeyboard:が呼ばれるようになっている。

SlackTextViewController.m:1048

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

- (void)willShowOrHideKeyboard:(NSNotification *)notification
{
    // ...
    
    // Updates the height constraints' constants
    self.keyboardHC.constant = [self appropriateKeyboardHeight:notification];
    self.scrollViewHC.constant = [self appropriateScrollViewHeight];
    
    // ...
}
  • self.keyboardHCおよびself.scrollViewHC-[setupViewConstraints]内で設定された、それぞれの高さに対する制約だ。
  • -[appropriateKeyboardHeight:notification]-[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;
}
  • 引数で渡されるnotificationにはUIKeyboardWillShowNotificationなどが入る。これらの通知のuserInfoUIKeyboardFrameEndUserInfoKeyにはキーボードが表示された後のframeを表すCGRectが含まれる。
  • self.isMovingKeyboardはキーボードが閉じようといるとき、または開こうとしているときにYESとなるようだ。
  • self.isMovingKeyboardYESである場合、画面全体の高さからキーボードのorigin.yを引いた値をkeyboardHeightとしている。
  • self.isMovingKeyboardNOである場合、これからキーボードが表示されるかそれとも非表示になるかでまた分岐する。
    • 非表示になる場合は0。
    • 表示する場合、キーボードのframeのWindow座標系を変換してその高さを取得しkeyboardHeightとしている。キーボードの座標系はデバイスの向きを考慮していないため、Window座標系かView座標系に変換する必要がある。

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);
}
  • スクロールビューの高さは親Viewの高さからサブビューの高さを引いた余りとなっている。

SlackTextViewController.m:1060

willShowOrHideKeyboard:に戻る。

- (void)willShowOrHideKeyboard:(NSNotification *)notification
{
    // ...
    
    // Updates the height constraints' constants
    self.keyboardHC.constant = [self appropriateKeyboardHeight:notification];
    self.scrollViewHC.constant = [self appropriateScrollViewHeight];
    
    // ...
}
  • キーボードに関する通知によってキーボードの高さを計算し、それに合わせてスクロールビューの高さを調整していることがわかった。
  • ただ、constantに値を代入してもすぐに反映されるわけではないため、どこかで再描画をリクエストしているはずだ。キーボードが表示されたあとに呼ばれるdidShowOrHideKeyboard:を見る。

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;
}
  • -[reloadInputAccessoryViewIfNeeded]で後ほど出てくるtextView.inputAccessoryViewが初期化される。
  • -[updatekeyboarddismissmodeifneeded]を詳しく見る。

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];
    }
}
  • UIScrollViewkeyboardDismissModeというプロパティはiOS 7から登場したプロパティで、スクロールビューがドラッグされたときのキーボードの振る舞いを以下の値で指定できる。
    • UIScrollViewKeyboardDismissModeNone: ドラッグでキーボードを閉じない。デフォルト値。
    • UIScrollViewKeyboardDismissModeOnDrag: ドラッグが始まったときにキーボードを閉じる。
    • UIScrollViewKeyboardDismissModeInteractive: スクロールビューから下にドラッグするとキーボードを閉じ、途中で上にドラッグすると閉じるのをキャンセルできる。
  • UITextViewinputAccessoryViewはキーボードの上に出てくる、よく「次へ」とか「閉じる」のようなボタンを載せるViewのこと。キーボードが表示され-[didShowOrHideKeyboard:]が呼ばれる中で初期化されているため、isPannableYESとなっているはず。
  • よって、keyboardDismissModeUIScrollViewKeyboardDismissModeInteractiveに変更され、SLKInputAccessoryViewKeyboardFrameDidChangeNotificationという通知に対してdidChangeKeyboardFrameというメソッドが呼ばれるように登録される。
    • この通知はtextViewcenter(iOS8以降場合)またはframe(それ未満の場合)が変更されたときに送信される。
    • つまり、textViewの描画領域が変更されたときに、-[didChangeKeyboardFrame:]が呼ばれることになる。

SlackTextViewController.m:1150

- (void)didChangeKeyboardFrame:(NSNotification *)notification
{
    // ...
    
    self.keyboardHC.constant = [self appropriateKeyboardHeight:notification];
    self.scrollViewHC.constant = [self appropriateScrollViewHeight];
    
    // ...
    
    [self.view layoutIfNeeded];
}
  • 再び2つのHC(高さに対する制約)の値を更新している。
  • 最後に-[CALayer layoutIfNeeded]を呼んでいる。このメソッドは描画が必要な上位レイヤーが見つからなくなるまでツリーを遡り、描画が必要なレイヤー全体を描画する。この段階で制約に対する変更が反映されることになる。

ここまでのおさらい

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

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

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

  • textViewの中身のテキストの行数が変更された場合。textViewの中でテキストが改行されると、当然その高さが変わるのでそれに併せてスクロールビューの高さを小さくしなければならなくなる。そういった場合に対処する実装だと思う。
  • ユーザーによってtextViewの位置が変更された場合。scrollViewProxykeyboardDismissModeUIScrollViewKeyboardDismissModeInteractiveとなっているため、ユーザーの操作によってキーボードを閉じることができる。キーボードを閉じる動作をした場合、当然frameも変更されるのでこのタイミングでも-[didChangeKeyboardFrame:]が呼ばれることになる。

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

続いて、ユーザー名や絵文字の補完がどのように実装されているのか調べる。ドキュメントによると、補完機能を利用する場合は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];
}
  • textViewの選択範囲、つまりカーソル位置が変わったとき-[UITextViewDelegate textViewDidChangeSelection:]が呼ばれる。
  • この中で呼ばれる-[SlackTextViewController processTextForAutoCompletion]の中で、さらに呼ばれる-[SlackTextViewController handleProcessesWord:range:]を見る。

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];
}
  • -[UITableView reloadData]の前に-[SlackTextViewController canShowAutoCompletion]が呼ばれているので、ドキュメントの通り、このタイミングで補完候補を用意する必要がある。

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 heightForAutoCompletionView]を実装して自動補完Viewの高さを返すように実装しておくと、その値がautoCompletionViewHC.constantにセットされる。
  • 最後に-[UIView slk_animateLayoutIfNeededWithBounce:options:animations]によってアニメーションつきで再描画され、セットされた値が反映される。

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];
}
  • 引数のstringに選択された文字列が入っている。
  • -[UITextView slk_insertText:]で選択された文字列をtextViewに挿入しているようだ。

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;
}
  • -[NSRange length]0の場合、何も選択されていない状態なので、カーソルのある位置に足りない部分の文字列を挿入している。
  • -[NSRange length]1以上の場合、文字列が選択されている状態なので、補完されるテキストと置換する。

自分をコピーするbotを作る

自分のTwitterアカウントをコピーするbotを簡単に作れるmirror botというものを作りました。@naoty_botはこれを使って作りました。

コピーbotを作る手順

1. bot用のアカウントを作ります。

2. 人間とbotそれぞれのアカウントでTwitterアプリケーションを作ります。ここから作れます。そして、人間とbotの両方のアカウント用の「Consumer Key」「Consumer Secret」「Access Token」「Access Token Secret」を取得します(下のスクショのモザイクかかってるところです)。

f:id:naoty_k:20141110230321p:plain

f:id:naoty_k:20141110230752p:plain

f:id:naoty_k:20141110230803p:plain

追記: bot用のアプリケーションを作成する際、権限をRead and Writeにする必要があります。一度Read Onlyでアクセストークンを発行している場合は権限を変更した後もう一度発行しなおして、Herokuアプリケーションの環境変数を新しいアクセストークンに替えてください。

3. こちらのHerokuボタンを押します(別サイトに飛びます)。もしHerokuのアカウントがなければ作ってください。

Deploy

4. 適当なHerokuアプリ名を入れて、「Env」の各項目に2.で取得した「Consumer Key」「Consumer Secret」「Access Token」「Access Token Secret」を入力します。人間のアカウントのものは「HUMAN*」に、botのアカウントのものは「BOT*」に入れていきます。

f:id:naoty_k:20141110232038p:plain

5.「Deploy for Free」ボタンを押してしばらく待ちます。Herokuにアプリケーションがデプロイされます。その後、アプリのダッシュボード画面で以下のようにdynosを1xにすると、アプリケーションが起動します。

f:id:naoty_k:20141110232352g:plain

6.終わり。

コピーbotの機能

  • 過去のtweetからランダムに選んで投稿します。ランダムに選ぶとき、現在の時間帯を考慮します。だから、朝には朝っぽいことをtweetするはずです。
  • 一日のtweet数やどの時間帯にtweetされる傾向があるかを計算し、そのパターンに従います。例えば、一日にたくさんtweetする人のbotはたくさんtweetしますし、あなたが通勤時間と帰宅時間にtweetする傾向があると、botもその時間帯にtweetする確率が高いです。
  • 話しかけるとreplyを返します。replyは過去にあなたがその人に返したreplyからランダムに選ばれます。
  • あなたがfavりやすいtweetを学習し、favります。

技術的な話

tweetするタイミングの決定

人間のtweetはHeroku Postgresqlのfree planの上限である10000レコードまで保存されます(上限を超えると古い順に消します)。そのとき、一日の中で何分目に投稿されたかを同時に記録します。たとえば、01:00の投稿は60分目だし、02:00の投稿は120分目、23:59の投稿は1439分目となります。すると、投稿数が多い分と少ない分がわかります。なので、n分に投稿される確率=n分の投稿数/総投稿数を計算することができます。ここから0分から1359分までの確率分布を作ることができます。この確率分布を累積分布にすると実装が簡単になります。そして、0から1までの乱数を生成して累積分布上の重なる分数をbottweetする分数として決定します。これを一日にtweetする回数分行い、その日tweetする分を事前に決定しておきます。

以上のようなことを行っているのが./lib/mirror_bot/scheduler.rbというファイルです。

favりやすいtweetの学習と分類

人間は大量に流れてくるtweetの中から特定のtweetだけを選んでfavっています。この行動は大量のメールの中から迷惑メールだけをゴミ箱送りにする行動と似ています。つまり、大量のデータから特定のカテゴリーに含まれるものを識別する、という問題に一般化できると考えました。そこで、スパムフィルタリングと同じアルゴリズムで、大量のtweetから特定のtweetだけをfavoriteというカテゴリーに分類する実装をしました。

スパムフィルタリングの実装はごく普通のベイジアンフィルタです。簡単に言ってしまうと、favられたtweetに含まれやすい単語とか含まれにくい単語を調べていくということをしていきます。形態素解析にはokuraを使いました。とても便利でした。各単語の各カテゴリーに含まれた回数はRedis(redistogo)に保存しています。

以上のようなことを行っているのが./lib/mirror_bot/classifier.rbというファイルです。

事前学習とHerokuボタン

以上の2つのモジュール、schedulerとclassifierを機能させるには事前に多くのデータを学習させることが必要です。schedulerについては過去3,200件のtweet、classifierについては800件ずつのfavったtweetとfavってないtweetを学習させています。これを行っているのがtrainerです。trainerは./lib/mirror_bot/trainer.rbで定義されており、./bin/mirror_botスクリプトから実行します。

Herokuボタンからbotをデプロイする場合、デプロイして起動するまでにtrainerを実行する必要があります。Herokuボタンによるデプロイを設定するapp.jsonにはscriptsという項目があり、こうしたセットアップのための設定が可能です。以下のように指定するだけです。

{
    "scripts": {
        "postdeploy": "bundle exec sequel -m migrations $DATABASE_URL && bin/mirror_bot train scheduler && bin/mirror_bot train classifier"
    }
}

これでデプロイしてから起動する直前に事前学習を実行することができました。

なぜ作ったか

最近、機械学習とか自然言語処理に興味が出てきて勉強をしはじめたのですが、何か具体的な目標がほしいと思って「ちょっと賢いbot」を作ることにしました。いろいろ試行錯誤した結果、自分の行動パターンを学習してまねするbotを作ることにしました。

得られた知見

  • botっぽいアイコンはhttp://robohash.org/で生成できて便利。
  • botによる発言を自動生成させようと試行錯誤したけど、結局コピーにすることにしました。まず、マルコフ連鎖を使った文章生成はbotっぽいけど人間らしさはないので、今回は却下。次に、word2vecを使って文章に含まれる単語を類義語と入れ替えることで似たような文章の生成を試みました。word2vecをRubyから使う術がないので、別プロセスでPythonのgensimを使った類義語サーバーを立ててプロセス間通信でRubyに返すみたいな実装をしてみました。ですが、これだとそもそもHerokuの無料枠では不可能でした。さらに、word2vecで使われるモデルファイルが大きすぎてディスクにのっかりません。VPSで挑戦してみましたが、今度は1GBのメモリにのっかりきらずに動きませんでした。思ったほど意味の通じる文章を生成できるわけでもなかったので、この方法は諦めました。
  • herokuのオペレーションをスムーズに進めるときにhttps://github.com/ddollar/heroku-confighttps://github.com/ddollar/heroku-redis-cliが便利でした。

参考

pod installしたらgit cloneしてくれるヤツ書いた

ghqを使ったローカルリポジトリの統一的・効率的な管理について - delirious thoughtsを拝見して良さそうだったので、iOS開発にも持ち込むためCocoaPodsのプラグインを書いた。20行くらいしか書いてないし、ghqとの連携もまだ実装できてないけど、取り急ぎ。

naoty/cocoapods-src · GitHub

使い方

$ gem install cocoapods-src

cocoapodsは入っている前提で、cocoapods-srcをインストールする。

$ pod install

インストールすると、あとはpod installすれば勝手にpodsgit cloneしてくれる。今のところ~/.cocoapods/src/に以下のような感じでダウンロードされる。

$ tree ~/.cocoapods/src -I .git -L 2
.cocoapods/src
├── .DS_Store
├── AFNetworking
│   ├── .cocoadocs.yml
│   ├── .gitignore
│   ├── .travis.yml
│   ├── AFNetworking
│   ├── AFNetworking.podspec
│   ├── AFNetworking.xcworkspace
│   ├── CHANGES
│   ├── CONTRIBUTING.md
│   ├── Example
│   ├── LICENSE
│   ├── README.md
│   ├── Rakefile
│   ├── Tests
│   └── UIKit+AFNetworking

今後

  • ghq連携
  • git以外のVCSのサポート

追記(10/22)

0.2.0にアップデートして、ghqと連携できるようになった。

~/.podrc~/.cocoapods/.podrc./.podrcのいずれかに以下のような設定を書くとgit cloneの代わりにghqを使ってダウンロードする。

cocoapods-src_use_ghq: true

「集合知プログラミング」を読んでる

集合知プログラミング」という本を先週から読み始めた。この本は機械学習をテーマとしていて、現実にありそうな問題(例えば、映画の評点から似ているユーザーを推薦するとか、数ある旅行プランの組み合わせから最適なプランを選択するとか)を題材にさまざまなアルゴリズムチュートリアル形式で学んでいける。登場するサンプルコードはすべてPythonで書かれているため、まずこの本を読む前に軽くPythonについて勉強した。機械学習の本というと、むずかしい数式がたくさんでてきて近寄りがたいイメージがあるのだけど、この本についてはほとんど数式は出てこないので、カジュアルに読み進められる。

まだ半分も進んでないけど、その中で一番おもしろかったのが最適化アルゴリズムの話だった。ある最適な値を求めたいとき、「となりあう値と比較して良い方を選択する」というのを繰り返していくとどこか最適な値で落ち着くはずというアルゴリズムヒルクライム)があるのだけど、これだと局所最適に陥ってしまうということを最近勉強した。つまり、全体を見渡すともっと最適な値があるのだけど、近くの値とだけ比較しているとそれを見逃してしまうということだ。また、別のアルゴリズム(模擬アニーリング)は、試行回数が少ないうちは悪い結果を受け入れ、回数を経るにつれてその悪い結果を受け入れ難くしていくことで局所最適を回避する。

これはいろんなところで当てはまりそうな考え方だなと思った。見える範囲、理解できる範囲だけで最適な選択をとろうとするとより適切な解を見落としてしまう。若いうちは結果が悪かろうともそれを受け止めることで局所最適を回避し全体最適に近づくことができるのかもしれない。

この本を読もうと思った理由としては、いろんな領域と機械学習を組み合わせるとなんか面白いものが作れそうな気がしたから。これまで自分が作ってきたソフトウェアの中で自分自身気に入っているものの多くは別の領域のアイデアを持ち込むところから生まれている。だから、組み合わせの可能性が大きい領域を何か新しく学びたいと思ったときに機械学習というものが浮かんでてきてこの本から取り組んでみることにした。今は「iOS x 機械学習」みたいな掛け合わせで何か面白いものが作れないか考えている。

集合知プログラミング

集合知プログラミング

ストリームを利用したローパスフィルタの実装

f:id:naoty_k:20140930233211p:plain

このスクリーンショットに映された2つの線は共にiPhoneの加速度センサーの値を表しており、下の緑が加工していない生データ、上の青い線がローパスフィルタという仕組みで揺れを除去したデータだ。

以前の記事Swiftを使ったストリームの実装をしてみたのだけど、その使いどころを考えてみたところセンサーデータの加工にストリームという概念が適しているのではないかと思いついた。センサーから送られてくるデータは連続的で、その加工には複雑な計算を要するためだ。

そこで、加速度センサーをグラフに表示する簡単なアプリを作ってみて、生データとストリームを使って加工したデータを視覚的に表現してみることにした。その結果が上のスクリーンショットとなる。今回はローパスフィルタと呼ばれる手法を用いて生データを加工した。そちらの方面にはまるっきり分からないのだけど、以下のようなとてもシンプルなアルゴリズムでデータを加工できるとのことだったので利用した。

今回の加工したデータ = 前回の加工したデータ * 0.9 + 今回の生データ * 0.1

このローパスフィルタを以前開発したストリームライブラリで実装してみる。

var x: [CGFloat] = []
var filteredX: [CGFloat] = []

let xStream = Stream<CGFloat>()

まず、生データと加工したデータをグラフに描画するための配列と生データを扱うストリームを用意する。加速度センサーから値を取得する度にこのストリームに値を出力していく。

override func viewDidLoad() {
    // ...

    motionManager.startAccelerometerUpdatesToQueue(NSOperationQueue.currentQueue(), withHandler: accelerometerHandler)
}

private func accelerometerHandler(data: CMAccelerometerData!, error: NSError!) {
    xStream.publish(CGFloat(data.acceleration.x))
}

ストリームに渡された生データをグラフに描画するための配列に入れるため、値が出力されたときに実行される関数を登録しておく。これで生データが出力されたときはいつでもこの関数が実行される。

override func viewDidLoad() {
    // ...

    xStream.subscribe { [unowned self] message in self.x.append(message) }
}

続いて、上のストリームに出力された生データを加工して出力する別のストリームを作成する。これはscan関数を利用することで簡単に実現できる。scan関数は「前回出力された値と今回出力された値を使って、新たな値を出力するストリーム」を簡単に作成できる。なので、上で示したローパスフィルタのアルゴリズムを以下のように実装することができる。

override func viewDidLoad() {
    // ...

    xStream.subscribe { [unowned self] message in self.x.append(message) }

    let filteredStream: Stream<CGFloat> = xStream.scan(0) { previousMessage, message in
        return previousMessage * 0.9 + message * 0.1
    }
}

最後に、加工した値の出力を見張ってグラフ描画用の配列に追加するための関数を登録しておく。

override func viewDidLoad() {
    // ...

    xStream.subscribe { [unowned self] message in self.x.append(message) }

    let filteredStream: Stream<CGFloat> = xStream.scan(0) { previousMessage, message in
        return previousMessage * 0.9 + message * 0.1
    }.subscribe { [unowned self] message in
        self.filteredX.append(message)
        // ...
    }
}

このように、ストリームの性質やストリームを扱う様々な関数を利用すると、簡単にセンサーデータを扱うプログラムを実装することができた。アプリのソースコードgithubにアップしてあるので、参考にしてほしい。

マシなiOSアプリのフォームを実装・デザインする

普段iOSのフロント寄りの実装やデザインについて手が着けられていなかったけど、Xcode6の新機能のおかげでそっちも興味がでてきたので、ログインフォームを想定してiOSアプリのフォームの設計について本気出して考えてみた。

最もシンプルなフォーム

f:id:naoty_k:20140918011114p:plain

  • メールアドレス用のUITextField(以下emailField)、パスワード用のUITextField(以下passwordField)、そしてログインボタン用のUIButton(以下loginButton)の3つをStoryboardで配置した。
  • emailFieldはKeyboard TypeをE-mail Addressに、Return KeyをNextに設定した。passwordFieldはSecure Text EntryのチェックをオンにしReturn KeyをGoに設定した。

問題点

  • emailFieldでReturn Keyを押してもpasswordFieldが選択されないし、passwordFieldでReturn Keyを押してもsubmitされない。
  • コントロール部品以外をタップしたとき、キーボードが閉じない。端末サイズが小さい場合、キーボードによって他のコントロールや表示すべきViewが隠れたままになる可能性がある。
  • 追加した3つのViewが指の大きさに対して小さい。ユーザーは正確にタップするために注意を向ける必要があり、間違ったViewをタップしてしまう可能性がある。

改善1: Return Keyで適切なアクションを起こす

// ViewController.swift

@IBOutlet var emailField: UITextField?
@IBOutlet var passwordField: UITextField?
@IBOutlet var loginButton: UIButton?

@IBAction func login() {
    println("Login")
}

// MARK: - UITextFieldDelegate

func textFieldShouldReturn(textField: UITextField) -> Bool {
    if (textField == emailField) {
        passwordField?.becomeFirstResponder()
    } else {
        login()
    }

    return true
}
  • login()loginButtonが押された場合、またはpasswordFieldでReturn Keyが押された場合に実行される。今後、このメソッドにログイン処理を実装していく予定。
  • emailFieldpasswordFielddelegateをこのViewControllerに設定しtextFieldShouldReturn(textField:)を実装することで、2つのUITextFieldでReturn Keyが押されたときの処理を実装できる。
  • becomeFirstResponder()はレシーバーのViewを最初に応答するオブジェクトとして設定する。キーボードはこのFirst Responderに合わせてキーボードタイプや入力先を替える。

改善2: キーボードを閉じる

@IBAction func login() {
    resignFirstResponderAtControls()
    println("Login")
}

override func touchesBegan(touches: NSSet, withEvent event: UIEvent) {
    resignFirstResponderAtControls()
}

private func resignFirstResponderAtControls() {
    emailField?.resignFirstResponder()
    passwordField?.resignFirstResponder()
}
  • 非公開メソッドとしてresignFirstResponderAtControls()を定義した。これによって2つのUITextFieldの選択状態を外しキーボードを閉じることができる。resignFirstResponder()メソッドはレシーバーのViewをFirst Responderでなくす。これによってキーボードが閉じる。
  • これをlogin()touchesBegan(touches:withEvent:)で呼び出す。
  • UIViewControllerUIResponderを継承しておりself.viewのイベントハンドリングを扱うことができる。そのため、touchesBegan(touches:withEvent:)resignFirstResponderAtControls()を呼ぶことで、追加した3つのView以外を選択されたときにキーボードを閉じることができる。

改善3: タップしやすくする

「ヒューマンユーザーインターフェイスガイドライン」(以下HIG)にはこのような指針が載っている。

アプリケーション内のタップ可能な要素には、約44x44ポイントのターゲット領域を割り当てる。

これに従って「44x44ポイント以上」にサイズを変更する。

まず、UITextFieldは高さが30ポイントに固定されているため、高さ44ポイントのViewの上にUITextFieldを乗せてボーダーを非表示にし、その親ViewがタップされたらUITextFieldがFirst Responderになるようにする。実装としては、UITextFieldを含む高さ44ポイントのUIViewのサブクラスを用意する。

// TextFieldContainer.swift

@IBDesignable
class TextFieldContainer: UIView {
    @IBInspectable
    var borderWidth: CGFloat = 0 {
        didSet {
            layer.borderWidth = borderWidth
        }
    }

    override func touchesBegan(touches: NSSet, withEvent event: UIEvent) {
        subviews.first?.becomeFirstResponder()
    }
}

Xcode 6からの新機能であるLive Viewsを利用し、カスタムViewも可能な限りStoryboard上でそのプロパティを変更できるようにする。

  • @IBDesignableによってカスタムViewをStoryboard上でレンダリングして、その見た目をStoryboardからも確認できるようになる。
  • @IBInspectableによって下のスクリーンショットのようにカスタムViewのborderWidthというプロパティをStoryboardから変更できるようになる。

f:id:naoty_k:20140918011350p:plain

次に、UIButtonもHIGの方針に従って修正する。UIButtonはサイズを自由に変更できるので、とりあえず44x44ポイントに変更した。ボタンの大きさは変更したものの、ボタンの「Login」というテキストはまだ小さいため、ユーザーの目からはサイズが大きくなったようには見えていない。そこで、ボタンにもボーダーをつけてみる。

// BorderedButton.swift

@IBDesignable
class BorderedButton: UIButton {
    @IBInspectable
    var borderWidth: CGFloat = 0 {
        didSet {
            layer.borderWidth = borderWidth
        }
    }
}

TextFieldContainerと同じようにstoryboardから枠線の幅を変えられるようにした。

マシなフォーム

以上の変更を行った結果このようになった。

f:id:naoty_k:20140918011412p:plain

間違いなくタップはしやすくなった。

問題点

  • フラットデザインに則っていない。標準のアプリや人気の高いアプリはiOS 7から導入されたフラットデザインに沿ってデザインされており、同様なインターフェイスをもたなければユーザーは慣れ親しんだ動作で直感的にアプリを操作できなくなってしまう。

改善4: フラットデザインに従う

HIGではフラットデザインの基本的な設計方針として以下の3つを挙げている。

  • 控えめであること
  • 明瞭であること
  • 奥行きを与えること

具体的な作業として

  • 画面全体を使う
  • 枠線を使わない
  • 余白を十分にとる

を意識してStoryboardを編集した。Auto Layoutで各Viewの余白を固定したり、枠線の太さを0ポイントにした。その結果、以下のようになった。

f:id:naoty_k:20140918011429p:plain

フラットデザインに対するよくある批判として「ボタンがどこにあるのか認識しにくい」というものがある。"Email"や"Login"といった文字がある部分にしかViewがないように見えてしまうため、Viewの領域を表す枠線や背景色を控えめに加えた方がもっとよくなると考えた。そこで、2つのUITextFieldの領域を控えめに表すため、領域の下辺だけ枠線を表示してみる。

// TextFieldContainer.swift

@IBDesignable
class TextFieldContainer: UIView {
    private var width: CGFloat {
        return CGRectGetWidth(frame)
    }
    private var height: CGFloat {
        return CGRectGetHeight(frame)
    }
    private let borderBottom: CALayer = CALayer()

    @IBInspectable
    var borderColor: UIColor = UIColor.blackColor() {
        didSet {
            setupBorderBottom()
        }
    }

    @IBInspectable
    var borderBottomWidth: CGFloat = 0 {
        didSet {
            setupBorderBottom()
        }
    }

    override func touchesBegan(touches: NSSet, withEvent event: UIEvent) {
        subviews.first?.becomeFirstResponder()
    }

    private func setupBorderBottom() {
        borderBottom.removeFromSuperlayer()
        borderBottom.frame = CGRectMake(0, height - borderBottomWidth, width, borderBottomWidth)
        borderBottom.backgroundColor = borderColor.CGColor
        layer.addSublayer(borderBottom)
    }
}
  • 以前の実装にあったborderWidthを削除し、下辺の枠線の太さを表すborderBottomWidthと枠線の色を表すborderColorを追加した。これらのプロパティがXcodeから変更されるたびにsetupBorderBottom()が呼び出されてborderが追加される。
  • 下辺だけの枠線は枠線の幅を高さとするCALayerとして実装した。
  • ショートカットのためwidthheightというcomputed propertyを用意した。

そして、Storyboardから枠線の色と幅を設定し余白を調整すると以下のようになった。

f:id:naoty_k:20140918011441p:plain

まとめ

最初と最後を比べると少しはマシなフォームになったと思う。改善したポイントをまとめると以下のようになる。

  • Return Keyで適切なアクションを起こす
  • キーボードを閉じる
  • タップしやすくする
  • フラットデザインに従う

現実の開発では、アプリごとのテーマに合わせた色やタイポグラフィを使うことになるだろうし、フォームのエラーメッセージの扱いについても触れられていない。残された課題については、経験を積む中で考えていくことにしたい。

最後に上で載せたコードを含んだプロジェクトをGitHubに公開したので参考にしてほしい。

naoty/BetterFormApp · GitHub