Android一個文件實現橫向縱向拉拽刷新 [復制鏈接]

2018-9-6 10:25
shareiOS 閱讀:602 評論:0 贊:0
Tag:  

再老的司機也難免遇到這樣的場景,產品跑過來大聲對我說:首頁要加刷新,下拉刷新非侵入式,上拉加載為侵入式,頭部輪播圖片最左邊向右繼續拖拽進入xx頁,最右邊向左繼續拖拽進入xx頁!噢,xx頁再加一個從中間下拉刷新吧!噢,設計已經出好了刷新的動畫和規范,照著做就好了。

(╯‵□′)╯︵┻━┻ 頓時有了掀桌子的小心情,怎么辦,寫一個統一的刷新的庫?太重了而且方法數爆了怎么辦?

然而,現在我有了EasyPullLayout,你想加什么隨便加就是了,上拉、下拉、左拉、右拉,任何姿勢我都能給,整個控件只有一個文件,不到500行代碼,支持橫向縱向,侵入非侵入,自定義拉拽行為以及刷新內容,ListView、RecyclerView、ViewPager等等什么內容都能包裹進來,再也不用導入這樣那樣的庫來支持各種各樣的刷新了。


看看效果

縱向

recyclerview

橫向



一共5個demo,其余的都傳了效果圖到github上,其中變形金剛動畫用到了我寫的另一個輕量級的控件EasyPath,使用方法很簡單,傳送門: 
https://github.com/huzenan/EasyPath


用法

1.布局

接著在布局文件中,在需要刷新的地方用EasyPullLayout包裹起來(例如根布局),并為EasyPullLayout下的子View聲明layout_type屬性,使得子View可以被EasyPullLayout識別,分別可以為content(必選)、edge_top、edge_bottom、edge_left和edge_right:

<com.hzn.lib.EasyPullLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/epl"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    >

    <com.hzn.easypulllayout.TransformerView
        android:id="@+id/topView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_type="edge_top" />

    <android.support.v7.widget.RecyclerView
        android:id="@+id/rv"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_type="content"
        tools:listitem="@layout/item"
        />
</com.hzn.lib.EasyPullLayout>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

因為layout_type屬性是EasyPullLayout提供的,所以不要忘記加入自定義的命名空間(如上為app)。

上面我們的邊緣視圖(TransformerView)是自定義的一個視圖,我們可以通過EasyPullLayout提供的接口來動態改變它的行為。

EasyPullLayout本身也提供屬性可選,用于控制拉拽行為和距離等,詳細可以參考github中的“屬性”一欄。


2.監聽

EasyPullLayout的設計遵從單一職責原則,只負責處理拉拽相關的操作,其他的均交給外部進行處理,因此其子View可以是任何一種View,這有點類似于RecyclerView只負責使用和回收操作。

EasyPullLayout有3個監聽可以設置,分別為:

OnEdgeListener: 
可選,用于通知EasyPullLayout當前是否到達邊緣,到達邊緣后EasyPullLayout會攔截觸摸事件,開始拉拽行為,默認會自動監聽layout_type為content的子View是否到達邊緣。

OnPullListener: 
可選,用于EasyPullLatout向外通知當前拉拽的一些參數(例如拉拽進度),我們可以利用這些參數來改變我們的邊緣視圖的行為,例如調整變形金剛矢量動畫當前的執行位置。

OnTriggerListener: 
必選,用于EasyPullLayout在觸發邊緣動作后向外通知,此時EasyPullLayout會一直停留在STATE_TRIGGERING(觸發中)的狀態,我們做一些耗時操作后,需要調用stop方法讓其回到STATE_IDLE(閑置)狀態,這樣才完成整個過程。


原理

在看了安卓的SwipeRefreshLayout,以及一些開源的刷新庫的源碼后,有了一些思路,總結起來,控件主要處理的問題有: 
1、為EasyPullLayout的子View提供layout_type屬性 
2、如何擺放子View 
3、在什么時機進行拉拽(即處理事件分發)

處理了這3個問題后,剩下的例如拉拽過程,以及觸發事件都很好實現了。


1.提供layout_type屬性

EasyPullLayout需要辨別子View的類型,因此需要子View聲明自己的類型,我們在ViewGroup的generateLayoutParams方法中,返回我們自己的LayoutParams:

// 返回LayoutParmas
override fun generateLayoutParams(attrs: AttributeSet?): ViewGroup.LayoutParams {
    return LayoutParams(context, attrs)
}

// 自定義LayoutParams
class LayoutParams : ViewGroup.MarginLayoutParams {
    // layout_type默認為NONE
    var type = TYPE_NONE

    constructor(c: Context?, attrs: AttributeSet?) : super(c, attrs) {
        //...
        // 在構造函數中,獲取layout_type屬性,存儲起來
        type = it?.getInt(R.styleable.EasyPullLayout_LayoutParams_layout_type, TYPE_NONE)
        //...
    }

