preface

A few days ago, I wrote an article about the working principle of View. There is no principle, but also practice. Just put the previous project written imitation wechat sliding button control package, so this article records the details of my implementation of this control.

Address: SwitchButton

rendering

Control effect is as follows:

Except for the color, it looks very similar to wechat.

To prepare

1. Select the custom View mode

Custom View has three ways to achieve: 1, combination control; 2. Inherit existing controls (such as Button); 3, Inherit View. The following are introduced respectively:

  • 1, combination control: we do not need to draw the content displayed on the view, but to combine several system native controls together, so that the control created is called combination control, such as the title bar is a very common combination control.
  • 2, inherit existing controls: we do not need to re-implement a control, just need to inherit an existing control, and then add some new functionality to the control. Its advantage is that it can not only add corresponding functions according to our needs, but also inherit the existing control encapsulated properties, and do not have to define their own measurement process.
  • 3, inherit View: we inherit View, rewrite the corresponding method, to implement a control again. The advantage of it is that it is flexible, it gives you a blank sheet of paper and you can do whatever you want with the paintbrush.

According to the actual situation, MY choice of this control is method 3: inherit View, rewrite onMeasure method to define its measurement process, rewrite onDraw() method to define its drawing process.

2. Select how to slide the control content

Since it’s a slide button, there must be a slide, so when I click on the button, if it’s on, the little circle of the button slides to the right, if it’s off, the little circle of the button slides to the left. I can think of three ways to slide the content of a control:

  • 1. Through Scroller: Call Scroller’s startScroll() method, pass in the starting point and end point coordinates, and rewrite the View’s computeScroll() method. Inside this method, call Scroller’s computeScrollOffset() method to start sliding. Then call the View’s scrollTo() or scrollBy() methods to update the View’s sliding distance, and call the View’s invalidate() or postInvalidate() methods to redraw the View.
  • 2. Continuously send delayed messages through Handler: The Handler sendMessageDelayed(Message MSG, Long delayMillis) method is used to continuously send delayed messages. After receiving the Message in Handler’s handlerMessage(), the sliding distance is updated. Then call the View’s invalidate() or postInvalidate() methods to redraw the View.
  • 3. Through animation: The View can be animated using a tween animation or a panning animation of a property animation, or a ValueAnimator can be used to set an initial value and a node value. When ValueAnimator’s start() method is called, the animation’s progress can be retrieved in the callback, and the sliding distance can be updated based on the animation’s progress. Then call the View’s invalidate() or postInvalidate() methods to redraw the View.

For method 1, it is more suitable for the situation of custom ViewGroup. If there are many sub-views in the custom ViewGroup that need to slide, Scroller can be considered. For example, The ViewPager of Android uses Scroller internally. For custom View, maybe method 2 and 3 are more suitable, I choose method 3: through ValueAnimator animation, in the construction of ValueAnimator pass the starting point and end point, and then start the animation, according to the animation progress calculation slide distance, let the button small circle move.

3. Do you want to consider the padding property

If you don’t include the padding in your custom control, then the padding of the user-defined control will be invalid. I chose not to include the padding of the user, because the contents of the slide button are only a small circle and only on one side, so the padding doesn’t make much sense. Considering the padding will complicate coordinate calculation in many places, I would rather let the user directly control the radius of the small circle, which is similar to the padding effect and simplifies calculation.

So whether or not you want to consider the padding property depends on the actual situation. Margin is determined by the parent ViewGroup, not by the View, so we don’t have to worry about margin.

implementation

1. Define control properties

In res -> values, right-click on a new XML file called attrs and define the control properties in the file as follows:

<resources>

    <declare-styleable name="SwitchButton" >
        <attr name="sb_openBackground" format="color"/>
        <attr name="sb_closeBackground" format="color"/>
        <attr name="sb_circleColor" format="color"/>
        <attr name="sb_circleRadius" format="dimension"/>
        <attr name="sb_status">
            <enum name="close" value="0"/>
            <enum name="open" value="1"/>
        </attr>
        <attr name="sb_interpolator">
            <enum name="Linear" value="0"/>
            <enum name="Overshoot" value="1"/>
            <enum name="Accelerate" value="2"/>
            <enum name="Decelerate" value="3"/>
            <enum name="AccelerateDecelerate" value="4"/>
            <enum name="LinearOutSlowIn" value="5"/>
        </attr>
    </declare-styleable>

