實現任意控件可拖動回彈或消失 - 仿QQ未讀消息

一、前言

最近需要做一個拖拽回彈或消失的效果,類似QQ未讀消息提示的小點點蔬螟。在參考了幾篇博文之后蓬推,有了大概思路就直接開始擼代碼了。先上效果圖,一個普通的ImageView實現拖拽回彈或消失的效果。

圖1 任意控件拖拽效果.jpg

二、使用

目前我已經將該功能上傳JitPack倉庫哆致,各位大大可以在Android 工程中直接使用。

1患膛、在你的工程根build.gradle文件中添加JitPack倉庫

    allprojects {
        repositories {
            ...
            maven { url 'https://jitpack.io' }
        }
    }

2摊阀、 添加依賴(一般是app中的build.gradle)

    dependencies {
            implementation 'com.github.ilyle:LiteDragView:1.0.0'
    }

3、 將你要實現拖拽的控件加入以下語句即可

    LiteDragHelper.bind(Context context, View view, @ColorInt int color);

其中,第一個參數是上下文對象胞此,第二個參數是你要實現拖拽的控件臣咖,第三個參數是顏色屬性@ColorInt(省略則默認紅色)。


相信來到這里豌鹤,各位都已經可以實現該拖拽回彈或消失的功能了。如果各位大大還想更深入了解其中實現的細節(jié)的布疙,當然可以繼續(xù)往下看。

三灵临、思路和實現

1、畫圓和畫曲線

要實現任意控件添加拖拽效果儒溉,首先我們應該解決核心的東西宦焦,即拖動出現中間粘性連接的效果顿涣。當拖動距離較小時動畫回彈波闹,拖拽距離過大時爆炸消失涛碑。實現效果如下:

圖2 粘性拖動效果.jpg

這里面包含了這么三種元素:

  • 固定圓:圓心(c1x, c1y)位于手指按壓的位置精堕,半徑r1隨拖動距離變長而變小蒲障;
  • 拖拽圓:圓心(c2x, c2y)隨手指在手機屏幕的位置,半徑r2隨拖動距離變長而變凶椤(不變也可以)毙籽;
  • 貝塞爾曲線:連接兩圓之間的曲線

接下去給出這三種元素的數學關系圖:

圖3 粘性效果數學關系圖.jpg

關鍵Android代碼(Kotlin)如下:

private val mPath: Path? // 貝塞爾曲線路徑
        get() {
            val distance = getDistanceByPoints(mFixCircle, mDrgCircle) // 獲取圓心距
            mFixCircle.radius = RADIUS_INIT - distance / 10 // 固定圓半徑
            mDrgCircle.radius = RADIUS_INIT - distance / 10 // 拖拽圓半徑
            if (mFixCircle.radius <= RADIUS_MIN) { // 超過拖拽距離則返回mPath為null,即不畫粘性效果
                mIsEvenOverRange = true // 拖拽過程中曾超過拖拽距離
                return null
            }
            if (!mIsEvenOverRange) { // 拖拽過程中曾超過拖拽距離則返回mPath為null巡扇,即不畫粘性效果
                val path = Path()
                val offsetX = (mFixCircle.radius * Math.sin(Math.atan(((mDrgCircle.point.y - mFixCircle.point.y) / (mDrgCircle.point.x - mFixCircle.point.x)).toDouble()))).toFloat()
                val offsetY = (mFixCircle.radius * Math.cos(Math.atan(((mDrgCircle.point.y - mFixCircle.point.y) / (mDrgCircle.point.x - mFixCircle.point.x)).toDouble()))).toFloat()
                val x1 = mFixCircle.point.x + offsetX
                val y1 = mFixCircle.point.y - offsetY
                val x2 = mDrgCircle.point.x + offsetX
                val y2 = mDrgCircle.point.y - offsetY
                val x3 = mDrgCircle.point.x - offsetX
                val y3 = mDrgCircle.point.y + offsetY
                val x4 = mFixCircle.point.x - offsetX
                val y4 = mFixCircle.point.y + offsetY
                val bezierPoint = PointF() // 貝塞爾一階曲線控制點垮衷,為起點和終點的中點
                bezierPoint.x = (mFixCircle.point.x + mDrgCircle.point.x) / 2
                bezierPoint.y = (mFixCircle.point.y + mDrgCircle.point.y) / 2
                path.moveTo(x1, y1)
                path.quadTo(bezierPoint.x, bezierPoint.y, x2, y2)
                path.lineTo(x3, y3)
                path.quadTo(bezierPoint.x, bezierPoint.y, x4, y4)
                path.lineTo(x1, y1)
                path.close()
                return path
            }
            return null
        }
    override fun onDraw(canvas: Canvas) {
        canvas.drawCircle(mDrgCircle.point.x, mDrgCircle.point.y, mDrgCircle.radius, mPaint) // 畫拖拽圓
        mPath?.let {
            canvas.drawCircle(mFixCircle.point.x, mFixCircle.point.y, mFixCircle.radius, mPaint) // 畫固定圓
            canvas.drawPath(it, mPaint) // 畫兩圓粘稠連線
        }
        canvas.drawBitmap(mDrgBmp, mDrgCircle.point.x - mDrgBmp.width / 2, mDrgCircle.point.y - mDrgBmp.height / 2, null) // 在拖拽圓上面畫被拖拽View

    }

