編輯:關於Android編程
概述
新版的音悅台 APP 播放頁面交互非常有意思,可以把播放器往下拖動,然後在底部懸浮一個小框,還可以左右拖動,然後回彈的時候也會有相應的效果,這種交互效果在頭條視頻和一些專注於視頻的app也是很常見的。
前幾天看網友有仿這個 效果,覺得不錯,現在分享出來,代碼可以再優化,這裡的播放器使用的是B站的ijkplayer,先上兩張動圖。
當圖片到達底部後,左右拖動
實現的思路
首先,要是拖動視圖縮小的效果,我們肯定需要自定義一個View,而根據我們項目的場景我們這裡需要兩個View,一個是拖動的View,另一個是浮動上下的View(可以縮小的View),為了實現拖動,我們知道必定會用到ViewDragHelper這個類,這個類專門為了拖動而設計的。
然後,對於拖動到底部的View,我們需要實現左右拖動的效果,這個其實也是比較容易實現的,我們通過ViewDragHelper的onViewPositionChanged方法來判斷當前視圖的狀況,就可以做View進行縮放和漸變了。
代碼分析
首先我們會自定義一個容器,容器的init方法會初始化兩個View:mFlexView (到底拖動的View)和mFollowView (跟隨觸摸縮放的View)
private void init(Context context, AttributeSet attrs) { final float density = getResources().getDisplayMetrics().density; final float minVel = MIN_FLING_VELOCITY * density; ViewGroupCompat.setMotionEventSplittingEnabled(this, false); FlexCallback flexCallback = new FlexCallback(); mDragHelper = ViewDragHelper.create(this, 1.0f, flexCallback); // 最小拖動速度 mDragHelper.setMinVelocity(minVel); post(new Runnable() { @Override public void run() { // 需要添加的兩個子View,其中mFlexView作為拖動的響應View,mLinkView作為跟隨View mFlexView = getChildAt(0); mFollowView = getChildAt(1); mDragHeight = getMeasuredHeight() - mFlexView.getMeasuredHeight(); mFlexWidth = mFlexView.getMeasuredWidth(); mFlexHeight = mFlexView.getMeasuredHeight(); } }); }
ViewDragHelper 的回調需要做的事情比較多,在 mFlexView 拖動的時候需要同時設置 mFlexView 和 mFollowView 的相應變化效果,在 mFlexView 釋放的時候需要處理關閉或收起等效果。所以這裡我們需要對ViewDragHelper個各種回調事件進行監聽。這也是本功能最核心的:
private class FlexCallback extends ViewDragHelper.Callback { @Override public boolean tryCaptureView(View child, int pointerId) { // mFlexView來響應觸摸事件 return mFlexView == child; } @Override public int clampViewPositionHorizontal(View child, int left, int dx) { return Math.max(Math.min(mDragWidth, left), -mDragWidth); } @Override public int getViewHorizontalDragRange(View child) { return mDragWidth * 2; } @Override public int clampViewPositionVertical(View child, int top, int dy) { if (!mVerticalDragEnable) { // 不允許垂直拖動的時候是mFlexView在底部水平拖動一定距離時設置的,返回mDragHeight就不能再垂直做拖動了 return mDragHeight; } return Math.max(Math.min(mDragHeight, top), 0); } @Override public int getViewVerticalDragRange(View child) { return mDragHeight; } @Override public void onViewReleased(View releasedChild, float xvel, float yvel) { if (mHorizontalDragEnable) { // 如果水平拖動有效,首先根據拖動的速度決定關閉頁面,方向根據速度正負決定 if (xvel > 1500) { mDragHelper.settleCapturedViewAt(mDragWidth, mDragHeight); mIsClosing = true; } else if (xvel < -1500) { mDragHelper.settleCapturedViewAt(-mDragWidth, mDragHeight); mIsClosing = true; } else { // 速度沒到關閉頁面的要求,根據透明度來決定關閉頁面,方向根據releasedChild.getLeft()正負決定 float alpha = releasedChild.getAlpha(); if (releasedChild.getLeft() < 0 && alpha <= 0.4f) { mDragHelper.settleCapturedViewAt(-mDragWidth, mDragHeight); mIsClosing = true; } else if (releasedChild.getLeft() > 0 && alpha <= 0.4f) { mDragHelper.settleCapturedViewAt(mDragWidth, mDragHeight); mIsClosing = true; } else { mDragHelper.settleCapturedViewAt(0, mDragHeight); } } } else { // 根據垂直方向的速度正負決定布局的展示方式 if (yvel > 1500) { mDragHelper.settleCapturedViewAt(0, mDragHeight); } else if (yvel < -1500) { mDragHelper.settleCapturedViewAt(0, 0); } else { // 根據releasedChild.getTop()決定布局的展示方式 if (releasedChild.getTop() <= mDragHeight / 2) { mDragHelper.settleCapturedViewAt(0, 0); } else { mDragHelper.settleCapturedViewAt(0, mDragHeight); } } } invalidate(); } @Override public void onViewPositionChanged(final View changedView, int left, int top, int dx, int dy) { float fraction = top * 1.0f / mDragHeight; // mFlexView縮放的比率 mFlexScaleRatio = 1 - 0.5f * fraction; mFlexScaleOffset = changedView.getWidth() / 20; // 設置縮放基點 changedView.setPivotX(changedView.getWidth() - mFlexScaleOffset); changedView.setPivotY(changedView.getHeight() - mFlexScaleOffset); // 設置比例 changedView.setScaleX(mFlexScaleRatio); changedView.setScaleY(mFlexScaleRatio); // mFollowView透明度的比率 float alphaRatio = 1 - fraction; // 設置透明度 mFollowView.setAlpha(alphaRatio); // 根據垂直方向的dy設置top,產生跟隨mFlexView的效果 mFollowView.setTop(mFollowView.getTop() + dy); // 到底部的時候,changedView的top剛好等於mDragHeight,以此作為水平拖動的基准 mHorizontalDragEnable = top == mDragHeight; if (mHorizontalDragEnable) { // 如果水平拖動允許的話,由於設置縮放不會影響mFlexView的寬高(比如getWidth),所以水平拖動距離為mFlexView寬度一半 mDragWidth = (int) (changedView.getMeasuredWidth() * 0.5f); // 設置mFlexView的透明度,這裡向左右水平拖動透明度都隨之變化 changedView.setAlpha(1 - Math.abs(left) * 1.0f / mDragWidth); // 水平拖動一定距離的話,垂直拖動將被禁止 mVerticalDragEnable = left < 0 && left >= -mDragWidth * 0.05; } else { // 不是水平拖動的處理 changedView.setAlpha(1); mDragWidth = 0; mVerticalDragEnable = true; } if (mFlexLayoutPosition == null) { // 創建子元素位置緩存 mFlexLayoutPosition = new ChildLayoutPosition(); mFollowLayoutPosition = new ChildLayoutPosition(); } // 記錄子元素的位置 mFlexLayoutPosition.setPosition(mFlexView.getLeft(), mFlexView.getRight(), mFlexView.getTop(), mFlexView.getBottom()); mFollowLayoutPosition.setPosition(mFollowView.getLeft(), mFollowView.getRight(), mFollowView.getTop(), mFollowView.getBottom()); // Log.e("FlexCallback", "225行-onViewPositionChanged(): 【" + mFlexView.getLeft() + ":" + mFlexView.getRight() + ":" + mFlexView.getTop() + ":" + mFlexView // .getBottom() + "】 【" + mFollowView.getLeft() + ":" + mFollowView.getRight() + ":" + mFollowView.getTop() + ":" + mFollowView.getBottom() + "】"); } }
接下來是處理測量和定位,我們實現的排列效果類似 LinearLayout 垂直排列的效果,這裡需要對 measureChildWithMargins 的 heightUse 重新設置;onLayout 的時候在位置緩存不為空的時候直接定位是因為 ViewDragHelper 在處理觸摸事件子元素在做一些平移之類的,若是有元素更新了 UI 會導致重新 Layout,因此在 FlexCallback 的 onViewPositionChanged 方法記錄位置,然後在回彈的時候需要通過Layout 恢復之前的視圖。
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int desireHeight = 0; int desireWidth = 0; int tmpHeight = 0; if (getChildCount() != 2) { throw new IllegalArgumentException("只允許容器添加兩個子View!"); } if (getChildCount() > 0) { for (int i = 0; i < getChildCount(); i++) { final View child = getChildAt(i); // 測量子元素並考慮外邊距 // 參數heightUse:父容器豎直已經被占用的空間,比如被父容器的其他子 view 所占用的空間;這裡我們需要的是子View垂直排列,所以需要設置這個值 measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, tmpHeight); // 獲取子元素的布局參數 final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); // 計算子元素寬度,取子控件最大寬度 desireWidth = Math.max(desireWidth, child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin); // 計算子元素高度 tmpHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin; desireHeight += tmpHeight; } // 考慮父容器內邊距 desireWidth += getPaddingLeft() + getPaddingRight(); desireHeight += getPaddingTop() + getPaddingBottom(); // 嘗試比較建議最小值和期望值的大小並取大值 desireWidth = Math.max(desireWidth, getSuggestedMinimumWidth()); desireHeight = Math.max(desireHeight, getSuggestedMinimumHeight()); } // 設置最終測量值 setMeasuredDimension(resolveSize(desireWidth, widthMeasureSpec), resolveSize(desireHeight, heightMeasureSpec)); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { if (mFlexLayoutPosition != null) { // 因為在用到ViewDragHelper處理布局交互的時候,若是有子View的UI更新導致重新Layout的話,需要我們自己處理ViewDragHelper拖動時子View的位置,否則會導致位置錯誤 // Log.e("YytLayout1", "292行-onLayout(): " + "自己處理布局位置"); mFlexView.layout(mFlexLayoutPosition.getLeft(), mFlexLayoutPosition.getTop(), mFlexLayoutPosition.getRight(), mFlexLayoutPosition.getBottom()); mFollowView.layout(mFollowLayoutPosition.getLeft(), mFollowLayoutPosition.getTop(), mFollowLayoutPosition.getRight(), mFollowLayoutPosition.getBottom()); return; } final int paddingLeft = getPaddingLeft(); final int paddingTop = getPaddingTop(); int multiHeight = 0; int count = getChildCount(); if (count != 2) { throw new IllegalArgumentException("此容器的子元素個數必須為2!"); } for (int i = 0; i < count; i++) { // 遍歷子元素並對其進行定位布局 final View child = getChildAt(i); MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); int left = paddingLeft + lp.leftMargin; int right = child.getMeasuredWidth() + left; int top = (i == 0 ? paddingTop : 0) + lp.topMargin + multiHeight; int bottom = child.getMeasuredHeight() + top; child.layout(left, top, right, bottom); multiHeight += (child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin); } }
觸摸事件的處理,由於縮放不會影響 mFlexView 真實寬高,ViewDragHelper 仍然會阻斷 mFlexView 的真實寬高的區域,所以這裡判斷手指是否落在 mFlexView 視覺上的范圍內,在才去調 ViewDragHelper 的 shouldInterceptTouchEvent 方法。
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { // Log.e("YytLayout", mFlexView.getLeft() + ";" + mFlexView.getTop() + " --- " + ev.getX() + ":" + ev.getY()); // 由於縮放不會影響mFlexView真實寬高,這裡手動計算視覺上的范圍 float left = mFlexView.getLeft() + mFlexWidth * (1 - mFlexScaleRatio) - mFlexScaleOffset * (1 - mFlexScaleRatio); float top = mFlexView.getTop() + mFlexHeight * (1 - mFlexScaleRatio) - mFlexScaleOffset * (1 - mFlexScaleRatio); // 這裡所做的是判斷手指是否落在mFlexView視覺上的范圍內 mInFlexViewTouchRange = ev.getX() >= left && ev.getY() >= top; if (mInFlexViewTouchRange) { return mDragHelper.shouldInterceptTouchEvent(ev); } else { return super.onInterceptTouchEvent(ev); } } @Override public boolean onTouchEvent(MotionEvent event) { if (mInFlexViewTouchRange) { // 這裡還要做判斷是因為,即使我不阻斷事件,但是此Layout的子View不消費的話,事件還是給回此Layout mDragHelper.processTouchEvent(event); return true; } else { // 不在mFlexView觸摸范圍內,並且子View沒有消費,返回false,把事件傳遞回去 return false; } }
同時我們需要對滾動事件進行監聽,我們需要在此關閉的整個平移執行事件。
@Override public void computeScroll() { if (mDragHelper.continueSettling(true)) { invalidate(); } else if (mIsClosing && mOnLayoutStateListener != null) { // 正在關閉的情況下,並且拖動結束後,告知將要關閉頁面 mOnLayoutStateListener.onClose(); mIsClosing = false; } } /** * 監聽布局是否水平拖動關閉了 */ public interface OnLayoutStateListener { void onClose(); } public void setOnLayoutStateListener(OnLayoutStateListener onLayoutStateListener) { mOnLayoutStateListener = onLayoutStateListener; } /** * 展開布局 */ public void expand() { mDragHelper.smoothSlideViewTo(mFlexView, 0, 0); invalidate(); }
而在實際的應用中要實現回彈後詳情頁面的效果,我們需要自己實現一個組合View,這個大家可以自己看源碼音悅台源碼
以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支持本站。
前言本篇文章帶大家體驗一下一種具有擴展性的適配器寫法。這個適配器主要用於Item有多種的情況下,當然只有一種類型也是適用的實現毫無疑問我們要繼承BaseAdapter,重
使用自定義Animation,實現View的左右搖擺效果,如圖所示:代碼很簡單,直接上源碼activity_maini.xml布局文件:<?xml vers
在Android程序開發中,我們經常會去用到Shape這個東西去定義各種各樣的形狀,首先我們了解一下Shape下面有哪些標簽,都代表什麼意思: solid:填充 andr
在我們玩手機游戲時能看到,很多游戲的登錄界面兩側往往會有一個小小的懸浮窗,可以提供相應功能菜單項,簡潔實用且不影響游戲體驗。具體效果如下圖所示。這篇博客將帶大家開發一個可