</resources>
Copy the code

This allows the user to use these properties when referencing the control, as follows:

    <com.example.library.SwitchButton
            android:id="@+id/sb_button2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:sb_interpolator="Accelerate"
            app:sb_status="open"
            app:sb_circleRadius="10dp"
            app:sb_closeBackground="@android:color/black"
            app:sb_openBackground="@android:color/holo_blue_bright"
            app:sb_circleColor="@android:color/white" />
Copy the code

The name of the attribute should be known by name. App is just a namespace, so you can choose any name, not the same as android. For what these properties mean, see SwitchButton.

2. Initialize the controller properties

Call init() in View constructors to get control properties and initialize the controller as follows:

public class SwitchButton extends View {
    public SwitchButton(Context context) {
        super(context);
        init(context, null);
    }

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

    public SwitchButton(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs);
    }

    private void init(Context context, @Nullable AttributeSet attrs) {
        TypedArray typedValue = context.obtainStyledAttributes(attrs, R.styleable.SwitchButton);
        mOpenBackground = typedValue.getColor(R.styleable.SwitchButton_sb_openBackground, DEFAULT_OPEN_BACKGROUND);
        mCloseBackground = typedValue.getColor(R.styleable.SwitchButton_sb_closeBackground, DEFAULT_CLOSE_BACKGROUND);
        / /...
        typedValue.recycle();
         / /...
        // Initial brush, animation, etc}}Copy the code

All control attributes defined in attrs are in AttributeSet, and the Class TypedArray helps us retrieve the values. Finally, remember to call TypedValue.recycle () to retrieve the resources.

Why rewrite three constructors? Because your control may be referenced in code or in the XML layout, if your control is referenced in the XML layout, the system calls a constructor with two parameters to initialize the controller. If you create a new control in code and add it to the container, in most cases you will initialize the control with a constructor that takes one argument, as in: SwitchButton button = new SwitchButton(this), and either a one-argument or two-argument system will eventually call the three-argument constructor, just in case all three constructors have to be overridden.

3. Rewrite the onMeasure method to set the measuring width and height of the button

Override the onMeasure method to set the width and height of the sliding control as follows:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int measuredWidthMode = MeasureSpec.getMode(widthMeasureSpec);
    int measuredHeightMode = MeasureSpec.getMode(heightMeasureSpec);
    // Remove the system to measure the width and height
    int measuredWidth = MeasureSpec.getSize(widthMeasureSpec);
    int measureHeight = MeasureSpec.getSize(heightMeasureSpec);
    
    int defaultWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 60, getResources().getDisplayMetrics());// The default width of the control
    int defaultHeight = (int) (defaultWidth *  0.5 f);// The default height of the control is half the default width
    
    //OFFSET == 6
    int offset = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, OFFSET * 2 * 1.0 f, getResources().getDisplayMetrics());// The width and height of the control should not be less than 12dp, otherwise the button will not look good
    
    // Consider the wrap_content case
    if(measuredWidthMode == MeasureSpec.AT_MOST && measuredHeightMode == MeasureSpec.AT_MOST){
        measuredWidth = defaultWidth;
        measureHeight = defaultHeight;
    }else if(measuredHeightMode == MeasureSpec.AT_MOST){
        measureHeight = defaultHeight;
        if(measuredWidth - measureHeight < offset)
            measuredWidth = defaultWidth;
    }else if(measuredWidthMode == MeasureSpec.AT_MOST){
        measuredWidth = defaultWidth;
        if(measuredWidth - measureHeight < offset)
            measureHeight = defaultHeight;
    }else {
        // Handle the input illegal width and height case, i.e. the height is greater than the width, just swap them
        if(measuredWidth < measureHeight){
            inttemp = measuredWidth; measuredWidth = measureHeight; measureHeight = temp; }}if(Math.abs(measureHeight - measuredWidth) < offset) throw new IllegalArgumentException("layout_width cannot close to layout_height nearly, the diff must less than 12dp!");
    
    setMeasuredDimension(measuredWidth, measureHeight);
    
}
Copy the code

