画面遷移のアニメーションをカスタムする

UINavigationController を使った画面遷移のアニメーションのデフォルトは、横からスライドするアニメーションですが、これをカスタムすることができます。

最近、アニメーションをカスタムしたりといったようにカスタムトランジションについて学んでみたので、実装方法を書いてみたいと思います。

登場人物

アニメーションを管理するので少しコード量が多くなってしまって複雑に感じるかと思うのですが、登場するクラスとプロトコルに関しては以下の3つだけです。

  • UINavigationController (クラス)
  • UINavigationControllerDelegate (プロトロル)
  • UIViewControllerAnimatedTransitioning (プロトコル)

カスタムトランジションのざっくりとした流れとしては、以下の通りです。

  1. 画面遷移のメソッドが呼ばれる (UINavigationController)
  2. 画面遷移のアニメーションを決定する (UINavigationControllerDelegate)
  3. アニメーションをする (UIViewControllerAnimatedTransitioning)

UIViewControllerAnimatedTransitioning

画面遷移時に行うアニメーションを定義するためのプロトコルです。
アニメーションをカスタムするためには、このプロトコルに準拠したオブジェクトを用意する必要があります。カスタムアニメーションに必要な最低限実装すべきメソッドは以下の2つです。

以下、上記メソッドの引数名は省略します。

transitionDuration

アニメーションにかける時間を定義するためのメソッドです。

animateTransition

遷移時に行うアニメーションを定義するためのメソッドです。このメソッド内でどういったアニメーションを行うのかを実装していきます。

アニメーションは、 このメソッドの引数として渡ってくる UIViewControllerContextTransitioning を用いて実装していくことになります。 このクラスが、遷移前の View 、遷移後の View、アニメーションを描写するための View を持っているので、これらを取り出してアニメーションを組み立てていきます。

なお、プッシュ時、ポップ時と共にこちらのメソッドが呼ばれるため、内部で分岐してアニメーションを切り替える必要があります。
しかし、このメソッドの引数として渡ってくる UIViewControllerContextTransitioning にはプッシュ、ポップを判別するための API が用意されていないため、自前で用意する必要があります。

UINavigationControllerDelegate

この Protocol にカスタムトランジションの様々な設定を行うためのメソッドが用意されています。 今回はアニメーションを変更するだけなので、以下のメソッドを実装します。

このメソッドの返り値として、 UIViewControllerAnimatedTransitioning に準拠したオブジェクトを返すことで、画面遷移時にカスタムしたアニメーションが行われるようになります。

nil を返すと、横からスライドするデフォルトのアニメーションとなります。

UINavigationController

わざわざ書くまでもないかと思いますが、このクラスによって画面遷移を行います。 上記の UINavigationControllerDelegate に準拠したオブジェクトを UINavigationController オブジェクトの delegate プロパティにセットすることで、画面遷移時に UINavigationControllerDelegate の各メソッドが呼ばれるようになります。

具体的な実装

UIViewControllerAnimatedTransitioning

このプロトコルに準拠したクラスを作ります。
UIViewControllerAnimatedTransitioning が NSObjectProtocol を継承しているので、作成するクラスも NSObject を継承させる必要があります。

class CustomAnimatedTransitioning: NSObject, UIViewControllerAnimatedTransitioning {

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        // ...
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        // ...
    }

}

transitionDuration

アニメーションにかける、良い感じの値を返すようにするだけです。

func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
    return 0.3
}

animateTransition

先にコードを書いてしまうと、プッシュの場合は以下のようになります。

// (1)
guard let sourceView = transitionContext.view(forKey: .from),
    let destinationView = transitionContext.view(forKey: .to) else {
        // アニメーション終了の通知
        transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        
        return
}

// (2)
let containerView = transitionContext.containerView
containerView.addSubview(sourceView)
containerView.insertSubview(destinationView, aboveSubview: sourceView)

// (3)
UIView.animate(
    withDuration: transitionDuration(using: transitionContext),
    animations: {
        // プッシュのカスタムアニメーション処理
    },
    completion: { _ in
        // アニメーション終了の通知
        transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
    }
)

(1) はじめに、引数の transitionContext からアニメーションに必要なオブジェクトを取り出します。
transitionContext.view(forKey: .from) で遷移元の画面の View、 transitionContext.view(forKey: .to) で遷移元の画面の View が取得できます。
これらのメソッドの返り値は Optional なので、取得できなかった場合は return していますが、このとき transitionContext.completeTransition(!transitionContext.transitionWasCancelled) によって画面遷移が終了したことを通知します。
このメソッドの引数には画面遷移が正常に終了したかどうかを Bool で渡します。
transitionContext.transitionWasCancelled で画面遷移がキャンセルされたかどうかを知ることができるので、今回はこの結果を渡しています。

