贝塞尔曲线下拉控件动画效果实现

导语:

根据手势做自己想要的动画效果呈现到界面,是一件超级酷炫的事情!阅读本文需要你了解这几个知识点:

1、贝塞尔曲线绘制方法
2、差值器之DecelerateInterpolator
3、Touch事件拦截机制
4、手势滑动监听
5、View的动态布局
6、自定义View

一、绘制贝塞尔曲线

自定义WaveView,重写onDraw方法。
<pre><code>
@Override
protected void onDraw(Canvas canvas) {

super.onDraw(canvas);

    //重置画笔
    path.reset();
    path.lineTo(0, headHeight);
    //绘制贝塞尔曲线
    path.quadTo(getMeasuredWidth() / 2, headHeight + waveHeight, getMeasuredWidth(), headHeight);
    path.lineTo(getMeasuredWidth(), 0);
    canvas.drawPath(path, paint);
}

</pre></code>

可以看出绘制贝塞尔曲线用的path.quadTo方法:

quadTo(float x1, float x2, float y1, float y2)
x1,y1为控制点的坐标,x2,y2为终点坐标值。

headHeight为绘制区域头部矩形区域,waveHeight为贝塞尔曲线区域。

WaveView的代码如下:
<pre><code>
public class WaveView extends View {

private int waveHeight;

private int headHeight;

private Path path;

private Paint paint;

private int color;

public WaveView(Context context) {
    this(context, null, 0);
}

public WaveView(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
}

public WaveView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    init();
}

private void init() {
    path = new Path();
    paint = new Paint();
    paint.setColor(Color.argb(150, 43, 43, 43));
    paint.setAntiAlias(true);
}

public void setColor(int color) {
    this.color = color;
    paint.setColor(color);
    invalidate();
}

public int getHeadHeight() {
    return headHeight;
}

public void setHeadHeight(int headHeight) {
    this.headHeight = headHeight;
}

public int getWaveHeight() {
    return waveHeight;
}

public void setWaveHeight(int waveHeight) {
    this.waveHeight = waveHeight;
}


@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    //重置画笔
    path.reset();
    path.lineTo(0, headHeight);
    //绘制贝塞尔曲线
    path.quadTo(getMeasuredWidth() / 2, headHeight + waveHeight, getMeasuredWidth(), headHeight);
    path.lineTo(getMeasuredWidth(), 0);
    canvas.drawPath(path, paint);
}

}
</pre></code>

如果定义headHeigt=100,waveHeight=200,绘制出来的View如下:

Paste_Image.png

二、动态布局

为了使下拉刷新控件适用任何布局,需要自定义一个布局,最好是继承FrameLayout布局,因为FrameLayout布局是叠加的。
在onAttachedToWindow方法中再新建一个FrameLayout,将下拉刷新头部的贝塞尔控件和文案显示控件放置里面,置顶。
<pre><code>
@Override
protected void onAttachedToWindow() {

super.onAttachedToWindow();

    //添加一个FrameLayout布局
    
    mFlayout = new FrameLayout(getContext());

    LayoutParams lp = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
    lp.gravity = Gravity.TOP;
    mFlayout.setLayoutParams(lp);
    this.addView(mFlayout);

    //头部贝塞尔控件和文案显示控件
    View refreshView = LayoutInflater.from(getContext()).inflate(R.layout.fresh_layout, null);
    txtRefresh = (TextView) refreshView.findViewById(R.id.txtRefresh);
    waveView = (WaveView) refreshView.findViewById(R.id.wave);
    waveView.setWaveHeight(WAVE_HEIGHT);
    waveView.setHeadHeight(WAVE_HEAD_HEIGHT);
    waveView.invalidate();
    mFlayout.addView(refreshView);

    //获取子控件
    childView = getChildAt(0);
}

</pre></code>

三、Touch事件拦截

下拉刷新,事件拦截有如下两种情况:

1、正在下拉中
2、子控件不能往上滑动

判断是否正在下拉可以用一个布尔值搞定
判断子控件是否能往上滑动需要我们去写一个方法
<pre><code>
/**
* 判断是否可以上拉
* @return
*/
private boolean canChildScrollUp() {
if(childView instanceof AbsListView) {
AbsListView absLv = (AbsListView) childView;
return absLv.getChildCount() > 0
&& (absLv.getFirstVisiblePosition() > 0 || absLv.getChildAt(0).getTop() < absLv.getPaddingTop());
} else {
return ViewCompat.canScrollVertically(childView, -1);
}
}
</pre></code>

这个方法可以用来判断View是否可以往上滑动,这里讲View分成两类,一类是列表ListView控件,一类是普通的View类。ListView控件判断是否有孩子,并且第一孩子需要在界面呈现,并且第一孩子的顶部坐标要小于ListView控件的paddingTop值。普通View类可以根据sdk自带的canScrollVertically去判断,有兴趣可以去看看源码。

该方法为了兼容更多Android系统,建议修改成下面的代码:
<pre><code>
/**
* 用来判断是否可以上拉
*
* @return boolean
*/
public boolean canChildScrollUp() {
if (mChildView == null) {
return false;
}
if (Build.VERSION.SDK_INT < 14) {
if (mChildView instanceof AbsListView) {
final AbsListView absListView = (AbsListView) mChildView;
return absListView.getChildCount() > 0
&& (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
.getTop() < absListView.getPaddingTop());
} else {
return ViewCompat.canScrollVertically(mChildView, -1) || mChildView.getScrollY() > 0;
}
} else {
return ViewCompat.canScrollVertically(mChildView, -1);
}
}
</pre></code>

然后重写onInterceptTouchEvent方法,完善Touch事件拦截
<pre><code>
public boolean onInterceptTouchEvent(MotionEvent ev) {

    if(mIsRefreshing) {
        return true; //如果下拉刷新,则拦截
    }
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            mTouchY =  ev.getY();
            mCurrentY = mTouchY;
        case MotionEvent.ACTION_MOVE:
            float currentY = ev.getY();
            float y = currentY - mTouchY; //计算当前滑动距离
            if(y > 0 && !canChildScrollUp()) {
                return true;
            }
            break;
    }
    return super.onInterceptTouchEvent(ev);
}

</pre></code>

手势滑动监听

监听手势滑动以及手势取消两个过程,即ACTION_MOVE和ACTION_CANCLE | ACTION_UP。
滑动过程主要根据滑动距离做动画效果,以及判断下拉刷新状态。滑动结束主要处理子控件的位置回归何处。当然,当onInterceptTouchEvent方法返回true,表示当前FrameLayout拦截Touch事件,触摸事件就会交给onTouch处理,所以重写onTouch方法如下:
<pre><code>
public boolean onTouchEvent(MotionEvent event) {
if(mIsRefreshing) {
return super.onTouchEvent(event);
}

    switch (event.getAction()) {
        case MotionEvent.ACTION_MOVE:
            float currentY = event.getY();
            float y = currentY - mTouchY;
            y = Math.min(WAVE_HEIGHT * 2, y);
            y = Math.max(0, y);
            //计算滑动距离
            float offsetY = decelerInterpolator.getInterpolation(y / WAVE_HEIGHT / 2) * y / 2;
            //子控件移动同样距离
            childView.setTranslationY(offsetY);

            //控件高度
            mFlayout.getLayoutParams().height = (int) offsetY;
            mFlayout.requestLayout();

            //贝塞尔曲线
            float fraction = offsetY / WAVE_HEAD_HEIGHT;
            waveView.setHeadHeight((int) (WAVE_HEAD_HEIGHT * limitValue(1, fraction)));
            waveView.setWaveHeight((int) (WAVE_HEIGHT * Math.max(0, fraction - 1)));
            waveView.invalidate();

            if(WAVE_HEAD_HEIGHT > WAVE_HEAD_HEIGHT * limitValue(1, fraction)) {
                txtRefresh.setText("下拉刷新");
            } else {
                txtRefresh.setText("释放刷新");
            }

            return true;
        case MotionEvent.ACTION_CANCEL:
        case MotionEvent.ACTION_UP:
            //如果滑动距离大于贝塞尔头部矩形区域高度,子控件回到矩形区域高度位置,否则子控件置顶
            if(childView.getTranslationY() >= WAVE_HEAD_HEIGHT) {
                setChildViewTransY(WAVE_HEAD_HEIGHT);
            } else {
                setChildViewTransY(0);
            }
            return true;
    }
    return super.onTouchEvent(event);
}