If you know how the View works, it’s easy to understand the code above, mainly in the case of wrap_content, we’re going to set a default width or height for the slide button, the default width is 60dp, the default height is 30DP which is half the width, If wrAP_content is not the case, let the View use the measured width or height. Finally, call setMeasuredDimension() to set the measured width and height of the View.

At the same time, we also need to consider the illegal input width and height, be sure to ensure that width > height, if the user input width and height is width < height, this will cause the button to stand up, in this case, I directly switch height and width; If the user enters the high is wide wide > is high, but if high close even equal wide, then lead to the sliding control is a circle, the button is not good-looking, so we have to control the width of high can’t vary too close together, in order to beautiful, I set the threshold value is 12 dp, if high wide difference less than 12 dp, I will throw an exception hint user.

4. In the onLayout() method, calculate the coordinates according to the width and height of the View

The slide control is divided into four parts: left circle, rectangle, right circle, and small circle, as follows:

The onDraw() method also draws the 4 parts of the slide button sequentially. In View, onMeasure() may be called multiple times. So the best in onLayout() method through getHeight() and getWidth() method to get the View of the true width and height, so in onLayout() method first according to the width and height of the View to calculate the radius of the left circle, the radius of the small circle, the rectangle left boundary x coordinates, the rectangle right boundary X coordinates, And the x coordinates of the center of the small circle are as follows:

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);
    // Find the radius of the left circle
    mLeftSemiCircleRadius = getHeight() / 2;
    // Radius of small circle = radius of large circle minus OFFER, OFFER = 6
    if(! checkCircleRaduis(mCircleRadius)) mCircleRadius = mLeftSemiCircleRadius - OFFSET;// The left x-coordinate of the rectangle
    mLeftRectangleBolder = mLeftSemiCircleRadius;
    // The x coordinate on the right side of the rectangle
    mRightRectangleBolder = getWidth() - mLeftSemiCircleRadius;
    // The x-coordinate of the center of a small circle is always changing
    mCircleCenter = isOpen ? mRightRectangleBolder : mLeftRectangleBolder;
}
Copy the code

Can see the left circle radius is half high View, then based on the left of the radius of the circle to draw other coordinates, small round and there will be some space between the left circle, so the left circle radius minus offset is worth the small round radius, the rectangle on the left side of the x coordinate directly is equal to the left of the radius of the circle, rectangle on the right side of the x coordinate View width reduction left round radius, The x coordinate of the center of a small circle determines whether the initial coordinate of its center is on the right or left edge of the rectangle, depending on whether the initial state is on or off.

Next, as long as you keep changing the x-coordinate of the center of the circle and redrawing the View, you can make the slide button slide.

5. Rewrite the onDraw() method to draw the button content

We know that the View draws itself in the onDraw() method, so we rewrite the onDraw() method to draw the four parts of the slide button as follows:

@Override
protected void onDraw(Canvas canvas) {
    / / the left circle
    canvas.drawCircle(mLeftRectangleBolder, mLeftSemiCircleRadius, mLeftSemiCircleRadius, mPathWayPaint);
    / / rectangle
    canvas.drawRect(mLeftRectangleBolder, 0, mRightRectangleBolder, getMeasuredHeight(), mPathWayPaint);
    / / right round
    canvas.drawCircle(mRightRectangleBolder, mLeftSemiCircleRadius, mLeftSemiCircleRadius, mPathWayPaint);
    / / small circle
    canvas.drawCircle(mCircleCenter, mLeftSemiCircleRadius, mCircleRadius, mCirclePaint);
}

Copy the code

Canvas is the canvas provided by the system, and what is drawn on canvas is the content displayed by View. According to the calculation in onLayout, we use Paint to draw four parts of the sliding button on canvas, which will be displayed as follows:

The next step is to let it slide, so that it will have the effect of the rendering.

6. Override the onTouchEvent() method to make the button slide

