1. 程式人生 > >Android 一個簡單的自定義WheelView實現

Android 一個簡單的自定義WheelView實現

2018/10/27 修改

效果圖:

沒有首尾連線,可以向上向下拉出,然後彈回

放手後有短暫的"回彈"的動畫

程式碼:

package com.example.crazyflower.mywheelview;

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;

import java.lang.ref.WeakReference;
import java.util.List;


/**
 * Created by CrazyFlower on 2018/4/2.
 */

public class MyWheelView extends View {

    private static final String TAG = "MyWheelView";

    private static final String RESILIENCE_DISTANCE_OF_ONCE = "resilience_distance_of_once";
    private static final String RESILIENCE_LEFT_TIMES = "left_times";

    private static final int RESILIENCE_TIMES = 5;
    private static final int RESILIENCE_TIME_INTERVAL = 50;

    private List<String> data;
    private int selectedItemIndex = 0;

    private float lastY;
    private float scrollY;

    private int viewWidth;
    private int viewHeight;
    private float itemHeight;
    private int itemNumber;
    private int halfItemNumber;
    private static final float maxScaleTextSizeToItemHeight = 0.9f;
    private static final float minScaleTextSizeToItemHeight = 0.72f;
    private float maxTextSize;
    private float minTextSize;

    private IWheelViewSelectedListener wheelViewSelectedListener;

    Paint selectedLinePaint;
    Paint selectedBackgroundPaint;
    Paint normalTextPaint;
    Paint selectedTextPaint;

    private Handler handler;

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

    public MyWheelView(Context context, AttributeSet attrs) {
        this(context, attrs, 1);
    }

