Pinterest風や標準写真アプリのようなUIViewControllerのカスタム遷移をステップ・バイ・ステップで実装を説明する

More than 1 year has passed since last update.


概要

下のようなカスタム遷移を実装を段階的に説明しながら、最終的に画像をタップしたら詳細画面へ遷移するサンプルを作ります。


  • Pinterestの写真をタップして次への遷移

  • 標準写真アプリの画像選択して全画面になる遷移


ゴール

v1.0.6.gif

今回使ったコードはGitHubにアップしてあります。

https://github.com/mothule/ResearchViewControllerTransition


実装の流れ


  1. カスタム遷移の最小コード

  2. フェードインで遷移させる

  3. 適当サイズの適当画像を適当位置に移動する遷移

  4. 遷移前の表示された画像を適当な位置に移動する遷移

  5. 遷移前の表示された画像を遷移後の画像位置に移動する遷移

  6. テーブルに並んだ画像をタップしたら詳細画面へ遷移する

  7. 画像の移動にバネを入れる


注意


  • 遷移先から遷移元に戻る処理は、ただ反対に処理するだけなので今回は対象外とします。

  • エラーハンドリングは意識していないです

  • 設計は意識していないです

  • 理解を優先するため細かい部分の対応コードは入っていない


カスタム遷移の最小コード

遷移処理を一切せず、1フレームで次画面へ遷移します。

v1.0.0.gif


ファイル一覧


  • FirstViewController.swift

  • SecondViewController.swift

  • ViewControllerTransition.swift


遷移元の画面


  • MyTransitionDelegate class を保持している

  • ボタンタップで次画面を呼んでいる

  • 次画面を呼ぶ際にtransitioningDelegateに MyTransitionDelegate セットしている


FirstViewController.swift

class FirstViewController: UIViewController {

var myTransition = MyTransitionDelegate()
@IBAction func onButton(sender: AnyObject) {
let vc = UIStoryboard(name: "Main",bundle: nil).instantiateViewControllerWithIdentifier("SecondViewController")
as! SecondViewController
vc.transitioningDelegate = myTransition
presentViewController(vc, animated: true, completion: nil)
}
}


遷移先の画面


  • ボタンタップで、画面閉じてる


SecondViewController.swift

class SecondViewController: UIViewController {

@IBAction func onButton(sender: AnyObject) {
self.dismissViewControllerAnimated(true, completion: nil)
}
}


カスタム遷移関係

下記箇条書きは下のコードと照らし合わせながら読んでください。


  • MyTransitionDelegate は FirstViewControllerが持っている(上記参照)

  • MyTransitionDelegate は UIViewControllerTransitioningDelegate protocolを採用している

  • UIViewControllerTransitioningDelegate protocolは表示用と非表示用のメソッドの実装が必要

  • 非表示メソッドでは MyDismissedAnimater のインスタンスを返している

  • 表示メソッドでは MyPresentedAnimater のインスタンスを返している

  • MyDismissedAnimater と MyPresentedAnimater は UIViewControllerAnimatedTransitioning protocolを採用している

  • UIViewControllerAnimatedTransitioning は アニメーション遷移メソッドの実装が必要

  • MyPresentedAnimater のアニメーション遷移メソッドは、次画面をビューにセットして、アニメ完了イベントを通知してるだけ

  • MyDismissedAnimater のアニメーション遷移メソッドは、アニメ完了イベントを通知してるだけ


ViewControllerTransition.swift

class MyTransitionDelegate : NSObject, UIViewControllerTransitioningDelegate {

func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning?
{
return MyPresentedAnimater()
}
func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning?
{
return MyDismissedAnimater()
}
}

class MyPresentedAnimater : NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval
{
return 1.0
}
// This method can only be a nop if the transition is interactive and not a percentDriven interactive transition.
func animateTransition(transitionContext: UIViewControllerContextTransitioning)
{
guard let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) else { return }
guard let container = transitionContext.containerView() else { return }
container.addSubview(toVC.view)
transitionContext.completeTransition(true)
}
}

class MyDismissedAnimater : NSObject, UIViewControllerAnimatedTransitioning {
func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval
{
return 1.0
}
// This method can only be a nop if the transition is interactive and not a percentDriven interactive transition.
func animateTransition(transitionContext: UIViewControllerContextTransitioning)
{
transitionContext.completeTransition(true)
}
}