其中圓半徑縮減參數我根據實際調試情況設為10(參見上述代碼塊第4乖坠、5行)。該參數越大仰迁,表明圓半徑縮減的越慢,能拖動的距離越長徐许;該參數越小,表明圓半徑縮減的越快翻默,能拖動的距離越短恰起。

該Demo中,初始半徑(RADIUS_INIT)為90(px)检盼,最小半徑(RADIUS_MIN)為2(px)。

2蹦渣、手勢操作

1貌亭、按壓:
(1) 初始化固定圓和拖拽圓
2、拖動:
(1)更新拖拽圓的圓心属提;
(2)算出圓心距離,更新兩圓半徑和貝塞爾曲線路徑斟薇;
(3)調用invalidate()觸發(fā)onDraw(),在其中畫圓和畫曲線堪滨;
3蕊温、抬手:
(1)固定圓半徑 >= 最小半徑,即拖動距離不足以拖斷义矛,則拖拽圓回彈;
(2)固定圓半徑 < 最小半徑了讨,即拖動距離足以拖斷,則拖拽圓消失前计;

關鍵Android代碼(Kotlin)如下:

因為是自定義View,所以需要該類(LiteDragView)需要繼承View丈屹,同時需要實現幾個構造方法(使用kotlin的特性真的會減少好多代碼):

class LiteDragView @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) 

接下去就要重寫onTouch方法伶棒,并返回true,表示消費此事件苞冯,不讓事件繼續(xù)往下傳遞了。

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        when (event?.action) {
            MotionEvent.ACTION_DOWN -> {
                // TODO: 初始化兩個圓的圓心坐標和半徑
            }
            MotionEvent.ACTION_MOVE -> {
                // TODO: 1舅锄、更新拖拽圓的圓心坐標(即手指在手機屏幕的位置)
                // TODO: 2、算出圓心距離畴蹭,并根據距離更新兩個圓的半徑
                // TODO: 3鳍烁、根據固定圓和拖拽圓的圓心和半徑,得到貝塞爾曲線的控制點(兩圓心中點)和節(jié)點(x1, y1)(x2, y2)(x3, y3)(x4, y4)幔荒,更新Path
                // TODO: 4、當固定圓半徑 < 最小半徑時右犹,表明拖拽距離夠長姚垃,此時只要畫出拖拽圓即可
                // TODO: 5、當固定圓半徑 >= 最小半徑時积糯,表明拖拽距離不夠長,此時畫出固定圓君编、拖拽圓和貝塞爾路徑
            }
            MotionEvent.ACTION_UP -> {
                // TODO: 1川慌、當固定圓半徑 < 最小半徑時偿荷,表明拖拽距離夠長唠椭,在拖拽圓處顯示爆炸動畫
                // TODO: 2贪嫂、當固定圓半徑 >= 最小半徑時艾蓝,表明拖拽距離不夠長,此時拖拽圓向固定圓直線移動(使用平動動畫的方式)
            }
        }
        return true
    }

四赢织、實現任意控件可拖動

上面實現了拖拽回彈或消失最核心的部分,現在要實現任意控件拖拽能出現上述效果茧吊,即圖1那樣的效果八毯。那么應該完成下面幾件事情:

  1. 我們構建一個LiteDragHelper類,并聲明bind方法讶踪,來作為任意控件實現拖拽的統一入口泊交,給該控件添加觸摸事件
