Android仿淘寶、京東Banner滑動檢視圖文詳情

語言: CN / TW / HK

我正在參加「掘金·啟航計劃」

寫在前面

本文基於 ViewPager2 實現的 Banner 效果,進而實現了仿淘寶、京東Banner滑動至最後一頁時繼續滑動來檢視圖文詳情的效果。關於 ViewPager2 的原理及其封裝,可以參見之前的兩篇文章:
1、Android 深入理解ViewPager2原理及其實踐(上篇)
2、Android 深入理解ViewPager2原理及其實踐(下篇)

效果圖

Banner滑動檢視圖文詳情

原理分析

滑動檢視更多 - Banner與右側的檢視更多View都是子View,被父View包裹,預設Banner的寬度是match_parent,而檢視更多則是在螢幕的右側,處於不可見狀態; - 當Banner進行左右滑動時,當前的滑動事件是在Banner中消費的,即父View不會進行攔截。 - 當Banner滑動到最右側且要繼續滑動時,此時父View會進行事件的攔截,從而事件由父View接管,並在父ViewonTouchEvent()中消費事件,此時就可以滑動父View中的內容了。怎麼滑動呢?在MOVE事件時通過scrollTo()/scrollBy()滑動,而在UP/CANCEL事件時,需要通過ScrollerstartScroll()自動滑動到檢視更多子View的左側或右側,從而完成一次事件的消費; - 當UP/CANCEL事件觸發時,檢視更多子View滑動的距離超過一半,認為需要觸發檢視更多操作了,當然這裡的值都可以自行設定。

核心程式碼

  • TJBannerFragment.kt ``` /**
  • 仿淘寶京東寶貝詳情Fragment */ class TJBannerFragment : BaseFragment() { private val mModels: MutableList = mutableListOf() private val mContainer: VpLoadMoreView by id(R.id.vp2_load_more)

    override fun getLayoutId(): Int { return R.layout.fragment_tx_news_n }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) { initVerticalTxScroll() }

    private fun initVerticalTxScroll() { mModels.add(TxNewsModel(MConstant.IMG_4, "美輪美奐節目", "奧運五環緩緩升起")) mModels.add(TxNewsModel(MConstant.IMG_1, "精美商品", "9塊9包郵")) mContainer.setData(mModels) { showToast("開啟更多頁面") } } } - **VpLoadMoreView.kt(父View)** class VpLoadMoreView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0, ) : LinearLayout(context, attrs, defStyle) {

    private val mMVPager2: MVPager2 by id(R.id.mvp_pager2) private var mNeedIntercept: Boolean = false //是否需要攔截VP2事件 private val mLoadMoreContainer: LinearLayout by id(R.id.load_more_container) private val mIvArrow: ImageView by id(R.id.iv_pull) private val mTvTips: TextView by id(R.id.tv_tips)

    private var mCurPos: Int = 0 //Banner當前滑動的位置 private var mLastX = 0f private var mLastDownX = 0f //用於判斷滑動方向 private var mMenuWidth = 0 //載入更多View的寬度 private var mShowMoreMenuWidth = 0 //載入更多發生變化時的寬度 private var mLastStatus = false // 預設箭頭樣式 private var mAction: (() -> Unit)? = null private var mScroller: OverScroller private var isTouchLeft = false //是否是向左滑動 private var animRightStart = RotateAnimation(0f, -180f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f).apply { duration = 300 fillAfter = true }

    private var animRightEnd = RotateAnimation(-180f, 0f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f).apply { duration = 300 fillAfter = true }

    init { orientation = HORIZONTAL View.inflate(context, R.layout.fragment_tx_news, this) mScroller = OverScroller(context) }

    /* * @param mModels 要載入的資料 * @param action 回撥Action / fun setData(mModels: MutableList, action: () -> Unit) { this.mAction = action mMVPager2.setModels(mModels) .setLoop(false) //非迴圈模式 .setIndicatorShow(false) .setLoader(TxNewsLoader(mModels)) .setPageTransformer(CompositePageTransformer().apply { addTransformer(MarginPageTransformer(15)) }) .setOrientation(ViewPager2.ORIENTATION_HORIZONTAL) .setAutoPlay(false) .setOnBannerClickListener(object : OnBannerClickListener { override fun onItemClick(position: Int) { showToast(mModels[position].toString()) } }) .registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { override fun onPageScrollStateChanged(state: Int) { if (mCurPos == mModels.lastIndex && isTouchLeft && state == ViewPager2.SCROLL_STATE_DRAGGING) { //Banner在最後一頁 & 手勢往左滑動 & 當前是滑動狀態 mNeedIntercept = true //父View可以攔截 mMVPager2.setUserInputEnabled(false) //VP2設定為不可滑動 } }

            override fun onPageSelected(position: Int) {
                mCurPos = position
            }
        })
        .start()
    

    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { mMenuWidth = mLoadMoreContainer.measuredWidth mShowMoreMenuWidth = mMenuWidth / 3 * 2 super.onLayout(changed, l, t, r, b) }

    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { when (ev?.action) { MotionEvent.ACTION_DOWN -> { mLastX = ev.x mLastDownX = ev.x } MotionEvent.ACTION_MOVE -> { isTouchLeft = mLastDownX - ev.x > 0 //判斷滑動方向 } } return super.dispatchTouchEvent(ev) }

    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { var isIntercept = false when (ev?.action) { MotionEvent.ACTION_MOVE -> isIntercept = mNeedIntercept //是否攔截Move事件 } //log("ev?.action: ${ev?.action},isIntercept: $isIntercept") return isIntercept }

    @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(ev: MotionEvent?): Boolean { when (ev?.action) { MotionEvent.ACTION_MOVE -> { val mDeltaX = mLastX - ev.x if (mDeltaX > 0) { //向左滑動 if (mDeltaX >= mMenuWidth || scrollX + mDeltaX >= mMenuWidth) { //右邊緣檢測 scrollTo(mMenuWidth, 0) return super.onTouchEvent(ev) } } else if (mDeltaX < 0) { //向右滑動 if (scrollX + mDeltaX <= 0) { //左邊緣檢測 scrollTo(0, 0) return super.onTouchEvent(ev) } } showLoadMoreAnim(scrollX + mDeltaX) scrollBy(mDeltaX.toInt(), 0) mLastX = ev.x } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { smoothCloseMenu() mNeedIntercept = false mMVPager2.setUserInputEnabled(true) //執行回撥 val mDeltaX = mLastX - ev.x if (scrollX + mDeltaX >= mShowMoreMenuWidth) { mAction?.invoke() } } } return super.onTouchEvent(ev) }

    private fun smoothCloseMenu() { mScroller.forceFinished(true) /* * 左上為正,右下為負 * startX:X軸開始位置 * startY: Y軸結束位置 * dx:X軸滑動距離 * dy:Y軸滑動距離 * duration:滑動時間 / mScroller.startScroll(scrollX, 0, -scrollX, 0, 300) invalidate() }

    override fun computeScroll() { if (mScroller.computeScrollOffset()) { showLoadMoreAnim(0f) //動畫還原 scrollTo(mScroller.currX, mScroller.currY) invalidate() } }

    private fun showLoadMoreAnim(dx: Float) { val showLoadMore = dx >= mShowMoreMenuWidth if (mLastStatus == showLoadMore) return if (showLoadMore) { mIvArrow.startAnimation(animRightStart) mTvTips.text = "釋放檢視圖文詳情" mLastStatus = true } else { mIvArrow.startAnimation(animRightEnd) mTvTips.text = "滑動檢視圖文詳情" mLastStatus = false } } } ``父View的註釋很清晰,不用過多解釋了,這裡需要注意一點,已知在Banner的最後一頁滑動時需要判斷滑動方向:繼續向左滑動,需要父View攔截滑動事件並自己進行消費;向右滑動時,父View不需要處理滑動事件,仍由Banner`進行事件消費。

