注册

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

仿微信消息列表


前言


最近自己在利用空闲时间开发一个APP,目的是为了巩固所学的知识并扩展新知,加强对代码的理解扩展能力。消息模块是参照微信做的,一开始并没有准备做滑动删除的功能,觉得删除嘛,后面加个长按的监听不就行了,但是对于有些强迫症的我来说,是不大满意这种解决方法的,但由于我对自定义view的了解还是比较少,而且之前也没有做过,所以就作罢。上周看了任玉刚老师的《Android开发艺术探索》中的View事件体系章节,提起了兴趣,就想着试一试吧,反正弄不成功也没关系。最后弄成了,但还是有些小瑕疵(在6、问题中),希望大佬能够指教一二。话不多说,放上一张动图演示下:


messlist.gif


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认识了,小试牛刀,自己还是很满意这个学习成果的。希望在接下来的学习中不要因为没有接触过而放弃学习,勇于迈出第一步。文章若出现错误,欢迎各位批评指正,写文不易,转载请注明出处谢谢。


作者:遨游在代码海洋的鱼
来源:juejin.cn/post/7269590511095054395

0 个评论

要回复文章请先登录注册