iOSのPull To Refreshのローディングアイコンをカスタマイズする

04 Jun 2015

スマートフォンアプリのUIのよくあるパターンとしてPull to refresh というものがある。 今でこそios のUIKit に標準として組み込まれているが、もともとはTweetieというアプリで Loren Brichterという人が考案したものらしい。 現在はTweetieはTwitterに買収され、Twitterが特許をもっているらしいけど。 ただ、Twitterはこの特許を行使はしないらしい。ヨカッタヨカッタ

このPull to refreshはタッチパネルの利点をこれほど生かしたUIはないだろうというくらいに 自然な操作感である。 UIKitに組み込まれたことによりほんの数行のコードを書くことでこの機能を自分のアプリに実装することができる。 しかし、このOS標準のパーツUIRefreshControlはローディングアイコンが味気ない。普通のぐるぐるするやつである。 いろんなアプリでこのローディングアイコンをカスタマイズして独自のものを表示している。 こういった細かいところに遊び心があるアプリは使っていて親近感が湧くものである。 自分のアプリでもぜひカスタマイズしたアイコンを利用したい。

幸運にもOS標準のAPIに似たAPIを提供してくれているライブラリ ISAlternativeRefreshControl というものがあり、UIKitのUIRefreshControlを使ったアプリも比較的少ない修正でカスタマイズすることができそうである。

まずは、ios開発者の友 CocoaPodsを使ってISAlternativeRefreshControlをインストールする。 以下をPodfileに追加しよう。

pod 'ISAlternativeRefreshControl'

例のごとく pod installする

pod install

これで準備はできた。

exampleを見てみるとISAlternativeRefreshControlを継承したクラスを作り、 幾つかのメソッドをオーバライドしてあげれば良さそうである。

progress はどれくらい引っ張られているかを表してるようで だいたい0~2.0の値 stateは文字通り状態を表していてだいたい以下のようなイメージである。

progress, stateに合わせてviewを変化させればよい。 今回は自分の作っているMusicFavのアイコンおんぷちゃんをくるくる回転させて、 更新が終わったら色を変化させようと思う。

回転はUIViewのCGAffineTransformでできる。 こんな感じである。

func updateView() {
    imageView.transform = CGAffineTransformMakeRotation(CGFloat(M_PI) * prog);
}

progressは0~2のなので少し係数をかけてやる。係数をかけたものがprogである。

override func didChangeProgress() {
    switch refreshingState {
    case .Normal:
        prog = (2.0 * progress) % 2.0
        updateView()
    case .Refreshing:
        break
    case .Refreshed:
        break
    }
}

これで引っ張っている途中のアニメーションができた。 次に、指が離れたあとのアニメーションである。 いくつかやり方はあるが今回はCABasicAnimationを使う。 stateがRefeshingになったタイミングでアニメーションをスタートさせる。

    override func willChangeRefreshingState(refreshingState: ISRefreshingState) {
        switch refreshingState {
        case .Normal:
            break
        case .Refreshing:
            startLayerAnimation()
        case .Refreshed:
            break
        }
    }

    func startLayerAnimation() {
        let layer              = imageView.layer;
        let animation          = CABasicAnimation(keyPath: "transform.rotation")
        let fromValue          = M_PI*Double(prog)
        let toValue            = fromValue + 2*M_PI
        animation.duration     = 0.5 * (toValue - fromValue) / (2*M_PI)
        animation.repeatCount  = 0
        animation.beginTime    = CACurrentMediaTime()
        animation.autoreverses = false
        animation.fromValue    = NSNumber(float: Float(fromValue))
        animation.toValue      = NSNumber(float: Float(toValue))
        animation.removedOnCompletion = false
        animation.fillMode     = kCAFillModeForwards
        animation.delegate     = self
        layer.addAnimation(animation , forKey:"rotate-animation")
    }

    override func animationDidStop(anim: CAAnimation, finished flag: Bool) {
        switch refreshingState {
        case .Normal:
            break
        case .Refreshing:
            startLayerAnimation()
        case .Refreshed:
            break
        }
    }

これで更新中のアニメーションができた。 最後は更新後のアニメーションである。 そのままだと更新後は即座にアイコンが上にスライドしてしまう。 アイコンを色違いのものに差し替えたあと少しだけ間を開けて上にスライドさせたい。 アイコンが正位置に戻ったところで止まるとなお良い。 そのためにアニメーションの状態を管理するenum AnimationStateを定義して、 通常のアニメーション中と正位置に戻すアニメーション中を区別する。 正位置に戻ったあとにアイコンを差し替え少し間を開けたあと上にスライドするようにする。

    enum AnimationState {
        case Normal
        case Animating
        case Completing
        case Completed
    }

    func startLayerAnimation(returnNormal: Bool) {
        let layer              = imageView.layer;
        let animation          = CABasicAnimation(keyPath: "transform.rotation")
        let fromValue          = M_PI*Double(prog)
        let toValue            = returnNormal ? (2*M_PI) : (fromValue + 2*M_PI)
        animation.duration     = 0.5 * (toValue - fromValue) / (2*M_PI)
        animation.repeatCount  = 0
        animation.beginTime    = CACurrentMediaTime()
        animation.autoreverses = false
        animation.fromValue    = NSNumber(float: Float(fromValue))
        animation.toValue      = NSNumber(float: Float(toValue))
        animation.removedOnCompletion = false
        animation.fillMode     = kCAFillModeForwards
        animation.delegate     = self
        layer.addAnimation(animation , forKey:"rotate-animation")
    }

    override func animationDidStop(anim: CAAnimation, finished flag: Bool) {
        switch animationState {
        case .Normal:
            break
        case .Animating:
            animationState = .Completing
            startLayerAnimation(false)
        case .Completing:
            startLayerAnimation(true)
            animationState = .Completed
        case .Completed:
            self.imageView.image = UIImage(named: "loading_icon_\(arc4random_uniform(4))")
            let startTime = dispatch_time(DISPATCH_TIME_NOW, Int64(1.0 * Double(NSEC_PER_SEC)))
            dispatch_after(startTime, dispatch_get_main_queue()) {
                super.endRefreshing()
            }
        }
    }


これで完成である。こんな感じになった。

Drawing

コミットはこちら

MusicFavの全体のソースはこちら

Tweet
comments powered by Disqus