メモ2ブログ

メモtoウェブログ。旧ブログはこちら。 http://sakebook.blogspot.jp/

Parallaxでリッチなチュートリアルを作成する

時々見かける、手が込んでる感じがするチュートリアルを作成するためのチュートリアルです。

6つのパターンを紹介します。GitHubに上がっているコードを元に説明します。

github.com

  • 背景をグラデーションさせる
  • インジケーターを表示する
  • インジケーターを変形させる
  • Viewをアニメーションさせる
  • Viewを指定位置へアニメーションさせる
  • 視差を使う

基本的な構成として、ViewPagerと、アダプターにFragmentPagerAdapterを使っています。 なので子要素はFragmentです。

背景をグラデーションさせる

f:id:sakebook:20160102002700g:plain

  • GradationActivity

以前紹介したArgbEvaluatorを利用しています。

sakebook.hatenablog.com

2色間のグラデーションを行うので、 画面数+1の色の配列を用意 するのがポイントです。

今回の例だと画面数が5つなので、6つの配列にし、6つ目は4つ目と同じ色を渡し、計算させています。

ViewPager.OnPageChangeListener#onPageScrolledpositionOffsetは、スクロールに応じて0~1の値を返すので、スクロール度合いを計算できます。

    @Override
    public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
        viewPager.setBackgroundColor((Integer) mArgbEvaluator.evaluate(positionOffset, colors[position], colors[position + 1]));
    }

インジケーターを表示する

f:id:sakebook:20160102002754g:plain

  • IndicatorActivity

様々な実装方法が考えられると思いますが、今回は、固定されたインジケーターの上に、色をつけたViewを適宜動かす方法で実装しました。

インジケーターは Activityに設置し、どのFragmentでも常に表示されるようにしています。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/layout_indicator"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.sakebook.android.sample.parallaxsample.acitvities.IndicatorActivity">

    <android.support.v4.view.ViewPager
        android:id="@+id/view_pager"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
    ...
    <View
        android:id="@+id/view_indicator_1"
        android:layout_toLeftOf="@+id/view_indicator_2"
        android:layout_alignTop="@+id/layout_footer"
        android:layout_marginTop="@dimen/large_margin"
        android:background="@drawable/circle"
        android:layout_width="@dimen/small_margin"
        android:layout_height="@dimen/small_margin"/>
    ...
    <View
        android:id="@+id/view_float_indicator"
        android:background="@drawable/circle_hover"
        android:layout_alignLeft="@+id/view_indicator_1"
        android:layout_alignRight="@+id/view_indicator_1"
        android:layout_alignTop="@+id/view_indicator_1"
        android:layout_alignBottom="@+id/view_indicator_1"
        android:layout_width="@dimen/small_margin"
        android:layout_height="@dimen/small_margin"/>

</RelativeLayout>

view_float_indicatorがインジケーターの上を移動する、色をつけたViewです。

グラデーションの時と同様に、スクロールに応じてViewを移動させる必要があるので、次のようにViewを移動させています。

    @Override
    public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
        indicatorFloatView.setTranslationX((position * indicatorSpace) + (positionOffset * indicatorSpace));
    }

フッター両端にテキストを置いて、位置に応じてテキストが変わるようにしています。

    @Override
    public void onPageSelected(int position) {
        if (position == 0) {
            back.setText(getString(R.string.footer_skip));
        } else {
            back.setText(getString(R.string.footer_back));
        }

        if (viewSparseArray.size() == (position + 1)) {
            next.setText(getString(R.string.footer_go));
        } else {
            next.setText(getString(R.string.footer_next));
        }
    }

インジケーターを変形させる

f:id:sakebook:20160102002918g:plain

  • TransformIndicatorActivity
  • IndicatorTransformer

これをすると大分リッチ感を演出できる気がしてます。 先ほどとは異なり、 Fragmentにインジケーターを配置しています。

インジケーターはlayout_indicator_transformerの中に配置しています。

<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    ...

    <LinearLayout
        android:id="@+id/layout_indicator_transformer"
        android:orientation="horizontal"
        android:gravity="center"
        android:layout_alignParentBottom="true"
        android:layout_width="match_parent"
        android:layout_height="?actionBarSize">

        <View
            android:id="@+id/view_indicator_1"
            android:layout_margin="@dimen/small_margin"
            android:background="@drawable/circle"
            android:layout_width="@dimen/small_margin"
            android:layout_height="@dimen/small_margin"/>
        ...

    </LinearLayout>

</RelativeLayout>

PageTransformer

スワイプした際のアニメーションをカスタムするためのインタフェースです。 スワイプするたびに、transformPage(View view, float position)が呼ばれるようになります。 次のようにViewPagerにセットします。

    private void initViewPager() {
        ...
        viewPager.setPageTransformer(false, new IndicatorTransformer());
        ...
    }