(2) 続いて、transitionContext.containerView により、 transitionContext からアニメーションを描画するためのキャンバスとなる View を取り出します。
そして、この View に先ほど取り出しておいた View 達を addSubviewinsertSubview で追加していきます。 これでアニメーションさせるための前準備は終わりです。

(3) 最後に、画面遷移時に行いたいアニメーション処理を実行します。
アニメーションが終えたら、画面遷移が終了となるので、これを通知するために transitionContext.completeTransition(!transitionContext.transitionWasCancelled) を実行します (試してはいませんが、このタイミングでは引数を true に固定してしまっても良いのかもしれません) 。

ポップの場合の処理も追加すると以下のようになるかと思います。

if プッシュなら {
    guard let sourceView = transitionContext.view(forKey: .from),
        let destinationView = transitionContext.view(forKey: .to) else {
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
            
            return
    }
    
    let containerView = transitionContext.containerView
    containerView.addSubview(sourceView)
    containerView.insertSubview(destinationView, aboveSubview: sourceView)
        
    UIView.animate(
        withDuration: transitionDuration(using: transitionContext),
        animations: {
            // プッシュのカスタムアニメーション処理
        },
        completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }
    )
} else {
    guard let sourceView = transitionContext.view(forKey: .to),
        let destinationView = transitionContext.view(forKey: .from) else {
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
            
            return
    }
    
    let containerView = transitionContext.containerView
    containerView.addSubview(destinationView)
    containerView.insertSubview(sourceView, aboveSubview: destinationView)
    
    UIView.animate(
        withDuration: transitionDuration(using: transitionContext),
        animations: {
            // ポップのカスタムアニメーション処理
        },
        completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        }
    )
}

プッシュとポップの判別

後ほど説明しますが、 UIViewControllerAnimatedTransitioning に準拠したオブジェクトを生成するタイミングで画面遷移時の UINavigationControllerOperation の値を取得することができるので、これを保持しておいて参照できるようにしておくのが、個人的には良いのではないかと思っています。

let operation: UINavigationControllerOperation

init(operation: UINavigationControllerOperation) {
    self.operation = operation
}

func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
    switch operation {
    case .push:
        // プッシュのカスタムアニメーション処理
    case .pop:
        // ポップのカスタムアニメーション処理
    case .none:
        break
    }
}

UINavigationControllerDelegate

このプロトコルに準拠したクラスを作ります。
こちらも NSObjectProtocol を継承しているので、作成するクラスも NSObject を継承させる必要があります。 今回は UINavigationController を継承したクラスを作成し、このクラスに適用することにします。 実装すべきメソッドを実装すると以下のようになります。

class NavigationController: UINavigationController, UINavigationControllerDelegate {
    
    func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return CustomAnimatedTransitioning(operation: operation)
    }
    
}

このメソッドの返り値として先ほどつくったクラスのオブジェクトを返すことで、画面遷移時にカスタムアニメーションが行われるようになります。

UIViewControllerAnimatedTransitioning に準拠したオブジェクトを生成するタイミングで画面遷移時の UINavigationControllerOperation の値を取得することができるので

先ほど上記のように書きましたが、それがこのタイミングとなります。
このメソッドの引数として 画面遷移の種類を表す UINavigationControllerOperation が渡ってくるので、これを CustomAnimatedTransitioning の初期化時に渡してあげることで、 animateTransition が呼ばれたタイミングでプッシュとポップを判別することができるようになり、アニメーションを切り替えることができます。

引数の fromVCtoVC は、それぞれ遷移元、遷移先の ViewController です。そのため、画面によってデフォルトのアニメーションとカスタムアニメーションを切り替えたい場合には、以下のように分岐させることで可能となります。

if toVC is CustomViewController || fromVC is CustomViewController {
    return CustomAnimatedTransitioning(operation: operation)
} else {
    // nil を返すとデフォルトのアニメーション
    return nil
}

UINavigationController

画面遷移に使用するこのクラスのオブジェクトの delegate プロパティに、先ほどの UINavigationControllerDelegate に準拠したオブジェクトをセットします。そうすることで、画面遷移のメソッドが呼ばれた時に delegate で実装したメソッドが呼ばれ、カスタムアニメーションが実行されることになります。

