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