object LiteDragHelper {
    @JvmStatic
    @JvmOverloads
    fun bind(context: Context, view: View, @ColorInt color: Int = Color.RED) {
        val liteDragView = LiteDragView(context) // 初始化LiteDragView
        liteDragView.setOrgView(view)
        liteDragView.getPaint().color = color
        liteDragView.getOrgView().setOnTouchListener { _, event ->
            when (event?.action) {
                MotionEvent.ACTION_DOWN -> {
                    liteDragView.handleActionDown(event)
                }
                MotionEvent.ACTION_MOVE -> {
                    liteDragView.handleActionMove(event)
                }
                MotionEvent.ACTION_UP -> {
                    liteDragView.handleActionUp(event)
                }
            }
            true
        }
    }
}

  1. 按壓:
    (1)初始化兩個圓的圓心和半徑廓俭;
    (2)獲得拖拽控件View的鏡像
    /**
     * 處理手勢按下操作
     */
    fun handleActionDown(event: MotionEvent) {
        mWindowManager.addView(this, mParams) // 添加LiteDragView
        val location = IntArray(2)
        mOrgView.getLocationInWindow(location) // 將mOrgView左上角的坐標存放在location中
        val c1x = location[0] + mOrgView.width / 2
        val c1y = location[1] + mOrgView.height / 2 - LiteDragUtils.getStatusBarHeight(mContext) // 圓心y坐標,要減去狀態(tài)欄的高度
        initCircle(c1x.toFloat(), c1y.toFloat())
        mDrgBmp = getViewBitmap(mOrgView)
    }
    /**
     * 初始化固定圓和拖拽圓
     *
     * @param x      x坐標
     * @param y      y坐標
     * @param radius 半徑
     */
    private fun initCircle(x: Float, y: Float, radius: Float = RADIUS_DEFAULT) {
        RADIUS_INIT = radius
        mFixCircle = Circle(PointF(x, y), RADIUS_INIT)
        mDrgCircle = Circle(PointF(x, y), RADIUS_INIT)
        invalidate()
    }
    /**
     * 獲取View的鏡像留晚,不能在OnCreate()調用告嘲,此時View還沒被測量,width和height均為0
     */
    private fun getViewBitmap(view: View): Bitmap {
        val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(bitmap)
        view.draw(canvas)
        return bitmap
    }

  1. 拖動:
    (1)原控件設為不可見赋焕;
    (2)更新拖拽圓的圓心仰楚;
    /**
     * 處理手勢移動操作
     */
    fun handleActionMove(event: MotionEvent) {
        if (mOrgView.visibility == View.VISIBLE) {
            mOrgView.visibility = View.INVISIBLE
        }
        updateDrgCircle(event.rawX, event.rawY - LiteDragUtils.getStatusBarHeight(mContext))
    }
    /**
     * 更新拖拽圓
     *
     * @param x 拖拽圓的圓心x坐標
     * @param y 拖拽圓的圓心y坐標
     */
    private fun updateDrgCircle(x: Float, y: Float) {
        mDrgCircle.point.set(x, y)
        invalidate() // 觸發(fā)onDraw()
    }
  1. 抬手:
    (1)固定圓半徑 >= 最小半徑犬庇,即拖動距離不足以拖斷侨嘀,則將原控件的鏡像移動回原控件位置;
    (2)固定圓半徑 < 最小半徑欢峰,即拖動距離足以拖斷涨共,則移除原控件的快照,并手指位置顯示爆炸效果
    /**
     * 處理手勢抬起操作
     */
    fun handleActionUp(event: MotionEvent) {
        if (mFixCircle.radius < RADIUS_MIN) { // 超過拖拽距離懊直,消失
            mDragListener.onBomb(mDrgCircle.point)
        } else { // 沒超過拖拽距離火鼻,回彈
            val animator = ObjectAnimator.ofFloat(0f, 1f)
            animator.duration = 300

            val startPoint = mDrgCircle.point
            val endPoint = mFixCircle.point

            animator.addUpdateListener { animation ->
                val percent = animation.animatedValue as Float
                val point = LiteDragUtils.getPointByPercent(startPoint, endPoint, percent)
                updateDrgCircle(point.x, point.y)
            }

            animator.interpolator = OvershootInterpolator(3f)
            animator.start()
            animator.addListener(object : AnimatorListenerAdapter() {
                override fun onAnimationEnd(animation: Animator) {
                    mIsEvenOverRange = false
                    mDragListener.onRestore() // 當動畫結束的時候,重新讓View可拖動
                }
            })
        }
    }
