自定义可滑动面板效果如下:
“且听细雨,勿湿衣襟” — 聆雨
一、效果要求
1)面板随手指上下滑动,要求流畅性;
2)面板下滑时,手指松开,回到原位;
3)面板上滑,距离不足时,回到原位;上滑之一定距离时,停在一个固定的地方,以便显示面板下面的内容;
4)上下滑动时的过渡回弹效果和面板透明度变化;
5)面板上的要求控制音频播放、展示天气等控制
二、实现难点及实现方法
1)面板本身连同内容随手指上下滑动 —— 自定义ViewGroup没跑了;
2)判断手指上下滑动,然后实现面板上下滑动 —— 重写ViewGroup的onTouchEvent()方法然后利用scroll的方法可实现滑动;
3)上下滑动要求回弹效果 —— 动画接入合适的插值器可以做到;
4)面板上实现一系列操作,包括音频控制,网络数据请求 —— 自定义FrameLayout,然后播放、请求网络等逻辑操作写到Fragment中,最后将Fragment,add进自定义view内即可,这种实现方式最为合理。
自定义View做与手指的交互,而功能的控制实现则放到Fragment中。MVC,View层只做展示,不做功能控制。
三、上代码,具体实现
按照上述需求,一步步实现:
1)首先自定义ViewGroup,且继承自FrameLayout;
public class ScrollPanel extends FrameLayout {
public ScrollPanel(Context context) {
super(context);
}
public ScrollPanel(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ScrollPanel(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
@Override
public void computeScroll() {
super.computeScroll();
}
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}
2)接着重写ScrollPanel的onTouchEvent()方法,用于控制面板随手指滑动;
@Override
public boolean onTouchEvent(MotionEvent event) {
final int action = event.getAction();
final float eventY = event.getY();
switch (action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_MOVE:
if (isOpened && (getHeight() - getScrollY()) < eventY) {
return true;
}
if (isDragging) {
float offset = 0;
if (mLastEventY != 0) offset = mLastEventY - eventY;
setDrawingCacheEnabled(true);
//offset值较小 用于首次启动滑动;getScrollY()值较大 存储了offset和值
scrollTo(0, (int) (getScrollY() + offset));
mLastEventY = eventY;
} else {
mLastEventY = 0;
}
setAlpha(0.85f);
isDragging = true;
break;
case MotionEvent.ACTION_DOWN:
if (isOpened && (getHeight() - getScrollY()) < eventY) {
return false;
} else {
mFirstEventY = eventY;
}
break;
case MotionEvent.ACTION_UP:
if (!isDragging) return false;
if (mFirstEventY > eventY && Math.abs(mFirstEventY - eventY) > DEFAULT_SLIDE_DISTANCE) { //判断手指上滑,同时滑动大于一个域值
//触发条件 移动到指定位置 - 动画结束时要停在指定区域 动画接收参数为手离开屏幕是的纵坐标 第二个长参数是个负值 即为ViewPager的高度
open();
} else {
//滑动不符合要求 回到原始区域 - 动画结束时返回原始状态 动画接收参数为手离开屏幕是的纵坐标 第二个长参数是0
close();
}
clearDragging();
break;
}
return true;
}
仔细看下onTouchEvent()中的逻辑处理:
①MotionEvent.ACTION_MASK 在Android中是应用于多点触摸操作 加上之后就可以处理多点触控的操作;
②MotionEvent.ACTION_DOWN时,用一个变量mLastEventY记录下手指首次触摸屏幕时点的纵坐标;
③MotionEvent.ACTION_MOVE时,获取手指move时的下一个触摸点的纵坐标,与mLastEventY记录的坐标值进行计算,获取两次手指移动的偏移量;接着调用scrollTo()实现面板滑动,接收的第一个参数为0,表示原位,第二个参数为getScrollY() + offset,表示面板偏移量。(注意,单独使用getScrollY() ,不会发生滑动;单独使用offset偏移量很小);最后在面板发生滑动之后,将ScrollPanel的透明度alpha设为0.85f;
④setDrawingCacheEnabled(true)用以优化,提高绘图速度,防止卡顿;
⑤MotionEvent.ACTION_UP时,进行手指滑动方向和滑动距离判断:若手指下滑,手指抬起时则调用close()方法关闭面板;若手指上滑,进一步作出判断,滑动距离足够时(本例中滑动距离自定义为DEFAULT_SLIDE_DISTANCE = 200),执行open()方法,调用动画,使得面板上滑至指定区域;滑动距离很少时,执行close()方法回到原位。
另外setDrawingCacheEnabled(true), 获取cache时会占有一定的内存在不用的时候要设为false。所以每次手指抬起时调用clearDragging(),用以关闭cache,同时归零一系列布尔值。
private void clearDragging() {
mLastEventY = 0;
mFirstEventY = 0;
isDragging = false;
setDrawingCacheEnabled(false);
}
3)上下滑动的过渡动画和透明度变化;
close()方法及对应的下滑动画:
public void close() {
setAnimationCacheEnabled(true);
setAnimatorCloseSet().start();
}
private AnimatorSet setAnimatorCloseSet() {
mCloseAnimator = ValueAnimator.ofInt(getScrollY(), SCROLL_TOP);
mCloseAnimator.setInterpolator(new OvershootInterpolator());
mCloseAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
int height = (Integer) valueAnimator.getAnimatedValue();
scrollTo(0, height);
}
});
mFadeInAnimator = ObjectAnimator.ofFloat(this, "alpha", getAlpha(), 1f);
mAnimatorCloseSet = new AnimatorSet();
mAnimatorCloseSet.setDuration(DEFAULT_CLOSE_DURATION);
mAnimatorCloseSet.play(mCloseAnimator).with(mFadeInAnimator);
mAnimatorCloseSet.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animator) {
}
@Override
public void onAnimationEnd(Animator animator) {
if (mSimplePanelListener != null) {
mSimplePanelListener.onClosed();
}
isOpened = false;
}
@Override
public void onAnimationCancel(Animator animator) {
}
@Override
public void onAnimationRepeat(Animator animator) {
}
});
return mAnimatorCloseSet;
}
①实现滑动的动画效果:close()方法调用的时机是在手指抬起,判断需要返回原位的时候执行,这里使用ValueAnimator,构造方法内传入两个参数——面板滑动的距离getScrollY()和面板复位值0,然后调用valueAnimator.getAnimatedValue()获取动画执行的当前点的值height ,传入scrollTo()方法里面,实现手指抬起时面板复位;
②实现面板滑动时的透明度变化,这里藉由属性动画ObjectAnimator.ofFloat(this, "alpha", getAlpha(), 1f)实现,将MotionEvent.ACTION_MOVE事件内改为0.85f透明度的面板透明度变为1f,完全不透明。
最后通过动画组合mAnimatorOpenSet的方式执行滑动动画和透明度变化动画。
open()方法及对应的上滑动画:
public void open() {
setAnimationCacheEnabled(true);
setAnimatorOpenSet().start();
}
private AnimatorSet setAnimatorOpenSet() {
height = getResources().getDisplayMetrics().heightPixels ;
int scrollTo = (int) (height * ratio);
mOpenAnimator = ValueAnimator.ofInt(getScrollY(), scrollTo);
mOpenAnimator.setInterpolator(new OvershootInterpolator());
mOpenAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
int height = (Integer) valueAnimator.getAnimatedValue();
scrollTo(0, height);
}
});
//在自定义View中使用ObjectAnimator属性动画时,第一个参数传入this即可
mFadeOutAnimator = ObjectAnimator.ofFloat(this, "alpha", getAlpha(), 0.85f);
mAnimatorOpenSet = new AnimatorSet();
mAnimatorOpenSet.setDuration(DEFAULT_OPEN_DURATION);
mAnimatorOpenSet.play(mOpenAnimator).with(mFadeOutAnimator);
mAnimatorOpenSet.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animator) {
}
@Override
public void onAnimationEnd(Animator animator) {
if (mSimplePanelListener != null) {
mSimplePanelListener.onOpened();
}
isOpened = true;
}
@Override
public void onAnimationCancel(Animator animator) {
}
@Override
public void onAnimationRepeat(Animator animator) {
}
});
return mAnimatorOpenSet;
}
①实现的逻辑同下滑是一样的,只不过,要注意,手指上滑超过一定距离时,此时面板不是复位,而是停在一个固定的地方不动,将面板下面的内容展示出来,本例中面板下面是一个ViewPager+Fragment实现的内容布局,所以停住的位置应该刚好放下ViewPager。
②这里我们通过 height = getResources().getDisplayMetrics().heightPixels 获取手机屏幕的高度,然后乘以一个系数值0.382,同时在Activity代码里将ViewPager的高度也动态的设置这个值,那么就可以实现高度刚好合适展示内容的需求。
③动画使用的插值器是 OvershootInterpolator - 结束偏移插值器,即动画完成时还滑动一小段距离,可实现过渡滑动效果。
自定义View — ScrollPanel 完整实现如下:
package com.chinstyle.scrollpanel;
import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.animation.OvershootInterpolator;
import android.widget.FrameLayout;
/**
* 作者 Chin_style
* 时间 2018/12/1
* 文件 ScrollPanel
* 描述 ①MotionEvent.ACTION_MASK 在Android中是应用于多点触摸操作 加上之后就可以处理多点触控的操作;
* ②利用Scroll类实现自定义view滑动的标准写法 scrollTo(0, (int) (getScrollY() + offset)); 后者是发动机(首次滑动) 前者是存储动力源(持续存储滑动值)
* ③关闭动画mOpenAnimator = ValueAnimator.ofInt(getScrollY(), scrollTo); 打开动画mOpenAnimator = ValueAnimator.ofInt(getScrollY(), scrollTo);
* 都是利用getScrollY()获取滑动偏移量,第二个参数要么是起始值0,要么是固定值- 按照一定比例取view的Height;
* ④核心思想:move时滑动在move中定义好;滑动结束时,操作在up事件中定义好,利用动画实现对应的逻辑。
* ⑤动画使用的插值器为 OvershootInterpolator
* ⑥onTouchEvent中move事件,使用Scroll类来实现滑动效果,亦可以使用Scroller类进行优化 实现有过渡的滑动效果。
* ⑦setDrawingCacheEnabled(true)用以优化,提高绘图速度,防止卡顿 获取cache时会占有一定的内存在不用的时候要设为false。
* 致谢 Thank you for your advice.
*/
public class ScrollPanel extends FrameLayout {
private static final int SCROLL_TOP = 0;
private static final float DEFAULT_SLIDE_DISTANCE = 200;
public float ratio = (float) 0.382;
private static int DEFAULT_CLOSE_DURATION = 250;
private static int DEFAULT_OPEN_DURATION = 300;
private boolean isOpened = false;
private boolean isDragging = false;
private float mLastEventY = 0;
private float mFirstEventY = 0;
private ValueAnimator mOpenAnimator;
private ObjectAnimator mFadeOutAnimator;
private AnimatorSet mAnimatorOpenSet;
private ValueAnimator mCloseAnimator;
private AnimatorSet mAnimatorCloseSet;
private ObjectAnimator mFadeInAnimator;
private SimplePanelListener mSimplePanelListener;
private int height;
public ScrollPanel(Context context) {
super(context);
}
public ScrollPanel(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ScrollPanel(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
@Override
public void computeScroll() {
super.computeScroll();
}
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
final int action = event.getAction();
final float eventY = event.getY();
switch (action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_MOVE:
if (isOpened && (getHeight() - getScrollY()) < eventY) {
return true;
}
if (isDragging) {
float offset = 0;
if (mLastEventY != 0) offset = mLastEventY - eventY;
setDrawingCacheEnabled(true);
scrollTo(0, (int) (getScrollY() + offset)); //offset值较小 用于首次启动滑动;getScrollY()值较大 存储了offset和值
mLastEventY = eventY;
} else {
mLastEventY = 0;
}
setAlpha(0.85f);
isDragging = true;
break;
case MotionEvent.ACTION_DOWN:
if (isOpened && (getHeight() - getScrollY()) < eventY) {
return false;
} else {
mFirstEventY = eventY;
}
break;
case MotionEvent.ACTION_UP:
if (!isDragging) return false;
if (mFirstEventY > eventY && Math.abs(mFirstEventY - eventY) > DEFAULT_SLIDE_DISTANCE) { //判断手指上滑,同时滑动大于一个域值
open(); //触发条件 移动到指定位置 - 动画结束时要停在指定区域 动画接收参数为手离开屏幕是的纵坐标 第二个长参数是个负值 即为ViewPager的高度
} else {
close(); //滑动不符合要求 回到原始区域 - 动画结束时返回原始状态 动画接收参数为手离开屏幕是的纵坐标 第二个长参数是0
}
clearDragging();
break;
}
return true;
}
private void clearDragging() {
mLastEventY = 0;
mFirstEventY = 0;
isDragging = false;
setDrawingCacheEnabled(false);
}
public void open() {
setAnimationCacheEnabled(true);
setAnimatorOpenSet().start();
}
public void close() {
setAnimationCacheEnabled(true);
setAnimatorCloseSet().start();
}
private AnimatorSet setAnimatorOpenSet() {
height = getResources().getDisplayMetrics().heightPixels ;
int scrollTo = (int) (height * ratio);
mOpenAnimator = ValueAnimator.ofInt(getScrollY(), scrollTo);
mOpenAnimator.setInterpolator(new OvershootInterpolator());
mOpenAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
int height = (Integer) valueAnimator.getAnimatedValue();
scrollTo(0, height);
}
});
//在自定义View中使用ObjectAnimator属性动画时,第一个参数传入this即可
mFadeOutAnimator = ObjectAnimator.ofFloat(this, "alpha", getAlpha(), 0.85f);
mAnimatorOpenSet = new AnimatorSet();
mAnimatorOpenSet.setDuration(DEFAULT_OPEN_DURATION);
mAnimatorOpenSet.play(mOpenAnimator).with(mFadeOutAnimator);
mAnimatorOpenSet.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animator) {
}
@Override
public void onAnimationEnd(Animator animator) {
if (mSimplePanelListener != null) {
mSimplePanelListener.onOpened();
}
isOpened = true;
}
@Override
public void onAnimationCancel(Animator animator) {
}
@Override
public void onAnimationRepeat(Animator animator) {
}
});
return mAnimatorOpenSet;
}
private AnimatorSet setAnimatorCloseSet() {
mCloseAnimator = ValueAnimator.ofInt(getScrollY(), SCROLL_TOP);
mCloseAnimator.setInterpolator(new OvershootInterpolator());
mCloseAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
int height = (Integer) valueAnimator.getAnimatedValue();
scrollTo(0, height);
}
});
mFadeInAnimator = ObjectAnimator.ofFloat(this, "alpha", getAlpha(), 1f);
mAnimatorCloseSet = new AnimatorSet();
mAnimatorCloseSet.setDuration(DEFAULT_CLOSE_DURATION);
mAnimatorCloseSet.play(mCloseAnimator).with(mFadeInAnimator);
mAnimatorCloseSet.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animator) {
}
@Override
public void onAnimationEnd(Animator animator) {
if (mSimplePanelListener != null) {
mSimplePanelListener.onClosed();
}
isOpened = false;
}
@Override
public void onAnimationCancel(Animator animator) {
}
@Override
public void onAnimationRepeat(Animator animator) {
}
});
return mAnimatorCloseSet;
}
public boolean isOpened() {
return isOpened;
}
public void setSlideRatio(float r) {
this.ratio = r;
}
public float getSlideRatio() {
return ratio;
}
public void addSimplePanelListener(SimplePanelListener listener) {
mSimplePanelListener = listener;
}
public interface SimplePanelListener {
abstract public void onOpened();
abstract public void onClosed();
}
}
四、如何使用ScrollPanel
ScrollPanel 继承自FrameLayout,且四个构造方法都实现了,这里我们按照刚开始的项目分析来实现:
1)在Activity的XML布局中静态的添加进ScrollPanel ;
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tab="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/dark_blue"
tools:context=".RainActivity">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_centerInParent="true"
android:layout_marginTop="90dp"
android:gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:lineSpacingExtra="10sp"
android:text="@string/rain_poetry"
android:textColor="@color/berry_blue"
android:textSize="14sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/activity_main_bottom_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:orientation="vertical">
<android.support.v4.view.ViewPager
android:id="@+id/activity_main_view_pager"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:overScrollMode="never">
</android.support.v4.view.ViewPager>
</LinearLayout>
</RelativeLayout>
<com.love.rain.view.SimplePanel
android:id="@+id/activity_main_panel_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="visible">
</com.love.rain.view.SimplePanel>
</FrameLayout>
根布局是FrameLayout,上面一层放入自定义ViewScrollPanel,下面一层放入一个TextView(展示古诗)和ViewPager;
2)Activity代码里进行调用,将Fragmentadd进ScrollPanel;
getSupportFragmentManager()
.beginTransaction()
.replace(R.id.activity_main_panel_layout, mFrontPanelFragment)
.commit();
同时调整ViewPager的高度,用以配合ScrollPanel进行内容展示,height和ratio同ScrollPanel中设置的:
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) mLinearLayout.getLayoutParams();
params.height = (int) (height*ratio);
mLinearLayout.setLayoutParams(params);
总结一下:
手滑动时,面板随手指的滑动是通过重写onTouchEvent()方法,利用view的scrollTo()方法实现面板滑动;
手抬起时,面板滑动到指定位置是通过属性动画ValueAnimator实现的(用ObjectorAnimator当然也行)
最后,代码已上传至github , https://github.com/shenbuqingyun/ScrollPanel
谢谢阅读!