Making Zoom View in PSLab Android app

This blog demonstrates how to make a zoom view in an Android app by taking example from one made in PSLab Android app. It will mainly reflect the work done under PR #1117 in PSLab Android repository. The demonstration shown in this blog is for zooming a complete layout. But individual components of a layout can also be given this zoom effect.

How to make a zoom view?

Below is a step by step guide on how to implement a zoom view in an Android app :

  • First make a  Zoom Layout class in Android Project which will further include GestureDetector, MotionEvent, etc.
  • Now extend the Zoom Layout class from a base layout provided by Android i.e. Relative Layout, Linear Layout, etc. as per need because we need to give zoom effect to a complete layout. In this demonstration, I will use the Relative Layout class as my base class.
  • Also to detect the gestures made by a user, we need to implement the ScaleGestureDetector.OnScaleGestureListener class. So, finally, the class implementation will look like this
public class ZoomLayout extends RelativeLayout implements ScaleGestureDetector.OnScaleGestureListener {
}
  • Now make default constructors and declare variables to define the range of the minimum and maximum possible zoom, coordinates before drag, coordinates after drag, etc.
private static final float MIN_ZOOM = 1.0f;
private static final float MAX_ZOOM = 4.0f;
private Mode mode = Mode.NONE;
private float scale = 1.0f;
private float lastScaleFactor = 0f;
private float startX = 0f;
private float startY = 0f;
private float dx = 0f;
private float dy = 0f;
private float prevDx = 0f;
private float prevDy = 0f;

public ZoomLayout(Context context) {
   super(context);
   init(context);
}

Here startX and startY are the initial coordinates of the layout, dx and dy are the new coordinates of the layout and prevDx and prevDy are the coordinates of the previous location of the layout. Also, mode is the current mode of the gesture which will be further elaborated upon in coming steps, and all other remaining variables are for scaling the screen on gesture movements. Also, init(context) is a method which will be explained in step 5.

  • Now, we will make a method named init() to initiate the process of scaling the layout on gesture detection.
public void init(Context context) {
        final ScaleGestureDetector scaleDetector = new ScaleGestureDetector(context, this);
        this.setOnTouchListener(new OnTouchListener() {
            @Override
            public boolean onTouch(View view, MotionEvent motionEvent) {
                switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) {
                    case MotionEvent.ACTION_DOWN:
                        if (scale > MIN_ZOOM) {
                            mode = Mode.DRAG;
                            startX = motionEvent.getX() - prevDx;
                            startY = motionEvent.getY() - prevDy;
                        }
                        break;
                    case MotionEvent.ACTION_MOVE:
                        if (mode == Mode.DRAG) {
                            dx = motionEvent.getX() - startX;
                            dy = motionEvent.getY() - startY;
                        }
                        break;
                    case MotionEvent.ACTION_POINTER_DOWN:
                        mode = Mode.ZOOM;
                        break;
                    case MotionEvent.ACTION_POINTER_UP:
                        mode = Mode.DRAG;
                        break;
                    case MotionEvent.ACTION_UP:
                        mode = Mode.NONE;
                        prevDx = dx;
                        prevDy = dy;
                        break;
                    default:
                        mode = Mode.NONE;
                        prevDx = dx;
                        prevDy = dy;
                        break;
                }
                scaleDetector.onTouchEvent(motionEvent);

                if ((mode == Mode.DRAG && scale >= MIN_ZOOM) || mode == Mode.ZOOM) {
                    getParent().requestDisallowInterceptTouchEvent(true);
                    float maxDx = (child().getWidth() - (child().getWidth() / scale)) / 2 * scale;
                    float maxDy = (child().getHeight() - (child().getHeight() / scale)) * scale;
                    dx = Math.min(Math.max(dx, -maxDx), maxDx);
                    dy = Math.min(Math.max(dy, -maxDy), maxDy);
                    applyScaleAndTranslation();
                }
                return true;
            }
        });
    }

The detailed explanation of the above code snippet is as follows:

  1. scaleDetector – A gesture detector variable to store the scaling of the screen i.e. how much the screen is zoomed
  2. onTouch() – It is the main method handling the calculations for zooming the layout and setting the position of the zoomed layout. The view attribute is the current view of the layout and the motionEvent attribute handles the different task for different gestures made by a user.

Here the mode variable is used to define one of the three gestures i.e. NONE, DRAG or ZOOM where

  1. NONE – No gesture detected on the screen
  2. DRAG – Sliding gestures are made
  3. ZOOM – Pinch gesture is made

Also, a detailed explanation of the motion events used in the switch case can be found out in the resources [1].

After the switch case, the if statement is used to do calculations based on the current child in focus and the previous coordinates if and only if the zoom hasn’t reached the maximum limit and the view is dragged to see the zoomed contents. Method getParent().requestDisallowInterceptTouchEvent(true) is used to disable the scroll effect of the parent layout if any. In this case, the zoomed layout is inside a bottom sheet and so by using this method, the bottom sheet isn’t closed on swipe down gesture.

  • Now create applyScaleAndTransition() method and child() method used in step 5.
private View child() {
        return getChildAt(0);
    }

This method is used to return the current child layout in focus i.e. visible on the screen.

private void applyScaleAndTranslation() {
        child().setScaleX(scale);
        child().setScaleY(scale);
        child().setTranslationX(dx);
        child().setTranslationY(dy);
    }

This method is used to apply the final calculations that are done in step 5 to the child layout in focus.

