前言

上一篇文章直接通过安卓自定义view的知识手撕了一个侧滑栏,做的还不错,很有成就感。这篇文章的控件没有上一篇的复杂,比较简单,通过一个内容滚动造成header折叠的控件学习一下滑动事件冲突问题、更改view节点以及CoordinatorLayout事件传递(超低仿),基本都是一个引子,希望学完这个控件,要继续省略学习下涉及的内容。

需求

这里就是希望做一个滚动通过内容能够折叠header的控件,在XML内写的控件能够有滚动效果,header暂时默认实现。

核心思想:

1、两部分,一个header和一个可以滚动的区域

2、header有两种状态,一个是完全展开状态,一个是折叠状态

3、在滚动区域向下滚动的时候,header会先滚动到折叠状态,header折叠后滚动区域才开始滚动

4、在滚动区域向上滚动的时候,滚动区域先滚动,滚动区域到顶了才开始展开header

5、低仿CoordinatorLayout,滚动区域效果通过自定义layoutParas向header传递

效果图

编写代码

import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.os.Build
import android.util.AttributeSet
import android.util.Log
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.forEach
import androidx.core.widget.NestedScrollView
import kotlin.math.abs
/**
 * 内容滚动造成header折叠的控件
 */
class ScrollingCollapseTopLayout @JvmOverloads constructor(
    context: Context,
    attributeSet: AttributeSet? = null,
    defStyleAttr: Int = 0
): ViewGroup(context, attributeSet, defStyleAttr) {
    //外部滑动距离
    private var mScrollHeight = 0f
    //上次纵坐标
    private var mLastY = 0f
    //当前控件宽高
    private var mHeight = 0
    private var mWidth = 0
    //两个部分
    private val header: Header = Header(context).apply {
        //设置header垂直方向,宽度铺满,高度自适应
        orientation = LinearLayout.VERTICAL
        layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
    }
    //NestedScrollView只允许一个子view(和ScrollView一样),这里放一个垂直的LinearLayout
    private val scrollArea: NestedScrollView = NestedScrollView(context).apply {
        layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
        addView(LinearLayout(context).apply {
            setBackgroundColor(Color.LTGRAY)
            orientation = LinearLayout.VERTICAL
            layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
        })
    }
    //XML里面的view
    private val xmlViews: ArrayList<View> = ArrayList()
    //获取XML内view结束,没执行onMeasure
    override fun onFinishInflate() {
        super.onFinishInflate()
        //在这里获得所有子view,拦截添加到scrollArea去
        if (xmlViews.size == 0) {
            forEach { view ->
                xmlViews.add(view)
            }
        }
        //更换view的节点
        removeAllViewsInLayout()
        addView(header)
        addView(scrollArea)
        //把当前控件全部view放到NestedScrollView内的LinearLayout内去
        (scrollArea.getChildAt(0) as ViewGroup).also { linear->
            for(view in xmlViews) {
                linear.addView(view)
            }
        }
    }
    //在onSizeChanged才能获得正确的宽高,会在onMeasure后得到,这里只是学一下
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        mHeight = h
        mWidth = w
    }
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        //测量header
        header.onScroll(mScrollHeight.toInt())
        header.measure(widthMeasureSpec,
            MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightMeasureSpec),MeasureSpec.AT_MOST))
        //先measure一下获得实际高度,再减去滑动的距离,也可以把header.measuredHeight写成全局变量
        if (header.measuredHeight != 0) {
            val scrolledHeight = header.measuredHeight   mScrollHeight
            val headerHeightMeasureSpec = MeasureSpec.makeMeasureSpec(scrolledHeight.toInt(),
                MeasureSpec.getMode(MeasureSpec.EXACTLY))
            //再次测量的目的是后面滚动部分要占满剩余高度
            header.measure(widthMeasureSpec, headerHeightMeasureSpec)
        }
        //测量滑动区域
        val leftHeight = MeasureSpec.getSize(heightMeasureSpec) - header.measuredHeight
        scrollArea.measure(widthMeasureSpec,
            MeasureSpec.makeMeasureSpec(leftHeight, MeasureSpec.EXACTLY))
        Log.e("TAG", "onMeasure: leftHeight=$leftHeight")
        Log.e("TAG", "onMeasure: scrollArea.height=${scrollArea.height}")
        Log.e("TAG", "onMeasure: scrollArea.measuredHeight=${scrollArea.measuredHeight}")
        //直接占满宽高
        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec),
            MeasureSpec.getSize(heightMeasureSpec))
    }
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        //简单布局下,上下两部分
        header.layout(l, t, r, t   header.measuredHeight)
        scrollArea.layout(l, t   header.measuredHeight, r,b)
    }
    //事件冲突使用外部拦截
    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        var isIntercepted = false
        ev?.let {
            when(ev.action) {
                //不拦截down事件
                MotionEvent.ACTION_DOWN -> mLastY = ev.y
                MotionEvent.ACTION_MOVE -> {
                    val dY = ev.y - mLastY
                    //如果折叠了,优先滚动折叠栏
                    val canScrollTop = scrollArea.canScrollVertically(-1)
                    val canScrollBottom = scrollArea.canScrollVertically(1)
                    //可以滚动
                    isIntercepted = if (canScrollTop || canScrollBottom) {
                        //手指向上移动时,没折叠前要拦截
                        val scrollUp = dY < 0 &&
                                mScrollHeight   dY > -header.collapsingArea.height.toFloat()
                        //手指向下移动时,没展开前且到顶了要拦截
                        val scrollDown = dY > 0 &&
                                mScrollHeight   dY < 0f &&
                                !canScrollTop
                        scrollUp || scrollDown
                    }else {
                        //不能滚动
                        true
                    }
                }
                //不拦截up事件
                //MotionEvent.ACTION_UP ->
            }
        }
        return isIntercepted
    }
    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(ev: MotionEvent?): Boolean {
        ev?.let {
            when(ev.action) {
                //MotionEvent.ACTION_DOWN ->
                MotionEvent.ACTION_MOVE -> {
                    //累加滑动值,请求重新布局
                    val dY = ev.y - mLastY
                    if (mScrollHeight   dY <= 0 &&
                        mScrollHeight   dY >= -header.collapsingArea.height) {
                            mScrollHeight  = dY
                            requestLayout()
                    }
                    mLastY = ev.y
                }
                //MotionEvent.ACTION_UP ->
            }
        }
        return super.onTouchEvent(ev)
    }
    //这里就做一个简单的折叠header,
    @Suppress("MemberVisibilityCanBePrivate")
    inner class Header @JvmOverloads constructor(
        context: Context,
        attributeSet: AttributeSet? = null,
        defStyleAttr: Int = 0,
    ): LinearLayout(context, attributeSet, defStyleAttr){
        //两个区域
        val defaultArea: TextView
        val collapsingArea: TextView
        init {
            //添加两个header区域
            defaultArea = makeTextView(context, "Default area", 80)
            collapsingArea = makeTextView(context, "Collapsing area", 300)
            addView(defaultArea)
            addView(collapsingArea)
        }
        //低配Behavior.onNestedPreScroll,这里就处理下ScrollingHideTopLayout传过来的距离
        @SuppressLint("SetTextI18n")
        fun onScroll(scrollHeight: Int) {
            val expandHeight = collapsingArea.height   scrollHeight
            //这里就改一下背景色的透明度吧
            if (abs(expandHeight) <= collapsingArea.height) {
                val alpha = expandHeight.toFloat() / collapsingArea.height * 255
                defaultArea.text = "Default area:${alpha.toInt()}"
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                    collapsingArea.setBackgroundColor(Color.argb(alpha.toInt(),88,88,88))
                }
            }
        }
        //创建TextView
        private fun makeTextView(context: Context, textStr: String, height: Int): TextView {
            //简单点height和textSize应该用dp和sp的,前面文章有
            return TextView(context).apply {
                layoutParams =
                    ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height)
                text = textStr
                gravity = Gravity.CENTER
                textSize = 13f
                setBackgroundColor(Color.GRAY)
            }
        }
    }
}

