こんにちは、Habr! 少し前、冒険、新しいプロジェクト、技術を探して、私は ロボットになりました Redmadrobotに定住しました。 椅子、モニター、Macbook、そしてウォームアップ用の小さな内部プロジェクトを手に入れました。 プロジェクトで使用するメディアコンテンツを表示するには、自己記述ライブラリを完成させて公開する必要がありました。 この記事では、1週間でタッチイベントを理解し、オープンソースになり、Android SDKのバグを見つけ、ライブラリを公開する方法を説明します。
開始する
ストアアプリの重要な機能の1つは、商品やサービスの動画や写真をあらゆる側面から表示できることです。 車輪を再発明したくなかったので、私たちに合った完成したライブラリを探しに行きました。
ユーザーが次のことができるように、このようなソリューションを見つけることを計画しました。
- 写真を見る;
- ピンチを使用して写真を拡大縮小し、ダブルタップします。
- ビデオを見る
- メディアコンテンツの反転。
- 縦にスワイプして写真カードを閉じます(スワイプして閉じます)。
私たちが見つけたものは次のとおりです。
- FrescoImageViewer-写真および基本的なジェスチャーの表示とスクロールをサポートしますが、ビデオの表示はサポートせず、 Frescoライブラリを対象としています。
- PhotoView-写真の表示をサポートします。スクロール、スワイプして閉じる以外の基本的なコントロールジェスチャのほとんどは、ビデオの表示をサポートしていません。
- PhotoDraweeView-機能はPhotoViewに似ていますが、Fresco用に設計されています。
見つかったライブラリはどれも要件を完全に満たしていないため、独自のライブラリを作成する必要がありました。
図書館を実現します
必要な機能を取得するために、他のライブラリから既存のソリューションを完成させました。 彼らは何が起こったのか控えめな名前のAndroidギャラリーを与えることにしました。
機能を実装します
写真の表示とズーム
写真を表示するために、PhotoViewライブラリを使用しました。これは、すぐに使用できるスケーリングをサポートしています。
ビデオを見る
ビデオを見るために、彼らはMediaPagerAdapterで再利用されるExoPlayerを取りました 。 ユーザーが初めてビデオを開くと、ExoPlayerが作成されます。 別の要素に切り替えるとキューに入れられるため、次にビデオを開始するときに、既に作成されているExoPlayerインスタンスが使用されます。 これにより、要素間の移行がよりスムーズになります。
メディアコンテンツのスクロール
ここでは、FrescoImageViewerのMultiTouchViewPagerを使用しましたが、これはマルチタッチイベントをインターセプトしないため、ジェスチャを追加して画像を拡大縮小することができました。
スワイプして閉じます
PhotoViewは、スワイプを非表示にしてデバウンスをサポートしていません(画像を拡大または縮小したときに元の画像サイズを復元します)。
これをどのように処理したかを次に示します。
タッチイベントを学習して、スワイプして実装します
スワイプをサポートして却下する前に、タッチイベントの仕組みを理解する必要があります。 ユーザーが画面に触れると、現在のアクティビティでdispatchTouchEvent(motionEvent: MotionEvent)
メソッドが呼び出され、 MotionEvent.ACTION_DOWN
がMotionEvent.ACTION_DOWN
ます。 このメソッドは、イベントの運命を決定します。 タッチ処理のためにmotionEvent
をonTouchEvent(motionEvent: MotionEvent)
にonTouchEvent(motionEvent: MotionEvent)
か、View階層で上から下にさらにプッシュできます。 ACTION_UP
より前のイベントおよび/または後続のイベントに関心があるビューは、trueを返します。
その後、ジェスチャがACTION_UP
イベントで終了するか、親ViewGroupが制御をACTION_UP
するまで、現在のジェスチャのすべてのイベントがこのビューに分類されます(その後、 ACTION_CANCELED
イベントが表示されます)。 イベントがビュー階層全体をonTouchEvent(motionEvent: MotionEvent)
し、誰も関心を持たなかった場合、 onTouchEvent(motionEvent: MotionEvent)
のアクティビティonTouchEvent(motionEvent: MotionEvent)
に戻ります。
Androidギャラリーライブラリでは、最初のACTION_DOWN
イベントがACTION_DOWN
dispatchTouchEvent()
に到達し、そこでmotionEvent
trueを返すonTouch()
実装に渡されます。 さらに、すべてのイベントは、次のいずれかになるまで同じチェーンを通過します。
-
ACTION_UP
; - ViewPagerは、スクロールのためにイベントをインターセプトしようとします。
- VerticalDragLayoutは、スワイプしてイベントをインターセプトして終了しようとします。
onInterceptTouchEvent(motionEvent: MotionEvent)
メソッドのonInterceptTouchEvent(motionEvent: MotionEvent)
のみがイベントをインターセプトできます。 ビューがMotionEventに関心がある場合でも、イベント自体は前のViewGroupチェーン全体のdispatchTouchEvent(motionEvent: MotionEvent)
通過します。 したがって、親は常に子供を「見る」。 すべての親ViewGroupはイベントをインターセプトし、 onInterceptTouchEvent(motionEvent: MotionEvent)
でtrueを返すことができ、すべての子MotionEvent.ACTION_CANCEL
はonTouchEvent(motionEvent: MotionEvent)
でonTouchEvent(motionEvent: MotionEvent)
を受け取ります。
例:ユーザーがRecyclerViewの要素に指を当てると、同じ要素でイベントが処理されます。 しかし、指を上下に動かし始めるとすぐに、RecyclerViewはイベントをインターセプトし、スクロールが開始され、ビューはACTION_CANCEL
イベントを受け取ります。
Androidギャラリーで、VerticalDragLayoutは、スワイプでイベントをインターセプトして、ViewPagerでスクロールできます。 ただし、ViewはrequestDisallowInterceptTouchEvent(true)
メソッドを呼び出すことにより、親がイベントをrequestDisallowInterceptTouchEvent(true)
するのを防ぐことができます。 これは、ビューがそのようなアクションを実行する必要がある場合に必要になる可能性があり、そのインターセプトは親にとって望ましくありません。
たとえば、プレーヤーのユーザーが特定の時間までトラックをスキップした場合。 親ViewPagerが水平スクロールをインターセプトした場合、次のトラックへの移行があります。
スワイプを無視して処理するために、VerticalDragLayoutを作成しましたが、PhotoViewからタッチイベントを受け取りませんでした。 これが起こる理由を理解するには、PhotoViewでタッチイベントがどのように処理されるかを把握する必要がありました。
処理順序:
- VerticalDragLayoutのMotionEvent.ACTION_DOWNの場合、
interceptTouchEvent()
トリガーされ、falseを返します。 このViewGroupは垂直ACTION_MOVEのみに関心があります。 ACTION_MOVE方向はdispatchTouchEvent()
で定義され、その後、イベントはsuper.dispatchTouchEvent()
メソッドに渡され、そこでイベントはVerticalDragLayoutのinterceptTouchEvent()
実装に渡されます。
-
ACTION_DOWN
イベントがonTouch()
メソッドに到達すると、ビューはイベント管理をインターセプトする機能を奪います。 後続のすべてのジェスチャーイベントは、interceptTouchEvent()
メソッドに分類されません。 コントロールをインターセプトする機能は、ジェスチャが完了した場合、または画像の左右の境界で水平方向のACTION_MOVE
した場合にのみ親に与えられます。
if (mAllowParentInterceptOnEdge && !mScaleDragDetector.isScaling() && !mBlockParentIntercept) { if (mScrollEdge == EDGE_BOTH || (mScrollEdge == EDGE_LEFT && dx >= 1f) || (mScrollEdge == EDGE_RIGHT && dx <= -1f)) { if (parent != null) { parent.requestDisallowInterceptTouchEvent(false); } } } else { if (parent != null) { parent.requestDisallowInterceptTouchEvent(true); } }
PhotoViewでは、親が水平のACTION_MOVE
場合にのみ制御を取得でき、スワイプして閉じると垂直のACTION_MOVE
であるため、VerticalDragLayoutはイベントコントロールをインターセプトしてジェスチャーを実装できません。 これを修正するには、垂直のACTION_MOVE
場合に制御をインターセプトする機能を追加する必要があります。
if (mAllowParentInterceptOnEdge && !mScaleDragDetector.isScaling() && !mBlockParentIntercept) { if (mHorizontalScrollEdge == HORIZONTAL_EDGE_BOTH || (mHorizontalScrollEdge == HORIZONTAL_EDGE_LEFT && dx >= 1f) || (mHorizontalScrollEdge == HORIZONTAL_EDGE_RIGHT && dx <= -1f) || mVerticalScrollEdge == VERTICAL_EDGE_BOTH || (mVerticalScrollEdge == VERTICAL_EDGE_TOP && dy >= 1f) || (mVerticalScrollEdge == VERTICAL_EDGE_BOTTOM && dy <= -1f)) { if (parent != null) { parent.requestDisallowInterceptTouchEvent(false); } } } else { if (parent != null) { parent.requestDisallowInterceptTouchEvent(true); } }
さて、最初の垂直の場合、
ACTION_MOVE
PhotoViewは親をインターセプトする機会を与えます:
次のACTION_MOVE
はVerticalDragLyoutでインターセプトされ、ACTION_CANCEL
イベントは子ビューに飛びます:
他のすべてのACTION_MOVE
は、標準チェーンに沿ってVerticalDragLayoutに移動します。 ViewGroupが子ビューからイベントを制御した後、子ビューが制御を取り戻せないことが重要です。
そこで、PhotoViewライブラリのサポートを終了するためにスワイプを実装しました。 ライブラリでは、PhotoViewの変更されたソースを別のモジュールで取り出して使用し、元のPhotoViewリポジトリにマージリクエストを作成しました。
PhotoViewにバインド解除を実装します
デバウンスとは、画像がその限界を超えてスケーリングされた場合の許容可能なスケールのアニメーション復元であることを思い出してください。
PhotoViewではそのような可能性はありませんでした。 しかし、私たちは他の誰かのオープンソースを掘り始めたのに、なぜそこで停止するのですか PhotoViewでは、ズーム制限を設定できます。 最初は、これは最小-x1および最大-x3です。 画像はこれらの制限を超えることはできません。
@Override public void onScale(float scaleFactor, float focusX, float focusY) { if ((getScale() < mMaxScale || scaleFactor < 1f) && (getScale() > mMinScale || scaleFactor > 1f)) { if (mScaleChangeListener != null) { mScaleChangeListener.onScaleChange(scaleFactor, focusX, focusY); } mSuppMatrix.postScale(scaleFactor, scaleFactor, focusX, focusY); // checkAndDisplayMatrix(); } }
はじめに、「最小値に達したときのスケーリングの禁止」条件を削除することにしました。単に
getScale() > mMinScale || scaleFactor > 1f
という条件をgetScale() > mMinScale || scaleFactor > 1f
しました。getScale() > mMinScale || scaleFactor > 1f
。 そして、突然...
デブンを獲得しました! どうやら、これはライブラリの作成者がライブラリを2回安全に再生することを決定し、デバウンスとスケーリングの制限の両方を行ったために発生したようです。 onTouchイベントの実装、つまりMotionEvent.ACTION_UP
場合、ユーザーが最大/最小よりも大きい/小さい場合、 AnimatedZoomRunnableが起動され、画像が元のサイズに戻ります。
@Override public boolean onTouch(View v, MotionEvent ev) { boolean handled = false; switch (ev.getAction()) { case MotionEvent.ACTION_UP: // If the user has zoomed less than min scale, zoom back // to min scale if (getScale() < mMinScale) { RectF rect = getDisplayRect(); if (rect != null) { v.post(new AnimatedZoomRunnable(getScale(), mMinScale, rect.centerX(), rect.centerY())); handled = true; } } else if (getScale() > mMaxScale) { RectF rect = getDisplayRect(); if (rect != null) { v.post(new AnimatedZoomRunnable(getScale(), mMaxScale, rect.centerX(), rect.centerY())); handled = true; } } break; } }
スワイプで閉じるだけでなく、ライブラリのソースでPhotoViewを確定し、元のPhotoViewにデバウンスを「追加」してプルリクエストを作成しました。
PhotoViewの突然のバグを修正
PhotoViewには非常に厄介なバグがあります。 ユーザーがダブルタップで画像を拡大したい場合
彼はてんかんの発作を起こしている画像のスケーリングが開始され、垂直に180度反転できます。 このバグは、CyanなどのGoogle Playの人気のあるアプリケーションでも見つかります。
長い検索の後、このバグをローカライズしました:時々、負のscaleFactorがスケーリングのための画像スケーリングのために入力行列に与えられ、それにより画像が反転します。
@Override public boolean onScale(ScaleGestureDetector detector) { // - scaleFactor < 0 // float scaleFactor = detector.getScaleFactor(); if (Float.isNaN(scaleFactor) || Float.isInfinite(scaleFactor)) return false; // scaleFactor callback // mListener.onScale(scaleFactor, detector.getFocusX(), detector.getFocusY()); return true; }
Android ScaleGestureDetectorからスケーリングするには 、次のように計算されるscaleFactor を取得します。
public float getScaleFactor() { if (inAnchoredScaleMode()) { // Drag is moving up; the further away from the gesture // start, the smaller the span should be, the closer, // the larger the span, and therefore the larger the scale final boolean scaleUp = (mEventBeforeOrAboveStartingGestureEvent && (mCurrSpan < mPrevSpan)) || (!mEventBeforeOrAboveStartingGestureEvent && (mCurrSpan > mPrevSpan)); final float spanDiff = (Math.abs(1 - (mCurrSpan / mPrevSpan)) * SCALE_FACTOR); return mPrevSpan <= 0 ? 1 : scaleUp ? (1 + spanDiff) : (1 - spanDiff); } return mPrevSpan > 0 ? mCurrSpan / mPrevSpan : 1; }
デバッグログでこのメソッドをオーバーライドすると、負のscaleFactorが取得される変数の特定の値を追跡できます。
mEventBeforeOrAboveStartingGestureEvent is true; SCALE_FACTOR is 0.5; mCurrSpan: 1075.4398; mPrevSpan 38.867798; scaleUp: false; spanDiff: 13.334586; eval result is -12.334586
spanDiffにSCALE_FACTOR == 0.5を掛けることにより、この問題を解決しようとした疑いがあります。 しかし、mCurrSpanとmPrevSpanの違いが3倍以上の場合、このソリューションは役に立ちません。 このバグのチケットはすでに開始されていますが、まだ修正されていません。
松葉杖この問題の最も簡単な解決策は、負のscaleFactor値を単純にスキップすることです。 実際には、ユーザーは、画像が通常よりもわずかに滑らかにズームされないことがあります。
結論の代わりに
プルリクエストの運命
ローカルで修正を行い、PhotoViewで最後のプルリクエストを作成しました。 いくつかのPRが1年間ハングしているという事実にもかかわらず、PRがmasterブランチに追加され、PhotoViewの新しいリリースでさえリリースされました。 その後、Androidギャラリーからローカルモジュールを切り取り、公式のPhotoViewソースを取得することにしました。 これを行うには、バージョン2.1.3で PhotoViewに追加されたAndroidXのサポートを追加する必要がありました。
ライブラリの場所
ここでAndroidギャラリーライブラリのソースコード( https://github.com/redmadrobot-spb/android-gallery )を使用手順とともに探します。 また、サポートライブラリを引き続き使用するプロジェクトをサポートするために、 android-gallery-deprecatedの別個のバージョンを作成しました。 ただし、サポートライブラリは1年でカボチャになるので注意してください!
次は何ですか
ライブラリは完全に私たちに合っていますが、開発の過程で新しいアイデアが生まれました。 それらのいくつかを次に示します。
- 個別のFragmentDialogだけでなく、任意のレイアウトでライブラリを使用する機能。
- UIをカスタマイズする機能。
- GildeとExoPlayerを置き換える機能。
- ViewPagerの代わりに何かを使用する機能。
参照資料
- わずかに異なる視点からのAndroidタッチシステム -トピックに関する非常に優れた記事で、他の優れた記事やビデオへのリンクがあり、Androidタッチシステムの仕組みを詳しく説明しています。
- Androidのジェスチャーの習得 -ロシア語のAndroid Touch Systemに関する記事。
UPD
記事の執筆中に、 FrescoImageViewerの開発者からの同様のライブラリ が登場しました 。 トランジションアニメーションのサポートが追加されましたが、これまでのところビデオのサポートしかありません。 :)