網易云音樂是1款非常優秀的音樂播放器,特別是播放界面,使用唱盤機風格,顯得格外古典優雅。這里拋磚引玉,原文地址:http://www.jianshu.com/p/cb54990219d9
首先來看1下網易的播放效果。
要實現上面的功能,我們需要對界面進行1個拆分,拆分后大概包括以下結構:
主界面布局從上到下可以劃分幾大區域,如圖:
如圖,由上到下主要分為:標題欄區、唱盤區域、時長顯示區域、播放控制區域。
標題欄
使用ToolBar實現,字體可能需要自定義。
唱盤區域
唱盤區域包括唱盤、唱針、底盤、和實現切換的ViewPager等控件,該布局比較復雜,本案例使用自定義控件實現唱盤區域。
時長顯示區域
使用RelativeLayout作為根布局,進度條使用SeekBar實現。
播放控制區域
比較簡單,使用LinearLayout作為根布局。
唱盤區域由控件DiscView實現,以RelativeLayout為根布局,子控件包括:底盤、唱針、ViewPager等。其中,底盤和唱針均用ImageView實現,然后使用ViewPager加載ImageView實現唱片的切換。如圖:
唱片布局以下:
<?ml version="1.0" encoding="utf⑻"?>
<com.achillesl.neteasedisc.widget.DiscView
mlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!--底盤-->
<ImageView
android:id="@+id/ivDiscBlackgound"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
/>
<!--ViewPager實現唱片切換-->
<android.support.v4.view.ViewPager
android:id="@+id/vpDiscContain"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
/>
<!--唱針-->
<ImageView
android:id="@+id/ivNeedle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_needle"/>
</com.achillesl.neteasedisc.widget.DiscView>
這里面觸及到1個DiscView類,這個是1個復合類,我們來看1些主要的功能。
唱盤控件DiscView提供1個接口IPlayInfo,代碼以下:
public interface IPlayInfo {
/*用于更新標題欄變化*/
void onMusicInfoChanged(String musicName, String musicAuthor);
/*用于更新背景圖片*/
void onMusicPicChanged(int musicPicRes);
/*用于更新音樂播放狀態*/
void onMusicChanged(MusicChangedStatus musicChangedStatus);
}
這上面定義的3個函數作用: 分別用于更新標題欄(音樂名、作者名)、更新背景圖片和控制音樂播放狀態(播放、暫停、上/下1首等)。
點擊主界面播放/暫停、上/下1首按鈕時,調用DiscView暴露的方法:
@Override
public void onClick(View v) {
if (v == mIvPlayOrPause) {
mDisc.playOrPause();
} else if (v == mIvNet) {
mDisc.net();
} else if (v == mIvLast) {
mDisc.last();
}
}
當主界面收到DiscView回調時,調用相干方法控制音樂播放,這樣邏輯就會很清晰,各分職責:
public void onMusicChanged(MusicChangedStatus musicChangedStatus) {
switch (musicChangedStatus) {
case PLAY:{
play();
break;
}
case PAUSE:{
pause();
break;
}
case NET:{
net();
break;
}
case LAST:{
last();
break;
}
case STOP:{
stop();
break;
}
}
}
音樂控制狀態時序如圖3⑶所示,點擊Activity的按鈕時,先調用DiscView的相干方法,并在適合的時機(如動畫結束)再將狀態回調到Activity,并通過廣播發送指令到Service,實現音樂狀態切換,最后通過廣播更新UI狀態。
這個狀態的切換只有你仔細視察就會明白它的流程了。項目架構介紹到這里,接下來是部份視覺效果和設計思路的介紹和項目的1些難點介紹。
解決大圖加載1般有幾種方案:
1. 設置largeHeap為true。
2. 根據圖片類型選定解碼格式。
3. 根據原始圖片寬高及目標顯示寬高,設置圖片采樣率。
根據實際經驗我們1般采取后兩種,第1種雖然通過增加堆內存來延緩了oom的時機,但是治標不治本。這里我們整理1個類。
private Bitmap getMusicPicBitmap(int musicPicSize, int musicPicRes) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(getResources(),musicPicRes,options);
int imageWidth = options.outWidth;
int sample = imageWidth / musicPicSize;
int dstSample = 1;
if (sample > dstSample) {
dstSample = sample;
}
options.inJustDecodeBounds = false;
//設置圖片采樣率
options.inSampleSize = dstSample;
//設置圖片解碼格式
options.inPreferredConfig = Bitmap.Config.RGB_565;
return Bitmap.createScaledBitmap(BitmapFactory.decodeResource(getResources(),
musicPicRes, options), musicPicSize, musicPicSize, true);
}
我相信有過幾年Java開發經驗或Android經驗的人都會知道這么1個常識:首先設置options.inJustDecodeBounds = true,這樣BitmapFactory.decodeResource的時候僅僅會加載圖片的1些信息,然后通過options.outWidth獲得到圖片的寬度,根據目標圖片尺寸算出采樣率。最后通過inPreferredConfig設置解碼格式,才正式加載圖片,這樣有效的避免了圖片的oom。
之前我們使用圓圈1般會自定義1個View,然后實現onDraw(),不過Android在android.support.v4.graphics.drawable 里面為我們實現了1個類RoundedBitmapDrawable。使用以下,我們可以對其做1個簡單的封裝:
private Drawable getDiscBlackgroundDrawable() {
int discSize = (int) (mScreenWidth * DisplayUtil.SCALE_DISC_SIZE);
Bitmap bitmapDisc = Bitmap.createScaledBitmap(BitmapFactory.decodeResource(getResources(), R
.drawable.ic_disc_blackground), discSize, discSize, false);
RoundedBitmapDrawable roundDiscDrawable = RoundedBitmapDrawableFactory.create
(getResources(), bitmapDisc);
return roundDiscDrawable;
}
LayerDrawable介紹
LayerDrawable也可包括1個Drawable數組,因此系統將會按這些Drawable對象的數組順序來繪制它們,索引最大的Drawable對象將會被繪制在最上面。 LayerDrawable有點類似PhotoShop圖層的概念。
我們在分析唱片布局的時候發現原View包括兩個ImageView,估計是1個用來顯示唱盤,1個用來顯示專輯圖片。
使用LayerDrawable生成復合圖片代碼:
private Drawable getDiscDrawable(int musicPicRes) {
int discSize = (int) (mScreenWidth * DisplayUtil.SCALE_DISC_SIZE);
int musicPicSize = (int) (mScreenWidth * DisplayUtil.SCALE_MUSIC_PIC_SIZE);
Bitmap bitmapDisc = Bitmap.createScaledBitmap(BitmapFactory.decodeResource(getResources(), R
.drawable.ic_disc), discSize, discSize, false);
Bitmap bitmapMusicPic = getMusicPicBitmap(musicPicSize,musicPicRes);
BitmapDrawable discDrawable = new BitmapDrawable(bitmapDisc);
RoundedBitmapDrawable roundMusicDrawable = RoundedBitmapDrawableFactory.create
(getResources(), bitmapMusicPic);
//抗鋸齒
discDrawable.setAntiAlias(true);
roundMusicDrawable.setAntiAlias(true);
Drawable[] drawables = new Drawable[2];
drawables[0] = roundMusicDrawable;
drawables[1] = discDrawable;
LayerDrawable layerDrawable = new LayerDrawable(drawables);
int musicPicMargin = (int) ((DisplayUtil.SCALE_DISC_SIZE - DisplayUtil
.SCALE_MUSIC_PIC_SIZE) * mScreenWidth / 2);
//調劑專輯圖片的4周邊距
layerDrawable.setLayerInset(0, musicPicMargin, musicPicMargin, musicPicMargin,
musicPicMargin);
return layerDrawable;
}
在上面代碼中,我們先生成了唱盤對象BitmapDrawable,然后通過RoundedBitmapDrawable生成圓形專輯圖片,然后寄存到Drawable[]數組中,并用來初始化LayerDrawable對象。最后,我們用setLayerInset方法調劑專輯圖片的4周邊距,讓它顯示在唱盤正中。
這個網上的資料很多,也有基于JNI實現的,這個使用JNI實現可以看1下我之前的博客JNI實現毛玻璃效果,這里為了方便大家使用,我就直接使用工具類的方式,關于模糊化的實現邏輯大家可以搜索1下“BlurUtil”,斟酌到這部份代碼可能會阻塞UI線程,因此將其放著單獨線程中履行。
private void try2UpdateMusicPicBackground(final int musicPicRes) {
if (mRootLayout.isNeed2UpdateBackground(musicPicRes)) {
new Thread(new Runnable() {
@Override
public void run() {
final Drawable foregroundDrawable = getForegroundDrawable(musicPicRes);
runOnUiThread(new Runnable() {
@Override
public void run() {
mRootLayout.setForeground(foregroundDrawable);
mRootLayout.beginAnimation();
}
});
}
}).start();
}
}
仔細視察網易云音樂,發現切換歌曲時,背景圖也會隨著變化。其實這類也很好做,可使用LayerDrawable加屬性動畫來實現。
思路以下:
1. 給LayerDrawable設置兩個圖層,第1圖層是前1個背景,第2圖層是準備顯示的背景。
2. 先把準備顯示的背景透明度設為0,因此完全透明,此時只顯示前1個背景圖。
3. 通過屬性動畫,動態將第2圖層的透明度從0調劑至100,其實不斷更新控件的背景。
public class BackgourndAnimationRelativeLayout etends RelativeLayout
//初始化LayerDrawable對象
private void initLayerDrawable() {
Drawable backgroundDrawable = getContet().getDrawable(R.drawable.ic_blackground);
Drawable[] drawables = new Drawable[2];
/*初始化時先將前景與背景色彩設為1致*/
drawables[INDE_BACKGROUND] = backgroundDrawable;
drawables[INDE_FOREGROUND] = backgroundDrawable;
layerDrawable = new LayerDrawable(drawables);
}
private void initObjectAnimator() {
objectAnimator = ObjectAnimator.ofFloat(this, "number", 0f, 1.0f);
objectAnimator.setDuration(DURATION_ANIMATION);
objectAnimator.setInterpolator(new AccelerateInterpolator());
objectAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
int foregroundAlpha = (int) ((float) animation.getAnimatedValue() * 255);
/*動態設置Drawable的透明度,讓前景圖逐步顯示*/
layerDrawable.getDrawable(INDE_FOREGROUND).setAlpha(foregroundAlpha);
BackgourndAnimationRelativeLayout.this.setBackground(layerDrawable);
}
});
objectAnimator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
/*動畫結束后,記得將原來的背景圖及時更新*/
layerDrawable.setDrawable(INDE_BACKGROUND, layerDrawable.getDrawable(
INDE_FOREGROUND));
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
}
//對外提供方法,用于播放漸變動畫
public void beginAnimation() {
objectAnimator.start();
}
我們來看1下唱針的變化,為了真實的摹擬真實的場景,唱針主要有以下狀態:
初始狀態為暫停/停止時,點擊播放按鈕,此時唱針移動到底部。
初始狀態為播放時,點擊暫停按鈕,此時唱針移到頂部。
初始狀態為播放時,手指按住唱盤并略微偏移,等唱針未移到頂部時,立刻松開手指,此時唱針回到頂部后立刻再回到唱盤位置。
初始狀態為暫停/停止時,點擊播放,此時唱針往下移動,當唱針還未移到底部,手指馬上按住唱盤并偏移,此時唱針立刻往頂部移動。
這里寫鏈接內容
初始狀態為播放/暫停/停止時,左右滑動唱片進行音樂切換,唱針動畫未結束時,立刻點擊上/下1首按鈕,進行音樂切換,此時唱針狀態不能出現混亂,反復做了步驟1的動作。
我們隊上面的圖片仔細分析,然后結合ViewPager的原理我們來看看。
唱片(即ViewPager)的狀態可以通過PageChangeListener得到。唱針的狀態,筆者用枚舉來表示,并且在動畫的開始、結束時對唱針狀態及時更新。那末我們很容易就想到case或枚舉。
private enum NeedleAnimatorStatus {
/*移動時:從唱盤往遠處移動*/
TO_FAR_END,
/*移動時:從遠處往唱盤移動*/
TO_NEAR_END,
/*靜止時:離開唱盤*/
IN_FAR_END,
/*靜止時:貼近唱盤*/
IN_NEAR_END
}
動畫開始時,更新唱針狀態:
@Override
public void onAnimationStart(Animator animator) {
/**
*根據動畫開始前NeedleAnimatorStatus的狀態,
*便可得出動畫進行時NeedleAnimatorStatus的狀態
**/
if (needleAnimatorStatus == NeedleAnimatorStatus.IN_FAR_END) {
needleAnimatorStatus = NeedleAnimatorStatus.TO_NEAR_END;
} else if (needleAnimatorStatus == NeedleAnimatorStatus.IN_NEAR_END) {
needleAnimatorStatus = NeedleAnimatorStatus.TO_FAR_END;
}
}
動畫結束時,更新唱針狀態:
@Override
public void onAnimationEnd(Animator animator) {
if (needleAnimatorStatus == NeedleAnimatorStatus.TO_NEAR_END) {
needleAnimatorStatus = NeedleAnimatorStatus.IN_NEAR_END;
int inde = mVpContain.getCurrentItem();
playDiscAnimator(inde);
} else if (needleAnimatorStatus == NeedleAnimatorStatus.TO_FAR_END) {
needleAnimatorStatus = NeedleAnimatorStatus.IN_FAR_END;
}
}
每種狀態都定義清楚,每一個動畫負責的功能都拆分這樣寫起來就比較清楚了。
比如需要播放動畫時,就包括兩個狀態:
- 唱針動畫暫停中,唱針處于遠端。
- 唱針動畫播放中,唱針處于從近端往遠端移動
那末我們調用代碼的時候就這么用:
/*播放動畫*/
private void playAnimator() {
/*唱針處于遠端時,直接播放動畫*/
if (needleAnimatorStatus == NeedleAnimatorStatus.IN_FAR_END) {
mNeedleAnimator.start();
}
/*唱針處于往遠端移動時,設置標記,等動畫結束后再播放動畫*/
else if (needleAnimatorStatus == NeedleAnimatorStatus.TO_FAR_END) {
mIsNeed2StartPlayAnimator = true;
}
}
至于其他的比較跨組件的界面更新,1般會使用廣播,大家也能夠使用事件總線(EventBus).
附上源碼,這里可能需要大家自己編譯。
附:仿網易云音樂界面源碼