主要问题

NestedScrollView的使用

要想中间内容能够滚动,并且和当前控件造成滑动冲突,就只能引入新的滑动控件了,这里使用了NestedScrollView,和ScrollView类似。NestedScrollView只允许有一个子view,至于为什么可以看下源码,内容不多。我这是直接创建了一个NestedScrollView,并往里面加个一个垂直的LinearLayout,后面更改xml里面的view节点,往LinearLayout里面放。

修改xml内view的节点

上一篇文章里面,侧滑栏在xml里面的位置会影响绘制的层级,我是在onLayout里面通过移除再添加的方式做的,那如果要把view改到其他view里面去该怎么办。一开始我觉得很简单嘛,直接在onMeasure里面得到所有xml里面的view,再添加到其他viewgroup里面不就行了!想法很简单,试一下结果出我问题了。

第一个问题是view添加到其他viewgroup必须先移除,那我就直接就removeViewInLayout,结果就出了第二个问题OverStackError,大致就是一直measure,试了下是addView导致的,逻辑还是有问题。后面想想不应该在onMeasure里面实现的,应该在viewgroup加载xml里面子view时拦截处理的。

于是找了下api,发现viewgroup提供了一个onFinishInflate方法,会在加载xml里面view完成时调用,关键是它只会调用一次,onMeasure会调用多次,正好符合了我们的需求。修改节点就简单了,for循环一下就ok。

