仿微信音讯列表

前语

最近自己在运用闲暇时刻开发一个APP,意图是为了巩固所学的知识并扩展新知,加强对代码的了解扩展才能。音讯模块是参照微信做的,一开始并没有预备做滑动删去的功用,觉得删去嘛,后边加个长按的监听不就行了,但是关于有些强迫症的我来说,是不大满足这种处理办法的,但因为我对自定义view的了解仍是比较少,并且之前也没有做过,所以就作罢。上周看了任玉刚教师的《Android开发艺术探究》中的View事情体系章节,提起了兴趣,就想着试一试吧,横竖弄不成功也不要紧。最终弄成了,但仍是有些小瑕疵(在6、问题中),期望大佬可以指教一二。话不多说,放上一张动图演示下:

仿微信列表左滑删除、置顶。。

1、典型的事情类型

在附上源码之前,想先向我们介绍下事情类型,在手指触摸屏暗地所产生的一系列事情中,典型的事情类型有如下几种:

  • ACTION_DOWN —- 手指刚触摸屏幕
  • ACTION_MOVE —- 手指在屏幕上移动
  • ACTION_UP —- 手指刚脱离屏幕

正常情况下、一次手指触摸屏幕的行为会触发一系列点击事情:

  • 点击屏暗地松开,事情序列为DOWN -> UP
  • 点击屏幕滑动后松开,事情序列为DOWN -> MOVE -> … -> MOVE -> UP

2、Scroller

Scroller – 弹性滑动目标,用于完结View的弹性滑动。 当运用View的scrollTo/scrollBy办法来完结滑动时,其进程是在瞬间完结的,这个进程没有过渡作用,用户体验感较差,这个时候就可以运用Scroller来完结有过渡作用的滑动,其进程不是瞬间完结的,而是在一定时刻间隔内完结的。

3、View的滑动

Android手机因为屏幕较小,为了给用户呈现更多的内容,就需求运用滑动来显现和隐藏一些内容,不管滑动作用多么绚丽,它们都是由不同的滑动外加特效完结的。View的滑动可以通过三种办法完结:

  • scrollTo/scrollBy:操作简单,合适对View内容的滑动。
  • 修正布局参数:操作略微杂乱,合适有交互的View。
  • 动画:操作简单,合适没有交互的View和完结杂乱的动画作用。

3.1、scrollTo/scrollBy

为了完结View的滑动,View提供了专门的办法来完结这一功用,也便是scrollTo/scrollBy。是基于所传参数的肯定滑动。

3.2、修正布局参数

即改变LayoutParams,比方想把一个布局向右平移100px,只需求将该布局LayoutParams中的marginLeft参数值增加100px即可。或者在该布局左面放入一个默许宽度为0px的空View,当需求向右平移时,从头设置空View的宽度就OK了。

3.3、动画

动画和Scroller相同具有过渡作用,View动画是对View的印象做操作,并不能真正改变View的方位,单击新方位无法触发onClick事情,在这篇文章中并没有运用到,所以不再赘叙了。

4、布局文件

