仿知乎懸浮功能按鈕FloatingActionButton
前段時間在看屬性動畫,恰巧這個按鈕的效果可以用屬性動畫實現,所以就來實踐實踐。效果基本出來了,大家可以自己去完善。
首先看一下效果圖:
我們看到點選FloatingActionButton後會展開一些item,然後會有一個蒙板效果,這都是這個View的功能。那麼這整個View肯定是個ViewGroup,我們一部分一部分來看。
首先是這個最小的Tag:
這個Tag帶文字,可以是一個TextView,但為了美觀,我們使用CardView,CardView是一個FrameLayout,我們要讓它具有顯示文字的功能,就繼承CardView自定義一個ViewGroup。
public class TagView extends CardView
內部維護一個TextView,在其建構函式中我們例項化一個TextView用來顯示文字,並在外部呼叫setTagText的時候把TextView新增到這個CardView中。
public class TagView extends CardView { private TextView mTextView; public TagView(Context context) { this(context, null); } public TagView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public TagView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); mTextView = new TextView(context); mTextView.setSingleLine(true); } protected void setTextSize(float size){ mTextView.setTextSize(size); } protected void setTextColor(int color){ mTextView.setTextColor(color); } //給內部的TextView新增文字 protected void setTagText(String text){ mTextView.setText(text); addTag(); } //新增進這個layout中 private void addTag(){ LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT , ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER); int l = dp2px(8); int t = dp2px(8); int r = dp2px(8); int b = dp2px(8); layoutParams.setMargins(l, t, r, b); //addView會引起所有View的layout addView(mTextView, layoutParams); } private int dp2px(int value){ return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP , value, getResources().getDisplayMetrics()); } }
接下來我們看這個item,它是一個tag和一個fab的組合:
tag使用剛才我們自定義的TagView,fab就用系統的FloatingActionButton,這裡顯然需要一個ViewGroup來組合這兩個子View,可以使用LinearLayout,這裡我們就直接使用ViewGroup。
public class TagFabLayout extends ViewGroup
我們為這個ViewGroup設定自定義屬性,是為了給tag設定text:
<declare-styleable name="FabTagLayout"> <attr name="tagText" format="string" /> </declare-styleable>
在構造器中獲取自定義屬性,初始化TagView並新增到該ViewGroup中:
public TagFabLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
getAttributes(context, attrs);
settingTagView(context);
}
private void getAttributes(Context context, AttributeSet attributeSet){
TypedArray typedArray = context.obtainStyledAttributes(attributeSet
, R.styleable.FabTagLayout);
mTagText = typedArray.getString(R.styleable.FabTagLayout_tagText);
typedArray.recycle();
}
private void settingTagView(Context context){
mTagView = new TagView(context);
mTagView.setTagText(mTagText);
addView(mTagView);
}
在onMeasure對該ViewGroup進行測量,這裡我直接把寬高設定成wrap_content的了,match_parent和精確值感覺沒有必要。TagView和FloatingActionButton橫向排列,中間和兩邊留一點空隙。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = 0;
int height = 0;
int count = getChildCount();
for(int i=0; i<count; i++){
View view = getChildAt(i);
measureChild(view, widthMeasureSpec, heightMeasureSpec);
width += view.getMeasuredWidth();
height = Math.max(height, view.getMeasuredHeight());
}
width += dp2px(8 + 8 + 8);
height += dp2px(8 + 8);
//直接將該ViewGroup設定為wrap_content的
setMeasuredDimension(width, height);
}
在onLayout中橫向佈局,tag在左,fab在右。
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
//為子View佈局
View tagView = getChildAt(0);
View fabView = getChildAt(1);
int tagWidth = tagView.getMeasuredWidth();
int tagHeight = tagView.getMeasuredHeight();
int fabWidth = fabView.getMeasuredWidth();
int fabHeight = fabView.getMeasuredHeight();
int tl = dp2px(8);
int tt = (getMeasuredHeight() - tagHeight) / 2;
int tr = tl + tagWidth;
int tb = tt + tagHeight;
int fl = tr + dp2px(8);
int ft = (getMeasuredHeight() - fabHeight) / 2;
int fr = fl + fabWidth;
int fb = ft + fabHeight;
fabView.layout(fl, ft, fr, fb);
tagView.layout(tl, tt, tr, tb);
bindEvents(tagView, fabView);
}
還要為這兩個子View註冊OnClickListener,這是點選事件傳遞的源頭。
private void bindEvents(View tagView, View fabView){
tagView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
if(mOnTagClickListener != null){
mOnTagClickListener.onTagClick();
}
}
});
fabView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
if (mOnFabClickListener != null){
mOnFabClickListener.onFabClick();
}
}
});
}
現在item的ViewGroup有了,我們還需要一個蒙板,一個主fab,那麼我們來看最終的ViewGroup。
思路也很清楚,蒙板是match_parent的,主fab在右下角(當然我們可以自己設定,也可以對外提供介面來設定位置),三個item(也就是TagFabLayout)在主fab的上面。至於動畫效果,在點選事件中觸發。
public class MultiFloatingActionButton extends ViewGroup
這裡我們還需要自定義一些屬性,比如蒙板的顏色、主Fab的顏色、主Fab的圖案(當然,你把主Fab直接寫在xml中就可以直接定義這些屬性)、動畫的duaration、動畫的模式等。
<attr name="animationMode">
<enum name="fade" value="0"/>
<enum name="scale" value="1"/>
<enum name="bounce" value="2"/>
</attr>
<attr name="position">
<enum name="left_bottom" value="0"/>
<enum name="right_bottom" value="1"/>
</attr>
<declare-styleable name="MultiFloatingActionButton">
<attr name="backgroundColor" format="color"/>
<attr name="switchFabIcon" format="reference"/>
<attr name="switchFabColor" format="color"/>
<attr name="animationDuration" format="integer"/>
<attr name="animationMode"/>
<attr name="position"/>
</declare-styleable>
在構造器中我們同樣是獲取並初始化屬性:
public MultiFloatingActionButton(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//獲取屬性值
getAttributes(context, attrs);
//新增一個背景View和一個FloatingActionButton
setBaseViews(context);
}
private void getAttributes(Context context, AttributeSet attrs){
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.MultiFloatingActionButton);
mBackgroundColor = typedArray.getColor(
R.styleable.MultiFloatingActionButton_backgroundColor, Color.TRANSPARENT);
mFabIcon = typedArray.getDrawable(R.styleable.MultiFloatingActionButton_switchFabIcon);
mFabColor = typedArray.getColorStateList(R.styleable.MultiFloatingActionButton_switchFabColor);
mAnimationDuration = typedArray.getInt(R.styleable.MultiFloatingActionButton_animationDuration, 150);
mAnimationMode = typedArray.getInt(R.styleable.MultiFloatingActionButton_animationMode, ANIM_SCALE);
mPosition = typedArray.getInt(R.styleable.MultiFloatingActionButton_position, POS_RIGHT_BOTTOM);
typedArray.recycle();
}
接著我們初始化、新增蒙板和主fab。
private void setBaseViews(Context context){
mBackgroundView = new View(context);
mBackgroundView.setBackgroundColor(mBackgroundColor);
mBackgroundView.setAlpha(0);
addView(mBackgroundView);
mFloatingActionButton = new FloatingActionButton(context);
mFloatingActionButton.setBackgroundTintList(mFabColor);
mFloatingActionButton.setImageDrawable(mFabIcon);
addView(mFloatingActionButton);
}
在onMeasure中,我們並不會對這個ViewGroup進行wrap_content的支援,因為基本上都是match_parent的吧,也不會有精確值,而且這個ViewGroup應該是在頂層的。我們看下onLayout方法,在這個方法中,我們對所有子View進行佈局。
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if(changed){
//佈局背景和主Fab
layoutFloatingActionButton();
layoutBackgroundView();
layoutItems();
}
}
首先佈局主Fab,它在右下角,然後新增點選事件,點選這個主Fab後,會涉及到旋轉主Fab,改變蒙板透明度,開啟或關閉items等操作,這些等下再說。
private void layoutFloatingActionButton(){
int width = mFloatingActionButton.getMeasuredWidth();
int height = mFloatingActionButton.getMeasuredHeight();
int fl = 0;
int ft = 0;
int fr = 0;
int fb = 0;
switch (mPosition){
case POS_LEFT_BOTTOM:
case POS_RIGHT_BOTTOM:
fl = getMeasuredWidth() - width - dp2px(8);
ft = getMeasuredHeight() - height - dp2px(8);
fr = fl + width;
fb = ft + height;
break;
}
mFloatingActionButton.layout(fl, ft, fr, fb);
bindFloatingEvent();
}
private void bindFloatingEvent(){
mFloatingActionButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
rotateFloatingButton();
changeBackground();
changeStatus();
if (isMenuOpen) {
openMenu();
} else {
closeMenu();
}
}
});
}
然後佈局背景:
private void layoutBackgroundView(){
mBackgroundView.layout(0, 0
, getMeasuredWidth(), getMeasuredHeight());
}
接著佈局items,併為items新增點選事件。每個item都是TagFabLayout,可以為它setOnTagClickListener和setOnFabClickListener,以便我們點選這兩塊區域的時候都要能響應,並且我們讓這兩個回撥函式中做同樣的事情:旋轉主Fab、改變背景、關閉items(因為能點選一定是展開狀態)。此時還要在這個ViewGroup中設定一個介面OnFabItemClickListener,用於將點選的位置傳遞出去,例如Activity實現了這個介面,就可以在onTagClick和onFabClick方法中呼叫mOnFabItemClickListener.onFabItemClick()方法。說一下這裡的佈局,是累積向上的,注意座標的計算。
private void layoutItems(){
int count = getChildCount();
for(int i=2; i<count; i++) {
TagFabLayout child = (TagFabLayout) getChildAt(i);
child.setVisibility(INVISIBLE);
//獲取自身測量寬高,這裡說一下,由於TagFabLayout我們預設形成wrap_content,所以這裡測量到的是wrap_content的最終大小
int width = child.getMeasuredWidth();
int height = child.getMeasuredHeight();
// 獲取主Fab測量寬高
int fabHeight = mFloatingActionButton.getMeasuredHeight();
int cl = 0;
int ct = 0;
switch (mPosition) {
case POS_LEFT_BOTTOM:
case POS_RIGHT_BOTTOM:
cl = getMeasuredWidth() - width - dp2px(8);
ct = getMeasuredHeight() - fabHeight - (i - 1) * height - dp2px(8);
}
child.layout(cl, ct, cl + width, ct + height);
bindMenuEvents(child, i);
prepareAnim(child);
}
}
private void bindMenuEvents(final TagFabLayout child, final int pos){
child.setOnTagClickListener(new TagFabLayout.OnTagClickListener() {
@Override
public void onTagClick() {
rotateFloatingButton();
changeBackground();
changeStatus();
closeMenu();
if(mOnFabItemClickListener != null){
mOnFabItemClickListener.onFabItemClick(child, pos);
}
}
});
child.setOnFabClickListener(new TagFabLayout.OnFabClickListener() {
@Override
public void onFabClick() {
rotateFloatingButton();
changeBackground();
changeStatus();
closeMenu();
if (mOnFabItemClickListener != null){
mOnFabItemClickListener.onFabItemClick(child, pos);
}
}
});
}
現在所有的佈局和點選事件都已經繫結好了,我們來看下rotateFloatingButton()、 changeBackground() 、 openMenu() 、closeMenu()這幾個和屬性動畫相關的函式。
其實也很簡單,rotateFloatingButton()對mFloatingActionButton的rotation這個屬性進行改變,以選單是否開啟為判斷條件。
private void rotateFloatingButton(){
ObjectAnimator animator = isMenuOpen ? ObjectAnimator.ofFloat(mFloatingActionButton
, "rotation", 45F, 0f) : ObjectAnimator.ofFloat(mFloatingActionButton, "rotation", 0f, 45f);
animator.setDuration(150);
animator.setInterpolator(new LinearInterpolator());
animator.start();
}
changeBackground()改變mBackgroundView的alpha這個屬性,也是以選單是否開啟為判斷條件。
private void changeBackground(){
ObjectAnimator animator = isMenuOpen ? ObjectAnimator.ofFloat(mBackgroundView, "alpha", 0.9f, 0f) :
ObjectAnimator.ofFloat(mBackgroundView, "alpha", 0f, 0.9f);
animator.setDuration(150);
animator.setInterpolator(new LinearInterpolator());
animator.start();
}
openMenu() 中根據不同的模式來實現開啟的效果,看一下scaleToShow(),這裡同時對scaleX、scaleY、alpha這3個屬性進行動畫,來達到放大顯示的效果。
private void openMenu(){
switch (mAnimationMode){
case ANIM_BOUNCE:
bounceToShow();
break;
case ANIM_SCALE:
scaleToShow();
}
}
private void scaleToShow(){
for(int i = 2; i<getChildCount(); i++){
View view = getChildAt(i);
view.setVisibility(VISIBLE);
view.setAlpha(0);
ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 0f, 1f);
ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 0f, 1f);
ObjectAnimator alpha = ObjectAnimator.ofFloat(view, "alpha", 0f, 1f);
AnimatorSet set = new AnimatorSet();
set.playTogether(scaleX, scaleY, alpha);
set.setDuration(mAnimationDuration);
set.start();
}
}
差不多達到我們要求的效果了,但是還有一個小地方需要注意一下,在menu展開的時候,如果我們點選menu以外的區域,即蒙板上的區域,此時ViewGroup是不會攔截任何Touch事件,如果在這個FloatingActionButton下面有可以被點選響應的View,比如ListView,就會在蒙板顯示的情況下進行響應,正確的邏輯應該是關閉menu。
那麼我們需要在onInterceptTouchEvent中處理事件的攔截,這裡判斷的方法是:如果menu是開啟的,我們在DOWN事件中判斷x,y是否落在了a或b區域,如下圖
如果是的話,該ViewGroup應該攔截這個事件,交由自身的onTouchEvent處理。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int x = (int)ev.getX();
int y = (int)ev.getY();
if(isMenuOpen){
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
if(judgeIfTouchBackground(x, y)){
intercepted = true;
}
intercepted = false;
break;
case MotionEvent.ACTION_MOVE:
intercepted = false;
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
}
}
return intercepted;
}
private boolean judgeIfTouchBackground(int x, int y){
Rect a = new Rect();
Rect b = new Rect();
a.set(0, 0, getWidth(), getHeight() - getChildAt(getChildCount() - 1).getTop());
b.set(0, getChildAt(getChildCount() - 1).getTop(), getChildAt(getChildCount() - 1).getLeft(), getHeight());
if(a.contains(x, y) || b.contains(x, y)){
return true;
}
return false;
}
在onTouchEvent中做關閉menu等操作。
@Override
public boolean onTouchEvent(MotionEvent event) {
if(isMenuOpen){
closeMenu();
changeBackground();
rotateFloatingButton();
changeStatus();
return true;
}
return super.onTouchEvent(event);
}
再看一下,效果不錯。
由於我做的小app中涉及到切換夜間模式,這個ViewGroup的背景色應該隨著主題改變,設定該View的背景色為
app:backgroundColor="?attr/myBackground"
重寫ViewGroup的 setBackgroundColor方法,這裡所謂的背景色其實就是蒙板的顏色。
public void setBackgroundColor(int color){
mBackgroundColor = color;
mBackgroundView.setBackgroundColor(color);
}
基本功能到這裡全部完成了,問題還有很多,比如沒有提供根據不同的position進行佈局、沒有提供根據不同mode設定menu開閉的效果,但是後續我還會繼續改進和完善^ ^。歡迎交流。如果大家需要原始碼,可以去我原始碼裡的customview裡面自取。在這裡