手前と奥を別々の速度で動かして奥行き感を出すアレ(Parallax)をScrollViewで何とかしてみる

こんにちはこんにちは!
今日は「手前と奥を別々の速度で動かして奥行き感を出すアレ」をScrollViewでなんとか作ってみたいと思います。2Dゲームなんかで遠くの景色がゆっくり動いてく感じのアレです。WebなんかではParallaxとか読んだり呼ばなかったりすると聞きました。

完成図




そうだね、全然わからないね。
今回も練習がてらgithubにプロジェクト上げたので適当に試してみて下さい。
GitHub - ytRino/ParallaxScrollViewSample: ScrollViewを重ねてParallaxな感じに動かしてみる

素材

今回使う素材はこんなかんじです
めんどくさいのでとりあえずmdpiに置いて勝手にスケールして頂きます。
GitHub - res/drawable-mdpi
https://github.com/ytRino/ParallaxScrollViewSample/raw/master/res/drawable-mdpi/layer3.png
https://github.com/ytRino/ParallaxScrollViewSample/raw/master/res/drawable-mdpi/layer2.png
https://github.com/ytRino/ParallaxScrollViewSample/raw/master/res/drawable-mdpi/layer1.png
もうちょっとわかりやすい画像用意しろよ自分。

レイヤに手を加える

  1. 手前のレイヤ(ScrollView)から、後ろのレイヤを動かする
  2. 後ろのレイヤのボタンが息してない

の2項目にわけます。

手前のレイヤ(ScrollView)から、後ろのレイヤを動かす

HorizontalScrollViewに連動させるレイヤを設定するメソッドを追加し、手前のレイヤがスクロールした後に呼ばれるonScrollChangedで、設定したレイヤをスクロールさせます。このメソッドは短い時間に何度も呼び出されるので若干アレゲではあります。
ParallaxScrollView

    /**
     * このレイヤーと一緒に動かす背景を設定
     * 
     * @param psv このレイヤーと一緒に動かす背景. nullを渡すとそれまでaddしたレイヤーがクリアされる
     * @return this
     */
    public ParallaxScrollView addBackLayer(ParallaxScrollView psv, float ratio) {
        if(mBackLayers == null) {
            mBackLayers = new ArrayList<ParallaxScrollView>();
        }
        if(psv == null) {
            mBackLayers.clear();
        }else {
            mBackLayers.add(psv);
            psv.setScrollRatio(ratio);
        }
        return this;
    }

    @Override
    protected void onScrollChanged(int l, int t, int oldl, int oldt) {
        super.onScrollChanged(l, t, oldl, oldt);
        // 力技
        if(mBackLayers != null) {
            for(ParallaxScrollView hsv: mBackLayers) {
                hsv.scrollTo(l, 0);
            }
        }
    }

    @Override
    public void scrollTo(int x, int y) {
        super.scrollTo((int)(x * mRatio), y);
    }

    public void setScrollRatio(float ratio) {
        mRatio = ratio;
    }

setRadio()はそのスクロール位置の比率です。これで後ろはよりゆっくりになるように設定することで遠近感をだします。ParallaxActivity#getLayerWidthRatioで計算しています。
あとはActivity側で手前のレイヤに奥のレイヤを追従するようにします。

        mLayer1 = (ParallaxScrollView)findViewById(R.id.layer1);  // 手前
        mLayer2 = (ParallaxScrollView)findViewById(R.id.layer2);  // 奥
        mLayer3 = (ParallaxScrollView)findViewById(R.id.layer3);  // もっと奥
        // layer2,3を1に追従させる
        mLayer1.addBackLayer(mLayer2, ratio2).addBackLayer(mLayer3, ratio3);

これでとりあえず連動して動くようになりました。
しかし問題があります。各レイヤには画像の他にボタンをつけています。後ろのレイヤがただの飾りならこれでもいいのですがボタンが有ると話は別です。手前のレイヤ(スクロールビュー)が画面いっぱいに広がってるのでタッチイベントをすべて拾ってしまい、後ろのレイヤ、そこに載っているボタンはさっぱり反応しません。

後ろのレイヤのボタンが息してない

このままでは飾りにしかならないのでうまく動くようにしましょう。
とりあえず手前のレイヤのタッチイベントにフックして後ろのレイヤにタッチイベントをそのまま投げてしまいます。
ただし、単に送ると後ろのレイヤ(スクロールビュー)自身がタッチに反応してスクロールするようになってしまい困るので、それも制御します。
タッチイベントを後ろのレイヤに投げるには、手前のレイヤタッチイベントで後ろのレイヤにdispatchTouchEventすればよさそう。
さらにスクロールを無効にするには…スクロールはタッチイベントに連動して行われます。スクロールさせないようにするためには

  • ScrollView#onTouchEventをオーバーライドして無視する
  • ScrollView#setOnTouchListenerでリスナをセットし#onTouchでtrueを返す(ScrollView#onTouchEventが呼ばれなくなる)

どっちにしろリスナは使うのでこれもリスナでやってしまいます。

        // layer2にタッチを伝播
        mLayer1.setOnTouchListener(new OnTouchDispatcher(mLayer2, false));
        // layer3にタッチを伝播 layer2,3はタッチイベントでスクロールなどさせないようにするのでtrue
        mLayer2.setOnTouchListener(new OnTouchDispatcher(mLayer3, true));
        // タッチを伝播しない
        mLayer3.setOnTouchListener(new OnTouchDispatcher(null, true));

ParallaxScrollView$OnTouchDispatcher

    /**
     * タッチイベントを伝播する<br>
     * 
     */
    public static class OnTouchDispatcher implements View.OnTouchListener {

        /** イベントの伝播先 */
        private final View mTarget;
        /** trueなら自身のタッチイベントを抑止(スクロールなど) */
        private final boolean mConsume;

        /**
         * @param receiver イベントの伝播先 伝播させない場合はnull
         * @param consume trueでタッチイベントを消費する
         */
        public OnTouchDispatcher(View receiver, boolean consume) {
            mTarget = receiver;
            mConsume = consume;
        }

        @Override
        public boolean onTouch(View v, MotionEvent e) {
            if(mTarget != null) {
                mTarget.dispatchTouchEvent(e);
            }
            return mConsume;
        }
    }

完成!




え?カクカク?オーバーシュートする?(∩゚д゚)アーアーきこえなーい
ともかくこれで、「なんかこうさ〜画面が動く感じの導線がほしいよね〜そうそう手前と背景があって奥行き感ある感じ?キャラがボタンになっててさ〜」といったなんとも言えない要求に対応できますね!
いったいどこにこんな仕様のアプリを作りたい人がいるのかよくわかりませんが(ぇ
もちろんこれでゲームなんか作れませんのであしからず。
めんどくさいひとのためのAPK
GitHub - ytRino/ParallaxScrollViewSample: ScrollViewを重ねてParallaxな感じに動かしてみる