    public MyWheelView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.MyWheelView);
        initAttributesData(typedArray);
        initDefaultData();
    }

    private void initAttributesData(TypedArray typedArray) {
        Log.d(TAG, "initDataAndPaint: ");

        itemNumber = typedArray.getInt(R.styleable.MyWheelView_item_number, 5);
        halfItemNumber = itemNumber / 2;

        selectedLinePaint = new Paint();
        selectedBackgroundPaint = new Paint();
        normalTextPaint = new Paint();
        selectedTextPaint = new Paint();

        selectedLinePaint.setColor(typedArray.getColor(R.styleable.MyWheelView_selected_line_color, Color.rgb(0, 0, 0)));
        selectedBackgroundPaint.setColor(typedArray.getColor(R.styleable.MyWheelView_selected_background_color, Color.rgb(255, 255, 255)));
        normalTextPaint.setColor(typedArray.getColor(R.styleable.MyWheelView_normal_text_color, Color.rgb(0, 0, 0)));
        selectedTextPaint.setColor(typedArray.getColor(R.styleable.MyWheelView_selected_text_color, Color.rgb(0, 255, 204)));

        selectedLinePaint.setStyle(Paint.Style.FILL_AND_STROKE);
        selectedLinePaint.setStrokeWidth(4);
        selectedBackgroundPaint.setStyle(Paint.Style.FILL);
    }

    /*
     * 初始化寬高有關的資料
     */
    private void initWHData() {
        viewWidth = getMeasuredWidth();
        viewHeight = getMeasuredHeight();
        itemHeight = ((float) viewHeight) / itemNumber;
        maxTextSize = maxScaleTextSizeToItemHeight * itemHeight;
        minTextSize = minScaleTextSizeToItemHeight * itemHeight;
    }

    private void initDefaultData() {
        //預設選中為0,實際上setData的時候也會初始化selectedItemIndex
        selectedItemIndex = 0;

        handler = new MyWheelViewHandler(this);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        initWHData();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        Log.d(TAG, "onDraw: " + selectedItemIndex);

        //如果沒有資料或者資料量為0,不繪製
        if (null == data || 0 == data.size()) {
            return;
        }

        drawSelectedRectangle(canvas);

        /*
         * draw the text
         * think about the effect, draw the selected, and (halfItemNumber + 1) items above it,
         * and (halfItemNumber + 1) items below it.
         */
        String text;
        Paint paint;
        float midY;
        for (int i = Math.max(0, selectedItemIndex - (halfItemNumber + 1)),
             max = Math.min(data.size() - 1, selectedItemIndex + (halfItemNumber + 1));
             i <= max; i++) {
            text = data.get(i);

            midY = itemHeight * (halfItemNumber - (selectedItemIndex - i)) + itemHeight / 2 - scrollY;
            if (i == selectedItemIndex)
                paint = selectedTextPaint;
            else
                paint = normalTextPaint;

            setTextPaint(paint, midY);
            canvas.drawText(text, (viewWidth - getTextWidth(paint, text)) / 2,
                    midY + getTextBaselineToCenter(paint), paint);
        }
    }

    //繪製選中item的背景和線條
    private void drawSelectedRectangle(Canvas canvas) {
        canvas.drawLine(0, itemHeight * halfItemNumber, viewWidth, itemHeight * halfItemNumber, selectedLinePaint);
        canvas.drawLine(0, itemHeight * (halfItemNumber + 1), viewWidth, itemHeight * (halfItemNumber + 1), selectedLinePaint);
        canvas.drawRect(0, itemHeight * halfItemNumber, viewWidth, itemHeight * (halfItemNumber + 1), selectedBackgroundPaint);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        Log.d(TAG, "onTouchEvent: " + event.getAction() + " " + event.getY());
        Message message;
        Bundle bundle;
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                handler.removeMessages(MyWheelViewHandler.RESILIENCE);
                lastY = event.getY();
                return true;
            case MotionEvent.ACTION_MOVE:
                scrollY -= event.getY() - lastY;
                lastY = event.getY();
                confirmSelectedItem();
                return true;
            case MotionEvent.ACTION_UP:
                message = handler.obtainMessage();
                message.what = MyWheelViewHandler.RESILIENCE;
                bundle = new Bundle();
                bundle.putFloat(RESILIENCE_DISTANCE_OF_ONCE, scrollY / RESILIENCE_TIMES);
                bundle.putInt(RESILIENCE_LEFT_TIMES, RESILIENCE_TIMES);
                message.setData(bundle);
                message.sendToTarget();
                return true;
        }
        return false;
    }

    /**
     * @return 該字串在width下 字串中間對齊控制元件中間時候的 drawText用的x
     */
    private float getTextWidth(Paint paint, String text) {
        return paint.measureText(text);
    }

    /**
     * @return 該字串在itemHeight下 字串中間對齊控制元件中間的baseLine的y
     */
    private float getTextBaselineToCenter(Paint paint) {
        Paint.FontMetricsInt fontMetrics = paint.getFontMetricsInt();
        return ((float) (- fontMetrics.bottom - fontMetrics.top)) / 2;
    }

    private void confirmSelectedItem() {
        //計算移動了幾個item的height了, < 0說明向上, >0說明向下
        int changedItemNumber = Math.round(scrollY / itemHeight);

        int lastItem = getSelectedItemIndex();
        //計算這次的【合法的】的index
        int tempSelectedItem = getSelectedItemIndex() + changedItemNumber;
        if (tempSelectedItem < 0)
            tempSelectedItem = 0;
        if (tempSelectedItem >= data.size())
            tempSelectedItem = data.size() - 1;
        this.selectedItemIndex = tempSelectedItem;
        //減去相應的scrollY值(為了可以上滑和下滑超出)
        scrollY -= itemHeight * (selectedItemIndex - lastItem);
        invalidate();
        if (lastItem != tempSelectedItem)
            noticeListener();
    }

    private void setTextPaint(Paint paint, float midY) {
        paint.setTextSize(maxTextSize - (maxTextSize - minTextSize) * Math.abs(viewHeight / 2 - midY) / (viewHeight / 2) );
    }

    public int getSelectedItemIndex() {
        return selectedItemIndex;
    }


    /*
     * 這個是專門給外部類呼叫設定用的,類內不應該呼叫。 也是因為類內不用主動
     */
    public void setDataWithSelectedItemIndex(List<String> data, int selectedItemIndex) {
        this.data = data;
        setSelectedItemIndex(selectedItemIndex);
    }

    /*
     * 這個是專門給外部類呼叫設定用的,類內不應該呼叫。 也是因為類內不用主動
     */
    public void setSelectedItemIndex(int selectedItemIndex) {
        //外部自己負責處理這個index是否合法
        this.selectedItemIndex = selectedItemIndex;
        //既然外部設定index,就不要這個偏移量了
        this.scrollY = 0;
        invalidate();
        noticeListener();
    }

    private void resilienceToCenter(float distance) {
        scrollY -= distance;
        invalidate();
    }

    private void noticeListener() {
        if (null != wheelViewSelectedListener)
            wheelViewSelectedListener.wheelViewSelectedChanged(this, data, selectedItemIndex);
    }

    public void setWheelViewSelectedListener(IWheelViewSelectedListener wheelViewSelectedListener) {
        this.wheelViewSelectedListener = wheelViewSelectedListener;
    }

    private static class MyWheelViewHandler extends Handler {

        static final int RESILIENCE = 1;

        private WeakReference<MyWheelView> myWheelViewWeakReference;

        private MyWheelViewHandler(MyWheelView myWheelView) {
            myWheelViewWeakReference = new WeakReference<>(myWheelView);
        }

        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);

            MyWheelView myWheelView;
            Message message;
            Bundle bundle;
            int leftTimes;
            switch (msg.what) {
                case RESILIENCE:
                    bundle = msg.getData();
                    myWheelView = myWheelViewWeakReference.get();
                    if (null != myWheelView)
                        myWheelView.resilienceToCenter(bundle.getFloat(RESILIENCE_DISTANCE_OF_ONCE, 0));

                    leftTimes = bundle.getInt(RESILIENCE_LEFT_TIMES, 0);
                    if (leftTimes > 1) {//如果還要重繪,傳送重繪的訊息,這裡重複使用了bundle
                        bundle.putInt(RESILIENCE_LEFT_TIMES, leftTimes - 1);
                        message = new Message();
                        message.what = RESILIENCE;
                        message.setData(bundle);
                        this.sendMessageDelayed(message, RESILIENCE_TIME_INTERVAL);
                    }
                    break;
            }
        }
    }

}

