Parallaxでリッチなチュートリアルを作成する
時々見かける、手が込んでる感じがするチュートリアルを作成するためのチュートリアルです。
6つのパターンを紹介します。GitHubに上がっているコードを元に説明します。
- 背景をグラデーションさせる
- インジケーターを表示する
- インジケーターを変形させる
- Viewをアニメーションさせる
- Viewを指定位置へアニメーションさせる
- 視差を使う
基本的な構成として、ViewPager
と、アダプターにFragmentPagerAdapter
を使っています。
なので子要素はFragment
です。
背景をグラデーションさせる
- GradationActivity
以前紹介したArgbEvaluator
を利用しています。
2色間のグラデーションを行うので、 画面数+1の色の配列を用意 するのがポイントです。
今回の例だと画面数が5つなので、6つの配列にし、6つ目は4つ目と同じ色を渡し、計算させています。
ViewPager.OnPageChangeListener#onPageScrolled
のpositionOffset
は、スクロールに応じて0~1
の値を返すので、スクロール度合いを計算できます。
@Override public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { viewPager.setBackgroundColor((Integer) mArgbEvaluator.evaluate(positionOffset, colors[position], colors[position + 1])); }
インジケーターを表示する
- 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)); } }
インジケーターを変形させる
- 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. } }
これに加え、leftTransition
とrightTransition
というメソッドを作成し、インジケーターの拡大縮小を行います。
前述したように、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をアニメーションさせる
- ParallaxAnimationActivity
- AnimationTransformer
あまりいいサンプルが思いつかず、星を動かしてみました。
動的にViewを生成しています。 X
横方向のみではなく、Y
縦方向にも動きます。
Viewを指定位置へアニメーションさせる
- 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
の値をかけて、
アニメーションの変化の過程を表現します。
pos
とtan
に引数を渡すと、それぞれ導線上の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]); }
視差を使う
- 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のチュートリアルはしとやかにリッチで参考になります。
リッチなチュートリアルを作成すると、アプリの期待も高まります。 ユーザの離脱を抑えて、より価値を表現するチュートリアルを作ってユーザの心をつかみましょう。
参考
AndroidのViewPager.PageTransformerでスライドアニメーションを作る / Qiita
Using ViewPager for Screen Slides / Android Developers
Android move object along a path / StackOverFlow