📜 ⬆️ ⬇️

How we made our Android Gallery library to view media content



Hi, Habr! Not so long ago, in search of adventure, new projects and technologies, I became a robot settled 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 a bug in Android sdk and publish a 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.


They planned to find such a solution so that the user could:



Here is what we found:



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


View and zoom photos
To view the photos, they took the PhotoView library, which supports scaling out of the box.


View video
To view 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 media content
Here we used MultiTouchViewPager from FrescoImageViewer, which does not intercept multi touch events, so we could 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, in the current Activity, the dispatchTouchEvent(motionEvent: MotionEvent) method is called, to which MotionEvent.ACTION_DOWN falls. This method decides the further fate of the event. You can pass a motionEvent to onTouchEvent(motionEvent: MotionEvent) to handle the touch or move it further up the hierarchy of the View. A view that is interested in the event and / or in subsequent events prior to ACTION_UP returns true.


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


')
In our Android Gallery library, the first ACTION_DOWN event comes to dispatchTouchEvent() in PhotoView, where motionEvent passed to the onTouch() implementation, which returns true. Then all the events go through the same chain, until one of the following happens:



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


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



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 the requestDisallowInterceptTouchEvent(true) method. 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 he 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, interceptTouchEvent() fired, which returns false, since 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 super.dispatchTouchEvent() method in ViewGroup, where the event is passed to the implementation of interceptTouchEvent() in VerticalDragLayout.



  2. When the ACTION_DOWN event reaches the onTouch() method in PhotoView, the view selects the ability to intercept event management. All subsequent gesture events do not fall into the interceptTouchEvent() method. The ability to intercept control is given to the parent only if the gesture is completed or if the horizontal ACTION_MOVE at 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 swipe to dismiss is vertical ACTION_MOVE , VerticalDragLayout cannot intercept event management for the 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 ACTION_MOVE PhotoView will allow the parent to intercept



    The following ACTION_MOVE will be intercepted in VerticalDragLyout, while the ACTION_CANCEL event will arrive in the children View:



    All other ACTION_MOVE will arrive in VerticalDragLayout on 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 source codes, which were moved to a separate module, and merge request was created in the original PhotoView repository.

    Implement debowns in PhotoView


    Recall that debounce is an animation of restoring an acceptable scale when an 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 zoom limit. 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 “no scaling on reaching the minimum” condition: we simply threw out the getScale() > mMinScale || scaleFactor > 1f getScale() > mMinScale || scaleFactor > 1f . And then suddenly ...



    Debown 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 in the case of 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; } } 

    Just like with the swipe to dismiss, we finalized the PhotoView in the source code of our library and created a pull request with the addition of a debown 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 by double tap and he has an epileptic seizure the 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 for scaling, this 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 variable values ​​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 this problem was tried to be solved 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.
    Kostylny The 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 nice 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. :)

Source: https://habr.com/ru/post/433076/


All Articles