[Android View] custom ViewGroup -- implementation of horizontal page turning view HorizontalView inherited from ViewGroup

Keywords: Java Android

Custom viewgroups can be divided into three categories according to their parent classes: inherited from ViewGroup, inherited from system specific ViewGroup (such as LinearLayout) and inherited from View.
The second is the simplest and the third is the most complex. Let's focus on the first case with moderate difficulty.

target

Follow ViewPager to complete a horizontal page turning view, and support sliding left and right to switch different pages.

start

Inherit ViewGroup

First, we create a HorizontalView class and implement its abstract methods.

public class HorinzontalView extends ViewGroup {
    public HorinzontalView(Context context) {
        super(context);
    }

    public HorinzontalView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public HorinzontalView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    public HorinzontalView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {

    }
}

Process wrap_content

Those who have read my last blog must know that custom controls must be wrapped first_ Content. Here we need to override the onMeasure method.

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //For wrap_content
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        measureChildren(widthMeasureSpec, heightMeasureSpec);
        //If there is no child View, set the width and height to 0
        if (getChildCount() == 0){
            setMeasuredDimension(0,0);
        }else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST){
            //Both width and height are AT_MOST, the width is set to the sum of the widths of all child elements, and the height is set to the height of the first child element
            View childOne = getChildAt(0);
            int childWidth = childOne.getMeasuredWidth();
            int childHeight = childOne.getMeasuredHeight();
            setMeasuredDimension(childWidth * getChildCount(), childHeight);
        }else if (widthMode == MeasureSpec.AT_MOST){
            //Width AT_MOST, the width is set to the sum of the widths of all child elements
            int childWidth = getChildAt(0).getMeasuredWidth();
            setMeasuredDimension(childWidth * getChildCount(), heightSize);
        }else if (heightMode == MeasureSpec.AT_MOST){
            //Gao is AT_MOST, the height is set to the height of the first child element
            int childHeight = getChildAt(0).getMeasuredHeight();
            setMeasuredDimension(widthSize, childHeight);
        }
    }

Implement onLayout

Of course, after the onMeasure method measures, you also need onLayout to lay out the control.

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childCount = getChildCount();
        int left = 0;
        View child;
        //Traversal subview
        for (int i = 0; i < childCount; i++) {
            child = getChildAt(i);
            if (child.getVisibility() != View.GONE){
                //If the View is not GONE, place it in the appropriate location
                int width = child.getMeasuredWidth();
                //The four parameters are:
				//l – Left position, relative to parent
				//t – Top position, relative to parent
				//r – Right position, relative to parent
				//b – Bottom position, relative to parent
                child.layout(left, 0, left+width, 0 + child.getMeasuredHeight());
                left += width;
            }
        }
    }

Handling sliding conflicts

Our control slides horizontally. If its content is a vertical sliding ListView, if we do not deal with it, it will lead to sliding conflict (because the click event is captured by the outer horizon view and cannot be conveyed to the content ListView).
The idea to solve the sliding conflict is: if we detect that the sliding direction is horizontal, let the parent View intercept, otherwise, do not intercept.

class HorinzontalView extends ViewGroup {
    //Used to handle sliding conflicts
    private int lastInterceptX;
    private int lastInterceptY;
    private int lastX;
    private int lastY;

	...

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercept = false;
        int x = (int) ev.getX();
        int y = (int) ev.getY();
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - lastInterceptX;
                int deltaY = y - lastInterceptY;
                if (Math.abs(deltaX) - Math.abs(deltaY) > 0){
                    //Sliding is horizontal, intercepting
                    intercept = true;
                }
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        lastX = x;
        lastY = y;
        lastInterceptX = x;
        lastInterceptY = y;
        return intercept;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
    //The slip time of interception will be processed here
        return super.onTouchEvent(event);
    }
}

Elastic sliding effect

We need to use Scroller to slide the page

	...
    int currentIndex = 0;
    int childWidth = 0;
    private Scroller scroller;

	...

	@Override
    public boolean onTouchEvent(MotionEvent event) {
    //Handle intercepted click events here
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch(event.getAction()){
            case MotionEvent.ACTION_DOWN:
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - lastX;
                scrollBy(-deltaX, 0);
                break;
            case MotionEvent.ACTION_UP:
                int distance = getScrollX() - currentIndex * childWidth;
                //If the sliding distance is more than half of childWidth
                //Then slide to the upper / lower sub View
                if (Math.abs(distance) > childWidth/2){
                    if (distance > 0){
                        currentIndex++;
                    }else{
                        currentIndex--;
                    }
                }
                smoothScrollTo(currentIndex * childWidth, 0);
                break;
        }
        lastX = x;
        lastY = y;

        return super.onTouchEvent(event);
    }
    
    @Override
    public void computeScroll() {
        super.computeScroll();
        if(scroller.computeScrollOffset()){
            scrollTo(scroller.getCurrX(), scroller.getCurrY());
            postInvalidate();
        }
    }

    private void smoothScrollTo(int destX, int destY) {
        scroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(), destY - getScrollY(), 1000);
        invalidate();
    }