onSizeChanged函数

上面用到了onFinishInflate方法,找资料的时候看到自定义view里面常用重写的方法还有一个onSizeChanged函数。其实用的也多,主要是自定义view时用来获取控件宽高的,当控件的Size发生变化,如measure结束,onSizeChanged被调用,这时候才能拿到宽高,不然拿到的height和width就是0。

滑动事件冲突处理

我觉得滑动事件冲突的处理都应该根据实际情况去处理,知识的话可以去看看《安卓开发艺术探讨》里面的相关知识,主要解决办法就是内部拦截法和外部拦截法。我这就是简单的外部拦截法,本来想写复杂点,看看能不能多学点东西,结果根据需求,最后的代码很简单。

外部拦截法原理就是在onInterceptTouchEvent方法中,通过根据场景判断是内部滚动还是外部滚动,外部滚动就直接拦截,内部是否能滚动可以通过canScrollVertically/canScrollHorizontally方法判断。我这逻辑很简单,首先判断下内部是否能滚动,内部不能滚动就直接交给外部处理;然后又分两种情况,一个是手指向上移动时,没折叠前要拦截,另一个就是手指向下移动时,没展开前且到内部顶了要拦截。无论真么处理,还是得根据情景,

模仿CoordinatorLayout

本来还想模仿CoordinatorLayout做一个滑动状态传递的,这里滚动控件用的NestedScrollingChild,想让当前控件继承NestedScrollingParent处理滑动冲突,后面觉得还是简单点自己在onInterceptTouchEvent方法中处理能学点东西。当然读者有兴趣可借机学习一下NestedScrollingChild和NestedScrollingParent。

对于CoordinatorLayout,我也是学习了一下其中原理,私以为大致就是CoordinatorLayout的LayoutParams内有一个Behavior属性,Behavior作用就是构建两个子控件的关联关系(在CoordinatorLayout的onMeasure中),建立关联关系后,当一个view变化就会造成关联的view跟着变化(CoordinatorLayout控制),当然原理没这么简单,还是要去看源码。

本来我也想按这个逻辑模仿一下的,首先就是给当前控件的LayoutParams加一个Behavior属性,当滚动控件设置这个Behavior属性时,Header类在measure的时候就创建一个Behavior属性的私有变量,当前控件通过NestedScrollingChild接受滚动事件,并交给Header类的Behavior属性的私有变量去处理,一套逻辑下来,总感觉有脱裤子放屁的感觉,毕竟我这个控件就两个子控件。CoordinatorLayout的目的是协调多 View 之间的联动,重点在多,我这真没必要。

其实说到底,CoordinatorLayout就是一个协调功能,关联两个控件,比如我这就是滚动控件发出滚动消息,当前控件收到滚动消息,传递到Header里面处理,就这么简单,多了倒是可以按上面逻辑处理。

header折叠效果

这里的header的折叠效果是从onMeasure里面得到的!在测量时,根据滑动值,修改header的heightMeasureSpec,把header的高度设置为原有高度减去滑动高度,测量完header之后,把剩余的高度给到滑动区域,onLayout的时候将两个控件挨着就行。滑动的时候,请求重新layout,header和滚动区域每次都会获得不一样的高度,看起来就有了折叠效果。

到此这篇关于Android实现滑动折叠Header全流程详解的文章就介绍到这了,更多相关Android Header内容请搜索Devmax以前的文章或继续浏览下面的相关文章希望大家以后多多支持Devmax!