mDragListener = object : OnDragListener {
            override fun onBomb(pointF: PointF) {
                // 移除消息氣泡貝塞爾View,同時添加一個爆炸的View動畫(幀動畫)
                mWindowManager.removeView(this@LiteDragView)
                mBombView.setBackgroundResource(R.drawable.anim_bomb)
                val bombDrawable = mBombView.background as AnimationDrawable
                mBombView.x = pointF.x - bombDrawable.intrinsicWidth / 2
                mBombView.y = pointF.y - bombDrawable.intrinsicHeight / 2
                mWindowManager.addView(mBombLayout, mParams)
                bombDrawable.start()

                mBombView.postDelayed({
                    mWindowManager.removeView(mBombLayout) // 動畫執(zhí)行完畢,把爆炸布局及時從WindowManager移除
                }, 1000)
            }

            override fun onRestore() {
                mWindowManager.removeView(this@LiteDragView)
                mOrgView.visibility = View.VISIBLE
            }

        }

反思和展望

后面可能會加上固定圓的初始半徑和被拖拽控件成比例凝危,這樣避免“控件大,拖拽短”的情況懦铺。同時支鸡,這是我的第一篇簡書博文,說實話很緊張牧挣,若文中有任何錯誤,請各位大大批評指正裆针。

源碼地址:https://github.com/ilyle/LiteDragView

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末寺晌,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子耘婚,更是在濱河造成了極大的恐慌陆赋,老刑警劉巖嚷闭,帶你破解...
    沈念sama閱讀 222,183評論 6 516
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件胞锰,死亡現場離奇詭異兢榨,居然都是意外死亡,警方通過查閱死者的電腦和手機色乾,發(fā)現死者居然都...
    沈念sama閱讀 94,850評論 3 399
  • 文/潘曉璐 我一進店門暖璧,熙熙樓的掌柜王于貴愁眉苦臉地迎上來君旦,“玉大人,你說我怎么就攤上這事金砍。” “怎么了琅绅?”我有些...
    開封第一講書人閱讀 168,766評論 0 361
  • 文/不壞的土叔 我叫張陵鹅巍,是天一觀的道長骆捧。 經常有香客問我,道長敛苇,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,854評論 1 299
  • 正文 為了忘掉前任括饶,我火速辦了婚禮脓豪,結果婚禮上,老公的妹妹穿的比我還像新娘楞泼。我一直安慰自己,他們只是感情好堕阔,可當我...
    茶點故事閱讀 68,871評論 6 398
  • 文/花漫 我一把揭開白布超陆。 她就那樣靜靜地躺著,像睡著了一般时呀。 火紅的嫁衣襯著肌膚如雪谨娜。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,457評論 1 311
  • 那天趴梢,我揣著相機與錄音坞靶,去河邊找鬼。 笑死彰阴,一個胖子當著我的面吹牛,可吹牛的內容都是我干的廉丽。 我是一名探鬼主播妻味,決...
    沈念sama閱讀 40,999評論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼焦履!你這毒婦竟也來了雏逾?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 39,914評論 0 277
  • 序言:老撾萬榮一對情侶失蹤屑宠,失蹤者是張志新(化名)和其女友劉穎仇让,沒想到半個月后躺翻,有當地人在樹林里發(fā)現了一具尸體卫玖,經...
    沈念sama閱讀 46,465評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 38,543評論 3 342
  • 正文 我和宋清朗相戀三年陕靠,在試婚紗的時候發(fā)現自己被綠了脱茉。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,675評論 1 353
  • 序言:一個原本活蹦亂跳的男人離奇死亡粗俱,死狀恐怖虚吟,靈堂內的尸體忽然破棺而出签财,到底是詐尸還是另有隱情,我是刑警寧澤邦鲫,帶...
    沈念sama閱讀 36,354評論 5 351
  • 正文 年R本政府宣布神汹,位于F島的核電站,受9級特大地震影響滔以,放射性物質發(fā)生泄漏氓拼。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 42,029評論 3 335
  • 文/蒙蒙 一坏匪、第九天 我趴在偏房一處隱蔽的房頂上張望撬统。 院中可真熱鬧适滓,春花似錦恋追、人聲如沸罚屋。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,514評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至瞧柔,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間撼唾,已是汗流浹背哥蔚。 一陣腳步聲響...
    開封第一講書人閱讀 33,616評論 1 274
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留渤愁,地道東北人深夯。 一個月前我還...
    沈念sama閱讀 49,091評論 3 378
  • 正文 我出身青樓,卻偏偏與公主長得像雹拄,于是被迫代替她去往敵國和親掌呜。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 45,685評論 2 360

推薦閱讀更多精彩內容