[iOS]Twitterっぽい画像の閉じ方を実装してみる

## はじめに
メモと脳内整理がてら。

今のTwitter公式クライアントで写真を表示すると、下か上にスライドすることで表示を閉じることができますよね。
昨今は端末サイズも大きくなってきて、UX的にもいいなあ…と思っていたので、折角なのでやったことない**ViewControllerのTransitionをカスタム**しての実現を目指してみました。
※でも実際のものよりはちょっと劣化版です…

下記記事を参考に進めさせて頂きました。
[Custom Transitions Using View Controllers](http://qiita.com/335g/items/94762b06a66cfb3be6ad)


## やること概要
* 遷移のカスタムアニメーションを作成する
    * 上／下にスワイプして閉じる
* 遷移のアニメーションの進行度合いを制御できるようにする
    * 下にあるViewがスライドするほどうっすら見えてくる

画像はUIScrollViewの中心に幅一杯のUIImageViewが乗っている状態で、ViewControllerをmodal表示する前提です。
上下にスワイプする際、UIScrollViewのオーバースクロールは今回利用しないことにしたのでUIScrollViewのBounce Verticallyを無効化しておきます。
![setting.png](https://qiita-image-store.s3.amazonaws.com/0/71259/05b1c956-e238-7608-ef13-adc19085ded7.png)

## 遷移のカスタムアニメーションの作成
ドラッグ中に少しずつ透明度が変化する動作はひとまず考えず、ViewController上の画像がスライドしていきながら遷移するアニメーションを作ります。

```title:ImageSlideOutTransition.h
@interface ImageSlideOutTransition : NSObject <UIViewControllerAnimatedTransitioning>

@property (assign, nonatomic) BOOL isScrolledUp; // 上下どちらにドラッグしているか

@end
```

```title:ImageSlideOutTransition.m
@implementation ImageSlideOutTransition

// 遷移アニメーションが何秒で完了するか
- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext {
    return 0.75f;
}

// 遷移元・先のViewControllerの挙動を定義
- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext {
    // 遷移元は画像を持っているViewController
    HasImageScrollViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];

    // 遷移先のViewControllerが遷移元のViewContollerの下に現れるようにする
    UIView *containerView = [transitionContext containerView];    
    [containerView insertSubview:toVC.view belowSubview:fromVC.view];

    // アニメーションの定義    
    [UIView animateWithDuration:[self transitionDuration:transitionContext]
                          delay:0.0
                        options:UIViewAnimationOptionCurveLinear
                     animations:^{
                         CGRect fromVCImageViewRect = fromVC.imageView.frame;

                         // ドラッグの方向で、どちらに画像がスライドするのか決定
                         if (self.isScrolledUp) {
                             fromVCImageViewRect.origin.y = -fromVC.view.frame.size.height;
                         } else {
                             fromVCImageViewRect.origin.y =  fromVC.view.frame.size.height;
                         }
                         fromVC.imageView.frame = fromVCImageViewRect;
                         
                         // 遷移元のViewControllerを薄く、遷移先のViewControolerをはっきり表示
                         fromVC.view.alpha = 0.0;
                         toVC.view.alpha = 1.0;
                     }
                     completion:^(BOOL finished){
                         // 遷移が完了したかどうかをtransitionContextに通知
                         BOOL isCompleted = ! [transitionContext transitionWasCancelled];
                         [transitionContext completeTransition:isCompleted];
                     }];
}

@end
```

とりあえず、このAnimationオブジェクトをmodal表示しているViewControllerに設定するだけでも、dismissViewControllerAnimatedした際には画面がスライドするようになります。

```
// UIViewControllerTransitioningDelegateを実装
@interface HasImageScrollViewController()<UIViewControllerTransitioningDelegate>
...

@implementation HasImageScrollViewController
-(void)viewDidLoad {
    [super viewDidLoad];
    
    self.transitioningDelegate = self; // Transitionを自身で制御する
...

// dismissした際のanimationをハック
-(id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed {
    return  [ImageSlideOutTransition new];
}
...
```

次節からドラッグ中に少しづつ遷移が進行するようにしていきます。

## InteractiveなTransitionの制御
**UIPercentDrivenInteractiveTransition**を使い、遷移を制御します。

UIViewControllerTransitioningDelegateの下記のメソッドをoverrideしてこのオブジェクトを返すようにします。UIPercentDrivenInteractiveTransitionオブジェクトは、遷移が今どれくらい完了しているかを**percentComplete**として保持・更新できます。
後に解放する必要があるのでpropertyで保持しておきます。

```
-(id<UIViewControllerInteractiveTransitioning>)interactionControllerForDismissal:(id<UIViewControllerAnimatedTransitioning>)animator {
    return self.interactiveTransitionController;
}
```

実際の使い方としては、UIPercentDrivenInteractiveTransitionオブジェクトを生成した後にviewControllerのdismissViewControllerAnimatedを開始させます。
こうすると即座には遷移が処理されず、UIPercentDrivenInteractiveTransitionオブジェクトのpercentCompleteを参照して、アニメーションの完了度合いを合わせて変化させてくれます。

```title:interactiveな遷移の開始
self.interactiveTransitionController = [UIPercentDrivenInteractiveTransition new];
// この時点では遷移は実行されない
[self dismissViewControllerAnimated:YES completion:nil];
...
// 更新することでアニメーションが進んだり戻ったりする
[self.interactiveTransitionController updateInteractiveTransition:transitionProgress];
```

遷移を完全に実行に移す場合、あるいはキャンセルする場合は、以下のメソッドを呼びます。

```title:遷移の実行
[self.interactiveTransitionController finishInteractiveTransition];
```

```title:遷移のキャンセル
[self.interactiveTransitionController cancelInteractiveTransition];
```

あとはこれらを組み合わせて、スワイプすると遷移が開始されある程度引っ張ったら実行／引っ張りが足りなければキャンセル、というように処理を記述します。

## UIPanGestureRecognizerのUIScrollViewへの追加
スワイプ中の処理を書く前に、スワイプに対し反応するUIPanGestureRecognizerをUISCrollViewに追加します。

```
UIPanGestureRecognizer *myPanGesture = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(pan:)];
[myPanGesture setDelegate:self];
[scrollView addGestureRecognizer:self.myPanGesture];
```

UIScrollViewにはもともとUIPanGestureが登録されており、このままだと片方しかジェスチャは有効化されません。
そのため、作成したジェスチャのdelegateを自身に設定し、全てのジェスチャを認識させます。
（ただしスワイプ中にピンチジェスチャされると座標が一気に移動したりするのでこれは同時に動かないようにします。）

```
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
    NSLog(@"%@, %@", gestureRecognizer, otherGestureRecognizer);
    if ([otherGestureRecognizer isKindOfClass:[UIPinchGestureRecognizer class]]) {
        return NO;
    } else {
        return YES;
    }
}
```

## スワイプジェスチャ時の処理
満たしたい動作は以下です。

* 一定距離引っ張って離すとmodalを閉じる
* フリックの容量で勢いをつけて離してもmodalを閉じる
* ズーム中は上下にスワイプしても画像は閉じない
* 引っ張っている間は左右方向のスワイプは無効

ざっくりこんな感じになりました。（長い…）

```
- (IBAction)pan:(UIPanGestureRecognizer *)sender {
    if (self.scrollView.zoomScale > 1.f || self.scrollView.contentOffset.x != 0) return;
    
    switch (sender.state) {
        case UIGestureRecognizerStateBegan:
            // ジェスチャの開始。遷移を開始・上下方向を決定する。
            self.isTransitioning = YES;
            [self.scrollView setScrollEnabled:NO];
            
            self.interactiveTransitionController = [UIPercentDrivenInteractiveTransition new];
            if ([sender translationInView:self.scrollView].y > 0) {
                self.mytransition.isScrolledUp = NO;
            } else {
                self.mytransition.isScrolledUp = YES;
            }
            [self dismissViewControllerAnimated:YES completion:nil];
            
            float transitionProgress = [sender translationInView:self.scrollView].y / self.view.frame.size.height;
            [self.interactiveTransitionController updateInteractiveTransition:fabs(transitionProgress)];
            break;
        case UIGestureRecognizerStateCancelled:
            // ジェスチャのキャンセル。遷移をキャンセルする
            self.isTransitioning = NO;
            [self.scrollView setScrollEnabled:YES];

            [self.interactiveTransitionController cancelInteractiveTransition];
            self.interactiveTransitionController = nil;
            break;
        case UIGestureRecognizerStateChanged:
            // ジェスチャ中。遷移の進行度合いを更新する
            if (self.isTransitioning) {
                // 方向が変更される場合、一旦キャンセルして初期化し直す
                if (self.mytransition.isScrolledUp && [sender translationInView:self.scrollView].y > 0) {
                    [self.interactiveTransitionController cancelInteractiveTransition];
                    self.interactiveTransitionController = [UIPercentDrivenInteractiveTransition new];
                    self.mytransition.isScrolledUp = NO;
                    
                    [self dismissViewControllerAnimated:YES completion:nil];
                } else if (!self.mytransition.isScrolledUp && [sender translationInView:self.scrollView].y < 0){
                    [self.interactiveTransitionController cancelInteractiveTransition];
                    self.interactiveTransitionController = [UIPercentDrivenInteractiveTransition new];
                    self.mytransition.isScrolledUp = YES;

                    [self dismissViewControllerAnimated:YES completion:nil];
                }
                
                float transitionProgress = [sender translationInView:self.scrollView].y / self.view.frame.size.height;
                
                [self.interactiveTransitionController updateInteractiveTransition:fabs(transitionProgress)];
            }
            break;
        case UIGestureRecognizerStateEnded:
            // ジェスチャの正常完了。遷移を実行させるかキャンセルする。
            self.isTransitioning = NO;
            [self.scrollView setScrollEnabled:YES];
            
            // 進行度0.5以上なら遷移実行へ
            if (self.interactiveTransitionController.percentComplete >= 0.5f){
                [self.interactiveTransitionController finishInteractiveTransition];
                self.interactiveTransitionController = nil;
                break;
            }
            
            // スワイプの勢いが一定以上なら遷移実行へ
            CGFloat velocityinView = [sender velocityInView:self.scrollView].y;
            if (fabs(velocityinView) > 1000) {
                if (self.mytransition.isScrolledUp && velocityinView < 0) {
                    [self.interactiveTransitionController finishInteractiveTransition];
                    self.interactiveTransitionController = nil;
                } else if (!self.mytransition.isScrolledUp && velocityinView > 0){
                    [self.interactiveTransitionController finishInteractiveTransition];
                    self.interactiveTransitionController = nil;
                } else {
                    [self.interactiveTransitionController cancelInteractiveTransition];
                    self.interactiveTransitionController = nil;
                }
            } else {
                [self.interactiveTransitionController cancelInteractiveTransition];
                self.interactiveTransitionController = nil;
            }
            break;
        case UIGestureRecognizerStateFailed:
            // ジェスチャの失敗。遷移をキャンセルする。
            self.isTransitioning = NO;
            [self.scrollView setScrollEnabled:YES];
            
            [self.interactiveTransitionController cancelInteractiveTransition];
            self.interactiveTransitionController = nil;
            break;
        default:
            break;
    }
}
```

## おわりに
挙動がちょっとひっかかる感じもあり、完全再現とは行きませんでしたが、
見てくれはTwitterみたいに画像をスライドして閉じるようにできました。
今回のでTransitionをいじるやり方がちょっとわかったので、これから色々試してみようと思います。