Android自定义view实现有header和footer作为layout使用的滚动控件
作者:捡一晌贪欢 发布时间:2023-07-31 19:29:54
前言
上两篇文章对安卓自定义view的事件分发做了一些应用,但是对于自定义view来讲,并不仅仅是事件分发这么简单,还有一个很重要的内容就是view的绘制流程。接下来我这通过带header和footer的Layout,来学习一下ViewGroup的自定义流程,并对其中的MeasureSpec、onMeasure以及onLayout加深理解。
需求
这里就是一个有header和footer的滚动控件,可以在XML中当Layout使用,核心思想如下:
1、由header、XML内容、footer三部分组成
2、滚动中间控件时,上面有内容时header不显示,下面有内容时footer不显示
3、滑动到header和footer最大值时不能滑动,释放的时候需要回弹
4、完全显示时隐藏footer
编写代码
编写代码这部分还真让我头疼了一会,主要就是MeasureSpec的运用,如何让控件能够超出给定的高度,如何获得实际高度和控件高度,真是纸上得来终觉浅,绝知此事要躬行,看书那么多遍,实际叫自己写起来真的费劲,不过最终写完,才真的敢说自己对measure和layout有一定了解了。
先看代码,再讲问题吧!
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import android.util.TypedValue
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.Scroller
import android.widget.TextView
import androidx.core.view.forEach
import kotlin.math.min
/**
* 有header和footer的滚动控件
* 核心思想:
* 1、由header、container、footer三部分组成
* 2、滚动中间控件时,上面有内容时header不显示,下面有内容时footer不显示
* 3、滑动到header和footer最大值时不能滑动,释放的时候需要回弹
* 4、完全显示时隐藏footer
*/
@SuppressLint("SetTextI18n", "ViewConstructor")
class HeaderFooterView @JvmOverloads constructor(
context: Context,
attributeSet: AttributeSet? = null,
defStyleAttr: Int = 0,
var header: View? = null,
var footer: View? = null
): ViewGroup(context, attributeSet, defStyleAttr){
var onReachHeadListener: OnReachHeadListener? = null
var onReachFootListener: OnReachFootListener? = null
//上次事件的横坐标
private var mLastY = 0f
//总高度
private var totalHeight = 0
//是否全部显示
private var isAllDisplay = false
//流畅滑动
private var mScroller = Scroller(context)
init {
//设置默认的Header、Footer,这里是从构造来的,如果外部设置需要另外处理
header = header ?: makeTextView(context, "Header")
footer = footer ?: makeTextView(context, "Footer")
//添加对应控件
addView(header, 0)
//这里还没有加入XML中的控件
//Log.e("TAG", "init: childCount=$childCount", )
addView(footer, 1)
}
//创建默认的Header\Footer
private fun makeTextView(context: Context, textStr: String): TextView {
return TextView(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp2px(context, 30f))
text = textStr
gravity = Gravity.CENTER
textSize = sp2px(context, 13f).toFloat()
setBackgroundColor(Color.GRAY)
//不设置isClickable的话,点击该TextView会导致mFirstTouchTarget为null,
//致使onInterceptTouchEvent不会被调用,只有ACTION_DOWN能被收到,其他事件都没有
//因为事件序列中ACTION_DOWN没有被消耗(返回true),整个事件序列被丢弃了
//如果XML内是TextView也会造成同样情况,
isFocusable = true
isClickable = true
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//父容器给当前控件的宽高,默认值尽量设大一点
val width = getSizeFromMeasureSpec(1080, widthMeasureSpec)
val height = getSizeFromMeasureSpec(2160, heightMeasureSpec)
//对子控件进行测量
forEach { child ->
//宽度给定最大值
val childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.AT_MOST)
//高度不限定
val childHeightMeasureSpec
= MeasureSpec.makeMeasureSpec(height, MeasureSpec.UNSPECIFIED)
//进行测量,不测量的话measuredWidth和measuredHeight会为0
child.measure(childWidthMeasureSpec, childHeightMeasureSpec)
//Log.e("TAG", "onMeasure: child.measuredWidth=${child.measuredWidth}")
//Log.e("TAG", "onLayout: child.measuredHeight=${child.measuredHeight}")
}
//设置测量高度为父容器最大宽高
setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec),
MeasureSpec.getSize(heightMeasureSpec))
}
private fun getSizeFromMeasureSpec(defaultSize: Int, measureSpec: Int): Int {
//获取MeasureSpec内模式和尺寸
val mod = MeasureSpec.getMode(measureSpec)
val size = MeasureSpec.getSize(measureSpec)
return when (mod) {
MeasureSpec.EXACTLY -> size
MeasureSpec.AT_MOST -> min(defaultSize, size)
else -> defaultSize //MeasureSpec.UNSPECIFIED
}
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
var curHeight = 0
//Log.e("TAG", "onLayout: childCount=${childCount}")
forEach { child ->
//footer最后处理
if (indexOfChild(child) != 1) {
//Log.e("TAG", "onLayout: child.measuredHeight=${child.measuredHeight}")
child.layout(left, top + curHeight, right,
top + curHeight + child.measuredHeight)
curHeight += child.measuredHeight
}
}
//处理footer
val footer = getChildAt(1)
//完全显示内容时不加载footer,header不算入内容
if (measuredHeight < curHeight - header!!.height) {
//设置全部显示flag
isAllDisplay = false
footer.layout(left, top + curHeight, right,top + curHeight + footer.measuredHeight)
curHeight += footer.measuredHeight
}
//布局完成,滚动一段距离,隐藏header
scrollBy(0, header!!.height)
//设置总高度
totalHeight = curHeight
}
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
//Log.e("TAG", "onInterceptTouchEvent: ev=$ev")
ev?.let {
when(ev.action) {
MotionEvent.ACTION_DOWN -> mLastY = ev.y
MotionEvent.ACTION_MOVE -> return true
}
}
return super.onInterceptTouchEvent(ev)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(ev: MotionEvent?): Boolean {
//Log.e("TAG", "onTouchEvent: height=$height, measuredHeight=$measuredHeight")
ev?.let {
when(ev.action) {
MotionEvent.ACTION_MOVE -> moveView(ev)
MotionEvent.ACTION_UP -> stopMove()
}
}
return super.onTouchEvent(ev)
}
private fun moveView(e: MotionEvent) {
//Log.e("TAG", "moveView: height=$height, measuredHeight=$measuredHeight")
val dy = mLastY - e.y
//更新点击的纵坐标
mLastY = e.y
//纵坐标的可滑动范围,0 到 隐藏部分高度,全部显示内容时是header高度
val scrollMax = if (isAllDisplay) {
header!!.height
}else {
totalHeight - height
}
//限定滚动范围
if ((scrollY + dy) <= scrollMax && (scrollY + dy) >= 0) {
//触发移动
scrollBy(0, dy.toInt())
}
}
private fun stopMove() {
//Log.e("TAG", "stopMove: height=$height, measuredHeight=$measuredHeight")
//如果滑动到显示了header,就通过动画隐藏header,并触发到达顶部回调
if (scrollY < header!!.height) {
mScroller.startScroll(0, scrollY, 0, header!!.height - scrollY)
onReachHeadListener?.onReachHead()
}else if(!isAllDisplay && scrollY > (totalHeight - height - footer!!.height)) {
//如果滑动到显示了footer,就通过动画隐藏footer,并触发到达底部回调
mScroller.startScroll(0, scrollY,0,
(totalHeight - height- footer!!.height) - scrollY)
onReachFootListener?.onReachFoot()
}
invalidate()
}
//流畅地滑动
override fun computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.currX, mScroller.currY)
postInvalidate()
}
}
//单位转换
@Suppress("SameParameterValue")
private fun dp2px(context: Context, dpVal: Float): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, dpVal, context.resources
.displayMetrics
).toInt()
}
@Suppress("SameParameterValue")
private fun sp2px(context: Context, spVal: Float): Int {
val fontScale = context.resources.displayMetrics.scaledDensity
return (spVal * fontScale + 0.5f).toInt()
}
interface OnReachHeadListener{
fun onReachHead()
}
interface OnReachFootListener{
fun onReachFoot()
}
}
主要问题
父容器给当前控件的宽高
这里就是MeasureSpec的理解了,onMeasure中给了两个参数:widthMeasureSpec和heightMeasureSpec,里面包含了父控件给当前控件的宽高,根据模式的不同可以取出给的数值,根据需要设定自身的宽高,需要注意setMeasuredDimension函数设定后,measuredWidth和measuredHeight才有值。
对子控件进行测量
这里很容易忽略的是,当继承viewgroup的时候,我们要手动去调用child的measure函数,去测量child的宽高。一开始我也没注意到,当我继承LineaLayout的时候是没问题的,后面改成viewgroup后就出问题了,看了下LineaLayout的源码,里面的onMeasure函数中实现了对child的测量。
对子控件的测量时,MeasureSpec又有用了,比如说我们希望XML中的内容不限高度或者高度很大,这时候MeasureSpec.UNSPECIFIED就有用了,而宽度我们希望最大就是控件宽度,就可以给个MeasureSpec.AT_MOST,注意我们给子控件的MeasureSpec也是有两部分的,需要通过makeMeasureSpec创建。
子控件的摆放
由于我们的footer和header是在构造里面创建并添加到控件中的,这时候XML内的view还没加进来,所以需要注意下footer实际在控件中是第二个,摆放的时候根据index要特殊处理一下。
其他控件我们根据左上右下的顺序摆放就行了,注意onMeasure总对子控件measure了才有宽高。
控件总高度和控件高度
因为需求,我们的控件要求是中间可以滚动,所以在onMeasure总,我们用到了MeasureSpec.UNSPECIFIED,这时候控件的高度和实际总高度就不一致了。这里我们需要在onLayout中累加到来,实际摆放控件的时候也要用到这个高度,顺势而为了。
header和footer的初始化显示与隐藏
这里希望在开始的时候隐藏header,所以需要在onLayout完了的时候,向上滚动控件,高度为header的高度。
根据需求,完全显示内容的时候,我们不希望显示footer,这里也要在onLayout里面实现,根据XML内容的高度和控件高度一比较就知道需不需要layout footer了。
header和footer的动态显示与隐藏
这里就和前面两篇文章类似了,就是在纵坐标上滚动控件,限定滚动范围,在ACTION_UP事件时判定滚动后的状态,动态去显示和隐藏header和footer,思路很明确,逻辑可能复杂一点。
使用
这里简单说下使用吧,就是作为Layout,中间可以放控件,中间控件可以指定特别大的高度,也可以wrap_content,但是内容很高。
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.silencefly96.module_common.view.HeaderFooterView
android:id="@+id/hhView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/teal_700"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:text="@string/test_string"
android:focusable="true"
android:clickable="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
</com.silencefly96.module_common.view.HeaderFooterView>
</androidx.constraintlayout.widget.ConstraintLayout>
这里的test_string特别长,滚动起来header和footer可以拉出来,释放会缩回去。还可以在代码中获得控件增加触底和触顶的回调。
中间为TextView时不触发ACTION_MOVE事件
上面XML布局中,如果不加clickable=true的话,控件中只会收到一个ACTION_DOWN事件,然后就没有然后了,即使是dispatchTouchEvent中也没有事件了。经查,原来不设置isClickable的话,点击该TextView会导致mFirstTouchTarget为null,致使onInterceptTouchEvent不会被调用,因为事件序列中ACTION_DOWN没有被消耗(未返回true),整个事件序列被丢弃了。
结语
实际上这个控件写的并不是很好,拿去用的话还是不太行的,但是用来学习的话还是能理解很多东西。
来源:https://blog.csdn.net/lfq88/article/details/127259569


猜你喜欢
- 前言如今发短信功能已经成为互联网公司的标配,本篇文章将一步步实现java发送短信考察了许多提供短信服务的三方,几乎所有都需要企业认证才可以使
- java API中提供了一个基于指针操作实现对文件随机访问操作的类,该类就是RandomAccessFile类,该类不同于其他很多基于流方式
- 前言当系统的并发比较高的时候,日志的处理输出也是一种性能的开销负担,所以,选择一个中间件来处理消费日志必不可少!下面是spring boot
- 一、总体概述官方文档:https://docs.devexpress.com/WindowsForms/8117/controls-and-
- Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。本篇不涉及其原理,只用代码构建项目简单试用一下其回滚
- 被kafka-client和springkafka版本坑上周刚刚欢天喜地的在linux上部了kafka,这周打算用spring-boot框架
- Eclipse Che被Eclipse官方称为下一代IDE,作为老牌的IDE,被其寄予厚望的Eclipse Che到底有什么特点,在这篇文章
- 一:串口通信简介前段时间因为工作需要研究了一下android的串口通信,网上有很多讲串口通信的文章,我在做的时候也参考了很多文章,现在就将我
- 前言前面我们学习完了设计模式,在其中我们有了解到原型模式。这里涉及到了克隆自身对象。那么也就是对对象进行拷贝。这里就涉及到了这么一个概念。深
- 类的结构包括 :1. 成员变量2. 成员方法3. 构造方法4. 代码块5. 内部类第一 构造方法的作用主要有以下三方面的作用:(1)在构造方
- 本文实例讲述了Android编程使用Fragment界面向下跳转并一级级返回的实现方法。分享给大家供大家参考,具体如下:1.首先贴上项目结构
- 1. 字段取别名,和属性名保持一致映射文件<mapper namespace="com.atguigu.mybatis.ma
- 本文实例讲解的是如何画一个满满圆形水波纹loadingview,这类效果应用场景很多,比如内存占用百分比之类的,分享给大家供大家参考,具体内
- 环境: VS2019 , OpencvSharp4 4.5.5.20211231 , .NET Framework 4.8界面设计:图像显示
- 您已经看到很多包含视频内容的应用程序,比如带有视频教程的食谱应用程序、电影应用程序和体育相关的应用程序。您是否想知道如何将视频内容添加到您的
- 前言上篇文章通过一个有header和footer的滚动控件(Viewgroup)学了下MeasureSpec、onMeasure以及onLa
- 相关概念1.Handler:可以看做是一个工具类,用来向消息队列中插入消息的;2.Thread:所有与Handler相关的功能都是与Thre
- 本文主要给大家介绍了关于RxJava的一些特殊用法,分享出来供大家参考学习,需要的朋友们下面来一起看看吧。一、按钮绑定通过 RxView 可
- 场景描述在项目开发的过程中,需要修改调试的时候偶每次都需要重启项目浪费时间,下面是我整理的两种常用的两种方式方式一修改启动配置方式(主要针对
- Android的PopupWindow是个很有用的widget,利用它可以实现悬浮窗体的效果,比如实现一个悬浮的菜单,最常见的应用就是在视频