Android自定義View實現帶音效和震動的SeekBar
需求描述
當我們需要做一些帶校準的功能時,需要調節(jié)一些值來反映校準的效果,或者是相機之類的應用,需要設置焦距,曝光值之類的,為了方便用戶設置這些值,通常需要用到滑動選擇的控件,比如系統提供的SeekBar控件。用戶通過滑動屏幕就能設置值。使用系統的seekBar雖然可以完成這些功能,但是不美觀。一般產品都不會采納系統的原生控件,所以只能是我們自己來通過自定義view繪制。今天我們要繪制的自定義View如下所示:
然后在第一次的時候,會有個動畫提示用戶,如何操作。效果如下:
最后用戶開始操作動畫就會消失,用戶操作時的效果如下:
本文就是主要介紹如何實現這樣一個控件,這個控件在滑動的時候會伴隨音效以及手機的震動感。
思路
當我們拿到一個自定義View控件需求的時候,首先我們需要先分析下這個自定義控件是否可以使用系統已經有的控件組合實現,如果不能,我們再分析這個自定義控件是一個view還是可以放子view的容器(ViewGroup)。如果是一個容器類的自定義控件,我們就需要繼承自ViewGroup。否則就需要我們繼承自View自己繪制,然后再添加對應的事件處理就行了。本文要實現的自定義控件屬于需要繼承自View自己繪制的。首先我們要繪制的View,為了方便我們稱為RulerSeekBar。這個RulerSeekBar由幾部分組成,分別是:提示文本、指示的指針、長短刻度以及數字。接下來我們需要做的就是計算出他們的對應坐標,然后使用繪圖API繪制出來就行了。繪制完View后我們需要做事件處理,比如滑動的時候的吸附效果,慣性滑動,音效,震動處理。而滾動的時候我們使用的是ScrollerView。其實自定義Android中沒有的view控件就是將需要繪制的View樣式分解成基本圖形,算出每個需要繪制的基本圖形坐標,使用繪圖的API將其分別繪制就行了,然后就是處理事件和調整細節(jié)。
繪制提示文本
RulerSeekBar的提示文本是支持多色字體的,這里我們主要使用Android系統提供的SpannableString,這個類運行我們定義各種樣式的文本,甚至可以放圖片,特別好用。不了解的小伙伴可以去百度下。這個類真的很炫。但是我們是繼承自View的,所以繪制SpannableString需要借助DynamicLayout的幫助。否則無法繪制出不同樣式的文本。
指示指針
指示指針包括兩部分,一個圖標,一個帶漸變的小圓矩形指針。我們算出他們的坐標后使用繪圖API繪制出來就行了
長短刻度和數字
刻度分為長刻度和短刻度,為了不混淆,我使用的是兩個畫筆繪制分別繪制。然后每個刻度的坐標計算,我們可以使用當前控件的寬除以每個刻度的間隔大小就能得出當前的寬可以繪制多少個刻度。而對于數字,我們可以根據設置的最大值和最小值,刻度間的間隔,當前的位置等信息,計算每個刻度的數字的坐標并繪制,這里處理的時候將每個刻度放大十倍處理,這樣可以防止在計算過程中精度的丟失,回調數據的時候再縮小10倍將值給到用戶
陰影效果繪制
我們仔細觀察可以發(fā)現,當我們的RulerSeekBar的兩邊刻度有個陰影效果,當我們左滑或者右滑的時候,會出現一個漸變,給人一種漸漸消失的感覺,這種效果我們主要通過改變畫筆的透明度實現的。具體的看代碼
吸附效果和慣性滑動
當我們滑動RulerSeekBar控件選擇數值時,有時候會滑動到兩個刻度之間,當我們放開手的時候,控件會自動吸附到兩個刻度中的一個。這種判斷就是當滑動的距離超過了一個閾值后就選擇后面的一個刻度,否則回彈回上一個刻度。而慣性滑動就是我們所說的Fling,指的是我們在屏幕上快速滑動然后突然停止后,由于慣性,還會滑動一段距離,這里我們需要借助于速度跟蹤器:VelocityTracker和Scroller實現,具體見代碼
音效震動處理
當滑動的時候有個音效感覺會好很多,這時候如果能加上震動效果就會更好,這里我們使用的是系統的 Vibrator實現震動,SoundPool實現音效播放。
提示動畫的實現
因為動畫只是一個橫向反復平移。所以我們可以借助于屬性動畫的ValueAnimator計算出值,然后調用View的invalidate()方法觸發(fā)view繪制需要動畫的對象就行,本文中需要動畫的對象是(小手圖標)
代碼解析
初始化
在初始化的時候我們將自定義的屬性解析出來并賦給當前類的成員變量,并且初始化畫筆和一些值
public RulerSeekBar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); // 初始化自定義屬性 initAttrs(context, attrs); // 滑動的閾值,后面會通過它去判斷當前的是操作是滑動還是觸摸操作 ViewConfiguration viewConfiguration = ViewConfiguration.get(context); TOUCH_SLOP = viewConfiguration.getScaledTouchSlop(); // 速度追蹤器的初始化 MIN_FLING_VELOCITY = viewConfiguration.getScaledMinimumFlingVelocity(); MAX_FLING_VELOCITY = viewConfiguration.getScaledMaximumFlingVelocity(); // 將距離值轉換成數字 convertValueToNumber(); // 畫筆等成員變量的初始化 init(context); }
在convertValueToNumber中我們將距離轉換成對應的數字
private void convertValueToNumber() { mMinNumber = (int) (minValue * 10); mMaxNumber = (int) (maxValue * 10); mCurrentNumber = (int) (currentValue * 10); mNumberUnit = (int) (gradationUnit * 10); mCurrentDistance = (float) (mCurrentNumber - mMinNumber) / mNumberUnit * gradationGap; mNumberRangeDistance = (float) (mMaxNumber - mMinNumber) / mNumberUnit * gradationGap; if (mWidth != 0) { mWidthRangeNumber = (int) (mWidth / gradationGap * mNumberUnit); } Log.d(TAG, "convertValueToNumber: mMinNumber: " + mMinNumber + " ,mMaxNumber: " + mMaxNumber + " ,mCurrentNumber: " + mCurrentNumber + " ,mNumberUnit: " + + mNumberUnit + " ,mCurrentDistance: " + mCurrentDistance + " ,mNumberRangeDistance: " + + mNumberRangeDistance + " ,mWidthRangeNumber: " + mWidthRangeNumber); + }
在init函數中,主要是對各種畫筆和震動音效的成員變量的初始化工作
private void init(Context context) { // 短刻度畫筆 mShortGradationPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mShortGradationPaint.setStrokeWidth(shortLineWidth); mShortGradationPaint.setColor(gradationColor); mShortGradationPaint.setStrokeWidth(shortLineWidth); mShortGradationPaint.setColor(gradationColor); mShortGradationPaint.setStrokeCap(Paint.Cap.ROUND); // 長刻度畫筆 mLongGradationPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mLongGradationPaint.setStrokeWidth(longLineWidth); mLongGradationPaint.setStrokeCap(Paint.Cap.ROUND); mLongGradationPaint.setColor(Color.parseColor("#FF4AA5FD")); // 指針畫筆,這里用到了LinearGradient ,主要是實現一種漸變效果。 int[] colors = new int[]{0x011f8d8, 0xff0ef4cb, 0x800cf2c3}; LinearGradient linearGradient = new LinearGradient( 0, 0, 100, 100, colors, null, Shader.TileMode.CLAMP ); mIndicatorPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mIndicatorPaint.setColor(indicatorLineColor); mIndicatorPaint.setStrokeWidth(indicatorLineWidth); mIndicatorPaint.setStrokeCap(Paint.Cap.ROUND); mIndicatorPaint.setShader(linearGradient); Bitmap originBp = BitmapFactory.decodeResource(getResources(), R.drawable.indicator); indicatorBp = Bitmap.createScaledBitmap(originBp, dp2px(222), dp2px(6.85f), true); originBp.recycle(); // 手勢圖標畫筆 mGestureAniPaint = new Paint(Paint.ANTI_ALIAS_FLAG); // 文字畫筆 mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG); mTextPaint.setTextSize(textGradationSize); mTextPaint.setColor(textGradationColor); mScroller = new Scroller(context); // 數字畫筆 mNumPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG); mNumPaint.setTextSize(textGradationSize); mNumPaint.setColor(textGradationColor); mSoundPool = new SoundPool(10,AudioManager.STREAM_MUSIC,0); soundId = mSoundPool.load(getContext(),R.raw.sound,1); // 震動效果 vibrator = (Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE); }
控件測量
在測量階段主要是決定控件的大小,這里我們只需要處理測量模式為AT_MOST的情況下的控件的高。這種模式下不做限制會導致子控件的高度變得異常:
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { mWidth = calculateSize(true, widthMeasureSpec); mHeight = calculateSize(false, heightMeasureSpec); mHalfWidth = mWidth >> 1; if (mWidthRangeNumber == 0) { mWidthRangeNumber = (int) (mWidth / gradationGap * mNumberUnit); } Log.d(TAG, "onMeasure: mWidthRangeNumber: " + mWidthRangeNumber + " ,mNumberUnit: " + mNumberUnit); setMeasuredDimension(mWidth, mHeight); }
private int calculateSize(boolean isWidth, int measureSpec) { final int mode = MeasureSpec.getMode(measureSpec); final int size = MeasureSpec.getSize(measureSpec); int realSize = size; if (mode == MeasureSpec.AT_MOST) { if (!isWidth) { int defaultSize = dp2px(74); realSize = Math.min(realSize, defaultSize); } } Log.d(TAG, "mode: " + mode + " ,size: " + size + " ,realSize: " + realSize); return realSize; }
控件繪制
繪制階段主要是繪制背景,然后繪制刻度和數字,最后繪制指針,然后動畫是根據變量isPlayTipAnim來決定是否繪制的,當用戶不點擊控件的時候,動畫會一直播放,用戶點擊了后停止對動畫的繪制
@Override protected void onDraw(Canvas canvas) { // 繪制背景 canvas.drawColor(bgColor); // 繪制刻度和數字 drawGradation(canvas); // 繪制指針 drawIndicator(canvas); // 繪制動畫的圖標 if (isPlayTipAnim) { drawGestureAniIcon(canvas); } }
提示動畫繪制
繪制動畫的時候我們可以使用一個ValueAnimator屬性動畫來確定一個動畫的范圍,當我們開始動畫的時候,這個類會給們計算變化的值,我們把這個值設置成小手圖標的X坐標,保持Y坐標不變,然后這個值每改變一次,就觸發(fā)一次重繪,這樣就完成了提示動畫的效果了,代碼如下所示:
private void drawGestureAniIcon(Canvas canvas) { if (mGestureTipBp == null) { Bitmap originBp = BitmapFactory.decodeResource(getResources(), R.drawable.ic_gesture_tip); mGestureTipBp = Bitmap.createScaledBitmap(originBp, dp2px(46), dp2px(47), true); mGestureAniTransX = mHalfWidth - (float) mGestureTipBp.getWidth() / 2 + dp2px(2); originBp.recycle(); valueAnimator = ValueAnimator.ofFloat( mHalfWidth - 11 * gradationGap, mHalfWidth + 7 * gradationGap); // 此處做動畫的范圍。按照真實情況合理調整。 valueAnimator.addUpdateListener(animation -> { mGestureAniTransX = (float) animation.getAnimatedValue(); // Log.d(TAG, "zhongxj111: mGestureAniTransX: " + mGestureAniTransX); invalidate(); }); valueAnimator.setDuration(2000); valueAnimator.setRepeatCount(ValueAnimator.INFINITE); valueAnimator.setRepeatMode(ValueAnimator.REVERSE); valueAnimator.start(); } canvas.drawBitmap(mGestureTipBp, mGestureAniTransX, stopLongGradationY - (float) mGestureTipBp.getHeight() / 2 - dp2px(15), mGestureAniPaint ); }
漸變效果的繪制
當繪制刻度的時候,我們需要去實現繪制漸變效果,就是我們的控件兩邊,如果用戶左右滑動的時候,我們的刻度有漸變的效果,感覺好像是慢慢消失一樣,這里有的讀者可能會想到讓UI切一張透明的背景,這種方法如果控件的背景是黑色的時候可行,但是控件的背景是其他的顏色的時候就會發(fā)現這個透明的背景很突兀,感興趣的讀者也可以去嘗試下。我的實現方式是通過用戶滑動的距離換算成透明度設置給刻度的畫筆,這樣用戶滑動的時候,距離是在變化的,或是變大,或是變小,這時候再把這個距離映射成透明的值即可。 我們的Paint的API設置透明值是一個整型的數,范圍是0~255
我們只要保證設置的值在這個區(qū)間即可。 我們滑動的時候會得到一個刻度距離最左邊或者最右邊的距離值,這個值正好可以用于換算成顏色值,注意:如果刻度間距離設置得很大,需要重新映射,這里我默認刻度在11dp下的,滑動的距離剛好在0~255之間 關鍵代碼如下:
// 給控件開始的6個刻度做漸變效果 if (distance < 6 * gradationGap) { Log.d(TAG, "distance==>" + distance + " ,curPosIndex=>" + curPosIndex + " ,perUnitCount: " + perUnitCount + " ,factor: " + factor + " ,6*gradationGap: " + 6 * gradationGap); //計算開始部分的透明值 int startAlpha = Math.abs((int) (distance)); mLongGradationPaint.setAlpha(startAlpha); mShortGradationPaint.setAlpha(startAlpha); mNumPaint.setAlpha(startAlpha); // 給控件的結尾做漸變效果 } else if (distance > mWidth - 6 * gradationGap) { // 計算結束的透明值 int endAlpha = Math.abs((int) ((mWidth + gradationGap) - distance)); // Log.d(TAG, "zhongxj: endAlpha: " + endAlpha); mLongGradationPaint.setAlpha(endAlpha); mShortGradationPaint.setAlpha(endAlpha); mNumPaint.setAlpha(endAlpha); } else { { mShortGradationPaint.setAlpha(255); mLongGradationPaint.setAlpha(255); mShortGradationPaint.setColor(gradationColor); mLongGradationPaint.setColor(Color.parseColor("#FF4AA5FD")); } }
這里還有一個難點就是結尾處的漸變值如何設置,因為結尾處的距離超過了0~255范圍,而且這個漸變值需要和開始部分的透明值保持對應并且是逐漸變小,開始處的透明值是逐漸增大的,比如:開始的透明值是1,2,3,4,那么結尾處的透明值就必須為4,3,2,1。處理的代碼為:
int endAlpha = Math.abs((int) ((mWidth + gradationGap) - distance));
這里我們可以舉個例子說明下,比如1,2,3,4,5,6,7,8,9,10 當處于2的時候distance為2,7的時候distance為7,gradationGap為1,mWidth為10,我們想要把7,8,9,10映射成4,3,2,1,只需要使用:(10+1)-distance(7,8,9,10)就行了,讀者可以去計算試試。
事件的處理
我們滑動屏幕時判斷如果是橫向滑動,則使用Scroll滾動到我們想要滾動的刻度。如果有慣性滾動,那么慣性滾動后,再自動吸附到最近的一個刻度上即可:
@Override public boolean onTouchEvent(MotionEvent event) { final int action = event.getAction(); final int x = (int) event.getX(); final int y = (int) event.getY(); Log.d(TAG, "onTouchEvent: " + action); if (mVelocityTracker == null) { mVelocityTracker = VelocityTracker.obtain(); } mVelocityTracker.addMovement(event); switch (action) { case MotionEvent.ACTION_DOWN: mScroller.forceFinished(true); mDownX = x; isMoved = false; isPlayTipAnim = false; if (valueAnimator != null) { valueAnimator.cancel(); } break; case MotionEvent.ACTION_MOVE: final int dx = x - mLastX; //判斷是否已經滑動 if (!isMoved) { final int dy = y - mLastY; // 滑動的觸發(fā)條件,水平滑動大于垂直滑動,滑動距離大于閾值 if (Math.abs(dx) < Math.abs(dy) || Math.abs(x - mDownX) < TOUCH_SLOP) { break; } isMoved = true; } mCurrentDistance -= dx; calculateValue(); break; case MotionEvent.ACTION_UP: // 計算速度:使用1000ms 為單位 mVelocityTracker.computeCurrentVelocity(1000, MAX_FLING_VELOCITY); // 獲取速度,速度有方向性,水平方向,左滑為負,右滑為正 int xVelocity = (int) mVelocityTracker.getXVelocity(); // 達到速度則慣性滑動,否則緩慢滑動到刻度 if (Math.abs(xVelocity) >= MIN_FLING_VELOCITY) { mScroller.fling((int) mCurrentDistance, 0, -xVelocity, 0, 0, (int) mNumberRangeDistance, 0, 0); invalidate(); } else { scrollToGradation(); } break; } mLastX = x; mLastY = y; return true; }
根據滑動的距離計算處需要滾動的刻度即可:
private void scrollToGradation() { mCurrentNumber = mMinNumber + Math.round(mCurrentDistance / gradationGap) * mNumberUnit; // 算出的值邊界設置,如果當前的值小于最小值,則選最小值,如果當前的值大于最大值,則取最大值 mCurrentNumber = Math.min(Math.max(mCurrentNumber, mMinNumber), mMaxNumber); mCurrentDistance = (float) (mCurrentNumber - mMinNumber) / mNumberUnit * gradationGap; currentValue = mCurrentNumber / 10f; // 當前的值是放大了10倍處理的,所以回調值的時候需要 縮小10倍 if (mValueChangedListener != null) { mValueChangedListener.onValueChanged(currentValue); } // 播放音效和震動效果 playSoundEffect(); startVibration(); // 觸發(fā)重繪 invalidate(); }
回調值給用戶
在滾動的時候和計算值的時候將值回調給調用者
/** * 當前值變化監(jiān)聽器 */ public interface OnValueChangedListener { void onValueChanged(float value); }
/** * 根據distance距離,計算數值 */ private void calculateValue() { // 限定范圍在最大值與最小值之間 mCurrentDistance = Math.min(Math.max(mCurrentDistance, 0), mNumberRangeDistance); mCurrentNumber = mMinNumber + (int) (mCurrentDistance / gradationGap) * mNumberUnit; // 因為值放大了10倍處理,所以回調值的時候需要縮小10倍 currentValue = mCurrentNumber / 10f; Log.d(TAG, "currentValue: " + currentValue + ",mCurrentDistance: " + mCurrentDistance + " ,mCurrentNumber: " + mCurrentNumber); if (mValueChangedListener != null) { mValueChangedListener.onValueChanged(currentValue); } invalidate(); }
private void scrollToGradation() { mCurrentNumber = mMinNumber + Math.round(mCurrentDistance / gradationGap) * mNumberUnit; // 算出的值邊界設置,如果當前的值小于最小值,則選最小值,如果當前的值大于最大值,則取最大值 mCurrentNumber = Math.min(Math.max(mCurrentNumber, mMinNumber), mMaxNumber); mCurrentDistance = (float) (mCurrentNumber - mMinNumber) / mNumberUnit * gradationGap; currentValue = mCurrentNumber / 10f; // 當前的值是放大了10倍處理的,所以回調值的時候需要 // 縮小10倍 if (mValueChangedListener != null) { mValueChangedListener.onValueChanged(currentValue); } // 播放音效和震動效果 playSoundEffect(); startVibration(); invalidate(); }
總結
本文主要介紹了一個RulerSeekBar的自定義View,文中只介紹了關鍵的實現部分,其他細節(jié)部分讀者感興趣可以閱讀源碼,源碼的地址為:RulerSeekBar 自定義View的地址,控件使用的是Java語言編寫,雖然現在Android開發(fā)中Kotlin是扛把子,但是由于是給只會使用JAVA的用戶開發(fā)的控件,所以我使用了JAVA語言,但是Kotlin也能使用,并且如果讀者有時間可以使用kotlin將這個控件實現一下,原理基本一樣,就是使用的語法不同而已。
以上就是Android自定義View實現帶音效和震動的SeekBar的詳細內容,更多關于Android自定義View實現SeekBar的資料請關注腳本之家其它相關文章!
相關文章
android使用url connection示例(get和post數據獲取返回數據)
這篇文章主要介紹了android使用URLConnection來get和post數據獲取返回的數據,大家參考使用吧2014-01-01Android模擬器"Failed To Allocate memory 8"錯誤如何解決
這篇文章主要介紹了Android模擬器"Failed To Allocate memory 8"錯誤如何解決的相關資料,需要的朋友可以參考下2017-03-03