Recently, I have developed a custom view, which is quite complicated. I feel it is necessary to share the implementation process.

The effect

Let’s take a look at the effects:

Let’s look at what this view needs to do.

  • First, it has a scale that represents the time period (or whatever), and you can see that the full scale is larger than the screen width, so you definitely need to be able to swipe left and right.
  • Second, you can have an unselectable region (gray block in GIF) and a selected region (blue block in GIF), click on the scale blank position to appear or move the selected region to the click position.
  • Click and drag the selected area to move it, and as it moves to the sides of the screen, the lower scale moves with it.
  • You can also click and drag the small white circle to the right of the selected area to change the size of the selected area. The lower scale also moves when you reach the screen boundary.
  • When a selected region overlaps with an unselected region, the selected region changes color.
  • The selected area is at least 1 scale. When the finger is lifted after moving, the selected area fits the scale.
  • Finally, some state changes need to be monitored, such as whether the overlap, select the location of the region change.

implementation

scale

Don’t be afraid to have so many features, let’s implement them one by one. The first is the scale. That’s easy. Since a full scale is larger than the width of the screen, let’s start with a few concepts:

Here, the width of the mobile phone screen is width, while the width of the scale is maxWidth. In fact, we only need to draw the visible part of the mobile phone screen. Offset here represents the offset between the left side of the mobile phone screen and the left side of the scale.

Define a View, handle constructs that all point to three arguments, and initialize them uniformly:

public class SelectView extends View { private final int DEFAULT_HEIGHT = dp2px(100); //wrap_content Private Paint mPaint; public int dp2px(finalfloat dpValue) {
        final float scale = getContext().getResources().getDisplayMetrics().density;
        return (int) (dpValue * scale + 0.5f);
    }

    public SelectView(Context context) {
        this(context, null);
    }

    public SelectView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public SelectView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        scroller = new OverScroller(context);
        init();
    }

    private void init() {
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setTextSize(textSize);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        width = widthSize;
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;
        } else{ height = DEFAULT_HEIGHT; / / wrap_content high}setMeasuredDimension(width, height); }}Copy the code

We handled the height of wrAP_content in onMeasure. Then get the size parameter in onSizeChanged:

private int width; // control width private int height; // Control height private int maxWidth; Private int totalWidth; Private int minOffset = 0; private int minOffset = 0; private int maxOffset; private int offset = minOffset; @override protected void onSizeChanged(int w, int h, int oldw, int oldh) {super.onsizechanged (w, int oldw, int oldh) h, oldw, oldh); totalWidth = titles.length * space; maxWidth = totalWidth - space; maxOffset = totalWidth - width;if (maxOffset < 0) {
            maxOffset = 0;
        }
        areaTop = (1 - areaRate) * height;
    }
Copy the code

