Android自定义控件 -- 仿Tim个人主页布局

作者:opLW

目录

1.ChainLayout – 简介
2.构建整体静态布局
3.加入动态元素

1.ChainLayout – 简介
  • 整体效果
    在这里插入图片描述在这里插入图片描述
    如图所示,将黑色边框以内的部分称为头部黑色边框以下称为可扩展部分
    注意: 头部事实上由可见和隐藏两部分组成,由于截图时头部处于正常状态,所以头部的隐藏部分无法被黑框圈住,此时黑框圈住的只是可见部分。
  • 功能列表
    • 头部:头部的高度、头部隐藏部分的高度(隐藏部分主要供下拉使用)。
    • 背景:颜色、图片以及图片的模糊程度。
    • 底部遮罩(蓝色部分):颜色、高度、倾斜方向。
    • 圆形头像:大小、Z轴高度、水平方向的Margin、底部的Margin。
    • 动效 放大背景时的变化速率、松开时背景恢复至初始状态的变化速率及时间。
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。
  • 搭建头部固定控件 添加固定控件部分比较简单,唯一需要需要注意的是添加的时机和自定义底部遮罩。
    • 添加头部控件的时机 都知道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设置了可点击,那么所有事件不会传到ChainLayoutonTouchEvent中。所以应该采取部分拦截的做法,对于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原创七言律诗,转载请注明出处

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值