    constructor(width: Int, height: Int) : super(width, height)
    constructor(source: MarginLayoutParams?) : super(source)
    constructor(source: ViewGroup.LayoutParams?) : super(source)
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

這樣我們便可以通過子View的LayoutParams對象獲取到這個type值。


2.如何擺放子View

EasyPullLayout繼承自ViewGroup,在完成xml解析后,即onFinishInflate方法中,獲取到子View后用一個HashMap來存儲,key對應View,value對應View的一些參數,接著再設置默認的OnEdgeListener:

override fun onFinishInflate() {
    super.onFinishInflate()
    // 遍歷子View
    var i = 0
    while (i < childCount) {
        getChildAt(i++).let {
            val lp = it.layoutParams as LayoutParams
            childViews.getByType(lp.type)?.let {
                throw Exception("Each child type can only be defined once!")
            } ?: childViews.put(it, ChildViewAttr()) // 存儲子View
        }
    }

    // 確保有一個子View的layout_type為content
    val contentView = childViews.getByType(TYPE_CONTENT) ?:
        throw Exception("Child type \"content\" must be defined!")

    // 設置默認的OnEdgeListener,可以被覆蓋
    setOnEdgeListener {
        // 若存在左側的邊緣視圖
        childViews.getByType(TYPE_EDGE_LEFT)?.let {
            // 此時判斷content是否能向左滾動
            if (!contentView.canScrollHorizontally(-1))
                // 若已經不能滾動,則返回TYPE_EDGE_LEFT表示已經到達了左側邊緣
                return@setOnEdgeListener TYPE_EDGE_LEFT
        }

        // 其余3個方向實現方法也一樣

        // 若都不滿足,則說明沒有到達邊緣,返回NONE
        TYPE_NONE
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33

然后做測量,遍歷子View,對每個子View進行測量,然后記錄下邊緣視圖的一些參數,以及根據這些參數初始化EasyPullLayout自身的一些參數:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    // 遍歷子View
    for ((childView, childViewAttr) in childViews) {
        // 要求該子View進行測量
        measureChildWithMargins(childView,
            widthMeasureSpec, 0, heightMeasureSpec, 0)
        // 得到子View的LayoutParams對象
        val lp = childView.layoutParams as LayoutParams

        when (lp.type) {
            // 類型為橫向的子View
            TYPE_EDGE_LEFT, TYPE_EDGE_RIGHT -> {
                // 把子View的size值記錄下來,在擺放子View時會用到
                // 橫向size對應為寬度加左右margin
                // 縱向size對應為高度加上下margin
                childViewAttr.size =
                    childView.measuredWidth + lp.leftMargin + lp.rightMargin

                // 初始化EasyPullLayout的屬性,例如拖拽距離等

            }

            // 縱向的實現方式也一樣

        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

然后開始擺放,根據測量時記錄的參數,我們將邊緣視圖分別擺放到四周:

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    // 首先獲取content,得到寬高用于給其他子View做參考
    val contentView = childViews.getByType(TYPE_CONTENT)
    val contentWidth = contentView?.measuredWidth
        ?: throw Exception("子View必須包含一個content")
    val contentHeight = contentView.measuredHeight

    for ((childView, childViewAttr) in childViews) {
        // 首先計算出子View的位置
        // 此時還未進行偏移,左上角都位于(0,0)
        val lp = childView.layoutParams as LayoutParams
        var left: Int = paddingLeft + lp.leftMargin
        var top: Int = paddingTop + lp.topMargin
        var right: Int = left + childView.measuredWidth
        var bottom: Int = top + childView.measuredHeight

        when (lp.type) {
            TYPE_EDGE_LEFT -> {
                // 左側的子View應該向左偏移,擺放在左側
                left -= childViewAttr.size
                right -= childViewAttr.size
            }

            // 其他3個方向的實現方式也一樣

        }
        childViewAttr.set(left, top, right, bottom) // child views' initial location
        childView.layout(left, top, right, bottom)
    }

    // 若設置了左側拖拽時固定
    if (fixed_content_left)
        // 改變左側邊緣視圖z-order,使其在頂部
        childViews.getByType(TYPE_EDGE_LEFT)?.bringToFront()
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35

可以看到最后我們還把子View的當前擺放位置記錄下來,因為EasyPullLayout是通過改變View的x和y屬性來達到位移效果的, 因此需要參考子View的初始位置。另外這樣做的好處是,我們可以不通過onLayout來重置位置,避免回調onLayout。


3.處理事件分發

首先要在onInterceptTouchEvent中適當地對事件進行攔截,在ACTION_MOVE事件中回調了OnEdgeListener,這樣就把是否進行攔截的判斷操作交給了外部進行處理,只要返回正確的類型,則開始對觸摸事件進行攔截:

override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
    // 不為閑置狀態時不處理觸摸事件
    if (currentState != STATE_IDLE)
        return false

    when (ev?.action) {
        MotionEvent.ACTION_DOWN -> {
            // 記錄按下位置
            downX = ev.x
            downY = ev.y
        }
        MotionEvent.ACTION_MOVE -> {
            // 回調OnEdgeListener,得到type值
            val type = onEdgeListener.invoke()
            currentType = type
            val dx = ev.x - downX
            val dy = ev.y - downY
            return when (type) {
                // 邊緣監聽返回的是左側,若向右拉拽且橫向比縱向偏移大
                // 則返回值為true,表示開始攔截觸摸事件
                TYPE_EDGE_LEFT -> ev.x > downX && Math.abs(dx) > Math.abs(dy)

                // 另外3個方向處理規則相同

                TYPE_NONE -> false
                else -> false
            }
        }
    }
    return false
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

攔截了觸摸事件后,開始進行拉拽操作,在onTouchEvent中,ACTION_MOVE事件對必要的子View進行偏移(設置了對應的fixed選項后,content不會進行偏移,達到侵入式效果),ACTION_CANCEL、ACTION_UP事件則將子View位置還原:

override fun onTouchEvent(event: MotionEvent?): Boolean {
    // 不為閑置狀態時不處理觸摸事件
    if (currentState != STATE_IDLE)
        return false

    when (event?.action) {
        MotionEvent.ACTION_MOVE -> {
            val x = event.x
            val y = event.y
            // 當前偏移值,sticky_factor參數使得拖拽時有黏著效果
            offsetX = (x - downX) * (1 - sticky_factor * 0.75f)
            offsetY = (y - downY) * (1 - sticky_factor * 0.75f)
            var pullFraction = 0f

            when (currentType) {
                TYPE_EDGE_LEFT -> {
                    // 限制offsetX的最小和最大值
                    offsetX = "..."
                    // 計算出當前拖拽進度,未拖拽時為0,到達觸發位置時為1
                    pullFraction = "..."
                }
            }

            // 是否經過觸發位置,使用該參數可以只在經過觸發位置時進行更新
            val changed =
                !(lastPullFraction < 1f && pullFraction < 1f ||
                    lastPullFraction == 1f && pullFraction == 1f)

            // 回調OnPullListener
            onPullListener?.invoke(currentType, pullFraction, changed)

            lastPullFraction = pullFraction

            when (currentType) {
                TYPE_EDGE_LEFT ->
                    for ((childView, childViewAttr) in childViews)
                        if ("如果設置了對應的fixed,且為content,則不偏移")
                            // 子View偏移
                            childView.x = childViewAttr.left + offsetX

                // 其他3個方向規則相同

            }
        }
        MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
            currentState = STATE_ROLLING
            // 松開手指,還原子View位置
            when (currentType) {
                TYPE_EDGE_LEFT, TYPE_EDGE_RIGHT -> rollBackHorizontal()
                TYPE_EDGE_TOP, TYPE_EDGE_BOTTOM -> rollBackVertical()
            }
        }
    }
    return true
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55

還原子View位置時,我們通過ValueAnimator,在一段時間內將子View還原,還原后的位置分2種情況,第一種還沒超過觸發偏移量,則還原回到初始位置,第二種已經超過了觸發偏移量,則回到觸發偏移量的位置,看圖比較直觀:

rollback分為橫向和縱向,下面貼出橫向的大致的流程:

private fun rollBackHorizontal() {
    // 需要還原的偏移量
    val rollBackOffset = "..."
    // 觸發位置的偏移量
    val triggerOffset = "..."
    // 動畫,值從1->0
    horizontalAnimator = ValueAnimator.ofFloat(1f, 0f).apply {
        duration = roll_back_duration
        interpolator = DecelerateInterpolator()
        // 動畫更新
        addUpdateListener {
            //...
            for ((childView, childViewAttr) in childViews)
                // 通過rollBackOffset和triggerOffset,以及animatedValue計算得出x
                childView.x = "..."
        }
        // 動畫結束后,還原一些參數,回調監聽
        addListener(object : AnimatorListenerAdapter() {
            override fun onAnimationEnd(animation: Animator?) {
                if (triggerOffset != 0 && currentState == STATE_ROLLING) {
                    // 還原到觸發位置
                    currentState = STATE_TRIGGERING
                    offsetX = triggerOffset.toFloat()
                    // 回調觸發監聽
                    onTriggerListener?.invoke(currentType)
                } else {
                    // 還原到初始位置
                    currentState = STATE_IDLE
                    offsetX = 0f
                }
            }
        })
        start()
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35


最后

因為是Kotlin寫的庫,所以沒有使用kotlin的項目,通過直接gradle直接導入后是看不到源碼的,不過不要慌,已經寫了一個簡單的java版本demo(下面有地址),可以直接用。嗯,隨便用,穩穩的。寫了好長,邏輯也是已經凌亂了,感謝大伙的圍觀,謝謝了!

https://github.com/huzenan/EasyPullLayoutJavaDemo


我來說兩句
您需要登錄后才可以評論 登錄 | 立即注冊
facelist
所有評論(0)
領先的中文移動開發者社區
18620764416
7*24全天服務
意見反饋:[email protected]

掃一掃關注我們

Powered by Discuz! X3.2© 2001-2019 Comsenz Inc.( 粵ICP備15117877號 )

两码中特期期