詳細:

    重要變數:

        scrollY:當前畫面  對於  把selectedItemIndex放在正中間時的畫面  的垂直偏移量。如效果圖中所示,如果當前選中項的下標為0,且上拉超出了很多,該值應該很大。當僅僅是小小拉動離開中間位置一點點的時候,該值應該很小。該值由onTouchEvent中的event處理得到。

        handler:用於處理“回彈”效果時的訊息傳遞者(由於要在主執行緒中更新)。

    重要方法:

        onDraw(),進行繪製。 沒有資料的話不進行繪製。呼叫drawSelectedRectangle()繪製中間那個凸顯選中的矩形。然後繪製被選中項上下(halfItemNumebr + 1)項(這幾項可以被看到)。如果沒有scrollY的話,每一項都在固定位置上,selected那項就在正中間,他的前一項後一項就在上下那個位置。但這顯然不是我們要的效果。為了在滑動中可以顯示滑倒一半的那種效果,加了scrollY,目的如上所說。midY是該項item垂直中線的Y的值,setText是根據該項item的midY離view中間的距離設定畫筆的textSize,getTextWidth是用來獲得字串畫出來的寬度,getBaseLineToCenter返回的是text的baseline到item中間的距離。

        onTouchEvent(),接受觸控事件,我本來想用GestureDetector幫我代勞,我寫幾個介面的處理就好(特別是onFling,滑動中很有用),但是GestureDetector中沒有單獨的ACTION_UP動作的介面,如果使用GestureDetector,然後自己再寫ACTION_UP的處理,感覺有點頭疼,就自己處理了。在這個過程中,ACTION_DOWN,起始觸控動作,我在這裡把handler中的訊息都清光了,為的是:如果上一次的回彈動畫還沒結束,則我應該把回彈動畫取消掉了,手指按下的時候就直接在當前畫面下繼續。ACTION_MOVE:手指滑動,這裡處理事件的時候,注意手指向下拉的時候,列表上滑,手指向上拉的時候,列表上滑。注意加減和正負號關係。對於每一次MOVE動作,利用lastY和event.getY()獲取偏移量,加到scrollY中,即可獲得整次手指操作的偏移量,然後在confirmSelectedItem中判斷當前選中哪個了,在confirmSelectedItem中會呼叫重繪函式。對於ACTION_UP:手指停止滑動,在這裡處理回彈(即偏移中間的回到中間,實際上就是每隔一段重新設定scrollY,然後重繪,給使用者一種動畫的感覺,我在這裡是每隔50ms,一次回彈重繪5次),用handler和message處理實現。自己實現onFling除了記錄時間,感覺沒有很好的思路,以後可能會加上。

        confirmSelectedItem(),根據scrollY,判斷當前應該是選個最靠近中間,然後修改selectedItemIndex,同時修改scrollY,這兩個要一起修改,這樣才能保持一致,然後重繪,然後與之前的selectedItemIndex比較,如果不同,呼叫監聽者的函式