フェードインで遷移させる

さきほどの最小コードに下記のように変更することで、フェードインで遷移させます。

v1.0.1.gif


ViewControllerTransition.swift

// This method can only  be a nop if the transition is interactive and not a percentDriven interactive transition.

func animateTransition(transitionContext: UIViewControllerContextTransitioning)
{
guard let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) else { return }
guard let container = transitionContext.containerView() else { return }
container.addSubview(toVC.view)

toVC.view.alpha = 0.0
UIView.animateWithDuration(transitionDuration(transitionContext), animations: { toVC.view.alpha = 1.0 }, completion: {_ in transitionContext.completeTransition(true) })
}


変更が加えられたのは、

toVC.view.alpha = 0.0

UIView.animateWithDuration(transitionDuration(transitionContext), animations: { toVC.view.alpha = 1.0 }, completion: {_ in transitionContext.completeTransition(true) })

の部分になります。アニメーションでアルファ値を0.0から1.0に変化させています。

アニメーション完了後に、遷移完了イベントを呼んでいます。


適当サイズの適当画像を適当位置に移動する遷移

では次にUIImageViewを作成し、アニメーションを使って動かしましょう。

- UIImageViewのframeは?

- UIImageは?

- アニメーションの内容は?

てきとーです。

v1.0.2.gif


ViewControllerTransition.swift

// This method can only  be a nop if the transition is interactive and not a percentDriven interactive transition.

func animateTransition(transitionContext: UIViewControllerContextTransitioning)
{
guard let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) else { return }
guard let container = transitionContext.containerView() else { return }

let imageView = UIImageView(image: UIImage(named: "hoge")) // hoge はプロジェクトに登録済み.
imageView.frame = CGRectZero
container.addSubview(toVC.view)
container.addSubview(imageView)
toVC.view.alpha = 0.0
UIView.animateWithDuration(transitionDuration(transitionContext), animations: {
toVC.view.alpha = 1.0
imageView.frame = CGRectMake(0, 350, 300, 200)
}, completion: {_ in transitionContext.completeTransition(true)
})
}



  • UIImageViewのframeは?


    • CGRectZeroです



  • UIImageは?


    • 近所の中華料理屋の四川麻婆豆腐です。



  • アニメーションの内容は?


    • 真ん中あたりです。



そう、てきとーです。


遷移前の表示された画像を適当な位置に移動する遷移

さっきのは、ボタンを押したらどこからともなく、画像が移動してきました。

今度は、見えている画像の位置、画像から、適当な場所に移動させます。

v1.0.3.gif


ViewControllerTransition.swift

// This method can only  be a nop if the transition is interactive and not a percentDriven interactive transition.

func animateTransition(transitionContext: UIViewControllerContextTransitioning)
{
guard let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) else { return }
guard let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey) else { return }
guard let container = transitionContext.containerView() else { return }

let imageView = (fromVC as! FirstViewController).copyImageView()
container.addSubview(toVC.view)
container.addSubview(imageView)
toVC.view.alpha = 0.0
UIView.animateWithDuration(transitionDuration(transitionContext), animations: {
toVC.view.alpha = 1.0
imageView.frame = CGRectMake(0, 250, 300, 200)
}, completion: {_ in transitionContext.completeTransition(true)
})
}


UIImageViewを適当に作らず、遷移前のUIImageViewを使います。


FirstViewController.swift

class FirstViewController: UIViewController {

func copyImageView() -> UIImageView {
let image = UIImageView(image: imageView.image)
image.frame = imageView.frame
image.contentMode = imageView.contentMode
return image
}
}

遷移前のUIImageViewのコピーを受け取れるようにメソッドを用意します。


遷移前の表示された画像を遷移後の画像位置に移動する遷移

今度は画像の移動先をちゃんとします。

遷移後のUIImageViewの位置へ移動させます。

v1.0.4.gif

遷移後のUIImamgeViewは次のように配置されています。

v1.0.4_view.png


ViewControllerTransition.swift

// This method can only  be a nop if the transition is interactive and not a percentDriven interactive transition.

func animateTransition(transitionContext: UIViewControllerContextTransitioning)
{
guard let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) else { return }
guard let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey) else { return }
guard let container = transitionContext.containerView() else { return }

