dely engineering blog

レシピ動画サービス「kurashiru」を運営するdelyのテックブログ

【Android】ViewPagerのページ切り替えをいい感じにする

こんにちは。delyでAndroidのエンジニアをしているkenzoです。
この記事はdely Advent Calendar 2018の13日目の記事です。

Qiita: https://qiita.com/advent-calendar/2018/dely Adventar: https://adventar.org/calendars/3535

 

昨日はkurashiruのwebグロース全般を担当しているinternet_ghostがこちらの記事を書きました。
クラシルでのSEO施策についてや、外部の方が気になりそうなポイントについて書かれています。ぜひご覧ください!

はじめに

今日は先日の記事に引き続き、AndroidアプリのViewPagerをいい感じの動きにしていく際にやったことをご紹介します。
今回はページ切り替え時の動きをいい感じにしていきたいと思います。

※ 注意: 今回の記事を読んで試しに実装する場合は一旦下まで読んでから実装してください。上の方のコード使わなくていいかもなので。

ViewPagerのページ切り替えをいい感じに

先日の記事で作成したサンプルアプリに少し機能を追加します。
ページを切り替えるボタン「←」「→」の設置と★のタップではじめに戻るようにします。

ページを切り替える処理を実装

ViewPagersetCurrentItemを使います。

star.setOnClickListener { viewPager.currentItem = 0 }

f:id:kenzo_aiue:20181211145045g:plain

これでページが切り替わるようになりました。

もう少しゆっくりページを切り替えたい

ページが切り替わるようになりましたが、ちょっと切り替わるスピード早いですよね?ズビュンって感じ。
なので、少しゆっくり切り替わるようにします。

ViewPagerを継承してCustomViewPagerを作成します。

class CustomViewPager @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : ViewPager(context, attrs) {

    companion object {
        private const val CUSTOM_DURATION: Int = 1000
    }

    init {
        ViewPager::class.java.getDeclaredField("mScroller").run {
            isAccessible = true
            set(this@CustomViewPager, CustomScroller(context))
        }
    }

    private class CustomScroller(context: Context) : Scroller(context, FastOutSlowInInterpolator()) {

        override fun startScroll(startX: Int, startY: Int, dx: Int, dy: Int, duration: Int) {
            super.startScroll(startX, startY, dx, dy, CUSTOM_DURATION)
        }
    }
}

ViewPagerで使用されるScrollerを自分で作成してセットします。 durationを1000msにしたので、1秒でページが切り替わるようになります。

f:id:kenzo_aiue:20181211145137g:plain

これでページがゆっくり切り替わるようになりました。

スワイプするときは今までどおりにしたい

ページがゆっくり切り替わるようになったと思ったら、今度はスワイプする際におかしな挙動をするようになってしまいました。

f:id:kenzo_aiue:20181211150722g:plain

ボタンを押した時はゆっくり切り替わってほしいけど、スワイプする時は今までどおりに動いてほしいですよね。
なので、先程作成したCustomViewPagerに手を入れてsetCurrentItemを呼ぶ時だけゆっくり切り替わるようにします。

class CustomViewPager @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : ViewPager(context, attrs) {

    companion object {
        private const val CUSTOM_DURATION: Int = 1000
    }

    private val interpolator: CustomInterpolator = CustomInterpolator()
    private val scroller: CustomScroller = CustomScroller(context, interpolator)

    init {
        ViewPager::class.java.getDeclaredField("mScroller").run {
            isAccessible = true
            set(this@CustomViewPager, scroller)
        }
        addOnPageChangeListener(object : OnPageChangeListener {

            override fun onPageScrollStateChanged(state: Int) {
                when (state) {
                    SCROLL_STATE_IDLE, SCROLL_STATE_DRAGGING -> { // ページ切り替えが終わったら、また、切り替え中にスワイプした際に元の挙動で切り替わるように
                        interpolator.isCustom = false
                        scroller.isCustom = false
                    }
                }
            }

            override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) = Unit

            override fun onPageSelected(position: Int) = Unit
        })
    }

    override fun setCurrentItem(item: Int) {
        interpolator.isCustom = true
        scroller.isCustom = true
        super.setCurrentItem(item)
    }

    private class CustomScroller(context: Context, interpolator: Interpolator) :
        Scroller(context, interpolator) {

        var isCustom: Boolean = false

        override fun startScroll(startX: Int, startY: Int, dx: Int, dy: Int, duration: Int) {
            super.startScroll(startX, startY, dx, dy, if (isCustom) CUSTOM_DURATION else duration)
        }
    }

    private class CustomInterpolator : FastOutSlowInInterpolator() {

        private val default: Interpolator = Interpolator { input -> (input - 1).pow(5) + 1 } // ViewPager内で作成されているInterpolatorをここで再実装
        var isCustom: Boolean = false

        override fun getInterpolation(input: Float): Float =
            if (isCustom) super.getInterpolation(input) else default.getInterpolation(input)
    }
}

