這篇文章主要用代碼解析Android如何實現(xiàn)仿抖音右滑清屏左滑列表功能,內(nèi)容簡而易懂,希望大家可以學(xué)習(xí)一下,學(xué)習(xí)完之后肯定會有收獲的,下面讓小編帶大家一起來看看吧。
企業(yè)建站必須是能夠以充分展現(xiàn)企業(yè)形象為主要目的,是企業(yè)文化與產(chǎn)品對外擴(kuò)展宣傳的重要窗口,一個合格的網(wǎng)站不僅僅能為公司帶來巨大的互聯(lián)網(wǎng)上的收集和信息發(fā)布平臺,創(chuàng)新互聯(lián)公司面向各種領(lǐng)域:成都砂巖浮雕等網(wǎng)站設(shè)計、成都全網(wǎng)營銷推廣解決方案、網(wǎng)站設(shè)計等建站排名服務(wù)。
概述
項目中要實現(xiàn)仿抖音直播間滑動清屏,側(cè)滑列表的功能,在此記錄下實現(xiàn)過程和踩坑記錄希望避免大家走些彎路,也當(dāng)作自己的一個總結(jié)
首先看下Demo中的效果
閱讀文章需要提前熟悉些事件分發(fā)的內(nèi)容,相信大家都已經(jīng)了解過了
關(guān)于這方面的知識,在Android中是再重要不過的了,是遲早都要掌握的知識,所以還是希望大家都能提早掌握,最好可以跟著源碼一起分析,理解掌握的更深刻一點(diǎn)
實踐
所以網(wǎng)上基于這部分內(nèi)容講解已經(jīng)很詳細(xì)了,這里就不再搬磚了,主要分享一下自己項目中結(jié)合這部分知識運(yùn)用過程中產(chǎn)生的一些想法和經(jīng)驗,解決的一些bug
以上就是功能在實現(xiàn)過程中要解決的問題,下面詳細(xì)展開
1. 布局結(jié)構(gòu)
布局結(jié)構(gòu)始終是界面設(shè)計時首先要考慮的一個問題,從接到一個需求開始,首先要根據(jù)項目中現(xiàn)有的布局結(jié)構(gòu),考慮如何更優(yōu)雅的嵌入布局層次。如果一不小心,走上了錯誤的實現(xiàn)道路,那么不好意思,即使功能最后實現(xiàn)了,到了后期,也有千萬種理由迫使你不得不走上重構(gòu)的道路。
比如實現(xiàn)不合理,導(dǎo)致的布局結(jié)構(gòu)復(fù)雜,嵌套冗余層次,比如代碼業(yè)務(wù)邏輯處理復(fù)雜蹩腳,比如資源浪費(fèi),內(nèi)存消耗過多等等。雖然功能好使,使用起來也沒有差別,但是,作為一個有追求的程序員,我們還是要避免這種情況的發(fā)生不是嗎
不巧的是,本文就屬于上述踩坑記錄,下面詳細(xì)分析
1.1 初步實現(xiàn)
上來以后,思路很直接明了的去想要實現(xiàn)清屏和滑屏的功能是每個房間都有的功能,每個房間又都是一個RecyclerView 的一個Item。所以,很明顯在Item的布局上包一層,實現(xiàn)清屏和側(cè)滑列表的功能就可以了,這樣每個房間都可以上下滑,切換房間。切換以后,滑屏的功能是在每個房間里的,互不影響,所以很好理解
我們項目中實現(xiàn)直播間上下滑切換的功能是RecyclerView + 自定義LinearLayoutManager實現(xiàn)的,這部分內(nèi)容網(wǎng)上demo很多,就不展開了
具體實施,是自定義布局繼承RelativeLayout,解析自定義的布局文件,里面包含,直播間的房間布局,和自己右側(cè)滑塊兒布局,然后用自己實現(xiàn)的布局替換之前的房間Item布局位置
這樣我們調(diào)用封裝的Container將清屏控件,和右側(cè)滑塊兒布局View分別添加到內(nèi)部即可
API提供如下
// 添加需要清屏的view fun addClearViews(vararg views: View?) // 添加需要滑入的view fun addSlideView(view: RightSlideLayout)
這樣我們在視頻播放頁面滑動,就可以在Container內(nèi)判斷手勢,處理清屏控件或者滑出右側(cè)滑塊兒了
右側(cè)滑塊再動態(tài)加載Fragment,展示列表布局,基本完成功能效果了
1.2 重構(gòu)
本來以為開開心心的可以上線了,誰知到下邊繼續(xù)體驗和對比抖音到過程中還是發(fā)現(xiàn)不足:
第一個是,右側(cè)滑塊兒(后邊稱RightSlider)包含在房間,這樣上下切換房間(后邊稱Container),RightSlider布局也會隨著Container新建而新建,雖然有RecyclerView的布局緩存,但是至少也會新建Holder幾次,造成資源的浪費(fèi)。第二個是,RightSlider的新建就會導(dǎo)致里邊的Fragment的新建,所以又會重新請求加載列表數(shù)據(jù),再次造成資源浪費(fèi),而且,新建后右側(cè)列表又會重新頂?shù)筋^,之前滑動過的距離就會丟失。這樣就造成,用戶從右側(cè)列表點(diǎn)擊切換房間后,再次滑出RightSlider切換房間,發(fā)現(xiàn)又要從頭開始往下滑,這樣肯定不符合用戶體驗。觀察抖音列表后發(fā)現(xiàn),每次滑動到固定位置點(diǎn)擊Item切換房間后,再次滑出滑塊兒,發(fā)現(xiàn)列表還是之前的位置,好像跟之前滑出的是一個滑塊兒的效果,于是恍然大悟,滑塊兒是跟Activity綁定的,也就是要把RightSlider放在跟Activity布局那一層
其實提出RightSlider到外層的過程中,還是走了不少彎路,因為之前畢竟已經(jīng)實現(xiàn)好的邏輯,如果改動布局結(jié)構(gòu),肯定要重寫滑動沖突、事件分發(fā)這部分代碼,工作量又不可預(yù)計。所以想著能不能不動布局結(jié)構(gòu)的情況下實現(xiàn)仿抖音效果
動態(tài)替換Fragment
首先想到的是滑出RightSlider里的列表每次都好像是同一個,那么保證里邊的Fragment是同一個不就好了,滑出的滑塊兒雖然不同,但是里邊裝載的Fragment列表是同一個,這樣就營造出同一個滑塊兒的效果。
但是實現(xiàn)過程中還是出現(xiàn)了問題,由于RecyclerView的預(yù)加載功能,導(dǎo)致我們項目中,從第一個房間上滑到下一個房間,過程中會新建兩個Holder,這樣Fragment替換就出了問題,切換房間后Fragment添加不上去,折騰一下午后最終放棄這個方案
固定List高度
然后想的,既然Fragment替換不了了,那么RecyclerView肯定不是同一個了,如果點(diǎn)擊后記錄當(dāng)前RecyclerView滑動的位置,下次滑出時,代碼固定到當(dāng)前位置不是也可以偽造出同一個滑塊兒的效果嘛,這部分也去找了一些資料,實現(xiàn)了個小demo。其中用到的主要方法是
/** * 獲取滑動距離 */ fun getScollYDistance(): Int { // 獲取recyclerview 的layoutManager val layoutManager = recyclerView.layoutManager as LinearLayoutManager // 獲取當(dāng)前第一個可見View的位置 val position = layoutManager.findFirstVisibleItemPosition() // 根據(jù)position 獲取當(dāng)前View val firstVisiableChildView = layoutManager.findViewByPosition(position) // 獲取當(dāng)前View 高度 val itemHeight = firstVisiableChildView.height // 滑動距離 return position * itemHeight - firstVisiableChildView.top }
滑動距離計算的思想是:根據(jù)當(dāng)前可見View 的position * 每個ItemView 的高度 + 當(dāng)前View已經(jīng)滑出去的部分
計算出高度后,每次加載時,調(diào)用RecyclerView的API
recyclerView.scrollBy(0,scroll) //scroll 剛才計算的高度
還有其他幾個滑動的方法:
// 帶動畫移動距離 public void smoothScrollBy(int dx, int dy) // 帶動畫移動到position public void smoothScrollToPosition(int position) // 移動到adapter position ,由LayoutManager實現(xiàn) public void scrollToPosition(int position) // 空實現(xiàn),無效 public void scrollTo(int x, int y)
原理上可以實現(xiàn),但是最后綜合比較還是放棄了這種方式,因為總感覺這種方法屬于投機(jī)取巧不是正道,還是老老實實將RightSlider 提到外面得了
2. 動畫
動畫也是這個功能中很重要的一個方面,因為動畫效果的流暢直接影響了用戶體驗,所以這方面也是細(xì)扣了很久。首先這個功能主要分成三個動畫效果:
2.1 進(jìn)場出場
包含清屏控件入場、出場:
mClearAnimator = ValueAnimator.ofFloat(0f, 1.0f).setDuration(300) mClearAnimator.addUpdateListener(ValueAnimator.AnimatorUpdateListener { valueAnimator -> val value = valueAnimator.animatedValue as Float translateClearChild((startX + value * (endX - startX)).toInt()) }) mClearAnimator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { isCleared = !isCleared } })
這里使用了屬性動畫ValueAnimator,其中 translateClearChild 負(fù)責(zé)移動View 代碼如下:
/** * 移動清屏控件 */ private fun translateClearChild(translate: Int) { for (i in mClearViews.indices) { mClearViews[i].translationX = translate.toFloat() } }
滑塊兒的入場、出場:
mSlideInAnimator = ValueAnimator.ofFloat(0f, 1.0f).setDuration(500) // 設(shè)置減速攔截器 mSlideInAnimator.interpolator = DecelerateInterpolator(3f) mSlideInAnimator.addUpdateListener(ValueAnimator.AnimatorUpdateListener { valueAnimator -> val value = valueAnimator.animatedValue as Float translateSlideView((startX + value * (endX - startX)).toInt()) }) mSlideInAnimator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationStart(animation: Animator) { mSlideView!!.visibility = View.VISIBLE mBgColorView.isClickable = true } override fun onAnimationEnd(animation: Animator) { if (!isSlideShow && translateX == 0) { isSlideShow = !isSlideShow } else if (isSlideShow && abs(translateX) == width - mSlideView!!.paddingLeft) { isSlideShow = !isSlideShow } if (!isSlideShow) { parent.requestDisallowInterceptTouchEvent(false) mSlideView!!.visibility = View.GONE removeView(mBgColorView) addView(mBgColorView, childCount - 4) } isSliderGoning = false } })
這里startX,endX 分別代表入場和出場時候,動畫起止位置。由于清屏控件沒有中間位置狀態(tài),直接是從0 到屏幕寬度兩個值之間替換;而滑塊兒中間由于要跟隨手勢移動,所以要記錄中間translateX,標(biāo)記為startX
2.2 跟隨手勢
跟隨手勢實現(xiàn)主要是攔截移動手勢,根據(jù)按下手勢位置坐標(biāo)和Move移動位置坐標(biāo)的差值,調(diào)用移動SliderView的方法
val x = event.rawX.toInt() // 標(biāo)記移動距離 val offsetX = x - mDownX when (event.action) { MotionEvent.ACTION_MOVE -> { if ((isSlideShow) && offsetX > 0 && mSlideInAnimator.isRunning && !isSliderGoning) { // 滑入情況下,向右滑一段松開,再向右滑,清除回彈動畫,跟隨手勢 mSlideInAnimator.cancel() translateSlideView(offsetX) } if ((isSlideShow) && offsetX > 0 && !mSlideInAnimator.isRunning) { // 滑入情況下,向右滑,跟隨手勢 translateSlideView(offsetX) } return true } }
2.3 顏色漸變
跟隨手勢滑動過程中還伴隨的左側(cè)空白區(qū)域顏色漸變,這部分可以在RightSlider移動過程中的距離值關(guān)聯(lián)起來,設(shè)置起始顏色透明和截止顏色灰色蒙層。再根據(jù)距離動態(tài)算出當(dāng)前顏色在區(qū)間范圍內(nèi)取值,主要代碼邏輯如下
/** * 移動滑塊兒 */ private fun translateSlideView(translate: Int) { val percent = (mSlideView!!.width.toFloat() - translate) / mSlideView!!.width // 根據(jù)百分比算出色值 val color = (MASK_DARK_COLOR * percent).toInt() shl 24 // 動態(tài)設(shè)置背景色漸變 mBgColorView.setBackgroundColor(color) translateX = translate mSlideView!!.translationX = translate.toFloat() }
3 事件分發(fā)
這部分可以說是本功能實現(xiàn)的核心,也是耗費(fèi)了相當(dāng)時間的精力,從最開始的Container包含RightSlider布局處理經(jīng)典的事件分發(fā)順序,到最后重構(gòu)布局,將RightSlider提到外層變成不是包含關(guān)系,而是并列或者說是覆蓋關(guān)系,中間對事件傳遞的順序理解又深入了一層
3.1 傳遞順序
重構(gòu)之前的布局結(jié)構(gòu)是每個Container包含了一個RightSlider,兩個是一個整體使用的,滑動的邏輯都可以在Container層內(nèi)的onInterceptTouchEvent方法內(nèi)處理。判斷是否攔截事件即可,然后RightSlider內(nèi)想要禁止父層Container攔截事件,可以使用parent.requestDisallowInterceptTouchEvent(true)禁止父層攔截;是屬于經(jīng)典模式的事件分發(fā)模型,事件分發(fā)的順序在一個U型結(jié)構(gòu)里,比較好處理
然后重構(gòu)以后布局結(jié)構(gòu)變成了如下圖所示
每個Container 共用一個RightSlider,這樣屬于事件的分發(fā)處理不在一個ViewGroup的U型模型里了,這樣的分發(fā)順序也是屬于自己的一個大膽嘗試,想著實在不行,還是要把Activity內(nèi)布局包一層,將Container和RightSlider 放在一個U型結(jié)構(gòu)里去處理。
還好最后不斷踩坑,終于實現(xiàn)了事件從Activity分發(fā),到RightSlider,再分發(fā)到Container的過程
這里貼下Demo里的布局實現(xiàn):
<androidx.constraintlayout.widget.ConstraintLayout 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" android:background="@mipmap/bg" tools:context=".MainActivity"> <com.fxf.slide.SlideContainerLayout android:id="@+id/layout_slider_container" android:layout_width="match_parent" android:layout_height="match_parent"> <LinearLayout android:id="@+id/ll12" android:layout_width="match_parent" android:layout_height="200dp" android:layout_gravity="center_horizontal" android:layout_marginTop="100dp" android:background="#00f" android:orientation="vertical"> <TextView android:id="@+id/tv111" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="111111111" android:textColor="#fff" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="222222222" android:textColor="#fff" /> </LinearLayout> </com.fxf.slide.SlideContainerLayout> <com.fxf.slide.RightSlideLayout android:id="@+id/layout_right_slider" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingLeft="60dp" android:visibility="gone"> <RelativeLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/shape_slider_background"> <View android:id="@+id/live_slide_bar" android:layout_width="4.5dp" android:layout_height="90dp" android:layout_centerVertical="true" android:layout_marginLeft="5dp" android:layout_marginRight="5dp" android:background="@drawable/shape_slider_dark_bar" /> <FrameLayout android:id="@+id/list_fragment" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_toRightOf="@+id/live_slide_bar" /> </RelativeLayout> </com.fxf.slide.RightSlideLayout> </androidx.constraintlayout.widget.ConstraintLayout>
其中做了部分簡化,主要幫助大家理解布局層次
然后貼下RightSlider核心分發(fā)代碼:
override fun dispatchTouchEvent(event: MotionEvent): Boolean { // 獲取坐標(biāo),這里用rawX 相對屏幕絕對位置,不然隨手勢移動過程中父布局的移動,導(dǎo)致獲取的坐標(biāo)左右抖動,會出現(xiàn)移動過程中左右一直抖動現(xiàn)象 val x = event.rawX.toInt() val y = event.rawY.toInt() // X方向位移 val offsetX = x - mDownX if (!mSlideContainerLayout.isSlideShow){ // Container滑塊兒沒滑出來不分發(fā)事件 return false } when (event.action) { MotionEvent.ACTION_DOWN -> { // 記錄按下點(diǎn)坐標(biāo) mDownX = x mDownY = y mSlideContainerLayout.setDownXY(mDownX,mDownY) } MotionEvent.ACTION_MOVE -> if (abs(x - mDownX) < abs(y - mDownY) && paddingLeft < x) { // 上下滑動情況處理 if (isSlideHorizontal) { return mSlideContainerLayout.dispatchTouchEvent(event) } } else if ( offsetX < 0 && mSlideContainerLayout.isAlignLeftSide()) { // 向左滑動,滑塊兒已經(jīng)靠最左邊了,不分發(fā) return super.dispatchTouchEvent(event) } else if (abs(x - mDownX) > abs(y - mDownY)){ // 水平方向移動,分發(fā)事件 isSlideHorizontal = true return mSlideContainerLayout.dispatchTouchEvent(event)// 事件傳遞給Container處理 } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL ->{ // 抬起時處理 if (offsetX < 0 && mSlideContainerLayout.isAlignLeftSide()){ return super.dispatchTouchEvent(event) } if (abs(x - mDownX) > abs(y - mDownY) || isSlideHorizontal){ isSlideHorizontal = false return mSlideContainerLayout.dispatchTouchEvent(event) } isSlideHorizontal = false } } return super.dispatchTouchEvent(event) }
3.2 滑動沖突
因為房間是可以上下滑動的,所以可以判斷如果滑塊兒沒滑粗來時,直接返回分發(fā),不讓RightSlider和Container處理事件
if (!mSlideContainerLayout.isSlideShow){ return false }
然后滑塊兒滑出來以后,因為里邊有列表,所以要消費(fèi)上下滑動事件,可以處理如下:
MotionEvent.ACTION_MOVE -> if (abs(x - mDownX) < abs(y - mDownY) && paddingLeft < x) { if (isSlideHorizontal) { return mSlideContainerLayout.dispatchTouchEvent(event) } }
其中paddingLeft < x 是因為滑塊左邊有一部分空白區(qū)域 paddingLeft ,所以當(dāng)x坐標(biāo)在此區(qū)域右側(cè)時才處理事件
Container動畫執(zhí)行過程中,說明正在消費(fèi)事件,此時禁止父層攔截事件
if (mClearAnimator.isRunning || mSlideInAnimator.isRunning || isSlideShow) { // 滑入情況下,禁止上下滑切換直播間 parent.requestDisallowInterceptTouchEvent(true) }
Container處理事件時候和直播間上的進(jìn)入房間頭像列表沖突,解決方法是判斷mDownY 大于進(jìn)入頭像列表高度時才處理事件,因為正常人滑入滑塊都是在屏幕中下部操作的,所以太靠上的部分不處理事件也可以接受
MotionEvent.ACTION_MOVE -> { if (!mClearAnimator.isRunning && mDownY > 200 && abs(x - mDownX) > abs(y - mDownY)) { // 清屏不在執(zhí)行時 && 高度大于200dp(解決進(jìn)入房間頭像滑動沖突)&& 橫向滑動時攔截事件 if (abs(x - mDownX) > 10) { return true } } }
3.3 滑動優(yōu)化
這部分有很多細(xì)節(jié)處理的地方,包括動畫執(zhí)行到一半情況下,再次左右滑動,先向左后向右,左右滑一半再上下滑等等各種情況具體可以看代碼中SlideContainerLayout中onTouchEvent方法內(nèi)處理邏輯,都添加了注釋
override fun onTouchEvent(event: MotionEvent): Boolean { mVelocityTracker!!.addMovement(event) val x = event.rawX.toInt() val offsetX = x - mDownX if (mLastOffsetList.size > 2){ mLastOffsetList.removeFirst() } mLastOffsetList.add(offsetX) var slideRight = (offsetX - mLastOffsetList.first) > 0 when (event.action) { MotionEvent.ACTION_MOVE -> { if ((isSlideShow) && offsetX > 0 && mSlideInAnimator.isRunning && !isSliderGoning) { // 滑入情況下,向右滑一段松開,再向右滑,清除回彈動畫,跟隨手勢 mSlideInAnimator.cancel() translateSlideView(offsetX) } if ((isSlideShow) && offsetX > 0 && !mSlideInAnimator.isRunning) { // 滑入情況下,向右滑,跟隨手勢 translateSlideView(offsetX) } return true } MotionEvent.ACTION_UP -> { mVelocityTracker!!.computeCurrentVelocity(10) if (isSlideShow && offsetX > 0 && abs(offsetX) > width / 3 && !isSliderGoning && mVelocityTracker!!.xVelocity >= 0) { // 滑入情況下,向右滑距離超過寬度1/3,滑出滑塊 startX = offsetX endX = width - mSlideView!!.paddingLeft isSliderGoning = true mSlideInAnimator.start() return true } if (abs(mVelocityTracker!!.xVelocity) > 1) { if (isCleared && offsetX < 0) { // 清屏情況下,左滑速度超過10個像素時 ===》滑入清屏控件 layerShowWithAnim() } else if (!isCleared && offsetX > 0 && !isSlideShow && !mSlideInAnimator.isRunning) { // 未清屏 && 向右速度 > 10 && 沒滑入滑塊 && 滑塊動畫沒執(zhí)行的時候 ===》清屏 layerGoneWithAnim() } else if (isSlideShow && offsetX > 0 && slideRight) { // 滑入情況下 && 向右速度 > 10 ===》滑出滑塊 mSlideInAnimator.cancel() isSliderGoning = true startX = translateX endX = width - mSlideView!!.paddingLeft mSlideInAnimator.start() } else if (isSlideShow && offsetX < 0 && translateX != 0) { // 滑入情況下 && 向左速度 > 10 && 已經(jīng)向右滑動了一段距離 ===》 滑塊回彈 startX = translateX endX = 0 mSlideInAnimator.start() } else if (!isSlideShow && offsetX < 0 && !mSlideInAnimator.isRunning) { // 沒滑入情況下 && 向左滑速度 > 10 && 沒右正在滑入情況下 ===》 滑入滑塊 sliderShowWithAnim() } else { if (isSlideShow && translateX != 0) { // 滑入情況下 && 已經(jīng)向右滑動過,速度沒達(dá)到松開 ===》回彈 startX = translateX mSlideInAnimator.start() } } }else { if (isSlideShow && translateX != 0) { // 滑入情況下 && 已經(jīng)向右滑動過,速度沒達(dá)到松開 ===》回彈 startX = translateX mSlideInAnimator.start() } } return super.onTouchEvent(event) } MotionEvent.ACTION_CANCEL -> { if (isSlideShow) { //取消事件時,滑入情況下回彈 startX = translateX mSlideInAnimator.start() } } } return super.onTouchEvent(event) }
以上就是關(guān)于用代碼解析Android如何實現(xiàn)仿抖音右滑清屏左滑列表功能的內(nèi)容,如果你們有學(xué)習(xí)到知識或者技能,可以把它分享出去讓更多的人看到。
當(dāng)前文章:用代碼解析Android如何實現(xiàn)仿抖音右滑清屏左滑列表功能
地址分享:http://jinyejixie.com/article14/gpiide.html
成都網(wǎng)站建設(shè)公司_創(chuàng)新互聯(lián),為您提供微信小程序、外貿(mào)建站、網(wǎng)站設(shè)計、網(wǎng)站營銷、網(wǎng)站改版、營銷型網(wǎng)站建設(shè)
聲明:本網(wǎng)站發(fā)布的內(nèi)容(圖片、視頻和文字)以用戶投稿、用戶轉(zhuǎn)載內(nèi)容為主,如果涉及侵權(quán)請盡快告知,我們將會在第一時間刪除。文章觀點(diǎn)不代表本網(wǎng)站立場,如需處理請聯(lián)系客服。電話:028-86922220;郵箱:631063699@qq.com。內(nèi)容未經(jīng)允許不得轉(zhuǎn)載,或轉(zhuǎn)載時需注明來源: 創(chuàng)新互聯(lián)