let imageView = (fromVC as! FirstViewController).copyImageView()
let destImageViewRect = (toVC as! SecondViewController).imageViewRect()
container.addSubview(toVC.view)
container.addSubview(imageView)
toVC.view.alpha = 0.0
UIView.animateWithDuration(transitionDuration(transitionContext), animations: {
toVC.view.alpha = 1.0
imageView.frame = destImageViewRect
}, completion: {_ in transitionContext.completeTransition(true)
})
}


移動先を適当に決めず、遷移後のUIImageViewの位置へ移動させます


SecondViewController.swift

class SecondViewController: UIViewController {

func imageViewRect() -> CGRect{
return imageView.frame
}
}

遷移後のUIImageViewの位置が受け取れるようにメソッドを用意します。


テーブルに並んだ画像をタップしたら詳細画面へ遷移する

前回の変更までで、遷移前の画像が、遷移後の画像の位置に移動するようになりました。

では次は、今度は少し変わって、テーブルに表示された画像を、セルを選ぶことで、セル内の画像位置から遷移後の画像位置へ移動させます。

v1.0.5.gif


FirstViewController.swift

class FirstViewController: UIViewController {

@IBOutlet weak var tableView: UITableView!
var myTransition = MyTransitionDelegate()

let imageQuantity:Int = 9
var imageView:UIImageView!

@IBAction func onButton(sender: AnyObject) {
}

func presentSecondView(){
let vc = UIStoryboard(name: "Main",bundle: nil).instantiateViewControllerWithIdentifier("SecondViewController")
as! SecondViewController
vc.transitioningDelegate = myTransition
presentViewController(vc, animated: true, completion: nil)
}

func copyImageView() -> UIImageView {
let image = UIImageView(image: imageView.image)
image.frame = imageView.convertRect(imageView.bounds, toView: self.view)
image.contentMode = imageView.contentMode
return image
}
}

extension FirstViewController : UITableViewDataSource{
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return imageQuantity
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
var cell = tableView.dequeueReusableCellWithIdentifier("cell")
if cell == nil {
cell = UITableViewCell(style: .Default, reuseIdentifier: "cell")
}
cell!.imageView!.contentMode = .ScaleAspectFill
cell!.imageView!.image = UIImage(named:"hoge\(indexPath.row)")
cell!.textLabel?.text = "hogehoge"
return cell!
}
}
extension FirstViewController : UITableViewDelegate{
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
tableView.deselectRowAtIndexPath(indexPath, animated: true)
let cell = tableView.cellForRowAtIndexPath(indexPath)
imageView = cell?.imageView
presentSecondView()
}
}


セルの文言は適当です。

画像の管理方法も適当です。

ここで重要なのは、

image.frame = imageView.convertRect(imageView.bounds, toView: self.view)

になります。

これはimageView.frameを、親Viewからの相対位置ではなく、 self.view からの相対位置に変換する処理です。


画像の移動にバネを入れる

最後の調整です。写真拡大時に少し遊びを入れます

v1.0.6.gif


ViewControllerTransition.swift

// This method can only  be a nop if the transition is interactive and not a percentDriven interactive transition.

func animateTransition(transitionContext: UIViewControllerContextTransitioning)
{
guard let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) else { return }
guard let fromVC = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey) else { return }
guard let container = transitionContext.containerView() else { return }

let imageView = (fromVC as! FirstViewController).copyImageView()
let destImageViewRect = (toVC as! SecondViewController).imageViewRect()
container.addSubview(toVC.view)
container.addSubview(imageView)
toVC.view.alpha = 0.0
UIView.animateWithDuration(transitionDuration(transitionContext), delay: 0.01, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.1, options: [], animations: {
toVC.view.alpha = 1.0
imageView.frame = destImageViewRect
}, completion: {_ in transitionContext.completeTransition(true)
})
}


UIView.animateWithDuration が バネ付きに変わっただけです。


まとめ

以上になります。

だいぶ把握できたのではないでしょうか?

一度に見ると複雑なものでも、動きを一つ一つシンプルなものに分解することで、容易に流れや処理をつかめれるようになります。

これは新人が陥りがちな、「物事を難しく考えすぎて、回りくどいことをしてしまい、かえって難しくしている」原因に対する解の一つになります。

このやるべきことを分解するスキルは、エンジニアである以上、一生関わっていくので、覚えておいて損はないと思います。

また、やるべきことのコアとなる機能の最小単位を知ることも、理解への近道です。