<?xml version="1.0" encoding="utf-8"?>
### <LinearLayout 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"
    xmlns:widget="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">
    <com.example.myapplication.view.ScrollerLinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">
        <RelativeLayout
            android:id="@+id/friend_item"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:paddingHorizontal="16dp"
            android:paddingVertical="10dp">
            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal">
                <com.makeramen.roundedimageview.RoundedImageView
                    android:id="@+id/friend_icon"
                    android:layout_width="45dp"
                    android:layout_height="45dp"
                    android:src="@mipmap/touxiang"
                    app:riv_corner_radius="5dp" />
                <LinearLayout
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:layout_gravity="center"
                    android:layout_marginLeft="12dp"
                    android:gravity="center_vertical"
                    android:orientation="vertical">
                    <TextView
                        android:id="@+id/friend_name"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:singleLine="true"
                        android:textColor="@color/black"
                        android:textSize="15dp"
                        tools:text="老友名" />
                    <TextView
                        android:id="@+id/friend_last_mess"
                        android:layout_width="match_parent"
                        android:layout_height="wrap_content"
                        android:layout_marginTop="3dp"
                        android:layout_marginEnd="18dp"
                        android:singleLine="true"
                        android:textColor="@color/color_dbdbdb"
                        android:textSize="12dp"
                        tools:text="最终一条信息内容" />
                </LinearLayout>
            </LinearLayout>
            <TextView
                android:id="@+id/last_mess_time"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_alignParentEnd="true"
                android:layout_marginTop="5dp"
                android:singleLine="true"
                android:textColor="@color/color_dbdbdb"
                android:textSize="11dp"
                tools:text="时刻" />
        </RelativeLayout>
        <LinearLayout
            android:layout_width="240dp"
            android:layout_height="match_parent"
            android:orientation="horizontal">
            <Button
                android:id="@+id/unread_item"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:clickable="true"
                android:background="@color/color_theme"
                android:gravity="center"
                android:text="标为未读"
                android:textColor="@color/color_FFFFFF" />
            <Button
                android:id="@+id/top_item"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:clickable="true"
                android:background="@color/color_orange"
                android:gravity="center"
                android:text="置顶"
                android:textColor="@color/color_FFFFFF" />
            <Button
                android:id="@+id/delete_item"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:clickable="true"
                android:background="@color/color_red"
                android:gravity="center"
                android:text="删去"
                android:textColor="@color/color_FFFFFF" />
        </LinearLayout>
    </com.example.myapplication.view.ScrollerLinearLayout>
    <View
        android:layout_width="match_parent"
        android:layout_height="1px"
        android:layout_alignParentBottom="true"
        android:layout_marginLeft="60dp"
        android:layout_marginRight="3dp"
        android:background="@color/color_e7e7e7" />
</LinearLayout>

ScrollerLinearLayout布局最多包含两个子布局(默许是这样,后边可能还会修正成自定义),一个是展示在用户面前充满屏幕宽度的布局,一个是待打开的布局,在该xml布局中,ScrollerLinearLayout布局包含了一个RelativeLayout和一个LinearLayoutLinearLayout中包含了三个按钮,分别是删去、置顶、标为未读。

5、自定义View-ScrollerLinearLayout

/**
 * @Copyright : China Telecom Quantum Technology Co.,Ltd
 * @ProjectName : My Application
 * @Package : com.example.myapplication.view
 * @ClassName : ScrollerLinearLayout
 * @Description : 文件描述
 * @Author : yulu
 * @CreateDate : 2023/8/17 17:05
 * @UpdateUser : yulu
 * @UpdateDate : 2023/8/17 17:05
 * @UpdateRemark : 更新阐明
 */
class ScrollerLinearLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) :
    LinearLayout(context, attrs, defStyleAttr) {
    private val mScroller = Scroller(context)  // 用于完结View的弹性滑动
    private val mTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
    private var mVelocityTracker: VelocityTracker? = null   // 速度追踪
    private var intercept = false   // 阻拦状况 初始值为不阻拦
    private var lastX: Float = 0f
    private var lastY: Float = 0f  // 用来记载手指按下的初始坐标
    var expandWidth = 720   // View待打开的布局宽度 需求手动设置 3*dp
    private var expandState = false   // View的打开状况
    private val displayWidth =
        context.applicationContext.resources.displayMetrics.widthPixels  // 屏幕宽度
    private var state = true
    override fun onTouchEvent(event: MotionEvent): Boolean {
        Log.e(TAG, "onTouchEvent $event")
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                if (!expandState) {
                    state = false
                }
            }
            else -> {
                state = true
            }
        }
        return state
    }
    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        Log.e(TAG, "onInterceptTouchEvent Result : ${onInterceptTouchEvent(ev)}")
        Log.e(TAG, "dispatchTouchEvent : $ev")
        mVelocityTracker = VelocityTracker.obtain()
        mVelocityTracker!!.addMovement(ev)
        return super.dispatchTouchEvent(ev)
    }
    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        Log.e(TAG, "onInterceptTouchEvent $ev")
        when (ev?.action) {
            MotionEvent.ACTION_DOWN -> {
                lastX = ev.rawX
                lastY = ev.rawY
                // 处于打开状况且点击的方位不在扩展布局中 阻拦点击事情
                intercept = expandState && ev.x < (displayWidth - expandWidth)
            }
            MotionEvent.ACTION_MOVE -> {
                // 当滑动的间隔超越10 阻拦点击事情
                intercept = lastX - ev.x > 10
                moveWithFinger(ev)
            }
            MotionEvent.ACTION_UP -> {
                // 判断滑动间隔是否超越布局的1/2
                chargeToRightPlace(ev)
                intercept = false
            }
            MotionEvent.ACTION_CANCEL -> {
                chargeToRightPlace(ev)
                intercept = false
            }
            else -> intercept = false
        }
        return intercept
    }
    /**
     * 将布局修正到正确的方位
     */
    private fun chargeToRightPlace(ev: MotionEvent) {
        val eventX = ev.x - lastX
        Log.e(TAG, "该事情滑动的水平间隔 $eventX")
        if (eventX < -(expandWidth / 4)) {
            smoothScrollTo(expandWidth, 0)
            expandState = true
            invalidate()
        } else {
            expandState = false
            smoothScrollTo(0, 0)
            invalidate()
        }
        // 收回内存
        mVelocityTracker?.apply {
            clear()
            recycle()
        }
        //清除状况
        lastX = 0f
        invalidate()
    }
    /**
     * 跟从手指移动
     */
    private fun moveWithFinger(event: MotionEvent) {
        //取得手指在水平方向上的坐标变化
        // 需求滑动的像素
        val mX = lastX - event.x
        if (mX > 0 && mX < expandWidth) {
            scrollTo(mX.toInt(), 0)
        }
        // 获取当前水平方向的滑动速度
        mVelocityTracker!!.computeCurrentVelocity(500)
        val xVelocity = mVelocityTracker!!.xVelocity.toInt()
        invalidate()
    }
    /**
     * 缓慢滚动到指定方位
     */
    private fun smoothScrollTo(destX: Int, destY: Int) {
        val delta = destX - scrollX
        // 在多少ms内滑向destX
        mScroller.startScroll(scrollX, 0, delta, 0, 600)
        invalidate()
        translationY = 0f
    }
    // 流畅地滑动
    override fun computeScroll() {
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.currX, mScroller.currY);
            postInvalidate()
        }
    }
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        expandWidth = childViewWidth()
        invalidate()
        super.onLayout(changed, l, t, r, b)
    }
    /**
     * 最多只允许有两个子布局
     */
    private fun childViewWidth(): Int {
        Log.e(TAG, "childCount ${this.childCount}")
        return if (this.childCount > 1) {
            val expandChild = this.getChildAt(1) as LinearLayout
            if (expandChild.measuredWidth != 0){
                expandWidth = expandChild.measuredWidth
            }
            Log.e(TAG, "expandWidth $expandWidth")
            expandWidth
        } else
            0
    }
    companion object {
        const val TAG = "ScrollerLinearLayout_YOLO"
    }
}

思路比较简单,便是在ACTION_DOWN时记载初始的横坐标,在ACTION_MOVE中判断是否需求阻拦该事情, 当滑动的间隔超越10,阻拦该点击事情,避免不必要的点击。并且View跟从手指移动。在ACTION_UPACTION_CANCEL中将布局修正到正确的方位,主要是根据滑动的间隔来判断是否要打开并记载打开的状况。在ACTION_DOWN中判断是否处于打开状况,如果在打开状况且点击的方位不在扩展布局中,阻拦点击事情,避免不必要的点击。

6、问题

自定义布局中的expandWidth参数在childViewWidth()办法和onLayout()办法中都赋值了一次,在onLayout()办法中检查日志expandWidth是有值的,可是在moveWithFinger()办法中打日志检查得到的expandWidth参数值仍然是0,导致无法正常滑动。去到其他的页面再返回到音讯界面就可以正常滑动了,再次检查日志参数也有值了,这个问题不知道如何处理,所以需求手动设置expandWidth的值。

7、小结

初步的和自定义View认识了,小试牛刀,自己仍是很满足这个学习效果的。期望在接下来的学习中不要因为没有触摸过而抛弃学习,勇于迈出第一步。文章若呈现错误,欢迎各位批评指正,写文不易,转载请注明出处谢谢。