</pre></code>

差值器DecelerateInterpolator

该差值器实现的效果:在动画开始的地方快然后慢。这里就不再赘述其他差值器了,感兴趣可以去看看差值器的源码,需要懂些数学公式。
该下拉刷新控件两个地方用到DecelerateInterpolator差值器,下拉刷新的过程以及刷新完成后的控件位置回归过程。
<pre><code>
/**
* 控件滑动结束后回归动画
* @param values
*/
private void setChildViewTransY(float... values) {
ObjectAnimator ani = ObjectAnimator.ofFloat(childView, View.TRANSLATION_Y, values);
ani.setInterpolator(new DecelerateInterpolator());
ani.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
int height = (int) childView.getTranslationY();
mFlayout.getLayoutParams().height = height;
mFlayout.requestLayout();
}
});
ani.start();
}
</pre></code>

运行效果

源码

<pre><code>
public class WaveFrameLayout extends FrameLayout {

FrameLayout mFlayout;
private boolean mIsRefreshing;//刷新的状态
private float mTouchY;//当前触摸位置
private float mCurrentY;//当前位置
private View childView;
private WaveView waveView;
TextView txtRefresh;

private final int WAVE_HEIGHT = 200;
private final int WAVE_HEAD_HEIGHT = 100;
private DecelerateInterpolator decelerInterpolator;

public WaveFrameLayout(Context context) {
    super(context);
    init(context);
}

public WaveFrameLayout(Context context, AttributeSet attrs) {
    super(context, attrs);
    init(context);
}


public WaveFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    init(context);
}


private void init(Context context) {
    if (isInEditMode()) {
        return;
    }
    decelerInterpolator = new DecelerateInterpolator(10);
}

@Override
protected void onAttachedToWindow() {
    super.onAttachedToWindow();
    //添加一个FrameLayout布局
    mFlayout = new FrameLayout(getContext());
    LayoutParams lp = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
    lp.gravity = Gravity.TOP;
    mFlayout.setLayoutParams(lp);
    this.addView(mFlayout);

    //头部贝塞尔控件和文案显示控件
    View refreshView = LayoutInflater.from(getContext()).inflate(R.layout.fresh_layout, null);
    txtRefresh = (TextView) refreshView.findViewById(R.id.txtRefresh);
    waveView = (WaveView) refreshView.findViewById(R.id.wave);
    waveView.setWaveHeight(WAVE_HEIGHT);
    waveView.setHeadHeight(WAVE_HEAD_HEIGHT);
    waveView.invalidate();
    mFlayout.addView(refreshView);

    //获取子控件
    childView = getChildAt(0);
}

/**
 * 判断是否可以上拉
 * @return
 */
