作者:opLW
目录
1.ChainLayout – 简介
2.构建整体静态布局
3.加入动态元素
1.ChainLayout – 简介
- 整体效果
如图所示,将黑色边框以内的部分称为头部,黑色边框以下称为可扩展部分。
注意: 头部事实上由可见和隐藏两部分组成,由于截图时头部处于正常状态,所以头部的隐藏部分无法被黑框圈住,此时黑框圈住的只是可见部分。 - 功能列表
2.整体静态布局的构建
- 搭建整体控件结构
- 自定义方向
- 要求 目标控件需要由两部分组成。第一部分是固定的头部,这一部分主要显示背景墙,头像以及装饰作用的遮罩。第二部分是可扩展部分,这一部分主要留出来供用户添加自己想要的内容。
- 父类的选择 显然有这种容器类作用的控件不可以继承View来实现,所以我们考虑继承ViewGroup。由于可扩展部分的高度可能很大,所以一个屏幕可能会显示不全。因此为了保证所有的内容可以显示,我们需要使用可以滚动的控件。可以滚动的控件有很多,如ListView、RecyclerView等,但这些定制化程度都比较高,我们并不需要使用到那么复杂的功能,只需要一个可以滚动的容器即可,所以我们考虑使用ScrollView作为父类。
- LinearLayout作为ChainLayout的子View ChainLayout由两部分组成:固定的头部和可扩展部分。ChainLayout继承自ScrollView,由于ScrollView只允许有一个子View,所以向ChainLayout添加一个LinearLayout来装固定的头部和可扩展的部分,同时将LinearLayout的高度设置为WRAP_CONTENT。
private fun initMainContainer() { mainContainer = LinearLayout(context) mainContainer.orientation = LinearLayout.VERTICAL val layoutParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT ) // 调用ScrollView的addView方法,添加唯一子View:mainContainer super.addView(mainContainer, -1, layoutParams) ...... }
- 构建可扩展部分
- 重写ChainLayout的所有addView方法 我们都知道通过addView方法添加子View。但此时ChainLayout已经拥有一个子View:LinearLayout了,所以我们需要重写ChainLayout的所有addView方法,将所有要添加到ChainLayout的控件转而添加到该LinearLayout中。
override fun addView(child: View?) { mainContainer.addView(child) } override fun addView(child: View?, index: Int) { mainContainer.addView(child, index) } ......省略其他addView方法
- 添加LinearLayout的注意点 由于需要重写ChainLayout所有的addView方法。所以第一个代码段中添加LinearLayout时,调用的是super.addView。
- 重写ChainLayout的所有addView方法 我们都知道通过addView方法添加子View。但此时ChainLayout已经拥有一个子View:LinearLayout了,所以我们需要重写ChainLayout的所有addView方法,将所有要添加到ChainLayout的控件转而添加到该LinearLayout中。
- 自定义方向
- 搭建头部固定控件 添加固定控件部分比较简单,唯一需要需要注意的是添加的时机和自定义底部遮罩。
- 添加头部控件的时机 都知道View会经历onMeasure、onLayout、onDraw三个步骤。而ViewGroup在执行自己的onMeasure方法时,会先执行子View的onMeasure方法。所以我们需要在ChainLayout初始化时将所有子View添加进去,保证所有子View得到测量。Java的初始化时在构造方法中,而Kotlin是在init代码块中。
- 制作底部遮罩 底部遮罩只是一个简单的不规则图形,所以我们只需要自定义View,继承自View并重写其onDraw方法即可。详细代码见ChainLayout的内部类Mask
3.加入动态元素
- 3.1 下拉
- 分析 下拉主要经历两个阶段的变化。第一阶段:头部渐渐下滑。第二阶段:头部继续下滑同时背景放大。虽然有两个阶段的变化,但是其中头部持续下滑是一直存在的。所以我们只需要让头部持续下滑并在合适的时间点,让图片开始放大即可。
- 代码 详细解释见注释
override fun onTouchEvent(ev: MotionEvent?): Boolean { if (ev == null) return true val deltaY = ev.y - lastY lastY = ev.y // ( 在默认状态下继续向下拉动 || 头部处在变化中 ) 这两种情况需要将滑动事件处理 if (ev.action == MotionEvent.ACTION_MOVE && (scrollY == 0 && deltaY > 0 || isHeadChanged)) { changeHeadStatus(deltaY) return true } ...... return super.onTouchEvent(ev) } private fun changeHeadStatus(deltaY: Float) { curTranslationY += deltaY // 向下拉到了最大值 if (curTranslationY > 0f) { curTranslationY = 0f return } ...... mainContainer.translationY = curTranslationY // 当隐藏部分逐渐减少,到达阀值时开始处理图片的放大 if (curTranslationY > -startScaleHiddenHeight) { val factor = 1 - (curTranslationY / -startScaleHiddenHeight) val scale = scaleCalculator.calculate(factor) headBackgroundIv.scaleX = 1 + scale headBackgroundIv.scaleY = 1 + scale } // 发生变化则做标记 isHeadChanged = true }
- 3.2 上滑
- 分析 上滑比较简单,需要注意的是:上滑时如果头部由于下拉产生变化,需要将头部恢复至初始状态。
- 代码 详细解释见注释
override fun onTouchEvent(ev: MotionEvent?): Boolean { if (ev == null) return true val deltaY = ev.y - lastY lastY = ev.y // ( 在默认状态下继续向下拉动 || 头部处在变化中 ) 这两种情况需要将滑动事件处理 if (ev.action == MotionEvent.ACTION_MOVE && (scrollY == 0 && deltaY > 0 || isHeadChanged)) { changeHeadStatus(deltaY) return true } ...... return super.onTouchEvent(ev) } private fun changeHeadStatus(deltaY: Float) { curTranslationY += deltaY ...... // 向上滑到了初始状态 if (curTranslationY <= -headHideHeight) { changeHeadToDefaultStatus(false) return } mainContainer.translationY = curTranslationY // 同样需要经过此函数,让上滑的头部背景变小 if (curTranslationY > -startScaleHiddenHeight) { val factor = 1 - (curTranslationY / -startScaleHiddenHeight) val scale = scaleCalculator.calculate(factor) headBackgroundIv.scaleX = 1 + scale headBackgroundIv.scaleY = 1 + scale } isHeadChanged = true }
- 3.3 下拉松开产生的动效
- 分析 下拉松开其实和前面的上滑原理是相似的,都是让头部上滑、背景变小。但我们可以加入动画来让变化更好看。
- 代码
override fun onTouchEvent(ev: MotionEvent?): Boolean { if (ev == null) return true ...... // 用户松开手指并且Head被拉动过,此时需要恢复至默认状态。 if (ev.action == MotionEvent.ACTION_UP && isHeadChanged) { changeHeadToDefaultStatus(true) return true } return super.onTouchEvent(ev) } /** * 将Head设置为默认值,主要将Head的translationY设置为负,达到隐藏部分Head的目的。 * @param needShowAnimation 是否使用动画,让变化更加自然。 */ private fun changeHeadToDefaultStatus(needShowAnimation: Boolean) { if (needShowAnimation) { makeAnimator() } else { curTranslationY = -headHideHeight.toFloat() mainContainer.translationY = -headHideHeight.toFloat() headBackgroundIv.scaleX = 1f headBackgroundIv.scaleY = 1f } isHeadChanged = false } private fun makeAnimator() { val originalScale = headBackgroundIv.scaleX valueAnimator.addUpdateListener { val fraction = it.animatedValue as Float mainContainer.translationY = curTranslationY + (-headHideHeight - curTranslationY) * fraction val scale = originalScale + (1f - originalScale) * fraction headBackgroundIv.scaleX = scale headBackgroundIv.scaleY = scale } ......省略其他参数 }
- 总结 由于这里需要修改控件的属性,所以只能用属性动画。而这里我们不使用
ObjectAnimator
,因为其是通过反射的方法来修改控件的属性值,这种方法比较消耗性能。同时由于我们是在控件的内部进行操作,可以方便的修改各项属性值,我们需要的只是一个控制动画变化的因素。因此在自定义控件时,我更加喜欢使用ValueAnimator来产生动画效果。
- 滑动事件的处理
- 分析 在可扩展部分添加的控件可能需要处理点击事件,如果将所有事件拦截,那么点击事件无法传到子
View
;如果不进行拦截,那么由于子View
设置了可点击,那么所有事件不会传到ChainLayout
的onTouchEvent
中。所以应该采取部分拦截的做法,对于DOWN不进行拦截,保证整个事件都可以传到子View。如果事件流中有MOVE则拦截,从而将后续的事件都交给ChainLayout处理。详细可见文章:Android触摸事件分发机制详解 - 代码
为什么要这样判断而不直接拦截所有override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { if (null == ev) return true //ACTION_DOWN首次到来时记录位置 if (ev.action == MotionEvent.ACTION_DOWN) { lastY = ev.y } // 如果前后两次位置超过2f则判定为滑动,将事件拦截 val deltaY = ev.y - lastY if (Math.abs(deltaY) >= 2f) { return true } return super.onInterceptTouchEvent(ev) }
ev.action == MotionEvent.ACTION_DOWN
的事件呢?经过测试,发现第一个ACTION_DOWN
会被认为是ACTION_MOVE
,具体原因尚不知晓,希望告知😘
- 分析 在可扩展部分添加的控件可能需要处理点击事件,如果将所有事件拦截,那么点击事件无法传到子
4.总结
- 隐藏部分头部的思路来源于SwipeLayout,其实很多可以下拉显示隐藏部分的控件都是利用TranslationY值来达到隐藏的目的。
- 详细代码可见ChainLayout,欢迎赐教❤。
- 使用示例
// 1.通过.xml添加ChainLayout以及 <com.oplw.common.customview.layout.ChainLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/chain_layout" android:layout_width="match_parent" android:layout_height="match_parent" app:headVisibleHeight="250dp" app:headHideHeight="100dp" app:headBottomMaskHeight="50dp" app:headBottomMaskSlope="50%" app:headBottomMaskColor="@android:color/holo_blue_bright" app:circleIvSize="100dp" app:circleIvElevation="8dp" app:circleIvMarginB="8dp" app:circleIvMarginH="16dp"> <!--添加想要的控件--> </com.oplw.common.customview.layout.ChainLayout > // 2.在代码中为背景、头像设置图片 chain_layout.circleIv.setImageResource(R.drawable.ic_head_iv_2) chain_layout.setHBackgroundRes(R.drawable.illustration_login)
- Chainlayout中用到的部分资源:图片模糊工具、模拟自然动画的数学公式以及在线调试动画网站
万水千山总是情,麻烦手下别留情。
如若讲得有不妥,文末留言告知我,
如若觉得还可以,收藏点赞要一起。
opLW原创七言律诗,转载请注明出处