原文链接: [Android] 写了个视差滚动布局 ParallaxLayout
新项目用到了大量的视差滚动效果,今天写了控件做支持,并非成熟但适用大多数简单场景,把大致思路在这里写下。
什么是视差滚动, 简单说就是
不同组件按不同速度滚动
像这种效果:
我们今天介绍的主要是以纵向的滚动效果为主,当然如果你要实现横向思路也一样。
1. 思路
简单实现这个效果思路很简单,只需要监听滚动控件的滚动行为,再根据不同组件的速度要求调整偏移量就🉑️了。
要做的通用点,我的思路是实现一个视差滚动的父控件,子view
添加一个layout_parallax_speed
的属性,这样无需多写java
代码,就可以得到所需要的参数,在布局代码中就可以完成定义。
最后的调用类似这种效果:
<ScrollView>
<ParallaxLayout>
<TextView layout_parallax_speed="1"/>
<ImageView layout_parallax_speed="1.5"/>
<LinearLayout layout_parallax_speed="0.8"/>
<AnyView layout_parallax_speed="0.7"/>
<AnyView layout_parallax_speed="1"/>
</ParallaxLayout>
</ScrollView>
其实这里要注意,我们的布局是介于 ScrollView
和视差组件之间的一个布局,因此它可能是 LinearLayout
可能是 RelativeLayout
也可能是 ConstraintLayout
, 可能是任意一个ViewGroup
的子类,因为我也不知道开发者需要一个什么样的内部结构。
再一点,这个布局本身不具备 Scroll
能力,仍需要嵌套在外部的可滚动组件。
可能你有疑问,为什么不直接写一个ParallaxScrollView
之类的呢。
这样就不需要担心内部结构的问题,不需要写多种布局的子类,自身也能控制滚动行为。
也就是类似下面这种结构的:
<ParallaxScrollView>
<LinearLayout>
<TextView layout_parallax_speed="1"/>
<ImageView layout_parallax_speed="1.5"/>
<LinearLayout layout_parallax_speed="0.8"/>
<AnyView layout_parallax_speed="0.7"/>
<AnyView layout_parallax_speed="1"/>
</LinearLayout>
</ParallaxScrollView>
原因也很简单,
Android
的ViewGroup
没有跨越父子关系设置LayoutParam
的能力(只能父子间,不能爷孙间),也就是说,这种结构上,最外侧的ParallaxScrollView
不能以正常方式获取到最内层的 layout_parallax_speed
属性。
2. 实现
需要解决两个问题
- 定义并获取各子控件的速度属性 layout_parallax_speed
- 检测滚动,对子控件做相对移动
2.2 定义并获取子控件布局属性
这里先介绍一下自定义ViewGroup
,定义和获取子View
布局参数的流程。
我们知道xml
中定义的这些属性都会转换成AttributeSet
, 然后在代码中保存在 LayoutParam
里。
然后会在addView
方法时, 然后把它和view
联系在一起。
ViewGroup 添加 View 流程图:
因此我们要做的三件事是:
- 定义一个
layout_parallax_speed
属性 - 定义一个
LayoutParam
,添加一个成员变量parallaxSpeed
用来保存 1 中定义的值 - 默认为子View 生成我们定义的
LayoutParams
相对的,我们的流程图应该是:
- 定义一个
layout_parallax_speed
属性
<declare-styleable name="ParallaxLayout">
<attr name="layout_parallax_speed" format="float"/>
</declare-styleable>
- 定义一个
LayoutParam
,解析layout_parallax_speed
值并保存
class LayoutParams : RelativeLayout.LayoutParams {
var parallaxSpeed: Float = 1f
constructor(context: Context, attrs: AttributeSet?): super(context, attrs) {
val a: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.ParallaxLayout)
parallaxSpeed = a.getFloat(R.styleable.ParallaxLayout_layout_parallax_speed, 1f)
a.recycle()
}
constructor(width: Int, height: Int): super(width, height)
constructor(layoutParams: MarginLayoutParams): super(layoutParams)
constructor(layoutParams: LayoutParams): super(layoutParams)
}
- 重写
generateLayoutParams
, 默认为子 View 生成我们定义的LayoutParams
override fun generateLayoutParams(attrs: AttributeSet?): RelativeLayout.LayoutParams {
return LayoutParams(context, attrs)
}
2.2 检测滚动
在添加到窗口时,对可滚动的父布局添加addOnScrollChangedListener
方法
// TODO 添加关键代码
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (parent != null && parent is ScrollView) {
(parent as ViewGroup).viewTreeObserver.addOnScrollChangedListener(this)
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
if (parent != null && parent is ScrollView) {
(parent as ScrollView).viewTreeObserver.removeOnScrollChangedListener(this)
}
}
如果你的最低API在24之上,那你就不需要用 viewTreeObserver
这么暴力的东西,可以直接parent.addOnScrollChangedListener
在onScrollChanged
事件中获取偏移量,计算各子View
的相对偏移值
/**
* parent 的 onScrollChanged 事件
* 父亲滚动改变时,根据每个元素的滚动速度进行调整 view.translationY
*/
private var parentLastScrollY = 0
override fun onScrollChanged() {
val currentScrollY = (parent as ViewGroup).scrollY
val delta = currentScrollY - parentLastScrollY
for (child in parallaxChildren) {
val translationDelta = -delta * (child.speed - 1f)
child.view.translationY += translationDelta.toInt()
}
parentLastScrollY = currentScrollY
}
3. 总结
最后再贴一遍整体流程图: ** 开头的部分是我们要做的工作