According to the event distribution mechanism of the View, if touch events are not intercepted, they will eventually be distributed to the View’s onTouchEvent() method. In this method, we can make different behavior of sliding button according to the type of event. We know that when the finger presses the button and then lifts it, the small circle of the sliding button will slide to the other side; When the finger presses the button and moves, the small circle of the sliding button moves with the finger. Knowing these two behaviors, we look at the onTouchEvent() method as follows:

@Override
public boolean onTouchEvent(MotionEvent event) {
    // Can be clicked when not animating
    if(isAnim) return false;
    switch(event.getAction()){
        case MotionEvent.ACTION_DOWN:
            // The initial x coordinate
            startX = event.getX();
            break;
        case MotionEvent.ACTION_MOVE:
            float distance = event.getX() - startX;
            // Update the center coordinates of a small circle
            mCircleCenter += distance / 10;
            // Control scope
            if (mCircleCenter > mRightRectangleBolder) {/ / the most right
                mCircleCenter = mRightRectangleBolder;
            } else if (mCircleCenter < mLeftRectangleBolder) {/ / the left
                mCircleCenter = mLeftRectangleBolder;
            }
            invalidate();
            break;
        case MotionEvent.ACTION_UP:
            float offset = Math.abs(event.getX() - Math.abs(startX));
            float diff;
            // There are two cases
            if (offset < mMinDistance) { //1. Click, press and lift the distance is less than mMinDistance to confirm the click
                if(isOpen){
                    diff = mLeftRectangleBolder - mCircleCenter;
                }else{ diff = mRightRectangleBolder - mCircleCenter; }}else {/ / 2. Sliding
                if (mCircleCenter > getWidth() / 2) {// Slide past the midpoint to the far right
                    this.isOpen = false;
                    diff = mRightRectangleBolder - mCircleCenter;
                } else{// Return to the origin without slipping past the midpoint
                    this.isOpen = true;
                    diff = mLeftRectangleBolder - mCircleCenter;
                }
            }
            mValueAnimator.setFloatValues(0, diff);
            mValueAnimator.start();
            startX = 0;
            break;
        default:
            break;
    }
    return true;
}

Copy the code

So let’s look at ACTION_DOWN, and when the finger presses, we record the x coordinate that the finger presses.

Then look at ACTION_MOVE, if press and move, we can let the small circle follow the finger to move, so in ACTION_MOVE, first calculate the distance that the finger moves, moving right distance is positive, moving left distance is negative, then add to the center coordinates of the small circle, You also need to control the center of the circle so that it does not go beyond the left and right edges of the rectangle, and finally call invalidate() to redraw the View, so that the onDraw() method will be executed again, updating the position of the circle and making the circle slowly slide.

Finally ACTION_UP, mMinDistance = new ViewConfiguration().getScaledTouchSlop(), which is the threshold defined by the system. If offset is greater than mMinDistance when lifting the finger, The finger is considered to be moving before it is lifted, otherwise it is considered to be clicking. If the finger is lifted after moving, then judge whether the center of the circle has slid past the midpoint to calculate the sliding distance. If it has slid past the midpoint (getWidth() / 2), let the circle slide to the right; if it has not slid past the midpoint, let the circle slide to the left; If the finger is just clicking the control, then calculate the sliding distance according to the control is currently in the open or closed state. If it is currently in the open state, let the small circle slide to the left, if it is currently in the closed state, let the small circle slide to the right; Diff is the distance between the center of the circle and the edge of the rectangle. It depends on the above situation. After calculating the distance, set it to ValueAnimator.

mValueAnimator.addUpdateListener(animation -> {
    float value = (float)animation.getAnimatedValue();
    mCircleCenter -= mPreAnimatedValue;
    // Update the center coordinates of a small circle
    mCircleCenter += value;
    mPreAnimatedValue = value;
    invalidate();
});
Copy the code

Then call invalidate() to redraw the View, and the onDraw() method will be executed again to update the position of the circle. Repeat this process until the end of the animation to slowly slide the circle.

conclusion

In the end, the effect of the renderings have been achieved. The principle of the whole process is still quite simple. The basic knowledge of animation and custom View is used.

Address: SwitchButton