前言
最近在找工作,于是打开拉勾,看了看首页,交互做的还是不错的。先来看看拉勾效果
然后最终实现的效果
布局是图片直接用,所以会失真。
实现思路
首先这个是一个MD
的效果,可以使用自定义Behavior
来实现这个效果,仔细体验会发现,这个交互是分三部分来实现的
头部部分(比如
banner
之类的),内容部分(比如TabLayout+ViewPager
),以及导航栏部分(实现渐变的效果)。这样就是自定义三个Behavior
。
布局
头部部分的高度是固定的,用来算后面滑动的一个范围;导航栏部分的搜索框边距固定,用来说伸缩动画的,dimens.xml定义如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="height">400dp</dimen>
<dimen name="search_margin_left">50dp</dimen>
<dimen name="search_margin_right">50dp</dimen>
</resources>
接下来就是布局,详细布局用图片替换
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
android:id="@+id/coordinator"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v4.widget.NestedScrollView
app:layout_behavior="@string/HeaderBehavior"
android:id="@+id/fl_head"
android:layout_width="match_parent"
android:layout_height="@dimen/height"
>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/iv_head"
android:layout_width="match_parent"
android:layout_height="@dimen/height"
android:scaleType="fitXY"
android:src="@drawable/top"/>
</FrameLayout>
</android.support.v4.widget.NestedScrollView>
<RelativeLayout
android:id="@+id/rlToolBar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:gravity="center_vertical"
app:layout_behavior="@string/SearchBehavior">
<ImageView
android:id="@+id/ivArrow"
android:layout_width="25dp"
android:layout_height="25dp"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:layout_centerVertical="true"
android:scaleType="fitXY"
android:visibility="gone"
android:src="@drawable/arrow" />
<android.support.v7.widget.CardView
android:layout_width="match_parent"
android:layout_height="30dp"
android:layout_marginLeft="@dimen/search_margin_left"
android:layout_marginRight="@dimen/search_margin_right"
android:layout_centerVertical="true"
app:cardBackgroundColor="@android:color/white"
app:cardCornerRadius="15dp">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="Android应用开发"
android:gravity="center"/>
</android.support.v7.widget.CardView>
<ImageView
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_alignParentRight="true"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:layout_centerVertical="true"
android:scaleType="fitXY"
android:src="@drawable/code"
android:tint="@android:color/white"/>
</RelativeLayout>
<LinearLayout
app:layout_behavior="@string/ContentBehavior"
android:id="@+id/llContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="?attr/actionBarSize"
android:orientation="vertical">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/iv_tab"
android:layout_width="match_parent"
android:layout_height="50dp"
android:scaleType="fitXY"
android:src="@drawable/tab"/>
</FrameLayout>
<android.support.v4.widget.NestedScrollView
android:id="@+id/nsv"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/iv_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scaleType="fitXY"
android:src="@drawable/content"/>
</android.support.v4.widget.NestedScrollView>
</LinearLayout>
</android.support.design.widget.CoordinatorLayout>
三个部分,头部FrameLayout
,导航栏RelativeLayout
透明覆盖再头部部分上面,内容部分LinearLayout
放置如TabLayout+ViewPager
,这里用一个子FrameLayout
和NestedScrollView
代替。内容部分给个底部边距?attr/actionBarSize
,抵消导航栏部分占用的高度。
同时这里定义了三个命名为HeaderBehavior
,SearchBehavior
和ContentBehavior
三个Behavior
,也是接下来要实现的滚动效果
Behavior
HeaderBehavior
主要是处理往上滚动的,onNestedPreScroll
里面进行translationY
设置来滑动。onInterceptTouchEvent
里面判断当手指松开的时候进行头部展开和关闭的操作。ContentBehavior
用AppBarLayout
里面的内部抽象类HeaderScrollingViewBehavior
来布局,放在头部部分的下面,由于HeaderScrollingViewBehavior
不是公共的,所以可以自己复制一份代码出来,HeaderScrollingViewBehavior
主要是onMeasureChild
和layoutChild
两个方法来进行大小和位置的分布。然后在Behavior
的layoutDependsOn
里面进行依赖头部滑动,onDependentViewChanged
来处理内容部分的滚动SearchBehavior
和ContentBehavior
同理,用DrawableCompat
来处理图片颜色渐变,TransitionManager
这个类来给margin
伸缩一个动画
在写各个Behavior
之前,会发现头部部分和内容部分滚动的速度和范围是不一样的,所以这里先定义下滚动的范围
object Utils{
//内容部分在头部滚动的范围
fun getScrollHeight(ctx: Context):Float{
val mHeight = ctx.resources.getDimension(R.dimen.height).toInt()
val actionBarSize = getActionBarHeight(ctx)
//如果上面内嵌到了状态栏,还要减去状态栏高度,同时内容部分底部边距也是状态栏高度+导航栏高度
return (mHeight - actionBarSize)/getScrollFriction()
}
//给定内容部分和头部滚动位移的一个倍数,
fun getScrollFriction():Float = 1.5f
fun getActionBarHeight(ctx: Context): Int {
var actionBarHeight = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 50f, ctx.resources.displayMetrics).toInt()
val tv = TypedValue()
if (ctx.theme.resolveAttribute(android.R.attr.actionBarSize, tv, true)) {
actionBarHeight = TypedValue.complexToDimensionPixelSize(tv.data, ctx.resources.displayMetrics)
}
return actionBarHeight
}
fun getStatusBarHeight(ctx: Context):Int{
var result = 0
val resourceId = ctx.resources.getIdentifier("status_bar_height", "dimen", "android")
if (resourceId > 0) {
result = ctx.resources.getDimensionPixelSize(resourceId)
}
return result
}
fun range(min:Float,max:Float,value:Float):Float{
return Math.min(Math.max(min,value),max)
}
}
基本的关系定义好后,接下来就是实现Behavior
HeaderBehavior
的代码:
class HeaderBehavior: CoordinatorLayout.Behavior<View> {
//滚动的View
private var mChildView: View? =null
//过了头部滑动高度后,重新调用onStartNestedScroll的时候用
private var axes:Int = ViewCompat.SCROLL_AXIS_VERTICAL
private var type:Int = 0
//手指松开后,通过`Scroller`类来实现头部的展示和关闭的操作
private var mScroller: Scroller
//滚动的高度
private var mHeight:Float = 0f
private var mScrollRunnable:ScrollerRunnable?= null
//是不是一开始头部就隐藏了
private var mStartHeaderHidden:Boolean = true
//滑动速度
private var velocityY:Float = 0f
constructor(ctx: Context,attributeSet: AttributeSet):super(ctx,attributeSet){
mScroller = Scroller(ctx)
mHeight = Utils.getScrollHeight(ctx)
}
//什么方向可以滚动,并且滑动到了头部高度就不拦截
override fun onStartNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, directTargetChild: View, target: View, axes: Int, type: Int): Boolean {
this.mChildView = child
this.axes = axes
this.type = type
return (axes == ViewCompat.SCROLL_AXIS_VERTICAL) && !isClose()
}
//滚动监听
override fun onNestedPreScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
//降低移动距离,防止抖动,ScrollFriction太大还是会有抖动。。。。
val tempDy = dy.toFloat()/(Utils.getScrollFriction()*2)
child.translationY = Utils.range(-mHeight,0f,child.translationY - tempDy)
consumed[1] = dy
}
//当头部关闭了,就不拦截事件了
override fun onNestedPreFling(coordinatorLayout: CoordinatorLayout, child: View, target: View, velocityX: Float, velocityY: Float): Boolean {
this.velocityY = velocityY
return !isClose()
}
override fun onInterceptTouchEvent(parent: CoordinatorLayout, child: View, ev: MotionEvent): Boolean {
when(ev.action){
MotionEvent.ACTION_DOWN -> {
mStartHeaderHidden = isClose()
}
MotionEvent.ACTION_MOVE -> {
//如果滑动中到了头部关闭之后就重新给事件分发下去
if(isClose()&&!mStartHeaderHidden) parent.onStartNestedScroll(parent,child,axes,type)
}
MotionEvent.ACTION_UP -> {
//手指松开的处理
handlerActionUp()
}
}
return super.onInterceptTouchEvent(parent, child, ev)
}
private fun createRunnable(){
if(mScrollRunnable==null) mScrollRunnable = ScrollerRunnable(mScroller,mChildView!!,(mHeight+0.5f).toInt())
}
private fun handlerActionUp(){
createRunnable()
if(velocityY > 10000){
mScrollRunnable?.scrollToClose()
return
}
if(Math.abs(mChildView!!.translationY) < (mHeight /2)){
mScrollRunnable?.scrollToOpen()
}else{
mScrollRunnable?.scrollToClose()
}
}
open fun isClose():Boolean{
return mChildView!=null && mChildView!!.translationY.toInt() <= - mHeight.toInt()
}
open fun scrollToOpen(){
createRunnable()
mScrollRunnable?.scrollToOpen()
}
}
open class ScrollerRunnable(private var scroller:Scroller,
private var childView:View,
private var height:Int):Runnable{
open fun scrollToOpen(){
val scrollY = childView.translationY.toInt()
scroller.startScroll(0,scrollY,0,-scrollY)
startScroll()
}
open fun scrollToClose(){
val currY = childView.translationY.toInt()
val scrollY = height - Math.abs(currY)
scroller.startScroll(0,currY,0,-scrollY)
startScroll()
}
private fun startScroll(){
if(scroller.computeScrollOffset()){
childView.postDelayed(this,16)
}
}
override fun run() {
if(scroller.computeScrollOffset()){
childView.translationY = scroller.currY.toFloat()
childView.postDelayed(this,16)
}
}
}
ContentBehavior
的代码:
class ContentBehavior:HeaderScrollingViewBehavior {
constructor(ctx: Context, attrs: AttributeSet): super(ctx,attrs)
//依赖头部部分的滚动上面
override fun layoutDependsOn(parent: CoordinatorLayout?, child: View?, dependency: View): Boolean {
return dependency.id == R.id.fl_head
}
//HeaderScrollingViewBehavior里面的onMeasureChild使用
override fun findFirstDependency(views: MutableList<View>): View? {
for (i in views.indices) {
val view = views[i]
if (view.id == R.id.fl_head) {
return view
}
}
return null
}
//结合依赖进行监听
override fun onDependentViewChanged(parent: CoordinatorLayout?, child: View?, dependency: View?): Boolean {
offsetChild(child!!, dependency!!)
return super.onDependentViewChanged(parent, child, dependency)
}
private fun offsetChild(child: View, dependency: View) {
//-0.5f滑动慢,最后一下没监听到
child.translationY = (dependency.translationY - 0.5f) * Utils.getScrollFriction()
}
}
SearchBehavior
的代码如下:
class SearchBehavior:CoordinatorLayout.Behavior<View> {
private val mMarginLeft:Int
private val mMarginRight:Int
private var mHeight:Float
private var mExpend:Boolean = true
private var mContext:Context
private var mSet: AutoTransition
private val mAnimDuration:Long = 300
constructor(ctx: Context, attributeSet: AttributeSet):super(ctx,attributeSet){
this.mMarginLeft = ctx.resources.getDimension(R.dimen.search_margin_left).toInt()
this.mMarginRight = ctx.resources.getDimension(R.dimen.search_margin_right).toInt()
this.mHeight = Utils.getScrollHeight(ctx)
mContext = ctx
mSet = AutoTransition()
mSet.duration = mAnimDuration
}
override fun layoutDependsOn(parent: CoordinatorLayout?, child: View?, dependency: View): Boolean {
return dependency.id == R.id.fl_head
}
override fun onDependentViewChanged(parent: CoordinatorLayout?, child: View?, dependency: View): Boolean {
if(child is ViewGroup){
//-0.5f滑动慢,最后一下没监听到
val currY = dependency.translationY- 0.5f
val alpha = Utils.range(0f,1f,Math.abs(currY)/mHeight*2)
//背景颜色
child.setBackgroundColor(Color.argb(alpha.toInt()*255,255,255,255))
//二维码图标变化
if(child.getChildAt(2) is ImageView){
val ivCode = (child.getChildAt(2) as ImageView)
val codeDrawable = ivCode.drawable
DrawableCompat.setTint(codeDrawable,
if(alpha <= 0.2f) Color.WHITE else Color.argb(alpha.toInt()*255,144,144,144))
ivCode.setImageDrawable(codeDrawable)
}
val expend = Math.abs(currY) >= mHeight
//箭头展示,动画结束后显示
val ivArrow = child.getChildAt(0)
if(!expend){
ivArrow.visibility = View.GONE
}else{
//动画结束后再显示
ivArrow.postDelayed({ivArrow.visibility = View.VISIBLE },mAnimDuration)
}
//根据margin做伸缩变化
toggle(child,expend,false)
}
return super.onDependentViewChanged(parent, child, dependency)
}
fun toggle(targetView: ViewGroup, expend:Boolean, force:Boolean){
if(expend != mExpend||force){
this.mExpend = expend
val height = targetView.height
if(height == 0 && !force){
targetView.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener{
override fun onPreDraw(): Boolean {
targetView.viewTreeObserver.removeOnPreDrawListener(this)
toggle(targetView,expend,true)
return true
}
})
}
if(expend) expand(targetView.getChildAt(1) as CardView) else reduce(targetView.getChildAt(1) as CardView)
}
}
private fun expand(targetView: ViewGroup){
val layoutParams = targetView.layoutParams as RelativeLayout.LayoutParams
layoutParams.width = RelativeLayout.LayoutParams.MATCH_PARENT
layoutParams.setMargins(mMarginLeft, 0, mMarginRight, 0)
targetView.layoutParams = layoutParams
beginDelayedTransition(targetView)
}
private fun reduce(targetView: ViewGroup) {
val layoutParams = targetView.layoutParams as RelativeLayout.LayoutParams
layoutParams.width = RelativeLayout.LayoutParams.MATCH_PARENT
layoutParams.setMargins(mMarginLeft - mMarginLeft*2/3,0, mMarginRight, 0)
targetView.layoutParams = layoutParams
beginDelayedTransition(targetView)
}
private fun beginDelayedTransition(view: ViewGroup) {
TransitionManager.beginDelayedTransition(view, mSet)
}
}
最终的实现效果如上,详细代码MDStudy-Github