Android仿抖音右滑清屏左滑列表功能的實現代碼
概述
項目中要實現仿抖音直播間滑動清屏,側滑列表的功能,在此記錄下實現過程和踩坑記錄希望避免大家走些彎路,也當作自己的一個總結
首先看下Demo中的效果
閱讀文章需要提前熟悉些事件分發的內容,相信大家都已經了解過了,網上也有很多優秀的文章,這里推薦兩篇自己讀過印象較深的文章
https://www.jb51.net/article/124249.htm
https://www.jb51.net/article/124861.htm
關于這方面的知識,在Android中是再重要不過的了,是遲早都要掌握的知識,所以還是希望大家都能提早掌握,最好可以跟著源碼一起分析,理解掌握的更深刻一點
實踐
所以網上基于這部分內容講解已經很詳細了,這里就不再搬磚了,主要分享一下自己項目中結合這部分知識運用過程中產生的一些想法和經驗,解決的一些bug
以上就是功能在實現過程中要解決的問題,下面詳細展開
1. 布局結構
布局結構始終是界面設計時首先要考慮的一個問題,從接到一個需求開始,首先要根據項目中現有的布局結構,考慮如何更優雅的嵌入布局層次。如果一不小心,走上了錯誤的實現道路,那么不好意思,即使功能最后實現了,到了后期,也有千萬種理由迫使你不得不走上重構的道路。
比如實現不合理,導致的布局結構復雜,嵌套冗余層次,比如代碼業務邏輯處理復雜蹩腳,比如資源浪費,內存消耗過多等等。雖然功能好使,使用起來也沒有差別,但是,作為一個有追求的程序員,我們還是要避免這種情況的發生不是嗎
不巧的是,本文就屬于上述踩坑記錄,下面詳細分析
1.1 初步實現
上來以后,思路很直接明了的去想要實現清屏和滑屏的功能是每個房間都有的功能,每個房間又都是一個RecyclerView 的一個Item。所以,很明顯在Item的布局上包一層,實現清屏和側滑列表的功能就可以了,這樣每個房間都可以上下滑,切換房間。切換以后,滑屏的功能是在每個房間里的,互不影響,所以很好理解
我們項目中實現直播間上下滑切換的功能是RecyclerView + 自定義LinearLayoutManager實現的,這部分內容網上demo很多,就不展開了
具體實施,是自定義布局繼承RelativeLayout,解析自定義的布局文件,里面包含,直播間的房間布局,和自己右側滑塊兒布局,然后用自己實現的布局替換之前的房間Item布局位置
這樣我們調用封裝的Container將清屏控件,和右側滑塊兒布局View分別添加到內部即可
API提供如下
// 添加需要清屏的view fun addClearViews(vararg views: View?) // 添加需要滑入的view fun addSlideView(view: RightSlideLayout)
這樣我們在視頻播放頁面滑動,就可以在Container內判斷手勢,處理清屏控件或者滑出右側滑塊兒了
右側滑塊再動態加載Fragment,展示列表布局,基本完成功能效果了
1.2 重構
本來以為開開心心的可以上線了,誰知到下邊繼續體驗和對比抖音到過程中還是發現不足:
第一個是,右側滑塊兒(后邊稱RightSlider)包含在房間,這樣上下切換房間(后邊稱Container),RightSlider布局也會隨著Container新建而新建,雖然有RecyclerView的布局緩存,但是至少也會新建Holder幾次,造成資源的浪費。第二個是,RightSlider的新建就會導致里邊的Fragment的新建,所以又會重新請求加載列表數據,再次造成資源浪費,而且,新建后右側列表又會重新頂到頭,之前滑動過的距離就會丟失。這樣就造成,用戶從右側列表點擊切換房間后,再次滑出RightSlider切換房間,發現又要從頭開始往下滑,這樣肯定不符合用戶體驗。觀察抖音列表后發現,每次滑動到固定位置點擊Item切換房間后,再次滑出滑塊兒,發現列表還是之前的位置,好像跟之前滑出的是一個滑塊兒的效果,于是恍然大悟,滑塊兒是跟Activity綁定的,也就是要把RightSlider放在跟Activity布局那一層
其實提出RightSlider到外層的過程中,還是走了不少彎路,因為之前畢竟已經實現好的邏輯,如果改動布局結構,肯定要重寫滑動沖突、事件分發這部分代碼,工作量又不可預計。所以想著能不能不動布局結構的情況下實現仿抖音效果
動態替換Fragment
首先想到的是滑出RightSlider里的列表每次都好像是同一個,那么保證里邊的Fragment是同一個不就好了,滑出的滑塊兒雖然不同,但是里邊裝載的Fragment列表是同一個,這樣就營造出同一個滑塊兒的效果。
但是實現過程中還是出現了問題,由于RecyclerView的預加載功能,導致我們項目中,從第一個房間上滑到下一個房間,過程中會新建兩個Holder,這樣Fragment替換就出了問題,切換房間后Fragment添加不上去,折騰一下午后最終放棄這個方案
固定List高度
然后想的,既然Fragment替換不了了,那么RecyclerView肯定不是同一個了,如果點擊后記錄當前RecyclerView滑動的位置,下次滑出時,代碼固定到當前位置不是也可以偽造出同一個滑塊兒的效果嘛,這部分也去找了一些資料,實現了個小demo。其中用到的主要方法是
/** * 獲取滑動距離 */ fun getScollYDistance(): Int { // 獲取recyclerview 的layoutManagerval layoutManager = recyclerView.layoutManager as LinearLayoutManager// 獲取當前第一個可見View的位置val position = layoutManager.findFirstVisibleItemPosition()// 根據position 獲取當前Viewval firstVisiableChildView = layoutManager.findViewByPosition(position)// 獲取當前View 高度val itemHeight = firstVisiableChildView.height// 滑動距離return position * itemHeight - firstVisiableChildView.top }
滑動距離計算的思想是:根據當前可見View 的position * 每個ItemView 的高度 + 當前View已經滑出去的部分
計算出高度后,每次加載時,調用RecyclerView的API
recyclerView.scrollBy(0,scroll) //scroll 剛才計算的高度
還有其他幾個滑動的方法:
// 帶動畫移動距離public void smoothScrollBy(int dx, int dy)// 帶動畫移動到positionpublic void smoothScrollToPosition(int position)// 移動到adapter position ,由LayoutManager實現public void scrollToPosition(int position)// 空實現,無效public void scrollTo(int x, int y)
原理上可以實現,但是最后綜合比較還是放棄了這種方式,因為總感覺這種方法屬于投機取巧不是正道,還是老老實實將RightSlider 提到外面得了
2. 動畫
動畫也是這個功能中很重要的一個方面,因為動畫效果的流暢直接影響了用戶體驗,所以這方面也是細扣了很久。首先這個功能主要分成三個動畫效果:
2.1 進場出場
包含清屏控件入場、出場:
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 負責移動View 代碼如下:
/** * 移動清屏控件 */ private fun translateClearChild(translate: Int) {for (i in mClearViews.indices) { mClearViews[i].translationX = translate.toFloat()} }
滑塊兒的入場、出場:
mSlideInAnimator = ValueAnimator.ofFloat(0f, 1.0f).setDuration(500)// 設置減速攔截器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.VISIBLEmBgColorView.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 分別代表入場和出場時候,動畫起止位置。由于清屏控件沒有中間位置狀態,直接是從0 到屏幕寬度兩個值之間替換;而滑塊兒中間由于要跟隨手勢移動,所以要記錄中間translateX,標記為startX
2.2 跟隨手勢
跟隨手勢實現主要是攔截移動手勢,根據按下手勢位置坐標和Move移動位置坐標的差值,調用移動SliderView的方法
val x = event.rawX.toInt()// 標記移動距離val offsetX = x - mDownXwhen (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 顏色漸變
跟隨手勢滑動過程中還伴隨的左側空白區域顏色漸變,這部分可以在RightSlider移動過程中的距離值關聯起來,設置起始顏色透明和截止顏色灰色蒙層。再根據距離動態算出當前顏色在區間范圍內取值,主要代碼邏輯如下
/** * 移動滑塊兒 */ private fun translateSlideView(translate: Int) {val percent = (mSlideView!!.width.toFloat() - translate) / mSlideView!!.width// 根據百分比算出色值val color = (MASK_DARK_COLOR * percent).toInt() shl 24// 動態設置背景色漸變mBgColorView.setBackgroundColor(color)translateX = translatemSlideView!!.translationX = translate.toFloat() }
3 事件分發
這部分可以說是本功能實現的核心,也是耗費了相當時間的精力,從最開始的Container包含RightSlider布局處理經典的事件分發順序,到最后重構布局,將RightSlider提到外層變成不是包含關系,而是并列或者說是覆蓋關系,中間對事件傳遞的順序理解又深入了一層
3.1 傳遞順序
重構之前的布局結構是每個Container包含了一個RightSlider,兩個是一個整體使用的,滑動的邏輯都可以在Container層內的onInterceptTouchEvent方法內處理。判斷是否攔截事件即可,然后RightSlider內想要禁止父層Container攔截事件,可以使用parent.requestDisallowInterceptTouchEvent(true)禁止父層攔截;是屬于經典模式的事件分發模型,事件分發的順序在一個U型結構里,比較好處理
然后重構以后布局結構變成了如下圖所示
每個Container 共用一個RightSlider,這樣屬于事件的分發處理不在一個ViewGroup的U型模型里了,這樣的分發順序也是屬于自己的一個大膽嘗試,想著實在不行,還是要把Activity內布局包一層,將Container和RightSlider 放在一個U型結構里去處理。
還好最后不斷踩坑,終于實現了事件從Activity分發,到RightSlider,再分發到Container的過程
這里貼下Demo里的布局實現:
<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.SlideContainerLayoutandroid: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'> <TextViewandroid:id='@+id/tv111'android:layout_width='wrap_content'android:layout_height='wrap_content'android:text='111111111'android:textColor='#fff' /> <TextViewandroid:layout_width='wrap_content'android:layout_height='wrap_content'android:text='222222222'android:textColor='#fff' /></LinearLayout> </com.fxf.slide.SlideContainerLayout> <com.fxf.slide.RightSlideLayoutandroid: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'> <Viewandroid: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' /> <FrameLayoutandroid: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核心分發代碼:
override fun dispatchTouchEvent(event: MotionEvent): Boolean {// 獲取坐標,這里用rawX 相對屏幕絕對位置,不然隨手勢移動過程中父布局的移動,導致獲取的坐標左右抖動,會出現移動過程中左右一直抖動現象val x = event.rawX.toInt()val y = event.rawY.toInt()// X方向位移val offsetX = x - mDownXif (!mSlideContainerLayout.isSlideShow){// Container滑塊兒沒滑出來不分發事件 return false}when (event.action) { MotionEvent.ACTION_DOWN -> { // 記錄按下點坐標mDownX = xmDownY = ymSlideContainerLayout.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()) { // 向左滑動,滑塊兒已經靠最左邊了,不分發return super.dispatchTouchEvent(event) } else if (abs(x - mDownX) > abs(y - mDownY)){ // 水平方向移動,分發事件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 滑動沖突
因為房間是可以上下滑動的,所以可以判斷如果滑塊兒沒滑粗來時,直接返回分發,不讓RightSlider和Container處理事件
if (!mSlideContainerLayout.isSlideShow){ return false }
然后滑塊兒滑出來以后,因為里邊有列表,所以要消費上下滑動事件,可以處理如下:
MotionEvent.ACTION_MOVE -> if (abs(x - mDownX) < abs(y - mDownY) && paddingLeft < x) {if (isSlideHorizontal) { return mSlideContainerLayout.dispatchTouchEvent(event)} }
其中paddingLeft < x 是因為滑塊左邊有一部分空白區域 paddingLeft ,所以當x坐標在此區域右側時才處理事件
Container動畫執行過程中,說明正在消費事件,此時禁止父層攔截事件
if (mClearAnimator.isRunning || mSlideInAnimator.isRunning || isSlideShow) { // 滑入情況下,禁止上下滑切換直播間 parent.requestDisallowInterceptTouchEvent(true) }
Container處理事件時候和直播間上的進入房間頭像列表沖突,解決方法是判斷mDownY 大于進入頭像列表高度時才處理事件,因為正常人滑入滑塊都是在屏幕中下部操作的,所以太靠上的部分不處理事件也可以接受
MotionEvent.ACTION_MOVE -> {if (!mClearAnimator.isRunning && mDownY > 200 && abs(x - mDownX) > abs(y - mDownY)) { // 清屏不在執行時 && 高度大于200dp(解決進入房間頭像滑動沖突)&& 橫向滑動時攔截事件 if (abs(x - mDownX) > 10) {return true }} }
3.3 滑動優化
這部分有很多細節處理的地方,包括動畫執行到一半情況下,再次左右滑動,先向左后向右,左右滑一半再上下滑等等各種情況具體可以看代碼中SlideContainerLayout中onTouchEvent方法內處理邏輯,都添加了注釋
override fun onTouchEvent(event: MotionEvent): Boolean {mVelocityTracker!!.addMovement(event)val x = event.rawX.toInt()val offsetX = x - mDownXif (mLastOffsetList.size > 2){ mLastOffsetList.removeFirst()}mLastOffsetList.add(offsetX)var slideRight = (offsetX - mLastOffsetList.first) > 0when (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 && 沒滑入滑塊 && 滑塊動畫沒執行的時候 ===》清屏layerGoneWithAnim() } else if (isSlideShow && offsetX > 0 && slideRight) {// 滑入情況下 && 向右速度 > 10 ===》滑出滑塊mSlideInAnimator.cancel()isSliderGoning = truestartX = translateXendX = width - mSlideView!!.paddingLeftmSlideInAnimator.start() } else if (isSlideShow && offsetX < 0 && translateX != 0) {// 滑入情況下 && 向左速度 > 10 && 已經向右滑動了一段距離 ===》 滑塊回彈startX = translateXendX = 0mSlideInAnimator.start() } else if (!isSlideShow && offsetX < 0 && !mSlideInAnimator.isRunning) {// 沒滑入情況下 && 向左滑速度 > 10 && 沒右正在滑入情況下 ===》 滑入滑塊sliderShowWithAnim() } else {if (isSlideShow && translateX != 0) { // 滑入情況下 && 已經向右滑動過,速度沒達到松開 ===》回彈 startX = translateX mSlideInAnimator.start()} }}else { if (isSlideShow && translateX != 0) {// 滑入情況下 && 已經向右滑動過,速度沒達到松開 ===》回彈startX = translateXmSlideInAnimator.start() }}return super.onTouchEvent(event) } MotionEvent.ACTION_CANCEL -> {if (isSlideShow) { //取消事件時,滑入情況下回彈 startX = translateX mSlideInAnimator.start()} }}return super.onTouchEvent(event) }
總結
最后通過這次實踐,感觸比較深的是功能實現之前,一定要做好充分的調研,研究好需求的細節,并預先想幾種實現策略,對比哪一種更合理。不要埋頭就寫,結果最后發現不符合需求還要重構
感謝,這里Contanier內的邏輯主要參考了gitHub上[這篇文章](https://github.com/lmxjw3/clearscreen )的處理不過里邊處理滑動沖突的邏輯比較少還是要自己結合項目處理
奉上GitHub 項目地址
項目地址
總結
到此這篇關于Android仿抖音右滑清屏左滑列表功能的實現代碼的文章就介紹到這了,更多相關android 抖音右滑清屏左滑列表內容請搜索好吧啦網以前的文章或繼續瀏覽下面的相關文章希望大家以后多多支持好吧啦網!
相關文章: