噢天啊,这个命名糟透了
支持下拉刷新,且下拉时显示全屏。看下效果图吧。最后有完整代码
本来是丝滑般顺畅,这模拟器卡出翔了。。。。
下面说使用?
支持添加header和content
他们分别对应addHeaderView,addContentView。伪代码
val topPage = findViewById<TopPageLinearLayout>(R.id.topPage)
val headerView = layoutInflater.inflate(R.layout.hedaer_view, topPage, false)
val toolBar = layoutInflater.inflate(R.layout.content_toolbar, topPage, false)
val recyclerLayout = layoutInflater.inflate(R.layout.content_view, topPage, false)
topPage.addHeaderView(headerView)
topPage.addContentView(toolBar)
topPage.addContentView(recyclerLayout)
HeaderView不可以重复添加,因为它的直接父是一个FrameLayout
ContentView可以重复添加,因为它的直接父是一个垂直的LinearLayout
它们的直接父类都是全屏显示的。
下拉刷新
下拉刷新只需要添加刷新监听即可,伪代码
topPage.addRefreshListener(object :TopPageLinearLayout.AddRefreshListener{
override fun refresh() {
GlobalScope.launch(Dispatchers.Main) {
delay(2000)
topPage.setRefresh(false)
}
}
})
这里模拟了2秒耗时请求,只要在成功或失败后调用setRefresh(false)即可。
下面说代码
TopPageLinearLayout继承自LinearLayout,如果有时间的话继承ViewGroup更好,可以做的比这个体验好很多,当然这个也可以做更好的优化。回头有时间整个2.0
主要关注点:
移动使用的是属性动画
有动画插值器,没提供外部方法,你可以看下代码直接改成直接想要的
下拉刷新的阈值是可以设置,目前是headerView高度的5/1,当然也就是屏幕的
关闭header的阈值也是5/1
contentView可以是任意view,但最好是recyclerView,这样便于获取滑动到顶点的状态,如果你非要在
LinearLayout或其他viewGroup中嵌套recyclerView,那你只需要在外部通过id获取到recyclerView并调
用TopPageLinearLayout的addRecyclerView即可。
下拉刷新默认不开启,开启请添加监听器addRefreshListener
下拉刷新的view就是一个TextView,如果觉得简单,可以替换,但只能自己改代码
当然还有一些其他的public方法,自己看一下吧,都有注释
代码
没有使用任何第三方库,复制应该不会报错
/**
* todo 问题:
* 1.显示在headerView的时候不应该再接收下拉事件 此时只处理上拉 √
* 3.现在内容区域使用recyclerView做的,可以解决滑动冲突的问题,但是用户可能是任意view 比如LinearLayout嵌套recyclerView ×
* 4.如果第一个item被隐藏或者没有完全显示到顶部 无法显示header 这不科学 监听recyclerView的onTouchEvent? 计算偏移量? 就不消费事件了? ×
* 5.已经位于顶部时 x轴往左滑动下面显示出背景? bug √
* 6.最好的用法是传入唯一子recyclerView 如果需要导航栏跟随内容区域滑动,可以将activity的theme改为noActionBar,再使用toolbar等控件addContentView
* 最后addRecyclerView,但是注意他们不会跟随recyclerView滑动,不要想着在LinearLayout里嵌入RecyclerView来用,这个暂不支持 √
* 7.如果不使用列表,比如传入某个view或viewGroup也支持,但是他们不能嵌套滑动列表,如recyclerView,ListView等。参加第3条 √
* 9.直接设置的屏幕高度导致子view内容现在屏幕外 可能是状态的高度导致的? √
* 8.支持下拉刷新,当滑动的距离达到下拉刷新阈值时应该显示一个view,比如文本,提示:松开刷新 这个view最好支持外部传入,比如外部自定义动画? √
*
*
*
* 1.先Gone掉headerView 这样默认显示的就是contentView
* 2.在Layout调用后,已获取到高度 获取view的高度并赋值
* 3.如果显示view后matchparent导致view丢失 考虑修改view的高度为测量后高度
*/
/**
*Created by chen on 2020
*/
open class TopPageLinearLayout : LinearLayout {
/**
* 表示当前显示的View contentView or headerView? 在滑动结束时赋值
*/
private enum class TargetView {
HEADER_VIEW,//headerView
CONTENT_VIEW//contentView
}
//headerView
private var headerView: FrameLayout? = null
//contentView
private var contentView: LinearLayout? = null
//当前显示的View contentView or headerView?
private var targetView: TargetView = TargetView.CONTENT_VIEW
//屏幕高度
private var heightPixels = -3
private var findY = 0.0f //up时获取手指滑动的总距离
// 打开header阈值 屏幕高度 / threshold = 阈值
private val OPEN_THRESHOLD = 5
// 关闭header阈值 屏幕高度 / threshold = 阈值
private val CLOSE_THRESHOLD = 5
/**
* down时记录的y点击位置
*/
private var interceptStartY = 0f
/**
* move时记录y移动位置 包括up时都需要使用到该坐标
*/
private var interceptEndY = 0f
/**
* contentView的子视图 @see [addContentView]的函数代码中可以看到 只有参数是recyclerView时才能被赋值
* 使用它的好处是,通过它的一些支持方法 当它滑动到顶点时
* 可以获取其已经处于顶点的状态,这样可以支持下拉显示headerView,当然recyclerView不是一定要作为唯一的子
* 但是调用 @see [addContentView] 时最好作为最后一个view添加,当recyclerView的列表滑动到顶点时,整个contentView中
* 的所有子view都会跟随滑动,比如你需要添加一个toolbar?
* topPage.addContentView(toolBar)
* topPage.addContentView(recyclerLayout)
* 这样添加后不会影响recyclerView的列表滑动,且当滑动到定点时 toolBar和recyclerLayout都会参与滑动
*
*
* 为了应对一些特殊的场景,比如recyclerView一定被嵌套,也可以定义类继承TopPageLinearLayout,添加一个
* 方法,只要将RecyclerView对象赋值给@see [recyclerView] ,这样一样可以做到在滑动到顶点时继续滑动header
*/
protected var recyclerView: View? = null
private val TAG = "TopPageLinearLayout"
/**
* 下拉刷新的view
*/
private var refreshView: TextView? = null
/**
* 下拉刷新的回调接口
*/
private var refreshListener: AddRefreshListener? = null
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defAttr: Int) : super(
context,
attrs,
defAttr
) {
this.orientation = VERTICAL
//添加一个headerView
headerView = FrameLayout(this.context)
val layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT)
headerView?.layoutParams = layoutParams
headerView?.visibility = View.GONE
//添加下拉刷新View
refreshView = TextView(context)
refreshView!!.text = "下拉刷新"
refreshView!!.setTextColor(Color.parseColor("#000000"))
refreshView!!.tag = false
refreshView!!.gravity = Gravity.CENTER or Gravity.BOTTOM
val refreshViewLayoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
headerView!!.addView(refreshView)
//下拉刷新的View处于屏幕低端
refreshView!!.setPadding(0, 100, 0, 100)
refreshViewLayoutParams.gravity = Gravity.BOTTOM
refreshView!!.layoutParams = refreshViewLayoutParams
//添加header
addView(headerView)
//添加内容View
contentView = LinearLayout(this.context)
contentView?.orientation = VERTICAL
val contentLayoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT)
contentView?.layoutParams = contentLayoutParams
//添加header
addView(contentView)
}
/**
* 手指往上滑动是负数
* 手指往下滑动是正数
*/
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
var intercept = false
when (event.action) {
MotionEvent.ACTION_DOWN -> {
interceptStartY = event.y
intercept = false
}
MotionEvent.ACTION_MOVE -> {
interceptEndY = event.y
val y = interceptEndY - interceptStartY
// y<0 表示上拉 此时y为负值
if (y < 0) {
//显示内容往下
intercept = false
// y>0表示是下拉操作 canChildScrollUp表示是否可以继续下拉
} else if (y > 0 && !canChildScrollUp(-1)) {
//已经到顶部 此时需要拦截事件
Log.d(TAG, "已经到顶部 此时需要拦截MOVE事件")
intercept = true
}
}
MotionEvent.ACTION_UP -> {
intercept = false
}
}
return intercept
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
}
MotionEvent.ACTION_MOVE -> {
interceptEndY = event.y
findY = interceptEndY - interceptStartY
//>0表示是向下滑动 此时y轴为正值 2.必须当前View是contentView
if (findY > 0 && targetView == TargetView.CONTENT_VIEW) { //向header滑动
val move = heightPixels - findY
translate(-move)
//下拉刷新的逻辑
if (refreshListener != null) {//当添加了下拉刷新的监听时 才处理这部分逻辑
//先获取打开header的阈值 超过这个阈值header将被打开
val closeThreshold = heightPixels / CLOSE_THRESHOLD
val refreshViewHeight = refreshView!!.height
if (findY > closeThreshold) { //当阈值达到开启header时 隐藏下拉刷新
refreshView?.visibility = View.INVISIBLE
refreshView?.tag = false
} else if (findY < refreshViewHeight) {//当滑动的距离不足刷新view的高度时 只显示下拉刷新
refreshView?.text = "下拉刷新"
refreshView?.visibility = View.VISIBLE
refreshView?.tag = false
} else if (findY > refreshViewHeight) {
//当滑动的距离超过刷新view的高度时 显示松开立即刷新 应该在此处记录tag
//当up时判断tag 开启刷新
refreshView?.visibility = View.VISIBLE
refreshView?.text = "松开立即刷新"
refreshView?.tag = true
}
}
} else if (targetView == TargetView.HEADER_VIEW && findY < 0) {//向content滑动 <0表示是向上滑动 此时y轴为负值
translate(findY)
}
}
MotionEvent.ACTION_UP -> {
//只有当View是contentView时才需要处理下面的事件
if (targetView == TargetView.CONTENT_VIEW) {
//up的时候需要判断当前滑动的距离 如果超过滑动的阈值 显示内容View或者显示headerView
//findY是手指从按下-滑动-抬起的总距离 因为headerView是全屏的,屏幕高度,所以通常这个阈值可能是屏幕的一半或其他值
if (findY > (heightPixels / OPEN_THRESHOLD)) {
//显示headerView
targetView = TargetView.HEADER_VIEW
Log.d(TAG, "CONTENT_VIEW:已经移动超过一半了")
translateTime(0f)
} else {
targetView = TargetView.CONTENT_VIEW
//显示contentView
//调用此处说明没有滑动超过设置的阈值 此处判断用户启用了下拉刷新
if (refreshListener !=null && refreshView?.tag == true) {
//满足开启下拉刷新的条件
Log.d(TAG, "满足开启下拉刷新的条件")
refreshView?.text = "玩命加载中..."
refreshListener?.refresh()
} else {
Log.d(TAG, "不满足开启下拉刷新的条件")
Log.d(TAG, "CONTENT_VIEW:还没有移动超过一半")
translateTime(-heightPixels.toFloat())
}
}
} else if (targetView == TargetView.HEADER_VIEW && findY < 0) { //处理关闭header方向的滑动结束
if (-findY > (heightPixels / CLOSE_THRESHOLD)) {
Log.d(TAG, "HEADER_VIEW:已经移动超过一半了")
targetView = TargetView.CONTENT_VIEW
//显示contentView
translateTime(-heightPixels.toFloat())
} else {
Log.d(TAG, "HEADER_VIEW:还没有移动超过一半")
//显示headerView
targetView = TargetView.HEADER_VIEW
translateTime(0f)
}
}
}
}
return super.onTouchEvent(event)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
super.onLayout(changed, l, t, r, b)
if (heightPixels == -3) { //设置Match-Parent会导致另一个View无法显示,而设置高度则可以 所以在测量后获取view的高度赋值
headerView?.visibility = View.VISIBLE
heightPixels = contentView!!.height
val layoutParams = headerView!!.layoutParams
layoutParams.height = heightPixels
val layoutParams1 = contentView!!.layoutParams
layoutParams1.height = heightPixels
translate(-heightPixels.toFloat())
Log.d(TAG, "onLayout-heightPixels:$heightPixels")
}
}
/**
* 无时间移动动画跟随移动
* 0表示原点
* 负数往上移动
* 正数往下移动
* 0回到原点
*
* 0表示原点
*
* 单参数时每次移动基于当前位置
*/
private fun translate(to: Float) {
if (headerView != null && contentView != null) {
val header = ObjectAnimator.ofFloat(headerView!!, "translationY", to)
val content = ObjectAnimator.ofFloat(contentView!!, "translationY", to)
val animatorSet = AnimatorSet()
animatorSet.duration = 0
//同时执行
animatorSet.playTogether(header, content)
animatorSet.start()
}
}
/**
* 有插值器效果的移动结束动画
* 0表示原点
* 负数往上移动
* 正数往下移动
* 0回到原点
*
* 0表示原点
*
* 单参数时每次移动基于当前位置
*/
private fun translateTime(to: Float) {
if (headerView != null && contentView != null) {
val header = ObjectAnimator.ofFloat(headerView!!, "translationY", to)
val content = ObjectAnimator.ofFloat(contentView!!, "translationY", to)
val animatorSet = AnimatorSet()
val interpolator = BounceInterpolator()
animatorSet.duration = 600
//同时执行
animatorSet.interpolator = interpolator
animatorSet.playTogether(header, content)
animatorSet.start()
}
}
/**
* @return Whether it is possible for the child view of this layout to
* scroll up. Override this if the child view is a custom view.
* 负数查询是否可以下拉 正数查询是否可以上拉
*/
private fun canChildScrollUp(direction: Int): Boolean {
return recyclerView?.canScrollVertically(direction) ?: false
}
/**
* 添加一个headerView
*/
fun addHeaderView(@NonNull view: View) {
//添加一个headerView
headerView?.addView(view)
}
/**
* 添加一个contentView
*/
fun addContentView(@NonNull view: View) {
if (view is RecyclerView) {
recyclerView = view
}
//添加一个headerView
contentView?.addView(view)
}
/**
* 添加一个刷新view
*/
fun addRefreshView(@NonNull view: View) {
headerView?.addView(view)
}
/**
* 给headerView设置背景颜色 当然完全可以通过传入的全屏headerView自己设置颜色
* 如果设置的颜色无效 则查看是否是传入的全屏headerView挡住了父的背景
*/
fun setHeaderViewBackgroundColor(@NonNull colorString: String) {
headerView?.setBackgroundColor(Color.parseColor(colorString))
}
/**
* 给contentView设置背景颜色
*/
fun setContentViewBackgroundColor(@NonNull colorString: String) {
contentView?.setBackgroundColor(Color.parseColor(colorString))
}
/**
* 当你的recyclerView不是顶级view时,你需要将你的recyclerView传递给我,便于我获取到滑动到顶点的状态
*
*/
fun addRecyclerView(recyclerView: RecyclerView) {
this.recyclerView = recyclerView
}
/**
* 开启/关闭 下拉刷新
*
* 开启指的是开启下拉刷新的功能
* 关闭指的是关闭正在刷新的view界面
*/
fun setRefresh(refresh: Boolean) {
//当为false时需要 判断当前是否处于刷新状态 如果是则需要关闭刷新页面
if (!refresh) {
val tag = refreshView?.tag ?: false
if (tag == true) {
refreshView?.tag = false
translateTime(-heightPixels.toFloat())
}
}
}
/**
* 添加下拉刷新的监听
*/
fun addRefreshListener(listener: AddRefreshListener) {
refreshListener = listener
}
interface AddRefreshListener {
fun refresh()
}
}
参考资料:SwipeRefreshLayout