⬆️ ⬇️

Custom View, scrolling and gestures in Android on the example of a simple image viewer

The article describes the details of the implementation of a simple viewer of pictures and shows some subtleties of scrolling implementation and gesture processing.



So, let's begin. We will develop applications for viewing images. The finished application looks like this (although the screenshots, of course, weakly convey functionality):

imageimage

You can install the application either from the Market or by manually installing from here . Source code is available here .



The main element of our application is the ImageViewer class, which we will develop. But it should also be noted that in order to select a file for viewing, I did not reinvent the wheel and took the finished “component” here .



A component is an activity that is called when starting from the main activity. After selecting a file, we load it and display it on the screen using the ImageViewer class. Consider the class in more detail.

')

A class is a successor of the View class and overrides only one of its onDraw method. The class also contains a constructor and an image loading method:

public class ImageViewer extends View { private Bitmap image = null; public ImageViewer(Context context) { super(context); } @Override public void onDraw(Canvas canvas) { if (image != null) canvas.drawBitmap(image, 0, 0, null); } public void loadImage(String fileName) { image = BitmapFactory.decodeFile(fileName); } } 


If we load the image in size larger than the screen of the smartphone, then only part of it will be displayed and we will not have a way to move or reduce it.



Now add the ability to scroll. Scrolling is essentially a gesture in which the user touches the screen with his finger, moves it without lifting it, and releases it. In order to be able to handle events associated with the touch-screen, you need to override the onTouchEvent method. The method accepts one parameter of the MotionEvent type and must return true if the event is processed. Through this method, you can implement support for any gesture, including scrolling.

To recognize the scrolling, we need to fix the moment of touching, moving and releasing. Fortunately, there is no need to do it manually, as in the Android SDK there is a class that does all the work for us. Thus, in order to recognize the scrolling gesture, you need to add a field of type GestureDetector to our class that is initialized by the object implementing the OnGestureListener interface (this object will receive scrolling events). You also need to override the onTouchEvent method in the ImageViewer class and pass event handling from it to our object of type OnGestureListener. The modified ImageViewer class (without unchanged methods) is presented below:

 public class ImageViewer extends View { private Bitmap image = null; private final GestureDetector gestureDetector; public ImageViewer(Context context) { super(context); gestureDetector = new GestureDetector(context, new MyGestureListener()); } @Override public boolean onTouchEvent(MotionEvent event) { if (gestureDetector.onTouchEvent(event)) return true; return true; } private class MyGestureListener extends SimpleOnGestureListener { @Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { scrollBy((int)distanceX, (int)distanceY); return true; } } } 


As you can see, in fact, we inherit MyGestureListener not from OnGestureListener, but from SimpleOnGestureListener. The latter class simply implements the OnGestureListener interface using empty methods. By this we save ourselves from the implementation of all methods, choosing only those that are needed.



Now if you upload a big picture, we at least can scroll it. But: firstly, we can scroll beyond the picture, secondly there are no scrollbars that would tell us where we are and how much is left to the edges.