なお、画面遷移のメソッドが呼ばれるタイミングより前に delegate をセットする必要があります。今回は、上記ですでに UINavigationController を継承したクラスをつくっているので、この init 内で delegate をセットするようしています。

class NavigationController: UINavigationController {
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
    }
    
    override init(rootViewController: UIViewController) {
        super.init(rootViewController: rootViewController)
        
        delegate = self
    }
    
}

extension NavigationController: UINavigationControllerDelegate {
    
    func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        if toVC is DestinationViewController || fromVC is DestinationViewController {
            return CustomAnimatedTransitioning(operation: operation)
        } else {
            return nil
        }
    }
    
}

以上で、アニメーションをカスタムするための実装は完了となります!

おわりに

この記事の頭に表示している GIF のアニメーションと同様のアニメーションを行うサンプルコードも用意してみたので、よかったらこちらも確認してみてください。

github.com

次は、インタラクティブなカスタムアニメーションを行うための実装について書いてみたいと思います。

UIPickerView に UILabel を表示するライブラリをつくった

タイトルの通り、 UIPickerView に UILabel を表示するライブラリを作りました。

github.com

これを使うとこんな感じにコンポーネントごとに簡単にラベルを表示することができます。
なお、Carthage からインストールできます。

f:id:komaji504:20170629235343p:plain

使い方

LabelPickerComponent

LabelPickerComponent という、コンポーネントとラベルをまとめたクラスのインスタンスを用意します。

let hours = (0...23).map { "\($0)" }
let attributes = [
    NSFontAttributeName: UIFont.systemFont(ofSize: 16.0)
]

let components  = [
    LabelPickerComponent(
        items: hours,
        itemAttributes: attributes
        maxItemWidth: 20.0,
        labelName: "hours",
        labelAttributes: attributes,
        labelNameWidth: 50.0
   )
]

maxItemWidth が item を表示するために必要な width で、items の中で最も大きい item に合わせて指定してください。
labelNameWidth は labelName を表示するために必要な width を指定してください。

LabelPicker

上記で用意したインスタンスを、 PickerView となる LabelPicker クラスのインスタンスにセットし、コンポーネントを表示する height を指定すると、ラベルの位置が自動で計算されます。

let pickerView = LabelPickerView()
pickerView.components = components
pickerView.rowHeight = 30.0

view.addSubview(pickerView)

PickerView の frame や maxItemWidth, labelNameWidth を使ってごりごり計算しているので、意図した表示にならない場合には maxItemWidth と labelNameWidth を調整して試してみてください。

オプション

オプションで見た目をちょっと変更できるプロパティを LabelPicker に2つ用意しました。

componentsWidthEqual

コンポーネントの width を等しくするかどうかの設定です。
true にすると全ての width が等しくなり、 false にするとコンポーネント間のスペースが等しくなるようにレイアウトされます。
(下の画像だと少しわかりづらいですね、、)

true ( デフォルト ) false
f:id:komaji504:20170629235343p:plain f:id:komaji504:20170629235302p:plain

contentSeparateWidth

item と labelName 間のスペースを指定できます。

0.0 ( デフォルト ) 15.0
f:id:komaji504:20170629235343p:plain f:id:komaji504:20170629235321p:plain

最後に

先日アプリを公開したのですが、このアプリの時間指定する UI に LabelPicker を使っているので、よかったら確認してみてください。


この記事を書いていて色々使いづらそうな点に気づいたので、そのうち修正してみようと思います。

初めてのライブラリ作成だったので、 ライブラリ開発の流れや、Carthage でインストールできるようにするための手順や、 CI でのテストの回し方等、あらゆるところでで色々つまづいたので、その辺りをまたブログにまとめようと思います。

iPhone用のアラームアプリを作った【iOS】

初の個人アプリをリリースしました!
そのアプリがこちらです。

簡単に言うとアラームアプリです。 アラームアプリは App Store にたくさんあるのですが、自分が欲しかった

  • 秒まで指定できる
  • 通知はバイブレーションのみ(音が鳴らない)

の2つの機能が備わっているものが見つからなかったので作ってみました(よく探せばあるかもですが) 。

作るに至った経緯