ちょっと長くなってしまいましたが、こんな感じになるよう変更しています。

  • ゆっくりとデフォルト両方の動きができるScrollerInterpolatorを作成(isCustomtrueでゆっくり)
  • ボタンで切り替えを行うタイミングでisCustomtrue
  • 切り替えが終わったらisCustomfalse
  • ゆっくり切り替え中にスワイプした場合にもデフォルトの動きができるようにisCustomfalse

f:id:kenzo_aiue:20181211155638g:plain

これで、ボタンで切り替える時はゆっくり、スワイプした時は今までどおりにページが切り替わるようになりました。

この実装でいいのか

アプリの挙動だけ見ると、うまいこと動くようになりました。
しかし、今回作成したCustomViewPagerはリフレクションでmScrollerにアクセスしています。
ViewPagerの中身の実装が変わってmScrollerがなくなる可能性も0ではないので、できれば違う方法で実現したいところです。*1

隣のページに移動するだけでいいなら

今度は先程作成したCustomViewPagerではなくViewPagerに戻し、別の実装をしてみます。
ViewPagerfakeDragBegin fakeDragEnd fakeDragByを用いてページを切り替えます。
これを用いると、隣のページへのスワイプを模した動きをさせることができます。(本記事ではfake dragと呼ぶことにします)

private var prevDragPosition = 0

override fun onCreate(savedInstanceState: Bundle?) {

    /* 省略 */

    left.setOnClickListener {
        if (viewPager.currentItem > 0) fakeDrag(false)
    }
    right.setOnClickListener {
        if (viewPager.currentItem + 1 < viewPager.adapter?.count ?: 0) fakeDrag(true)
    }
}

private fun fakeDrag(forward: Boolean) {
    if (prevDragPosition == 0 && viewPager.beginFakeDrag()) {
        ValueAnimator.ofInt(0, viewPager.width).apply {
            duration = 1000L
            interpolator = FastOutSlowInInterpolator()
            addListener(object : Animator.AnimatorListener {

                override fun onAnimationStart(animation: Animator?) = Unit

                override fun onAnimationEnd(animation: Animator?) {
                    viewPager.endFakeDrag()
                    prevDragPosition = 0
                }

                override fun onAnimationCancel(animation: Animator?) {
                    viewPager.endFakeDrag()
                    prevDragPosition = 0
                }

                override fun onAnimationRepeat(animation: Animator?) = Unit
            })
            addUpdateListener {
                val dragPosition: Int = it.animatedValue as Int
                val dragOffset: Float = ((dragPosition - prevDragPosition) * if (forward) -1 else 1).toFloat()
                prevDragPosition = dragPosition
                viewPager.fakeDragBy(dragOffset)
            }
        }.start()
    }
}

このようなことをしています。

  • beginFakeDragでfake dragを始める
  • いい感じのdurationinterpolatorValueAnimatorを用意
  • fakeDragByで少しずつスクロールさせる
  • ViewPagerwidth分のスクロールが終わったらendFakeDragでfake dragを終了

f:id:kenzo_aiue:20181211154305g:plain

このようにCustomViewPagerと同様の挙動で隣のページへ切り替えることができました。

おわりに

今回と前回使ったサンプルアプリのソースはこちらです。
先日の記事「Androidにいい感じの動きをさせていく話(ViewPagerとその他Viewとの連動」と合わせてみなさまのアプリ上のViewPagerの動き方をよりよいものにしていくための一助になれば幸いです。

明日は検索エンジニアsakuraの「社内SQL勉強会を開催しました」です。お楽しみに!

*1:リフレクションでmScrollerを変更するやり方はぐぐるといっぱい出てくるし、やたら使われてそうなので、そうそう変えられないかなーとは思いますけど