Let's start the second problem first. An Internet search leads us to override the computeHorizontalScrollRange and computeVerticalScrollRange methods. These methods should return the actual size of the image (in fact, there are more methods related to scrollbars - these are computeHorizontalScrollExtent, computeHorizontalScrollOffset and the same pair for vertical scrollbar. If you override them, then you can return more arbitrary values). But this is not enough - the scrollbars in the first need to be included, in the second they should be initialized. They are included by the setHorizontalScrollBarEnabled and setVerticalScrollBarEnabled methods, initialized by the initializeScrollbars method. But bad luck - the latter method takes a slightly obscure parameter of type TypedArray. This parameter should contain a set of standard attributes for View. The list can be seen here in the XML Attributes table. If we created our view from XML, Android runtime would automatically make such a list. But since we are creating the class programmatically, we must also create this list programmatically. To do this, you need to create an attrs.xml file in the res \ values ​​directory with the following contents:

 <?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="View"> <attr name="android:background"/> <attr name="android:clickable"/> <attr name="android:contentDescription"/> <attr name="android:drawingCacheQuality"/> <attr name="android:duplicateParentState"/> <attr name="android:fadeScrollbars"/> <attr name="android:fadingEdge"/> <attr name="android:fadingEdgeLength"/> <attr name="android:fitsSystemWindows"/> <attr name="android:focusable"/> <attr name="android:focusableInTouchMode"/> <attr name="android:hapticFeedbackEnabled"/> <attr name="android:id"/> <attr name="android:isScrollContainer"/> <attr name="android:keepScreenOn"/> <attr name="android:longClickable"/> <attr name="android:minHeight"/> <attr name="android:minWidth"/> <attr name="android:nextFocusDown"/> <attr name="android:nextFocusLeft"/> <attr name="android:nextFocusRight"/> <attr name="android:nextFocusUp"/> <attr name="android:onClick"/> <attr name="android:padding"/> <attr name="android:paddingBottom"/> <attr name="android:paddingLeft"/> <attr name="android:paddingRight"/> <attr name="android:paddingTop"/> <attr name="android:saveEnabled"/> <attr name="android:scrollX"/> <attr name="android:scrollY"/> <attr name="android:scrollbarAlwaysDrawHorizontalTrack"/> <attr name="android:scrollbarAlwaysDrawVerticalTrack"/> <attr name="android:scrollbarDefaultDelayBeforeFade"/> <attr name="android:scrollbarFadeDuration"/> <attr name="android:scrollbarSize"/> <attr name="android:scrollbarStyle"/> <attr name="android:scrollbarThumbHorizontal"/> <attr name="android:scrollbarThumbVertical"/> <attr name="android:scrollbarTrackHorizontal"/> <attr name="android:scrollbarTrackVertical"/> <attr name="android:scrollbars"/> <attr name="android:soundEffectsEnabled"/> <attr name="android:tag"/> <attr name="android:visibility"/> </declare-styleable> </resources> 