毎日、通勤で約1時間電車に乗っていたのですが、座れる時にはアラームをセットして寝ていました。
その際には、寝過ごさないように、他の方が作ったアラームを使って目的駅に到着する1分前に時間をセットしていました。
そのため、1分前になったらアラームが鳴って起きてはいたのですが、めちゃくちゃ眠い状態だと判断が鈍ってしまうようで、1分あればもう一度寝られると考えて二度寝してしまい、危うく乗り過ごしそうになることが何度もありました。
幸い乗り過ごしたことは一度もなかったのですが、これはいつかやらかすと思い、そもそも二度寝したくてもできないぎりぎりの時間(30秒前とか)をセットできれば良いのではと思いました。
ですが、この時使っていたアプリは1分単位でしか時間をセットできなかったので、秒まで指定できるアラームを探したところ、自分の欲しいようなアプリがなかなかなかったので、今回のアプリを作るに至りました。

技術的な話

このアプリを作った目的の一つとしては、新しいことを学ぶということもありました。
業務では、結構大きなサービスの iOS 開発をしているのですが、普段触るものとは違う技術を触ってみたい、また、一からアプリを作ることで、いつもであれば学ぶことのできない新しい学びがあるのではないかというように思っていました。

RxSwift + MVVM

iOS 開発のアーキテクチャについて学びたいと思っていたので、流行りの RxSwift を使い、結果的に MVVM について学ぼうということで RxSwift を使いました。

とりあえず Rx の雰囲気は掴めたような気がしているのですが、 RxSwift でコードちゃんと書いていますと言えるほどには身につかなかったなあという気がしています。
ですが、 RxSwift の学習コストの高さや、データバインドの良さや、関数型言語のように記述できることの便利さ等は実感できたので、ひとまずよかったかなあと思います。

ライブラリ公開

ライブラリを作ったことがなかったので、アプリを作っていく中で、ある程度抽象化できそうな部分があったらライブラリを作って公開してみたいなと思いました。

結果的に、 LabelPicker という UIPickerView 関連のライブラリを公開することができました。

komaji504.hateblo.jp

アプリ内では、時刻、時間を指定する UI に使用しています。
標準の UIDatePicker でも時刻、時間はもちろん指定できるのですが、秒指定と単位の表示ができなかったので、これらを解決するためのものとして作りました。
これでライブラリの作り方を知れたので、また何か抽象化できそうなことがあればやってみようと思います。


他にも学べることはたくさんあったので、やって満足しています!
残念だった点としては、今はもう引っ越してしまい、長い電車通勤ではなくなってしまったため、ドッグフーディングができなくなってしまったということがありました。
でもまあ、日常生活の中で利用する機会はあるかと思うのでその際には使っていきます。

アプリに関して何かあればコメントください〜

Auto Layout の Content Hugging Priority と Content Compression Resistance Priority

すぐ忘れるので。

Content Hugging Priority

コンテンツの表示サイズに沿って表示されることの優先度。

そのため、この値が大きい方が優先的にコンテンツと同じサイズになる。

Content Compression Resistance Priority

コンテンツが圧縮されないことの優先度。

そのため、この値が大きい方が優先的にコンテンツが見切れないようになる。

iOS の時間表示設定を取得する

iOS の日付と時刻の設定の24時間表示が ON になっていれば24時間表示用の UILabel を、そうでなければ12時間表示用の UILabel を表示するという用に、時間表示設定によって View を切り替えたいと思ってググってみたのですが、解決策が見つからなかったので軽く調査してみました。

Locale の調査

DateFormatter を扱う時等に、強制的に24時間表記で時刻を扱えるようにと NSLocale.system を使用したことを思い出して、Locale が時間表示設定の情報を持っているのではと思って確認してみました。

(lldb) po NSLocale.system
▿  (fixed)
  - identifier : ""
  - kind : "fixed"

identifier と kind しか持っていない、、、

念のために24時間表示設定がONの時の Local.current も確認してみました。

(lldb) po NSLocale.current
▿ ja_JP (current)
  - identifier : "ja_JP"
  - kind : "current"

Locale が時間表示設定の情報を持っているわけではなさそうですね、、、


となれば、次は———

と別の方法で調査できたらよかったのですが、思いつく方法がなかったし、ググっても見つからないということは時間表示設定はそもそも取得できないのではと思ったので、以下の方法で無理やり判別することにしました。

if String(describing: Date()).contains("午") {
    print("12時間表示")
} else {
    print("24時間表示")
}

もっと良い判別方法があれば是非とも教えていただきたいです、、、

Swift の Date の操作

Date の操作方法が全く覚えられないので、調べたことをここにまとめていく。

日時を指定して Date 生成

Calendar(identifier: .gregorian).date(from: DateComponents(year: 2017, month: 8, day: 13))

ある Date の次の日の Date 生成

Calendar(identifier: .gregorian).date(byAdding: .day, value: 1, to: Date())

