How we made our library Android Gallery for viewing media content

Published on December 28, 2018

How we made our library Android Gallery for viewing media content



    Hi, Habr! Not so long ago, in search of adventure, new projects and technologies, Ibecame a robotsettled in redmadrobot. I received a chair, a monitor and a MacBook, and for warming up, a small internal project. It was necessary to finish and publish a samopisny library for viewing media content that we use in our projects. In the article I will tell you how to get into touch events in a week, to become an open source, find the bug in Android sdk and publish the library.


    Start


    One of the important features of our application stores is the ability to view videos and photos of goods and services in close proximity and on all sides. We did not want to reinvent the wheel and went in search of a ready-made library that would suit us.


    Planned to find such a solution so that the user could:


    • view photos;
    • scale the photo with pinch to zoom and double tap;
    • view video;
    • flipping media content;
    • close the photo card with a vertical swipe (swipe to dismiss).

    Here is what we found:


    • FrescoImageViewer - supports viewing and scrolling photos and basic gestures, but does not support video viewing and is intended for the Fresco library .
    • PhotoView - supports viewing photos, most of the basic gestures of management, except for scrolling, swipe to dismiss, does not support viewing videos.
    • PhotoDraweeView - PhotoView is similar in functionality, but designed for Fresco.

    Since none of the found libraries fully met the requirements, we had to write our own.


    We implement the library


    To get the desired functionality, we have refined existing solutions from other libraries. What turned out, they decided to give the modest name of the Android Gallery .


    We implement functionality


    Viewing and scaling photos
    To view photos, we took the PhotoView library, which supports scaling out of the box.


    Watching the video
    For viewing the video they took ExoPlayer , which is reused in the MediaPagerAdapter . When a user opens a video for the first time, ExoPlayer is created. When you move to another item, it is queued, so the next time you start the video, the already created instance of ExoPlayer will be used. This makes the transition between elements smoother.


    Flipping through media content
    Here we used MultiTouchViewPager from FrescoImageViewer, which does not intercept multi touch events, so we were able to add gestures to it to scale the image.


    Swipe to dismiss
    In PhotoView, there was no support for swipe to dismiss and debowns (restoring the original size of the picture when the picture is scaled up or down).
    That's how we managed to handle it.


    We study touch events to implement swipe to dismiss


    Before moving on to support swipe to dismiss, you need to figure out how touch events work. When the user touches the screen, the current Activity method invokes dispatchTouchEvent(motionEvent: MotionEvent)where it goes MotionEvent.ACTION_DOWN. This method decides the further fate of the event. You can pass motionEventin onTouchEvent(motionEvent: MotionEvent)to touch processing or start further, from top to bottom on the View hierarchy. A view that is interested in the event and / or in subsequent events before ACTION_UPreturns true.


    After all events of the current gesture (gesture) will fall into this View, until the gesture ends with an event ACTION_UPor the parent ViewGroup takes control (then an event will come to the View ACTION_CANCELED). If an event bypasses the entire hierarchy of View and no one is interested, it returns back to Activity in onTouchEvent(motionEvent: MotionEvent).



    In our Android Gallery library, the first event ACTION_DOWNcomes to dispatchTouchEvent()PhotoView, where it is motionEventpassed to the implementation onTouch()that returns true. Then all the events go through the same chain, until one of the following happens:


    • ACTION_UP;
    • The ViewPager will attempt to intercept the event for scrolling;
    • VerticalDragLayout will attempt to intercept the event for the swipe to dismiss.

    Event interception can be performed only by the ViewGroup in the method onInterceptTouchEvent(motionEvent: MotionEvent). Even if View is interested in a MotionEvent, the event itself will pass through the dispatchTouchEvent(motionEvent: MotionEvent)entire previous ViewGroup chain. Accordingly, parents always “observe” their children. Any parent ViewGroup can intercept the event and return true in the method onInterceptTouchEvent(motionEvent: MotionEvent), then all child View will receive MotionEvent.ACTION_CANCELin onTouchEvent(motionEvent: MotionEvent).


    Example: a user holds a finger on a certain item in RecyclerView, then events are processed in the same item. But as soon as he starts moving his finger up / down, RecyclerView intercepts the events and starts scrolling, and View gets the event ACTION_CANCEL.



    In Android, Gallery VerticalDragLayout can intercept events for swipe to dismiss or ViewPager for browsing. But View can forbid a parent to intercept events by calling a method requestDisallowInterceptTouchEvent(true). This may be necessary if View needs to perform such actions, the interception of which by its parent is not desirable for us.


    For example, when a user in a player scrolls a track to a specific time. If the parent ViewPager intercepted the horizontal scroll, there would be a transition to the next track.


    We wrote VerticalDragLayout to handle the swipe to dismiss, but it didn’t receive a touch of events from PhotoView. To understand why this is happening, I had to figure out how touch events are handled in PhotoView.


    Processing order:


    1. When MotionEvent.ACTION_DOWN in VerticalDragLayout is triggered interceptTouchEvent(), it returns false, because This ViewGroup is only interested in vertical ACTION_MOVE. The direction of ACTION_MOVE is defined in dispatchTouchEvent(), after which the event is passed to the method super.dispatchTouchEvent()in the ViewGroup, where the event is passed to the implementation interceptTouchEvent()in VerticalDragLayout.



    2. When an event ACTION_DOWNreaches a method onTouch()in PhotoView, the view selects the ability to intercept event management. All subsequent gesture events do not fall into the method interceptTouchEvent(). The ability to intercept control is given to the parent only if the gesture is completed or if the horizontal occurs ACTION_MOVEat the right / left border of the image.
      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);
          }
      }



      Since PhotoView allows the parent to intercept control only in the case of horizontal ACTION_MOVE, and the swipe to dismiss is vertical ACTION_MOVE, VerticalDragLayout cannot intercept event management to make a gesture. To fix, you need to add the ability to intercept controls in the case of vertical 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);
      }
      }

      Now in the case of the first vertical, the ACTION_MOVEPhotoView will allow the parent to intercept:



      The following ACTION_MOVEwill be intercepted in VerticalDragLyout, and the event will fly to the children View ACTION_CANCEL:



      All others ACTION_MOVEwill arrive in VerticalDragLayout via a standard chain. It is important that after the ViewGroup intercepts event management from the child View, the child View can not regain control.



      So we implemented swipe to dismiss support for the PhotoView library. In our library, we used the modified SourceView sources, made in a separate module, and in the original PhotoView repository we created a merge request .

      Implement debowns in PhotoView


      Recall that the debounce is an animation of restoring the allowable scale when the image is scaled beyond its limits.



      In PhotoView, this was not possible. But since we began to dig someone else's open source, why stop there? In PhotoView, you can set a limit on the zoom. Initially, this is the minimum - x1 and maximum - x3. The image cannot go beyond these limits.



      @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();
          }
      }

      To begin with, we decided to remove the “prohibition of scaling upon reaching the minimum” condition: we simply threw out the condition getScale() > mMinScale || scaleFactor > 1f. And then suddenly ...



      Deboons earned! Apparently, this happened due to the fact that the creators of the library decided to double insure themselves by doing both the debounce and the scaling restriction. In the implementation of the onTouch event, namely MotionEvent.ACTION_UP, if the user has scaled more / less than the maximum / minimum, AnimatedZoomRunnable is launched , which returns the image to its original size.


      @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;
          }
      }

      As well as with the swipe to dismiss, we finalized PhotoView in the source code of our library and created a pull request with the addition of a debounce to the original PhotoView.


      Fix a sudden bug in PhotoView


      There is a very nasty bug in PhotoView. When the user wants to enlarge the image with a double tap andhe has an epileptic seizurethe image starts to scale, it can roll 180 degrees vertically. This bug can be found even in popular applications from Google Play, for example, in CIAN.



      After a long search, we still localized this bug: sometimes a negative scaleFactor is fed into the matrix image transformation to be scaled to the input, which is what causes the image to be flipped.


      CustomGestureDetector


      @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;
      }

      To scale from the Androids ScaleGestureDetector, we need scaleFactor, which is calculated as follows:


      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;
      }

      If you impose debag logs on this method, you can track exactly what values ​​of the variables result in a negative 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

      There is a suspicion that they tried to solve this problem by multiplying the spanDiff by SCALE_FACTOR == 0.5. But this solution will not help if the difference between mCurrSpan and mPrevSpan is more than three times. This bug has even got a ticket , but it has not been fixed yet.
      KostylnyThe simplest solution to this problem is to simply skip the negative values ​​of scaleFactor. In practice, the user will not notice that the image is sometimes zoomed slightly less smoothly than usual.


      Instead of conclusion


      The fate of pull requests


      We made a local fix and created the last pull request in PhotoView. Despite the fact that some PRs have been hanging there for a year, our PRs have been added to the master branch and even a new release of PhotoView has been released. After that, we decided to cut the local module from the Android Gallery and pull up the official PhotoView sources. For this, we had to add support for AndroidX, which was added to PhotoView in version 2.1.3 .


      Where to find a library


      The source code of the Android Gallery library is here - https://github.com/redmadrobot-spb/android-gallery , along with instructions for use. And to support projects that still use the Support Library, we have created a separate version of android-gallery-deprecated . But be careful, because after a year the Support Library will turn into a pumpkin!


      What's next


      Now the library completely suits us, but in the process of developing new ideas have arisen. Here are some of them:


      • the ability to use the library in any layout, not just a separate FragmentDialog;
      • ability to customize the UI;
      • the possibility of replacing Gilde and ExoPlayer;
      • the ability to use something instead of the ViewPager.

      Links


      • A very good article on the topic with links to other excellent articles and videos, which describes in detail the principle of the Android Touch System.
      • Mastering gestures in Android - an article about the Android Touch System in Russian.

      UPD


      While writing the article, a similar library from the FrescoImageViewer developers was released . They added support for the transition animation , but we only have support for the video so far. :)