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即可。