UnitTest 用のデータを共通化する【Swift】

テストをする際には複数のオブジェクトの初期化をする必要があり、この作業が面倒でテストを書くのが面倒になってしまうことがあったので、どうにかできないかと思い調べてみたところ、良さそうな方法がありました。

clean-swift.com

これを、自分が使いやすいと思った形に少しアレンジしたものを紹介してみようと思います。

Seeds Struct

以下のように Human というクラスがあったとします。

class Human {
    
    var name: String
    var age: Int
    var height: Double
    var job: String
    
    init(name: String, age: Int, height: Double, job: String) {
        self.name = name
        self.age = age
        self.height = height
        self.job = job
    }
    
}

この Human に関するテストを書くといったときに、以下のようにテストを書く度に値を指定して初期化していくかと思いますが、本当に全部の値を指定しなければならないというケースはそれほど多くはないのではないかと思います。

let takeru = Human(name: "takeru", age: 30, height: 180.0, job: "engineer")
let rika = Human(name: "rika", age: 28, height: 160.0, job: "designer")

こういった時に上記の記事に書いてあるようにして Seeds というテスト用のデータを持つ Struct を用意することで、Xcode 等の補完機能を用いながら以下のように簡単にデータを作成することができます。

struct Seeds {
    
    struct Humans {
        
        static let takeru = Human(name: "takeru", age: 30, height: 180.0, job: "engineer")
        static let rika = Human(name: "rika", age: 28, height: 160.0, job: "designer")
        
    }
    
}

Seeds.Humans.takeru
 => Human(name: "takeru", age: 30, height: 180.0, job: "engineer")
Seeds.Humans.rika
 => Human(name: "rika", age: 28, height: 160.0, job: "designer")

Problem

ですが、上記の様に Stored Property を static で宣言してしまうとシングルトンになってしまいます。
そのため、テスト間でオブジェクトのプロパティの値を変更する時に、思わぬところでテストが落ちてしまうということがあるかもしれないので、値を変更する際には気をつけなければなりません。

let takeru = Seeds.Humans.takeru
takeru.age = 10

// 一度プロパティの値を変更するとその後も維持されてしまう
Seeds.Humans.takeru.age
 => 10

Computed Property

そこで、テストデータの定義には以下のように Computed Property を用いるようにします。
こうすることで、以下のように Seeds からオブジェクトを呼ぶ出す度に異なるオブジェクトが返ってくるため、安心してプロパティの値を変更することができます。

struct Seeds {
    
    struct Humans {
        
        static var takeru: Human {
            return Human(name: "takeru", age: 30, height: 180.0, job: "engineer")
        }
        static var rika: Human {
            return Human(name: "rika", age: 28, height: 160.0, job: "designer")
        }
        
    }
    
}

let takeru = Seeds.Humans.takeru
takeru.age = 10

// 異なるオブジェクトなので 30 のまま
Seeds.Humans.takeru.age
 => 30

Unique Value

先ほどの Human に一意の値となる myNumber というプロパティを持たせる必要が生じたとします。

class Human {
    
    var name: String
    var age: Int
    var height: Double
    var job: String
    let myNumber: Int // 追加
    
    init(name: String, age: Int, height: Double, job: String, myNumber: Int) {
        self.name = name
        self.age = age
        self.height = height
        self.job = job
        self.myNumber = myNumber // 追加
    }
    
}

こういった、 オブジェクト毎に異なる一意の値を持たせたいという場合においても、Seeds に以下の uniqueInt の様なコンピューテッドプロパティを持たせることで、オブジェクトを呼び出す度に自動的に持たせることが可能となります。

struct Seeds {
    
    static var increment = 0
    static var uniqueInt: Int {
        increment += 1
        return increment
    }
    
    struct Humans {
        
        static var takeru: Human {
            return Human(name: "takeru", age: 30, height: 180.0, job: "engineer", myNumber: Seeds.uniqueInt)
        }
        static var rika: Human {
            return Human(name: "rika", age: 28, height: 160.0, job: "designer", myNumber: Seeds.uniqueInt)
        }
        
    }
    
}

Seeds.Humans.takeru.myNumber
 => 1
Seeds.Humans.takeru.myNumber
 => 2

テストは人間が見過ごしてしまうような部分や人間がすべきでないような部分をカバーすることができるので書くに越したことはないのですが、やはり辛い面も多々あるかと思うので、テストを書く度に毎回同じ様なオブジェクトを用意していて辛いなあと感じたら、こういったテストデータ作るくんを用意してみてはいかがでしょうか!