private boolean canChildScrollUp() {
    if(childView instanceof AbsListView) {
        AbsListView absLv = (AbsListView) childView;
        return absLv.getChildCount() > 0
                && (absLv.getFirstVisiblePosition() > 0 || absLv.getChildAt(0).getTop() < absLv.getPaddingTop());
    } else {
        return ViewCompat.canScrollVertically(childView, -1);
    }
}

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    if(mIsRefreshing) {
        return true; //如果下拉刷新,则拦截
    }
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            mTouchY =  ev.getY();
            mCurrentY = mTouchY;
        case MotionEvent.ACTION_MOVE:
            float currentY = ev.getY();
            float y = currentY - mTouchY; //计算当前滑动距离
            if(y > 0 && !canChildScrollUp()) {
                return true;
            }
            break;
    }
    return super.onInterceptTouchEvent(ev);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
    if(mIsRefreshing) {
        return super.onTouchEvent(event);
    }

    switch (event.getAction()) {
        case MotionEvent.ACTION_MOVE:
            float currentY = event.getY();
            float y = currentY - mTouchY;
            y = Math.min(WAVE_HEIGHT * 2, y);
            y = Math.max(0, y);
            //计算滑动距离
            float offsetY = decelerInterpolator.getInterpolation(y / WAVE_HEIGHT / 2) * y / 2;
            //子控件移动同样距离
            childView.setTranslationY(offsetY);

            //控件高度
            mFlayout.getLayoutParams().height = (int) offsetY;
            mFlayout.requestLayout();

            //贝塞尔曲线
            float fraction = offsetY / WAVE_HEAD_HEIGHT;
            waveView.setHeadHeight((int) (WAVE_HEAD_HEIGHT * limitValue(1, fraction)));
            waveView.setWaveHeight((int) (WAVE_HEIGHT * Math.max(0, fraction - 1)));
            waveView.invalidate();

            if(WAVE_HEAD_HEIGHT > WAVE_HEAD_HEIGHT * limitValue(1, fraction)) {
                txtRefresh.setText("下拉刷新");
            } else {
                txtRefresh.setText("释放刷新");
            }

            return true;
        case MotionEvent.ACTION_CANCEL:
        case MotionEvent.ACTION_UP:
            //如果滑动距离大于贝塞尔头部矩形区域高度,子控件回到矩形区域高度位置,否则子控件置顶
            if(childView.getTranslationY() >= WAVE_HEAD_HEIGHT) {
                setChildViewTransY(WAVE_HEAD_HEIGHT);
            } else {
                setChildViewTransY(0);
            }
            return true;
    }
    return super.onTouchEvent(event);
}

/**
 * 控件滑动结束后回归动画
 * @param values
 */
private void setChildViewTransY(float... values) {
    ObjectAnimator ani = ObjectAnimator.ofFloat(childView, View.TRANSLATION_Y, values);
    ani.setInterpolator(new DecelerateInterpolator());
    ani.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            int height = (int) childView.getTranslationY();
            mFlayout.getLayoutParams().height = height;
            mFlayout.requestLayout();
        }
    });
    ani.start();
}


/**
 * 限定值
 */
public float limitValue(float a, float b) {
    float valve = 0;
    final float min = Math.min(a, b);
    final float max = Math.max(a, b);
    valve = valve > min ? valve : min;
    valve = valve < max ? valve : max;
    return valve;
}

}
</pre></code>

布局代码

<pre><code>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<TextView
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="@color/colorPrimary"
android:gravity="center"
android:text="下拉控件"
android:textColor="@color/colorAccent" />

<WaveFrameLayout
    android:id="@+id/waveFlayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <TextView
                android:id="@+id/txtShow"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:gravity="center"
                android:text="hello world!" />
        </LinearLayout>

    </ScrollView>

</WaveFrameLayout>

</LinearLayout>
</pre></code>
布局的格式调不来,注意一点就好,WaveView里面嵌套ScrollView或ListView,才能响应滑动监听。后续加入事件监听,下拉完成后的后续操作。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,290评论 6 491
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,107评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,872评论 0 347
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,415评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,453评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,784评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,927评论 3 406
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,691评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,137评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,472评论 2 326
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,622评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,289评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,887评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,741评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,977评论 1 265
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,316评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,490评论 2 348

推荐阅读更多精彩内容