Android实现滑动折叠Header全流程详解的更多相关文章

  1. html5 canvas合成海报所遇问题及解决方案总结

    这篇文章主要介绍了html5 canvas合成海报所遇问题及解决方案总结,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧

  2. AmazeUI 手机版页面的顶部导航条Header与侧边导航栏offCanvas的示例代码

    这篇文章主要介绍了AmazeUI 手机版页面的顶部导航条Header与侧边导航栏offCanvas,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下

  3. Html5 video标签视频的最佳实践

    这篇文章主要介绍了Html5 video标签视频的最佳实践,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧

  4. HTML5在微信内置浏览器下右上角菜单的调整字体导致页面显示错乱的问题

    HTML5在微信内置浏览器下,在右上角菜单的调整字体导致页面显示错乱的问题,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友参考下吧

  5. ios – containerURLForSecurityApplicationGroupIdentifier:在iPhone和Watch模拟器上给出不同的结果

    我使用默认的XCode模板创建了一个WatchKit应用程序.我向iOSTarget,WatchkitAppTarget和WatchkitAppExtensionTarget添加了应用程序组权利.(这是应用程序组名称:group.com.lombax.fiveminutes)然后,我尝试使用iOSApp和WatchKitExtension访问共享文件夹URL:延期:iOS应用:但是,测试NSURL

  6. xcode – Cordova plugin.xml添加“Header Search Paths”条目

    我希望将Cordova插件添加到Xcode项目后,在“HeaderSearchPaths”下添加一个新条目.如何在Cordovaplugin.xml文件中进行配置?

  7. Ionic – Splash Screen适用于iOS,但不适用于Android

    我有一个离子应用程序,其中使用CLI命令离子资源生成的启动画面和图标iOS版本与正在渲染的启动画面完美配合,但在Android版本中,只有在加载应用程序时才会显示白屏.我检查了config.xml文件,所有路径看起来都是正确的,生成的图像出现在相应的文件夹中.(我使用了splash.psd模板来生成它们.我错过了什么?这是config.xml文件供参考,我觉得我在这里做错了–解决方法在config.xml中添加以下键:它对我有用!

  8. ios – iPhone 6 Plus横向模式中的奇怪错误,带有标签和固定元素

    我有一些疯狂的问题iPhone6Safari(主要是iOS9,虽然iOS8也有一些小故障),标签模式打开.所有固定元素都以纵向和横向正确定位,但在横向模式下不可见和/或移动,并打开一个或多个标签.即使它们不可见,它们仍然可以点击并与其他内容重叠.旋转设备在一定程度上解决了问题,以及在标签之间切换.将元素位置从固定设置为静态和背面也有帮助.HTML:CSS:应用程序非常大,但我已经成功地在codep

  9. ios – 无法启动iPhone模拟器

    /Library/Developer/CoreSimulator/Devices/530A44CB-5978-4926-9E91-E9DBD5BFB105/data/Containers/Bundle/Application/07612A5C-659D-4C04-ACD3-D211D2830E17/ProductName.app/ProductName然后,如果您在Xcode构建设置中选择标准体系结构并再次构建和运行,则会产生以下结果:dyld:lazysymbolbindingFailed:Symbol

  10. ios – 使用CocoaPods post install hook将自定义路径添加到HEADER_SEARCH_PATHS

    解决方法在Podfile中定义一个方法:然后在post_install中调用该方法:

随机推荐

  1. Flutter 网络请求框架封装详解

    这篇文章主要介绍了Flutter 网络请求框架封装详解,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧

  2. Android单选按钮RadioButton的使用详解

    今天小编就为大家分享一篇关于Android单选按钮RadioButton的使用详解,小编觉得内容挺不错的,现在分享给大家,具有很好的参考价值,需要的朋友一起跟随小编来看看吧

  3. 解决android studio 打包发现generate signed apk 消失不见问题

    这篇文章主要介绍了解决android studio 打包发现generate signed apk 消失不见问题,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧

  4. Android 实现自定义圆形listview功能的实例代码

    这篇文章主要介绍了Android 实现自定义圆形listview功能的实例代码,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下

  5. 详解Android studio 动态fragment的用法

    这篇文章主要介绍了Android studio 动态fragment的用法,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下

  6. Android用RecyclerView实现图标拖拽排序以及增删管理

    这篇文章主要介绍了Android用RecyclerView实现图标拖拽排序以及增删管理的方法,帮助大家更好的理解和学习使用Android,感兴趣的朋友可以了解下

  7. Android notifyDataSetChanged() 动态更新ListView案例详解

    这篇文章主要介绍了Android notifyDataSetChanged() 动态更新ListView案例详解,本篇文章通过简要的案例,讲解了该项技术的了解与使用,以下就是详细内容,需要的朋友可以参考下

  8. Android自定义View实现弹幕效果

    这篇文章主要为大家详细介绍了Android自定义View实现弹幕效果,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下

  9. Android自定义View实现跟随手指移动

    这篇文章主要为大家详细介绍了Android自定义View实现跟随手指移动,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下

  10. Android实现多点触摸操作

    这篇文章主要介绍了Android实现多点触摸操作,实现图片的放大、缩小和旋转等处理,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下

返回
顶部