使用:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.example.crazyflower.mywheelview.MainActivity">

    <com.example.crazyflower.mywheelview.MyWheelView
        android:id="@+id/test_wheelview"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        app:item_number="5"
        app:selected_background_color="#EEEEEE"
        app:selected_line_color="#EEEEEE"
        app:selected_text_color="#CC007FCC" />


    <TextView
        android:id="@+id/selected_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:layout_centerHorizontal="true"
        android:textSize="32sp"/>

</RelativeLayout>

item_number,selected_background_color,selected_line_color,selected_text_color,自定義View屬性。加在attrs.xml中

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="MyWheelView">
        <attr name="item_number" format="integer" />
        <attr name="selected_line_color" format="color" />
        <attr name="selected_background_color" format="color" />
        <attr name="normal_text_color" format="color" />
        <attr name="selected_text_color" format="color" />
    </declare-styleable>
</resources>

效果看名字應該看得出來了2333。

然後是在程式碼中的使用:

package com.example.crazyflower.mywheelview;

import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.widget.TextView;

import java.util.ArrayList;
import java.util.List;

public class MainActivity extends AppCompatActivity {

    private static final String TAG = "MainActivity";
    private TextView textView;

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

        MyWheelView myWheelView = findViewById(R.id.test_wheelview);

        List<String> data = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            data.add(i + "");
        }
        data.add("123453212");
        data.add("123jhfsagdfsdafds");
        for (int i = 0; i < 10; i++) {
            data.add(i + "");
        }
        myWheelView.setDataWithSelectedItemIndex(data, 0);

        textView = findViewById(R.id.selected_text);

        myWheelView.setWheelViewSelectedListener(new IWheelViewSelectedListener() {
            @Override
            public void wheelViewSelectedChanged(MyWheelView myWheelView, List<String> data, int position) {
                Log.d(TAG, "wheelViewSelectedChanged: " + data.get(position));
                textView.setText(data.get(position));
            }
        });
    }
}

使用的時候只要設定data和初始的selected_item,然後需要監聽的話可以設定監聽的,不需要監聽的話,只需要在合適的時候呼叫getSelectedItemIndex()就好了。

注意:先設定監聽,再設定data的話,會通知監聽者。先設定data,再設定監聽的話,則不會。這個主要是在初始化的時候,看你的需求。

聯動的話:在某個wheelview監聽者的函式被呼叫後,呼叫另外一個wheelview的setDateWithIndex即可。

參考:https://blog.csdn.net/junzia/article/details/50979382

整個專案:https://github.com/AngrySwordCrazyFlower/MyWheelView

如有錯誤,歡迎指出