The file simply lists all the attributes that were specified in the table mentioned above (except for some that are indicated by the compiler as an error — apparently the documentation lists the latest one). Changed ImageViewer class (except fixed methods):

 public class ImageViewer extends View { private Bitmap image = null; private final GestureDetector gestureDetector; public ImageViewer(Context context) { super(context); gestureDetector = new GestureDetector(context, new MyGestureListener()); // init scrollbars setHorizontalScrollBarEnabled(true); setVerticalScrollBarEnabled(true); TypedArray a = context.obtainStyledAttributes(R.styleable.View); initializeScrollbars(a); a.recycle(); } @Override protected int computeHorizontalScrollRange() { return image.getWidth(); } @Override protected int computeVerticalScrollRange() { return image.getHeight(); } } 


I don’t want to stop there, so let's add support for the fling gesture. This gesture is simply an addition to the scrolling gesture, but the speed of moving the finger in the last moments (before releasing) is taken into account, and if it is not zero, the scrolling continues with a gradual fading. Support for this gesture is already in GestureDetector - so we only need to override the onFling method in the MyGestureListener class. Having caught this event, we need to change the position of scrolling for some time. Of course, this can be done "manually" using timers or something else, but again, in the Android SDK, there is already a class that implements the necessary functionality. Therefore, we need to add another Scroller type field to the ImageViewer class, which will deal with “residual” scrolling - to start scrolling, you need to call its fling method. You also need to show scrollbars (they are hidden when they are not needed) by calling the awakenScrollBars method. And the last thing you need to do is override the computeScroll method, which should directly do scrolling using the scrollTo method (the Scroller class itself does not scroll - it just works with coordinates). The code for the modified ImageViewer class is presented below:

 public class ImageViewer extends View { private Bitmap image = null; private final GestureDetector gestureDetector; private final Scroller scroller; public ImageViewer(Context context) { super(context); gestureDetector = new GestureDetector(context, new MyGestureListener()); scroller = new Scroller(context); // init scrollbars setHorizontalScrollBarEnabled(true); setVerticalScrollBarEnabled(true); TypedArray a = context.obtainStyledAttributes(R.styleable.View); initializeScrollbars(a); a.recycle(); } @Override public void computeScroll() { if (scroller.computeScrollOffset()) { int oldX = getScrollX(); int oldY = getScrollY(); int x = scroller.getCurrX(); int y = scroller.getCurrY(); scrollTo(x, y); if (oldX != getScrollX() || oldY != getScrollY()) { onScrollChanged(getScrollX(), getScrollY(), oldX, oldY); } postInvalidate(); } } private class MyGestureListener extends SimpleOnGestureListener { @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { scroller.fling(getScrollX(), getScrollY(), -(int)velocityX, -(int)velocityY, 0, image.getWidth() - getWidth(), 0, image.getHeight() - getHeight()); awakenScrollBars(scroller.getDuration()); return true; } } } 


At the end of the conversation about fling, you need to make one small thing - when you touch your finger while scrolling away from a throw, you need to stop scrolling. This time we will do it “manually” in the onTouchEvent method. The modified method is presented below:

 @Override public boolean onTouchEvent(MotionEvent event) { // check for tap and cancel fling if ((event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN) { if (!scroller.isFinished()) scroller.abortAnimation(); } if (gestureDetector.onTouchEvent(event)) return true; return true; } 


You can already admire quite interesting physics, but you can see some "glitches" when scrolling outside the picture. This is due to the fact that fling only works within the picture, and scrolling works without throwing everywhere. Those. we will be able to go beyond the picture only if it is scrolled very smoothly (so that fling does not work). It is possible to correct this “cant” by introducing a restriction on processing into the onFling method and process the throw only if it does not go beyond the boundaries of the picture. The modified method is presented below:

 @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { boolean scrollBeyondImage = ((getScrollX() < 0) || (getScrollX() > image.getWidth()) || (getScrollY() < 0) || (getScrollY() > image.getHeight())); if (scrollBeyondImage) return false; scroller.fling(getScrollX(), getScrollY(), -(int)velocityX, -(int)velocityY, 0, image.getWidth() - getWidth(), 0, image.getHeight() - getHeight()); awakenScrollBars(scroller.getDuration()); return true; } 


Now, again, we can freely scroll beyond the picture. It seems that we have already recalled this problem ... She has an elegant solution, which lies in the fact that when you release your finger (at the end of scrolling beyond the picture) you need to return the picture smoothly to the “proper” place. And again we will do this “manually” in the onTouchEvent method:

 @Override public boolean onTouchEvent(MotionEvent event) { // check for tap and cancel fling if ((event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN) { if (!scroller.isFinished()) scroller.abortAnimation(); } if (gestureDetector.onTouchEvent(event)) return true; // check for pointer release if ((event.getPointerCount() == 1) && ((event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_UP)) { int newScrollX = getScrollX(); if (getScrollX() < 0) newScrollX = 0; else if (getScrollX() > image.getWidth() - getWidth()) newScrollX = image.getWidth() - getWidth(); int newScrollY = getScrollY(); if (getScrollY() < 0) newScrollY = 0; else if (getScrollY() > image.getHeight() - getHeight()) newScrollY = image.getHeight() - getHeight(); if ((newScrollX != getScrollX()) || (newScrollY != getScrollY())) { scroller.startScroll(getScrollX(), getScrollY(), newScrollX - getScrollX(), newScrollY - getScrollY()); awakenScrollBars(scroller.getDuration()); } } return true; } 


Now we can say with confidence that we figured out the scrolling. We can move to the last gesture that I would like to implement - this is a pinch zoom gesture.



From the side, the gesture looks like stretching or compressing something imaginary on the smartphone screen with two fingers. Step by step gesture happens like this: pressing with one finger, pressing with the second finger, changing the position of one or two fingers without releasing, releasing the second finger. To determine the magnitude of scaling, it is necessary to calculate the ratio between the distances between the fingers at the moment of the start of the gesture and at the moment of the end of the gesture. The distance between the fingers is in the formula sqrt (pow (x2 - x1, 2) + pow (y2 - y1, 2)). You also need to note some position of the scrolling that you want to save - because if you gesture to enlarge the picture, then the position of the scrolling will change (due to the resized image). This position - or rather the point, the position of which you want to maintain, in the terminology of the Android SDK is called the focal point, and it is located in the middle between two fingers.

You can implement the gesture yourself as always, but this, fortunately, has already been implemented in the Android SDK (albeit only since version 2.2). This will be helped by the ScaleGestureDetector class, the instance of which will be added to our class. The ScaleGestureDetector is initialized with an object that supports the OnScaleGestureListener interface, so we will also create an internal class MyScaleGestureListener that implements the onScaleBegin, onScale and onScaleEnd methods. Don't forget to transfer control to the ScaleGestureDetector from the onTouchEvent method. And the most important thing is that you need to somehow use the scaling data: they need to be taken into account in all places where the width and height of the image used to appear (that is, you actually need to multiply these parameters by the scaling factor). The final code of the ImageViewer class can be viewed in the source code.

That's all. Hope to be helpful.

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



All Articles