Swipe quickly to other pages

In many cases, users will not slide for a long distance, but will slide relatively short and fast. We also need to adapt the fast sliding. In order to capture the sliding speed, we need to borrow the speed tracker VelocityTracker.
First, we need to add the code to initialize the speed tracker in the constructor.

    private void init(){
        scroller = new Scroller(getContext());
        //Think about it, why call the obtain method here instead of new an object?
        tracker = VelocityTracker.obtain();
    }

Then we add the code related to the sliding speed to the logic for handling click events:

	@Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch(event.getAction()){
            case MotionEvent.ACTION_DOWN:
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - lastX;
                scrollBy(-deltaX, 0);
                break;
            case MotionEvent.ACTION_UP:
                int distance = getScrollX() - currentIndex * childWidth;
                if (Math.abs(distance) > childWidth/2){
                    if (distance > 0){
                        currentIndex++;
                    }else{
                        currentIndex--;
                    }
                }else{
                    //Calculate the current sliding speed
                    tracker.computeCurrentVelocity(10);
                    float xV = tracker.getXVelocity();
                    //If the sliding speed is greater than 50, "fast sliding" is considered to have occurred
                    if (Math.abs(xV) > 50){
                        if (xV > 0){
                            currentIndex--;
                        }else{
                            currentIndex++;
                        }
                    }
                }
                currentIndex = currentIndex < 0 ? 0 : Math.min(currentIndex, getChildCount() - 1);
                smoothScrollTo(currentIndex * childWidth, 0);
                //Reset speed calculator
                tracker.clear();
                break;
            default:
                break;
        }
        lastX = x;
        lastY = y;

        return super.onTouchEvent(event);
    }

When sliding, click the screen to prevent sliding

When we slide to the next page, because elastic sliding takes time, within this time, we click the screen again, hoping to intercept this sliding, and then operate the page.
To implement the above logic, we need to make a judgment in the onInterceptEvent method, if in action_ If the Scroller has not finished executing when down, it means that the last sliding is still in progress. At this time, we can interrupt the sliding.

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercept = false;
        int x = (int) ev.getX();
        int y = (int) ev.getY();
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                intercept = false;
                if (!scroller.isFinished()){
                    //If the Scroller does not complete its execution, break it
                    scroller.abortAnimation();
                }
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - lastInterceptX;
                int deltaY = y - lastInterceptY;
                //Sliding is transverse
                intercept = Math.abs(deltaX) - Math.abs(deltaY) > 0;
                break;
            case MotionEvent.ACTION_UP:
                break;
            default:
                break;
        }
        lastX = x;
        lastY = y;
        lastInterceptX = x;
        lastInterceptY = y;
        return intercept;
    }

Apply HorizontalView

Now, our control has begun to take shape. Let's simply use it~

public class MainActivity extends AppCompatActivity {

    private ListView lv_one;
    private ListView lv_two;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        init();
    }

    private void init() {
        lv_one = findViewById(R.id.lv_one);
        lv_two = findViewById(R.id.lv_two);

        List<String> strs1 = new ArrayList<>();
        List<Character> strs2 = new ArrayList<>();

        for (int i = 0; i < 15; i++) {
            strs1.add(String.valueOf(i+1));
            strs2.add((char) ('A' + i));
        }

        ArrayAdapter<String> arrayAdapter1 =
                new ArrayAdapter<>(this, android.R.layout.simple_expandable_list_item_1, strs1);
        ArrayAdapter<Character> arrayAdapter2 =
                new ArrayAdapter<>(this, android.R.layout.simple_expandable_list_item_1, strs2);

        lv_one.setAdapter(arrayAdapter1);
        lv_two.setAdapter(arrayAdapter2);
    }
}
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:content=".MainActivity">

    <com.example.myview.HorinzontalView
        android:layout_width="match_parent"
        android:layout_height="match_parent">

            <ListView
                android:id="@+id/lv_one"
                android:layout_width="match_parent"
                android:layout_height="match_parent"/>

            <ListView
                android:id="@+id/lv_two"
                android:layout_width="match_parent"
                android:layout_height="match_parent"/>

    </com.example.myview.HorinzontalView>

</RelativeLayout>

After using the logic, run the program, and you will get a simple ViewPager~

Further

Now you have basically completed its main functions. If you want to go further, you can start from the following aspects:

  1. Adapt your own padding to the Margin of the child View
  2. What actions are required when the page where the control is located is destroyed?
  3. What should I do if there is a click event in the content?

Reference article:
Android advanced light

Posted by chugger93 on Mon, 29 Nov 2021 09:36:39 -0800