滑動方向需要起始位置(DOWN事件)的X座標 - 滑動時的X座標(MOVE事件) 的差值進行判斷,那問題在哪裡取起始位置的X座標呢?在父ViewonInterceptTouchEvent()->DOWN事件裡嗎?這裡是不行的,因為滑動方向是在MOVE事件裡判斷的,在父ViewonInterceptTouchEvent()->DOWN事件裡攔截的話,後續事件不會往Banner裡傳遞了。這裡可以選擇在父ViewdispatchTouchEvent()->DOWN事件裡即可解決。

VpLoadMoreView對應的XML佈局: ```

<!--ViewPager2-->
<org.ninetripods.lib_viewpager2.MVPager2
    android:id="@+id/mvp_pager2"
    android:layout_width="match_parent"
    android:layout_height="200dp"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

<!--載入更多View-->
<LinearLayout
    android:id="@+id/load_more_container"
    android:layout_width="100dp"
    android:layout_height="200dp"
    android:gravity="center_vertical"
    android:orientation="horizontal">

    <ImageView
        android:id="@+id/iv_pull"
        android:layout_width="18dp"
        android:layout_height="18dp"
        android:layout_gravity="center_vertical"
        android:layout_marginStart="10dp"
        android:src="@drawable/icon_arrow_pull" />

    <TextView
        android:id="@+id/tv_tips"
        android:layout_width="16dp"
        android:layout_height="match_parent"
        android:layout_marginStart="10dp"
        android:gravity="center_vertical"
        android:text="滑動檢視圖文詳情"
        android:textColor="#333333"
        android:textSize="14sp"
        android:textStyle="bold" />
</LinearLayout>

`` 這裡的父View(VpLoadMoreView)LinearLayout,且必須是橫向佈局,XML的頂層佈局使用的merge標籤,這樣既可以優化一層佈局,又可以在父View中直接操作載入圖文詳情的子View`。

原始碼地址

完整程式碼地址參見:Android仿淘寶、京東Banner滑動至最後檢視圖文詳情