Then start drawing:

    private String[] titles = {"GMT"."09:30"."In its"."At 10:30"."11"."At"."Twelve"."12:30"."13:00"."And"."Then"."Passion"."15:00"."Hold"."Our"."Ticket"."17:00"."The same"."18:00"}; private int space = dp2px(40); Private int lineWidth = dp2px(1); Private int textSize = dp2px(12); private int textMargin = dp2px(8); // Margin value private int rate = 1; // The ratio of short ticks to long ticks (>=1) privatefloatLineRate = 0.4 f; @override protected void onDraw(Canvas) {super.ondraw (Canvas); drawLine(canvas); } private void drawLine(Canvas canvas) { mPaint.setStyle(Paint.Style.STROKE); mPaint.setStrokeWidth(lineWidth); mPaint.setColor(Color.BLACK); canvas.drawLine(0, height, width, height, mPaint);for (int i = 0; i < titles.length; i++) {
            int position = i * space;
            if(position >= offset && position <= offset + width) {// Determine whether it can be displayed on the screen int x = position-offset;ifDrawLine (x, 0, x, height, mPaint); drawLine(x, 0, x, height, mPaint); mPaint.setStyle(Paint.Style.FILL); canvas.drawText(titles[i], x + textMargin, textSize, mPaint); mPaint.setStyle(Paint.Style.STROKE); }elseDrawLine (x, height * (1-linerate), x, height, mPaint); }}}}Copy the code

Here titles represent the identity of the scale, and each element represents a scale (here I’m writing bytes, actually using set, not necessarily time, anything that represents the scale is acceptable). Set the ratio of the length and length of the scale by rate. Here I set 1:1. If we run it, we can only see the scale starting from 0, we can’t see the whole scale, we need to implement the touch event to move.

Realize sliding scale

We override onTouchEvent to implement the sliding effect:

    private float downX, downY;
    private floatlastX; @override public Boolean onTouchEvent(MotionEvent) {int action = event.getAction(); switch (action) {case MotionEvent.ACTION_DOWN:
                downX = event.getX();
                lastX = downX;
                break;
            case MotionEvent.ACTION_MOVE:
                float x = event.getX();
                float dx = x - lastX;
                changeOffsetBy(-dx);
                lastX = x;
                postInvalidate();
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
               
                break;
            default:
                break;
        }
        return true;
    }

    private void changeOffsetBy(float dx) {
        offset += dx;
        if (offset < minOffset) {
            offset = minOffset;
        } else if(offset > maxOffset) { offset = maxOffset; }}Copy the code

We calculate the change in X direction dx for each move event, and then change the offset by this dx, and deal with the boundary case. Then call postInvalidate to refresh the screen. Run it and see! Now we can slide the scale. But there seems to be a problem, usually when we use the ScrollView swipe hard, you can see the finger off the screen, but the content continues to scroll. At present, the customized view can only be swiped by finger. If the finger leaves the screen, it cannot be swiped. This experience is obviously not good enough, let’s achieve the effect of inertia sliding!

Inertial sliding

To achieve inertial sliding, we need to use two classes: VelocityTracker and OverScroller. VelocityTracker introduces the Android View Sliding assistant class OverScroller

private int minFlingVelocity; // private VelocityTracker; private OverScroller scroller; private int lastFling; Private void init(Context Context) {... scroller = new OverScroller(context); minFlingVelocity = ViewConfiguration.get(getContext()).getScaledMinimumFlingVelocity(); } @Override public boolean onTouchEvent(MotionEvent event) {if (velocityTracker == null) {
            velocityTracker = VelocityTracker.obtain();
        }
        velocityTracker.addMovement(event);

         int action = event.getAction();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                scroller.forceFinished(true);
                downX = event.getX();
                lastX = downX;
                break;
            case MotionEvent.ACTION_MOVE:
                float x = event.getX();
                float dx = x - lastX;
                changeOffsetBy(-dx);
                lastX = x;
                postInvalidate();
                break;
            case MotionEvent.ACTION_UP:
            caseMotionEvent. ACTION_CANCEL: / / processing of inertia sliding velocityTracker.com puteCurrentVelocity (1000, 8000);float xVelocity = velocityTracker.getXVelocity();
                if (Math.abs(xVelocity) > minFlingVelocity) {
                    scroller.fling(0, 0, (int) xVelocity, 0, Integer.MIN_VALUE,
                        Integer.MAX_VALUE, 0, 0);
                }
                velocityTracker.clear();
                break;
            default:
                break;
        }
        return true;
    }

    @Override
    public void computeScroll() {
        if (scroller.computeScrollOffset()) {
            int currX = scroller.getCurrX();
            floatdx = currX - lastFling; // Already at the boundary, no longer dealing with inertiaif ((offset <= minOffset && dx > 0) || offset >= maxOffset && dx < 0) {
                scroller.forceFinished(true);
                return;
            }
            changeOffsetBy(-dx);
            lastFling = currX;
            postInvalidate();
        } else{ lastFling = 0; // reset the previous value to avoid the second inertial slide calculation error dx}}Copy the code

VelocityTracker.com puteCurrentVelocity method the second argument to the maximum inertia speed, here I set 8000, avoid sliding scale too fast. Scroller calls the scroll. fling method to give the calculated speed to Scroller, and then computeScroll method to obtain the current value, and the last value of the difference calculated dx, dx change offset also used to refresh the interface to achieve sliding effect.

Non-selectable region

The scale is complete, and then there’s the non-optional gray area. I used two int values to indicate the area of the scale, with each scale indicating the smallest unit, the first int indicating the starting position of the scale, and the last int indicating the number of scales occupied.

    private List<int[]> unselectableList = new ArrayList<>();
    private List<RectF> unselectableRectFs = new ArrayList<>();
    private RectF tempRect = new RectF();

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        drawLine(canvas);
        drawUnselectable(canvas);
    }

    private void drawUnselectable(Canvas canvas) {
        generateUnselectableRectFs();
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setColor(Color.parseColor("# 99878787"));
        for (RectF rectF : unselectableRectFs) {
            float left = Math.max(rectF.left, offset) - offset;
            float right = Math.min(rectF.right, offset + width) - offset;
            tempRect.set(left, rectF.top, right, rectF.bottom);
            canvas.drawRect(tempRect, mPaint);
        }
    }

    private void generateUnselectableRectFs() {// Avoid repeated generationif (unselectableRectFs.size() > 0 
                && unselectableList.size() == unselectableRectFs.size()) {
            return;
        }
        unselectableRectFs.clear();
        for (int[] ints : unselectableList) {
            int start = ints[0];
            int count = ints[1];
            int max = titles.length - 1;
            if (start > max || start + count > max) {
                throw new IllegalArgumentException("unselectable area has wrong start or count, " +
                        "the total limit is" + max);
            }
            if (count > 0) {
                unselectableRectFs.add(new RectF(start * space, areaTop,
                        (start + count) * space, height));
            }
        }
    }

    public void addUnseletable(int start, int count) {
        unselectableList.add(new int[]{start, count});
        postInvalidate();
    }