transformPage(View view, float position)

viewはViewPagerが現在保持しているViewが順に呼ばれます。 ViewPagerはデフォルトで、表示されているView+前後1つの計3つViewを保持するので、両端以外では、スワイプの動作の度に3度呼ばれます。 positionでViewの状態を判定します。

  • position <= -1ならば、現在表示しているViewより 左にある、隠れているViewです。
  • -1 < position && position < 0ならば、 現在表示されているViewで、左側にあるものです。スワイプ中は2つのViewが表示されるので、その左側です。
  • 0 <= position && position <= 1ならば、 現在表示されているViewで、右側にあるものです。スワイプ中は2つのViewが表示されるので、その右側です。
  • それ以外ならば、現在表示しているViewより 右にある、隠れているViewです。

現在見えていないViewの扱いは、変更した値を初期値に戻すなどの使い方をするようです。今回は未使用です。

Fragmentにインジケーターを配置しているので、そのままではスワイプとともに動いてしまいます。なので、setTransitionXで負の値を掛けて、 スワイプしてもその場に居続けるようにしています。 居続けて欲しいのはFragment全体ではなく、インジケーターが配置されているlayout_indicator_transformerです。

    @Override
    public void transformPage(View view, float position) {
        float alpha = 0;
        int width = view.getWidth();
        if (position <= -1) { // [-Infinity,-1)
            // This page is way off-screen to the left.
        } else if (-1 < position && position < 0) {
            // left side shown View
            alpha = position + 1;
            view.findViewById(R.id.layout_indicator_transformer).setTranslationX(-width * position);
            leftTransition(view, position, alpha);
        } else if (0 <= position && position <= 1) {
            // right side shown View
            alpha = 1 - position;
            view.findViewById(R.id.layout_indicator_transformer).setTranslationX(-width * position);
            rightTransition(view, position, alpha);
        } else { // (1,+Infinity]
            // This page is way off-screen to the right.
        }
    }

これに加え、leftTransitionrightTransitionというメソッドを作成し、インジケーターの拡大縮小を行います。

前述したように、ViewはViewPagerで管理しているViewが順に呼ばれます。どのViewなのかは、 Tag or idで取得します。

Fragment生成時にsetTagをしておくと、任意のViewを判別できると思います。 または、Viewのidで判別もできます。Fragmentのルートのレイアウトのidが取得できるので、Tagは他の用途に利用したい時などはidで判別すると良いです。

今回は共通のxmlを利用しているので、Tagで識別します。

    private void leftTransition(View view, float position, float alpha) {
        View indicator1 = view.findViewById(R.id.view_indicator_1);
        View indicator2 = view.findViewById(R.id.view_indicator_2);
        View indicator3 = view.findViewById(R.id.view_indicator_3);
        View indicator4 = view.findViewById(R.id.view_indicator_4);
        View indicator5 = view.findViewById(R.id.view_indicator_5);

        // use tag or layout id
        switch ((int)view.getTag()) {
            case 0:
                indicator1.setScaleX(2 + position);
                indicator1.setScaleY(2 + position);
                indicator2.setScaleX(1 - position);
                indicator2.setScaleY(1 - position);
                break;
            case 1:
                indicator2.setScaleX(2 + position);
                indicator2.setScaleY(2 + position);
                indicator3.setScaleX(1 - position);
                indicator3.setScaleY(1 - position);
                break;
            case 2:
                indicator3.setScaleX(2 + position);
                indicator3.setScaleY(2 + position);
                indicator4.setScaleX(1 - position);
                indicator4.setScaleY(1 - position);
                break;
            case 3:
                indicator4.setScaleX(2 + position);
                indicator4.setScaleY(2 + position);
                indicator5.setScaleX(1 - position);
                indicator5.setScaleY(1 - position);
                break;
            case 4:
                indicator5.setScaleX(2 + position);
                indicator5.setScaleY(2 + position);
                break;
        }
    }

Viewをアニメーションさせる

f:id:sakebook:20160102003041g:plain

  • ParallaxAnimationActivity
  • AnimationTransformer

あまりいいサンプルが思いつかず、星を動かしてみました。 動的にViewを生成しています。 X横方向のみではなく、Y縦方向にも動きます。

Viewを指定位置へアニメーションさせる

f:id:sakebook:20160102003152g:plain

  • TransitionAnimationActivity

PageTransformerは使っていません。 動かしたいアニメーションの導線をPathで書きます。

    private void sunSets(float fraction) {
        final Path path = new Path();
        ...
        float x = noon[0];
        float y = noon[1];
        path.moveTo(x + 0, y + 0);
        path.cubicTo(
                noon[0] / 3 * 4, morning[1] / 4,
                noon[0] / 3 * 5, morning[1] / 5 * 2,
                evening[0], evening[1]
        );
        ...
    }