So, now the Zoom Layout is ready for use and can be used as same as we use the Relative Layout in the XML files. The final output produced by using the Zoom Layout as a child of bottom sheet in PSLab Android app is as shown in figure 1.

Figure 1. Demonstration of Zoom Layout made in PSLab Android app

Resources

  1. https://developer.android.com/reference/android/view/MotionEvent – Documentation of motion event gestures in android

Making Bottomsheet responsive using Custom Gesture Detector in PSLab Android App

In the previous blog Creating Instruction Guide using Bottomsheet, I have created the Bottom Sheet guide in instrument activities in PSLab Android app. But simply adding the Bottom Sheet in the layout is not enough as it could lead to some UI issues like no proper way to show or hide the Bottom Sheet, therefore, he/she will find it difficult to work with Bottom Sheet that could degrade User Experience.

We need to make the Bottom Sheet responsive and interactive which we can do by capturing swipe gestures done by the user and overriding their functionality i.e. when the user slides up with the finger then the Bottom Sheet will reveal itself and when the user slides the finger down the Bottom Sheet will hide.

For this Android provides a class GestureDetector which is used with another class SimpleOnGestureListener which acts as a listener to capture Gesture events like swipe, pinch, scroll, long press etc.

In this blog, I will create a custom gesture listener that will listen to the swipe events and according to the gestures it will show/hide the Bottom Sheet.

I will start by creating a gesture listener class called “SwipeGestureListener” extending the class ‘GestureDetector.SimpleOnGestureListener’ and also as I need swipe gestures to control the Bottom Sheet, so I will pass the reference of the Bottom Sheet as a parameter in the constructor.

public class SwipeGestureListener extends GestureDetector.SimpleOnGestureListener{
   private  BottomSheetBehavior bottomSheet;

   public SwipeGestureDetector(BottomSheetBehavior bt) {
       bottomSheet = bt;
   }  
}

Now in this listener class as we are concerned with the swipe events so will only override the below method provided by ‘GestureDetector.SimpleOnGestureListener’ interface

public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY)

This method is called whenever the user swipes its finger in any direction.

In the above code, we can see that the method provides with object e1 and e2 of type MotionEventThe MotionEvent class is used to report movements in terms of Action Codes like ACTION_DOWN, ACTION_UP and also contains other information about the touch like the pressure of the touch, x and y coordinate, orientation of the contact area etc. 

The e1 object will have the attribute values relating to the point when the swipe started and the e2 object will have attribute values relating to the point when the swipe has ended.

Now, the main thing we need to determine if the direction of the swipe which is not directly available using the MotionEvent object.

So, to determine the direction of the swipe I will fetch the coordinates of the initial point and terminal point of the swipe using the objects initial and final point i.e., e1 and e2.

//Initial Point
float x1 = e1.getX(), y1 = e1.getY();

//Final Point
float x2 = e2.getX(), y2 = e2.getY();

Then, using these coordinates to calculate the angle of the swipe and based on the angle I will return the direction of the swipe as shown in the code below

private Direction getDirection(float x1, float y1, float x2, float y2) {

       Double angle = Math.toDegrees(Math.atan2(y1 - y2, x2 - x1));

       if (angle > 45 && angle <= 135)
           return Direction.TOP;
       if (angle >= 135 && angle < 180 || angle < -135 && angle > -180)
           return Direction.LEFT;
       if (angle < -45 && angle>= -135)
           return Direction.DOWN;
       if (angle > -45 && angle <= 45)
           return Direction.RIGHT;

       return null;     // required by java to avoid error
   }

As of now, I have the direction of the swipe so I will apply switch case and handle the swipe up and swipe down gesture as below:

  1. When the user slides up:-  Show the Bottom Sheet by changing the state of the Bottom Sheet from STATE_HIDDEN to STATE_COLLAPSED(partially viewable).
                                          
  2. When the user slides down: – Hide the Bottom Sheet by changing the state of the Bottom Sheet to STATE_HIDDEN.

For doing this, we will modify the onFIing()’ method as shown below

@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
   switch (getDirection(e1.getX(), e1.getY(), e2.getX(), e2.getY())) {
       case TOP:
           bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED);
           return true;
       case LEFT:
           return true;
       case DOWN:
           if(bottomSheet.getState()==BottomSheetBehavior.STATE_COLLAPSED){
               bottomSheet.setState(BottomSheetBehavior.STATE_HIDDEN);
           }
           return true;
       case RIGHT:
           return true;
       default:
           return false;
   }
}

Now, the custom gesture listener is implemented but it cannot start listening to the touch event on its own, so we need to resolve this by performing the following steps:

  1. Firstly, we need to create an object of class GestureDetector and pass the current activity context and the object of class ‘SwipeGestureListener’ as parameters. Also while creating the listener for ‘SwipeGestureListener’ we need to pass the object of the Bottom Sheet in it as a parameter.

    GestureDetector gestureDetector = new GestureDetector(this, new SwipeGestureListener(bottomSheetBehavior)); 
  2. Then we need to override the ‘onTouchEvent()’ method of our Activity and pass the event which is received as a parameter to the GestureDetector.
    Doing this will pass the touch event that it received to the GestureDetector for it to handle.

    @Override
    public boolean onTouchEvent(MotionEvent event) {
       gestureDetector.onTouchEvent(event);                
       return super.onTouchEvent(event);
    }
    

The Bottom Sheet is now responsive to the gestures on the screen and this will improve the User Experience.

Resources

  1. Detect Common Gestures – Android Developer Article –  Android documentation
  2. Choreographic animations with Android’s Bottom Sheet – Blog by Orkhan Gasimli