Copy the code

I use one list to hold the set non-selectable regions, and then another list to hold the location information converted to RectF. Here the RectF is relative to the overall scale, so offset needs to be subtracted when drawing to the screen, and only part of the screen needs to be visible. To avoid creating too many temporary variables in the onDraw method, I declare a member variable, tempRect, to hold temporary parameters when drawing.

Optional area

The non-optional areas are completed, as are the optional areas. Since there can only be one optional region, we only need to define a RectF. In addition, it is necessary to consider the discoloration when intersecting with non-optional areas. I have selected Overlapping areas to judge by INTERSECting intersects method of RectF.

    private int selectedBgColor = Color.parseColor("#654196F5");
    private int selectedStrokeColor = Color.parseColor("#4196F5");
    private int overlappingBgColor = Color.parseColor("#65FF6666");
    private int overlappingStrokeColor = Color.parseColor("#FF6666"); private int selectedStrokeWidth = dp2px(2); private int extendRadius = dp2px(7); // Expand the radius of the circle privatefloatExtendTouchRate = 1.5 f; // Expand the ratio of touch area to view (>=1) private Boolean highway; Unselectable private RectF selectedRectF; Private RectF extendPointRectF; @override protected void onDraw(Canvas Canvas) {super.ondraw (Canvas); drawLine(canvas); drawUnselectable(canvas); drawSelected(canvas); } private void drawSelected(Canvas canvas) {if (selectedRectF == null) {
            return;
        }
        overlapping = checkOverlapping();
        float left = Math.max(selectedRectF.left, offset) - offset;
        floatright = Math.min(selectedRectF.right, offset + width) - offset; tempRect.set(left, selectedRectF.top, right, selectedRectF.bottom); / / FILL mPaint. SetStyle (Paint. Style. The FILL); mPaint.setColor(overlapping ? overlappingBgColor : selectedBgColor); canvas.drawRect(tempRect, mPaint); / / border mPaint. SetStyle (Paint. Style. STROKE); mPaint.setStrokeWidth(selectedStrokeWidth); mPaint.setColor(overlapping ? overlappingStrokeColor : selectedStrokeColor); canvas.drawRect(tempRect, mPaint);if((selectedRectf.right-offset) == Right) {// Expand the frame mPaint. SetColor (highway? overlappingStrokeColor : selectedStrokeColor); canvas.drawCircle(tempRect.right, tempRect.centerY(), extendRadius, mPaint); // Expand the circle to fill mPaint. SetColor (color.white); mPaint.setStyle(Paint.Style.FILL); canvas.drawCircle(tempRect.right, tempRect.centerY(), extendRadius, mPaint); // Extend the position of the circle, ExtendPointRectF = new RectF(selectedRectf. right - extendRadius * extendTouchRate, selectedRectF.centerY() - extendRadius * extendTouchRate, selectedRectF.right + extendRadius * extendTouchRate, selectedRectF.centerY() + extendRadius * extendTouchRate); }else {
            extendPointRectF = null;
        }
    }

    private boolean checkOverlapping() {
        if(selectedRectF ! = null) {for (RectF rectF : unselectableRectFs) {
                if (rectF.intersects(selectedRectF.left, selectedRectF.top,
                        selectedRectF.right, selectedRectF.bottom)) {
                    return true; }}}return false;
    }