PathMeasure

生成したPathを、PathMeasureに渡すことで、始点から終点までに通る導線のxy座標を、取得できるようになります。 fractionには0~1の値が入っています。onPageScrolledで呼び出し、positionOffsetの値を利用しています。

    private void sunSets(float fraction) {
        final Path path = new Path();
        ...
        sunSetsPathMeasure.setPath(path, false);
        float[] point = new float[2];
        sunSetsPathMeasure.getPosTan(sunSetsPathMeasure.getLength() * fraction, point, null);
        ...
    }

getPosTan(float distance, float[] pos, float[] tan)

distanceには、自身の値を渡します。この値に、0~1の値をかけて、 アニメーションの変化の過程を表現します。

postanに引数を渡すと、それぞれ導線上のxy座標とtangentXYが格納されます。今回はposのみ引数を渡しています。

posの引数に渡した配列を利用して、アニメーションさせたいViewを所定の位置へ移動させます。

    private void sunSets(float fraction) {
        ...
        final View v = findViewById(R.id.sun);
        v.setScaleX(1f - 1f * (1 - fraction) / 2);
        v.setScaleY(1f - 1f * (1 - fraction) / 2);
        ...
        float[] point = new float[2];
        sunSetsPathMeasure.getPosTan(sunSetsPathMeasure.getLength() * fraction, point, null);
        v.setX(point[0]);
        v.setY(point[1]);
    }

視差を使う

f:id:sakebook:20160102003247g:plain

  • ParallaxImageActivity
  • ImageFragment
  • ImageTransformer

Viewをアニメーションさせるでも一部利用したのですが、さらに分かりやすい形で表現します。

ViewPagerの両端に隙間をつけます。

    private void initViewPager() {
        ...
        viewPager.setPageMargin(getResources().getDimensionPixelSize(R.dimen.small_margin));
        ...
    }

transformPageで視差をつけます。 このとき扱うViewの数が増えてきた場合、ViewHolderを作成する方が効率が良いので、一応作成します。

ImageFragmentの中で定義しています。

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View view = inflater.inflate(layout, container, false);

        ViewHolder holder = new ViewHolder(view);
        view.setTag(holder);

        holder.title.setText(number + "");
        holder.subTitle.setText(text);
        holder.backImage.setImageResource(drawable);
        holder.author.setText(author);
        view.setBackgroundColor(Color.rgb((int) (Math.random() * 255), (int) (Math.random() * 255), (int) (Math.random() * 255)));
        return view;
    }

    public static class ViewHolder {

        public ImageView backImage;
        public TextView title;
        public TextView subTitle;
        public TextView author;

        public ViewHolder(View view) {
            this.backImage = (ImageView) view.findViewById(R.id.image_back);
            this.title = (TextView) view.findViewById(R.id.text_title);
            this.subTitle = (TextView) view.findViewById(R.id.text_sub_title);
            this.author = (TextView) view.findViewById(R.id.text_author);
        }
    }

生成したViewHolerをViewにsetTagしておきます。

transformPageで、先ほどsetTagしたViewHolderを取得します。 getTagがnullにはならないはずですが、一応入れています。

    private void viewTransition(View view, float position, float alpha) {
        ImageFragment.ViewHolder holder;
        if (view.getTag() == null) {
            holder = new ImageFragment.ViewHolder(view);
            view.setTag(holder);
        } else {
            holder = (ImageFragment.ViewHolder) view.getTag();
        }

        int width = view.getWidth();
        holder.title.setTranslationX(- width * position);
        holder.subTitle.setTranslationX(width * 2 * position);
        holder.backImage.setTranslationX(- width / 2 * position);
    }

3つのViewの動きを変えます。

  • holder.titleその場に留まるように
  • holder.subTitleスワイプする指より早く捌けます
  • holder.backImageスワイプする指より遅く捌けます

遅く捌ける画像と、ViewPagerの端が見えること、スワイプする指とは異なる動きをするViewによって、視差をより感じます。

まとめ

  • 動きに合わせて何かをしたい場合は、変化量を正規化する
  • 色はArgbEvaluator, 移動はPathMeasure
  • スワイプ時に相互のViewに影響を与える場合はPageTransformerを使う

紹介したものを組み合わせれば、大体のことはできると思います。 Slackのチュートリアルはしとやかにリッチで参考になります。

リッチなチュートリアルを作成すると、アプリの期待も高まります。 ユーザの離脱を抑えて、より価値を表現するチュートリアルを作ってユーザの心をつかみましょう。

参考

動きに合わせて色をグラデーションさせる / メモ2ブログ

AndroidのViewPager.PageTransformerでスライドアニメーションを作る / Qiita

Using ViewPager for Screen Slides / Android Developers

Android move object along a path / StackOverFlow

PathMeasure / Android Developers

Great animations with PageTransformer / Medium