-
实现效果
anim.gif
图中的灰色区域都是可滑动区域,其中我用三种文本颜色表示区域中三个不同的View,黑色部分是TextView,蓝色部分是LinearLayout,红色部分是ViewPager内部的内容。
-
技术难点
首先要禁用ViewPager的随手指拖动,手指滑动如果抬起时没有一个fling的势头则不会触发动画效果;
其次点击灰色区域的手势监听实现滚动的同时,不能影响ViewPager内容的点击事件,也就是点击红色区域还会触发点击事件;
最后就是这三部分的动画是联动的。
-
布局和代码
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:paddingTop="15dp" android:paddingBottom="15dp" android:background="@android:color/darker_gray" android:layout_width="match_parent" android:layout_height="wrap_content" > <TextView android:id="@+id/airport" android:layout_width="0dp" android:layout_weight="1" android:layout_marginStart="23dp" android:layout_height="wrap_content" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:text="无题" android:textSize="20sp" android:textColor="@android:color/black" /> <LinearLayout android:id="@+id/ll_title" android:layout_marginTop="15dp" android:layout_marginStart="23dp" android:layout_marginEnd="20dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/airport" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" > <TextView android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="姓名" android:textSize="12sp" android:textColor="@android:color/holo_blue_dark" /> <TextView android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="性别" android:textSize="12sp" android:textColor="@android:color/holo_blue_dark" /> <TextView android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="年龄" android:textSize="12sp" android:textColor="@android:color/holo_blue_dark" /> <TextView android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="身高" android:textSize="12sp" android:textColor="@android:color/holo_blue_dark" /> <TextView android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="臂展" android:textSize="12sp" android:textColor="@android:color/holo_blue_dark" /> </LinearLayout> <com.mph.jetpackproj.cc_demo.view_pager.NoScrollViewPager android:id="@+id/flightDataPanel" android:layout_marginStart="23dp" android:layout_marginEnd="20dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/ll_title" android:layout_width="match_parent" android:layout_height="wrap_content" /> <LinearLayout android:id="@+id/indicator" android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/flightDataPanel" app:layout_constraintEnd_toEndOf="parent" android:orientation="horizontal" android:padding="5dp" > </LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout>
airport对应黑色部分View,ll_title对应蓝色部分View,flightDataPanel对应红色部分ViewPager。
NoScrollViewPager继承自ViewPager:
/** * * @author mph * @date 2020/9/24 */ class NoScrollViewPager(context: Context, attrs: AttributeSet?) : ViewPager(context, attrs) { private var beforeX = 0f private var lastX = 0f companion object { private const val FLING_MIN_VELOCITY = 100 } private lateinit var mVelocityTracker: VelocityTracker /** * wrap_content高度 */ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { var newHeightMeasureSpec = heightMeasureSpec var height = 0 for (i in 0 until childCount) { val child: View = getChildAt(i) child.measure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)) val h: Int = child.measuredHeight if (h > height) height = h } newHeightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) super.onMeasure(widthMeasureSpec, newHeightMeasureSpec) } /** * 禁止滑动 */ override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { when (ev.action) { MotionEvent.ACTION_DOWN -> { mVelocityTracker = VelocityTracker.obtain() mVelocityTracker.addMovement(ev) beforeX = ev.x } MotionEvent.ACTION_MOVE -> { mVelocityTracker.addMovement(ev) } MotionEvent.ACTION_UP -> { lastX = ev.x mVelocityTracker.addMovement(ev) //最近500毫秒内的速度 mVelocityTracker.computeCurrentVelocity(500) val velocityX = abs(mVelocityTracker.xVelocity) mVelocityTracker.clear() mVelocityTracker.recycle() return when { beforeX == lastX -> { // 点击事件 super.onInterceptTouchEvent(ev) } velocityX > FLING_MIN_VELOCITY -> { if (beforeX > lastX) { // 向左滑动 listener?.onLeftFling() } else if (beforeX < lastX) { // 向右滑动 listener?.onRightFling() } beforeX = 0f lastX = 0f true } else -> { true } } } } return false } interface FlingListener { fun onLeftFling() fun onRightFling() } private var listener: FlingListener? = null fun setFlingListener(listener: FlingListener?) { this.listener = listener } }
VelocityTracker是用来检测手势滑动速度的,在ACTION_DOWN的时候obtain,并且在每个事件中都要addMovement,最后在ACTION_UP时通过computeCurrentVelocity(500)计算最近500毫秒内的速度,也就是松开手指时的速度,然后调用mVelocityTracker.xVelocity获取x轴上的速度,这里我判断大于100则属于onFling动作beforeX 和lastX大小来判断是左滑还是右滑,最后记得clear和recycle。同时注意这里并没有消费掉点击事件。
ViewPager部分解决了再来看整个的灰色区域的View封装:
/** * * @author mph * @date 2020/9/24 */ class AirportBigScreen @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : LinearLayout(context, attrs, defStyleAttr), GestureDetector.OnGestureListener, View.OnTouchListener { companion object { //左滑/右滑 private const val LEFT_SCROLL = 0 private const val RIGHT_SCROLL = 1 //动画 private const val DELAY_TIME_DURATION = 180L private const val ANIM_DURATION = 200L } private var mDetector: GestureDetector = GestureDetector(context, this) private val mRootView = LayoutInflater.from(context).inflate(R.layout.view_airport_big_screen, this) private var mData: ArrayList<BigScreenResponse.Data.AirportDataItem> = arrayListOf() private var mViewPager: NoScrollViewPager private var mAdapter: MyPagerAdapter private var mIndicator: LinearLayout private var mAirport: TextView private var mTitlePanel: LinearLayout private var mAnimSet: AnimatorSet? = null init { setOnTouchListener(this) mViewPager = mRootView.findViewById(R.id.flightDataPanel) mViewPager.setFlingListener(object : NoScrollViewPager.FlingListener { override fun onLeftFling() { // Toast.makeText(context, "向左滑", Toast.LENGTH_SHORT).show() switchPager(mViewPager.currentItem + 1, LEFT_SCROLL) } override fun onRightFling() { // Toast.makeText(context, "向右滑", Toast.LENGTH_SHORT).show() switchPager(mViewPager.currentItem - 1, RIGHT_SCROLL) } }) mAdapter = MyPagerAdapter(context) mViewPager.adapter = mAdapter mIndicator = mRootView.findViewById(R.id.indicator) mAirport = mRootView.findViewById(R.id.airport) mTitlePanel = mRootView.findViewById(R.id.ll_title) } fun setData(data: List<BigScreenResponse.Data.AirportDataItem>) { mData.clear() mData.addAll(data) mAdapter.notifyDataSetChanged() //指示标记 initIndicator() } private fun initIndicator() { mIndicator.removeAllViews() //只有一个机场的时候不显示下面的切换提示View if (mData.size > 1) { for (i in mData?.indices ?: IntRange(0, 0)) { val item = View(context) val lp: LinearLayout.LayoutParams = LinearLayout.LayoutParams( dip2px(context, 8f), dip2px(context, 2f) ) lp.leftMargin = dip2px(context, 2f) lp.rightMargin = dip2px(context, 2f) item.layoutParams = lp item.setBackgroundResource(R.drawable.module_main_selector_indicator_item_back) item.isSelected = false mIndicator.addView(item) } if (null != mIndicator.getChildAt(mViewPager.currentItem)) { mIndicator.getChildAt(mViewPager.currentItem).isSelected = true } } mAirport.text = mData[0].airportName } /** * @param index viewpager新标签下标 * @param flag 触发事件是左滑还是右滑 */ private fun switchPager(index: Int, flag: Int) { //如果合法范围内 if (index >= 0 && index < mData.size) { //触发动画 when (flag) { LEFT_SCROLL -> startAnimOnLeftScroll(index) RIGHT_SCROLL -> startAnimOnRightScroll(index) } } } /** * 左滑 */ private fun startAnimOnLeftScroll(index: Int) { onLeftOut(index) } /** * 右滑 */ private fun startAnimOnRightScroll(index: Int) { onRightOut(index) } /** * 左边滑出 */ private fun onLeftOut(index: Int) { mAnimSet = AnimatorSet() val animator0 = ObjectAnimator.ofFloat( mAirport, "translationX", -(mAirport.width + mAirport.marginStart).toFloat() ) val animator1 = ObjectAnimator.ofFloat( mTitlePanel, "translationX", -(mTitlePanel.width + mTitlePanel.marginStart).toFloat() ) val animator2 = ObjectAnimator.ofFloat( mViewPager, "translationX", -(mViewPager.width + mViewPager.marginStart).toFloat() ) animator1.startDelay = DELAY_TIME_DURATION animator2.startDelay = DELAY_TIME_DURATION mAnimSet?.duration = ANIM_DURATION mAnimSet?.interpolator = LinearInterpolator() mAnimSet?.playTogether( animator0, animator1, animator2 ) mAnimSet?.addListener(object : Animator.AnimatorListener { override fun onAnimationRepeat(animation: Animator?) { } override fun onAnimationEnd(animation: Animator?) { //动画结束切换viewpager mViewPager.setCurrentItem(index, false) /**更新机场名字*/ val info: BigScreenResponse.Data.AirportDataItem = mData[mViewPager.currentItem] mAirport.text = info.airportName mAirport.translationX = getScreenWidth(context).toFloat() mTitlePanel.translationX = getScreenWidth(context).toFloat() mViewPager.translationX = getScreenWidth(context).toFloat() onRightIn() } override fun onAnimationCancel(animation: Animator?) { } override fun onAnimationStart(animation: Animator?) { } }) mAnimSet?.start() } /** * 右边滑入 */ private fun onRightIn() { mAnimSet = AnimatorSet() val animator0 = ObjectAnimator.ofFloat( mAirport, "translationX", mRootView.left.toFloat() ) val animator1 = ObjectAnimator.ofFloat( mTitlePanel, "translationX", mRootView.left.toFloat() ) val animator2 = ObjectAnimator.ofFloat( mViewPager, "translationX", mRootView.left.toFloat() ) animator1.startDelay = 150 animator2.startDelay = 150 mAnimSet?.duration = 300 mAnimSet?.interpolator = LinearInterpolator() mAnimSet?.playTogether( animator0, animator1, animator2 ) mAnimSet?.addListener(object : Animator.AnimatorListener { override fun onAnimationRepeat(animation: Animator?) { } override fun onAnimationEnd(animation: Animator?) { //更新指示条 updateIndicators() } override fun onAnimationCancel(animation: Animator?) { } override fun onAnimationStart(animation: Animator?) { } }) mAnimSet?.start() } /** * 右边滑出 */ private fun onRightOut(index: Int) { mAnimSet = AnimatorSet() val animator0 = ObjectAnimator.ofFloat( mAirport, "translationX", getScreenWidth(context).toFloat() ) val animator1 = ObjectAnimator.ofFloat( mTitlePanel, "translationX", getScreenWidth(context).toFloat() ) val animator2 = ObjectAnimator.ofFloat( mViewPager, "translationX", getScreenWidth(context).toFloat() ) animator0.startDelay = DELAY_TIME_DURATION mAnimSet?.duration = ANIM_DURATION mAnimSet?.interpolator = LinearInterpolator() mAnimSet?.playTogether( animator0, animator1, animator2 ) mAnimSet?.addListener(object : Animator.AnimatorListener { override fun onAnimationRepeat(animation: Animator?) { } override fun onAnimationEnd(animation: Animator?) { //动画结束切换viewpager mViewPager.setCurrentItem(index, false) /**更新机场名字*/ val info: BigScreenResponse.Data.AirportDataItem = mData[mViewPager.currentItem] mAirport.text = info.airportName mAirport.translationX = -(mAirport.width + mAirport.marginStart).toFloat() mTitlePanel.translationX = -(mTitlePanel.width + mTitlePanel.marginStart).toFloat() mViewPager.translationX = -(mViewPager.width + mViewPager.marginStart).toFloat() onLeftIn() } override fun onAnimationCancel(animation: Animator?) { } override fun onAnimationStart(animation: Animator?) { } }) mAnimSet?.start() } /** * 左边滑入 */ private fun onLeftIn() { mAnimSet = AnimatorSet() val animator0 = ObjectAnimator.ofFloat( mAirport, "translationX", (mRootView.left).toFloat() ) val animator1 = ObjectAnimator.ofFloat( mTitlePanel, "translationX", (mRootView.left).toFloat() ) val animator2 = ObjectAnimator.ofFloat( mViewPager, "translationX", (mRootView.left).toFloat() ) animator0.startDelay = DELAY_TIME_DURATION mAnimSet?.duration = ANIM_DURATION mAnimSet?.interpolator = LinearInterpolator() mAnimSet?.playTogether( animator0, animator1, animator2 ) mAnimSet?.addListener(object : Animator.AnimatorListener { override fun onAnimationRepeat(animation: Animator?) { } override fun onAnimationEnd(animation: Animator?) { //更新指示条 updateIndicators() } override fun onAnimationCancel(animation: Animator?) { } override fun onAnimationStart(animation: Animator?) { } }) mAnimSet?.start() } /** * @desc 更新indicator */ private fun updateIndicators() { //只有一个机场的时候不显示下面的切换提示View if (mData.size > 1) { for (i in mData.indices) { mIndicator.getChildAt(i).isSelected = false } mIndicator.getChildAt(mViewPager.currentItem).isSelected = true } } inner class MyPagerAdapter(context: Context) : PagerAdapter() { private var mContext = context override fun isViewFromObject(view: View, `object`: Any): Boolean { return view == `object` } override fun getCount(): Int { return mData.size } @SuppressLint("InflateParams") override fun instantiateItem(container: ViewGroup, position: Int): Any { return (LayoutInflater.from(mContext) .inflate(R.layout.vp_item, null) as RecyclerView).apply { layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL)) val mAdapter = AirportFlightAdapter( mData[position].flightInfo, R.layout.view_large_screen_adapter_item ) adapter = mAdapter container.addView(this) } } override fun getItemPosition(`object`: Any): Int { return POSITION_NONE } override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) { container.removeView(`object` as View) } } override fun onTouch(v: View?, event: MotionEvent?): Boolean { return mDetector.onTouchEvent(event) } override fun onShowPress(e: MotionEvent?) { } override fun onSingleTapUp(e: MotionEvent?): Boolean { return false } override fun onDown(e: MotionEvent?): Boolean { return true } override fun onFling( e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float ): Boolean { val e1RawX: Float = e1?.rawX ?: 0f val e2RawX: Float = e2?.rawX ?: 0f if (abs(e2RawX - e1RawX) > 20) { //往右滑 if (e2RawX - e1RawX > 0) { // Toast.makeText(context, "往右滑--x轴加速度: $velocityX", Toast.LENGTH_SHORT).show() switchPager(mViewPager.currentItem - 1, RIGHT_SCROLL) } else if (e2RawX - e1RawX < 0) { // Toast.makeText(context, "往左滑--x轴加速度: $velocityX", Toast.LENGTH_SHORT).show() switchPager(mViewPager.currentItem + 1, LEFT_SCROLL) } return true } return true } override fun onScroll( e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float ): Boolean { return false } override fun onLongPress(e: MotionEvent?) { } private fun dip2px(context: Context?, dipValue: Float?): Int { if (context == null || dipValue == null) { return 0 } return (context.resources.displayMetrics.density * dipValue).roundToInt() } fun getScreenWidth(context: Context?): Int { if (context == null) { return 0 } return context.resources.displayMetrics.widthPixels } }
mViewPager.setFlingListener来设置ViewPager在onFling时的操作,这个操作就是动画、标题切换和ViewPager显示页切换,同样在封装View本身的onFling时也要触发这一系列操作,封装View本身的手势监听我们用GestureDetector.OnGestureListener来实现,注意我们还实现了View.OnTouchListener的onTouch方法来把触摸事件交给mDetector.onTouchEvent(event)处理,这样就可以监听手势变化。
滑动触发的操作起点就是switchPager方法,一个参数是ViewPager要切换的新tab的index,另一个是左/右滑动标志,阅读代码可知,它是有一个特定的执行顺序的,比如左滑的顺序就是,mAirport、mTitlePanel、mViewPager向左滑出屏幕,然后调用mViewPager.setCurrentItem()切换tab,同时更改mAirport的值,因为在执行右边划入的时候看到的应该是即将要显示的新的值,所以在此时滑出屏幕后不可见时要默默地切换成新值。然后在右边滑入动画前执行setTranslationX方法让这三个View都移动到屏幕右侧不可见,因为右边滑入动画是从右滑到左,所以在它之前要把起始位置修改到左边,不然只会看到一个左边滑出的reverse效果,可以看成setTranslationX操作是瞬间完成的,看不到从左到右的效果。最后执行右边滑入操作。
最后要讲一下动画的实现:
ObjectAnimator.ofFloat方法第一个参数是要执行动画的对象,这里显然是TextView、LinearLayout和ViewPager,第二个参数是要改变的属性,这里是translationX,最后是可变参数,可有多个值,意思是View所在当前坐标轴y轴要移动到的距离,View的位置自然会跟着y轴变化。
左边滑出时设置的是-(mAirport.width + mAirport.marginStart).toFloat(),因为mAirport初始位置离屏幕左边缘有一个margin,所以向左移动到不可见的话除了要移动一个View的宽度还要移动一个margin的宽度,接下来setTranslationX和属性动画的设置效果一样,mAirport.translationX = getScreenWidth(context).toFloat()就是把mAirport移动到屏幕右边缘外面,最后再调用右边滑入的属性动画把View恢复到原来位置,至此整个效果就完成了。
三个动画使用AnimatorSet来联动执行,注意mAirport的动画和另外两个View的动画是有时间差值的,左滑的时候mAirport动画先执行,右滑的时候mAirport动画延迟执行。
-
总结和demo地址
实现的关键就是translationX属性的属性动画,还有view.setTranslationX(float x)进行平移,他们两个都是相当于移动坐标系y轴,比如说初始位置在距屏幕左侧30像素的位置,然后translationX设置成30(即y轴移动到30的位置上),那此时的y轴就是在30像素的位置上,自然View会向右移动到距离屏幕左侧60像素的位置上,margin也会影响偏移,所以偏移的时候要考虑margin。
demo地址:目录是JetpackLearning /app /src /main /java /com /mph /jetpackproj /cc_demo /view_pager
网友评论