Copy the code

Click, move, expand

From the previous analysis, we know that there are many different events in this view: click, move the scale, move the selected area, expand the selected area. We define these four types for subsequent event handling:

    public static final int TYPE_MOVE = 1;
    public static final int TYPE_EXTEND = 2;
    public static final int TYPE_CLICK = 3;
    public static final int TYPE_SLIDE = 4;
Copy the code

Then modify onTouchEvent:

private boolean linking; Private Handler Handler = new BookHandler(this); private int boundary = space / 2; Private static class extends Handler {private static final int DELAY_MILLIS = 10; // Refresh rate (0~16) private WeakReference<SelectView> selectViewWeakReference; BookHandler(SelectView selectView) { super(); selectViewWeakReference = new WeakReference<>(selectView); } @Override public void handleMessage(Message msg) { SelectView view = selectViewWeakReference.get();if(view ! = null) {float dx = (float) msg.obj;
                view.changeOffsetBy(dx);
                if (msg.what == MESSAGE_EXTEND) {
                    float r = view.selectedRectF.right + dx;
                    view.resetSelectedRight(r);
                } else if (msg.what == MESSAGE_MOVE) {
                    float l = view.selectedRectF.left + dx;
                    float r = view.selectedRectF.right + dx;
                    view.resetSelectedRectF(l, r);
                }
                view.postInvalidate();
                if (view.linking) {
                    sendMessageDelayed(Message.obtain(msg), DELAY_MILLIS);
                }
            }
        }
    }

    @Override
    public boolean performClick() {
        return super.performClick();
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (velocityTracker == null) {
            velocityTracker = VelocityTracker.obtain();
        }
        velocityTracker.addMovement(event);

        int action = event.getAction();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                scroller.forceFinished(true);
                downX = event.getX();
                lastX = downX;
                downY = event.getY();
                checkTouchType();
                break;
            case MotionEvent.ACTION_MOVE:
                float x = event.getX();
                float dx = x - lastX;
                if (touchType == TYPE_EXTEND) {
                    handleExtend(dx);
                } else if (touchType == TYPE_MOVE) {
                    handleMove(dx);
                } else if (touchType == TYPE_SLIDE) {
                    changeOffsetBy(-dx);
                }
                lastX = x;
                postInvalidate();
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                float upX = event.getX();
                float upY = event.getY();
                if (Math.abs(upX - downX) < touchSlop && Math.abs(upY - downY) < touchSlop) {
                    touchType = TYPE_CLICK;
                    performClick();
                }

                handleActionUp(upX);
                break;
            default:
                break;
        }
        return true;
    }

    private void checkTouchType() {
        RectF extend = null;
        if(extendPointRectF ! = null) { extend = new RectF(extendPointRectF.left - offset, extendPointRectF.top, extendPointRectF.right - offset, extendPointRectF.bottom); Timber.i("extend:" + extend.toString());
        }
        RectF selected = null;
        if(selectedRectF ! = null) { selected = new RectF(selectedRectF.left - offset, selectedRectF.top, selectedRectF.right - offset, selectedRectF.bottom); Timber.i("selected:" + selected.toString());
        }

        if(extend ! = null && extend.contains(lastX, downY)) { touchType = TYPE_EXTEND; }else if(selected ! = null && selected.contains(lastX, downY)) { touchType = TYPE_MOVE; }else {
            touchType = TYPE_SLIDE;
        }
    }

    private void handleExtend(floatDx) {// If linkage is being performed, avoid unnecessary stops caused by finger shakingif (linking && Math.abs(dx) < touchSlop) {
            return;
        }
        floatright = selectedRectF.right += dx; Message = null;if(dx > 0 && width - (right-offset) < boundary // Slide the selected area to the right of the screen && offset < maxOffset) {message = handler.obtainMessage(MESSAGE_EXTEND, linkDx); }else if (dx < 0 && right > selectedRectF.left
                && right - offset < boundary && offset > minOffset) {
            message = handler.obtainMessage(MESSAGE_EXTEND, -linkDx);
        }
        if(message ! = null) {if(! linking) { linking =true; handler.sendMessage(message); }}else {
            stopLinking();
            resetSelectedRight(right);
        }
    }

    private void handleMove(floatDx) {// If linkage is being performed, avoid unnecessary stops caused by finger shakingif (linking && Math.abs(dx) < touchSlop) {
            return;
        }
        float left = selectedRectF.left += dx;
        float right = selectedRectF.right += dx;
        Message message = null;
        if((dx < 0 && left-offset < boundary && offset > minOffset)) {// Select the area and slide to the left of the screen and continue to swipe left on message = handler.obtainMessage(MESSAGE_MOVE, -linkDx); }else if(dx > 0 && width - (right-offset) < boundary && offset < maxOffset) {// Slide the selected area to the right of the screen and continue to swipe message = right handler.obtainMessage(MESSAGE_MOVE, linkDx); } Timber.e("message:" + message);
        if(message ! = null) {// At two boundaries, need linkageif(! linking) { linking =true; handler.sendMessage(message); }}else {
            stopLinking();
            resetSelectedRectF(left, right);
        }
    }

    private void handleActionUp(float upX) {
        if (touchType == TYPE_CLICK) {
            int start = (int) ((upX + offset) / space);
            int[] area = getSelected();
            setSelected(start, area == null ? CLICK_SPACE : area[1]);
        } else if (touchType == TYPE_EXTEND) {
            stopLinking();
            int right = Math.round(selectedRectF.right / space) * space;
            resetSelectedRight(right);
            postInvalidate();
        } else if (touchType == TYPE_MOVE) {
            stopLinking();
            int[] area = getSelected();
            if(area ! = null) {setSelected(area[0], area[1]); }}else if(touchType = = TYPE_SLIDE) {/ / processing of inertia sliding velocityTracker.com puteCurrentVelocity (1000, 8000);float xVelocity = velocityTracker.getXVelocity();
            if (Math.abs(xVelocity) > minFlingVelocity) {
                scroller.fling(0, 0, (int) xVelocity, 0, Integer.MIN_VALUE,
                        Integer.MAX_VALUE, 0, 0);
            }
            velocityTracker.clear();
        }
    }

    private void stopLinking() {
        linking = false; handler.removeCallbacksAndMessages(null); } private void resetSelectedRectF(private void resetSelectedRectF(private void resetSelectedRectF(float left, float right) {
        if (left < 0) {
            left = 0;
            right = selectedRectF.right - selectedRectF.left;
        }
        if (right > maxWidth) {
            right = maxWidth;
            left = maxWidth - (selectedRectF.right - selectedRectF.left);
        }
        int minSpace = minSelect * space;
        if(right-left < minSpace) {// Minimum valueif (maxWidth - selectedRectF.left < minSpace) {
                right = maxWidth;
                left = maxWidth - minSpace;
            } else{ right = selectedRectF.left + minSpace; } } selectedRectF.left = left; selectedRectF.right = right; } /** * resetSelectedRight(private void resetSelectedRight)float right) {
        if (right > maxWidth) {
            right = maxWidth;
        }
        int minSpace = minSelect * space;
        if(right-selectedRectf.left < minSpace) {// Minimum valueif (maxWidth - selectedRectF.left < minSpace) {
                right = maxWidth;
                selectedRectF.left = maxWidth - minSpace;
            } else{ right = selectedRectF.left + minSpace; } } selectedRectF.right = right; } /** * convert the selection to a field ** @param start start position * @param count number */ public voidsetSelected(int start, int count) {
        if (start > titles.length - 1) {
            throw new IllegalArgumentException("wrong start");
        }
        int right = (start + count) * space;
        if(right > maxWidth) { // int cut = Math.round((right - maxWidth) * 1f / space); // start -= cut; // Move to the left right = maxWidth; } int left = start * space;if (selectedRectF == null) {
            selectedRectF = new RectF(left, areaTop, right, height);
            if (selectChangeListener != null) {
                selectChangeListener.onSelected();
            }
        } else{ selectedRectF.set(left, areaTop, right, height); } notifySelectChangeListener(start, count); postInvalidate(); } /** * converts the selected area to the selected content ** @return [start, count]
     */
    public int[] getSelected() {
        if (selectedRectF == null) {
            return null;
        }
        int[] area = new int[2];
        float w = selectedRectF.right - selectedRectF.left;
        area[0] = Math.round(selectedRectF.left / space);
        area[1] = Math.round(w / space);
        return area;
    }
Copy the code

PerformClick will tell you what method you need to override when you override onTouchEvent, because you may not have considered setting OnClickListener to the view. If you do not call performClick in onTouchEvent, then the setOnClickListener method is invalid.

You may have noticed that this time is complicated and there is a linking field, which means whether linkage is taking place. Let me explain the concept of linkage: In fact, you might notice that as I move or expand the selected area, if I move it to the edge of the screen, the scale behind it will move, and it doesn’t actually change the location of the selected area on the screen, it just moves the scale. In the beginning, I also changed the offset by dx, but there was a problem. After moving to the edge of the screen, the area where the finger could move was too small to generate enough DX (if the finger did not move, there would be no new touch events). The best experience was when I moved the phone to the edge of the screen, and the ruler moved itself at a certain rate until it reached a maximum offset or minimum offset. Therefore, I used Handler. When the conditions are met and the message is sent, the linkage starts, and a DX offset will be generated to change the offset at a fixed speed. Of course, you also need to cancel the handler’s task as soon as you leave the edge of the screen.

So far, the function has been basically realized, run to see the effect ~

What do I need to do back there? Now the view can only play by itself, I need it to interact with other views, such as what areas are selected and how state changes are generated.

Change of state

Declare both interfaces and call back their methods when appropriate so that the outside world is aware of the view’s state changes.

    public interface OverlappingStateChangeListener {
        void onOverlappingStateChanged(boolean isOverlapping);
    }

    public interface SelectChangeListener {
        void onSelected();

        void onSelectChanged(int start, int count);
    }
Copy the code

perfect

The next step is to add some APIS according to the business, such as adding non-selectable regions, changing the scale range, all depends on the requirements.

The source code

Finally, attach the code: SelectView