Android 动画实战-仿微博雷达功能

前言

在应用中使用动画,可以给用户带来良好的交互体验。通过之前对Android动画的分类总结,尝试了使用属性动画实现支付宝支付效果及购物车添加动画的效果,今天在这里模仿一下微博雷达页面效果

对Android动画不太熟悉或遗忘的知识,可以通过下面两篇文章了解。

Android 动画总结Android 动画实战

此次模仿新浪微博雷达页的功能,虽然只有一个Activity,但使用到了很多知识。包括
- 属性动画(雷达效果图)
- Android touch 事件传递机制
- Android 6.0 动态权限判断
- 百度LBS/POI 搜索
- EventBus

有兴趣的同学可以查看Github 源码

效果图

老习惯,先看看效果图。

由于使用模拟器截取gif后,效果实在惨不忍睹,所以只能放上一张静态预览图。有兴趣的小伙伴,可以下载APK_DEMO安装到手机后查看效果。
至于真实的微博雷达效果是怎样,玩微博的同学可以对比一下。

功能分析

这里主要从实现的几个功能点做一下分析。

雷达效果图

总的来说,这个雷达效果图应该是整个微博雷达页面模仿效果相似度最高的一个View。使用属性动画实现这个雷达扫描效果非常简单。

动画初始化

private void initRoateAnimator() {
        mRotateAnimator.setFloatValues(0, 360);
        mRotateAnimator.setDuration(1000);
        mRotateAnimator.setRepeatCount(-1);
        mRotateAnimator.setInterpolator(new LinearInterpolator());
        mRotateAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mRotateDegree = (Float) animation.getAnimatedValue();
                invalidateView();
            }
        });
        mRotateAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationStart(Animator animation) {
                super.onAnimationStart(animation);
                mTipText = "正在探索周边的...";
                //旋转动画启动后启动扫描波纹动画
                mOutGrayAnimator.start();
                mInnerWhiteAnimator.start();
                mBlackAnimator.start();
            }

            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                //取消扫描波纹动画
                mOutGrayAnimator.cancel();
                mInnerWhiteAnimator.cancel();
                mBlackAnimator.cancel();
                //重置界面要素
                mOutGrayRadius = 0;
                mInnerWhiteRadius = 0;
                mBlackRadius = 0;
                mTipText = "未能探索到周边的...,请稍后再试";
                invalidateView();
            }
        });
    }

    private void initOutGrayAnimator() {
        mOutGrayAnimator.setFloatValues(mBlackRadius, getMeasuredWidth() / 2);
        mOutGrayAnimator.setDuration(1000);
        mOutGrayAnimator.setRepeatCount(-1);
        mOutGrayAnimator.setInterpolator(new LinearInterpolator());
        mOutGrayAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mOutGrayRadius = (Float) animation.getAnimatedValue();
            }
        });
    }

    private void initInnerWhiteAnimator() {
        mInnerWhiteAnimator.setFloatValues(0, getMeasuredWidth() / 3);
        mInnerWhiteAnimator.setDuration(1000);
        mInnerWhiteAnimator.setRepeatCount(-1);
        mInnerWhiteAnimator.setInterpolator(new AccelerateInterpolator());
        mInnerWhiteAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mInnerWhiteRadius = (Float) animation.getAnimatedValue();
            }
        });
    }

    private void initBlackAnimator() {
        mBlackAnimator.setFloatValues(0, getMeasuredWidth() / 3);
        mBlackAnimator.setDuration(1000);
        mBlackAnimator.setRepeatCount(-1);
        mBlackAnimator.setInterpolator(new DecelerateInterpolator());
        mBlackAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mBlackRadius = (Float) animation.getAnimatedValue();
            }
        });
    }

这里首先定义了一些动画效果,并在他们各自的Update 回调方法里实现了属性值的更新。这里只有在mRotateAnimator的Update回调了执行了invalidateView(),避免了过渡绘制,浪费资源;属性值每次更新后,就会调用onDraw 方法,会通过canvas绘制视图,这样不断刷新,就会呈现出雷达扫描的效果。

canvas 绘制动画

@Override
    protected void onDraw(Canvas canvas) {
        //绘制波纹
        canvas.drawCircle(getMeasuredWidth() / 2, getMeasuredHeight() / 2, mBlackRadius, mBlackPaint);
        canvas.drawCircle(getMeasuredWidth() / 2, getMeasuredHeight() / 2, mInnerWhiteRadius, mInnerWhitePaint);
        canvas.drawCircle(getMeasuredWidth() / 2, getMeasuredHeight() / 2, mOutGrayRadius, mOutGrayPaint);

        //绘制背景
        Bitmap mScanBgBitmap = getScanBackgroundBitmap();
        if (mScanBgBitmap != null) {
            canvas.drawBitmap(mScanBgBitmap, getMeasuredWidth() / 2 - mScanBgBitmap.getWidth() / 2, getMeasuredHeight() / 2 - mScanBgBitmap.getHeight() / 2, new Paint(Paint
                    .ANTI_ALIAS_FLAG));
        }

        //绘制按钮背景
        Bitmap mButtonBgBitmap = getButtonBackgroundBitmap();
        canvas.drawBitmap(mButtonBgBitmap, getMeasuredWidth() / 2 - mButtonBgBitmap.getWidth() / 2, getMeasuredHeight() / 2 - mButtonBgBitmap.getHeight() / 2, new Paint(Paint.ANTI_ALIAS_FLAG));

        //绘制扫描图片
        Bitmap mScanBitmap = getScanBitmap();
        canvas.drawBitmap(mScanBitmap, getMeasuredWidth() / 2 - mScanBitmap.getWidth() / 2, getMeasuredHeight() / 2 - mScanBitmap.getHeight() / 2, new Paint(Paint.ANTI_ALIAS_FLAG));
        //绘制文本提示
        mTextPaint.getTextBounds(mTipText, 0, mTipText.length(), mTextBound);
        canvas.drawText(mTipText, getMeasuredWidth() / 2 - mTextBound.width() / 2, getMeasuredHeight() / 2 + mScanBackgroundBitmap.getHeight() / 2 + mTextBound.height() + 50, mTextPaint);

    }

滑动推荐或不喜欢

这里上拉推荐,下拉不感兴趣的滑动效果和真实效果有一定差距。实现方案是借鉴下拉刷新和下拉加载框架的内容。只是修改了头部和底部的隐藏View。同时,也需要实现在滑动时,对头部和底部tab的隐藏效果。因此在touch事件的ACTION_DOWN 和ACTION_UP 环节,添加了回调单独处理。

监听滑动状态

   /**
     * 监听当前是否处于滑动状态
     */
    public interface OnPullListener {
    
    
        /**
         * 手指正在屏幕上滑动
         */
        void pull();

        /**
         * 手指已从屏幕离开,结束滑动
          */
        void pullDone();
    }

处理滑动

public boolean onTouchEvent(MotionEvent event) {

        int y = (int) event.getRawY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                // onInterceptTouchEvent已经记录
                // mLastMotionY = y;
                break;
            case MotionEvent.ACTION_MOVE:

                if (mPullListener != null) {
                    mPullListener.pull();
                }

                int deltaY = y - mLastMotionY;
                if (mPullState == PULL_DOWN_STATE) {
                    // PullToRefreshView执行下拉
                    Log.i(TAG, " pull down!parent view move!");
                    headerPrepareToRefresh(deltaY);
                    // setHeaderPadding(-mHeaderViewHeight);
                } else if (mPullState == PULL_UP_STATE) {
                    // PullToRefreshView执行上拉
                    Log.i(TAG, "pull up!parent view move!");
                    footerPrepareToRefresh(deltaY);
                }
                mLastMotionY = y;
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:


                int topMargin = getHeaderTopMargin();
                if (mPullState == PULL_DOWN_STATE) {
                    if (topMargin >= 0) {
                        // 开始刷新
                        headerRefreshing();
                    } else {
                        // 还没有执行刷新,重新隐藏
                        setHeaderTopMargin(-mHeaderViewHeight);
                        setHeadViewAlpha(0);
                        if (mPullListener != null) {
                            mPullListener.pullDone();
                        }
                    }
                } else if (mPullState == PULL_UP_STATE) {
                    if (Math.abs(topMargin) >= mHeaderViewHeight
                            + mFooterViewHeight) {
                        // 开始执行footer 刷新
                        footerRefreshing();
                    } else {
                        // 还没有执行刷新,重新隐藏
                        setHeaderTopMargin(-mHeaderViewHeight);
                        setFootViewAlpha(0);
                        if (mPullListener != null) {
                            mPullListener.pullDone();
                        }
                    }
                }
                break;
        }
        return super.onTouchEvent(event);
    }

* 处理卡片切换*

class MyHeadListener implements SmartPullView.OnHeaderRefreshListener {

        @Override
        public void onHeaderRefresh(SmartPullView view) {
            refreshView.onHeaderRefreshComplete();
            index = index + 1;
            cardAnimActions();
        }


    }
class MyFooterListener implements SmartPullView.OnFooterRefreshListener {

        @Override
        public void onFooterRefresh(SmartPullView view) {
            refreshView.onFooterRefreshComplete();
            index = index + 1;
            cardAnimActions();
        }
    }

这里我们在上下拉刷新的执行回调中,立即完成相应的刷新流程,并执行一张卡片隐藏和下一张卡片显示的动画,这样无论是上拉推荐还是下拉不感兴趣,都会去更新一次卡片内容。

卡片显示隐藏动画


private void cardAnimActions() {

        cardHideAnim.start();
        cardHideAnim.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                Log.e(TAG, "onAnimationEnd: the index is " + index);
                backFrame.setBackgroundColor(colors[index % 3]);
                if (poiInfos != null && poiInfos.size() > 0) {
                    if (index < poiInfos.size()) {
                        name.setText(poiInfos.get(index).name);
                        address.setText(poiInfos.get(index).address);
                        phoneNum.setText(poiInfos.get(index).phoneNum);
                    }
                }
                cardShowAnim.start();
            }
        });

    }

这里cardHideAnim和cardShowAnim分别是两个属性 动画的组合,二者内容刚好相反,使用了卡片Scale和alpha的属性动画的组合;具体可查看源码。

LBS定位和POI 搜索

通过上面的内容,完成了所有动画相关的操作。接下来就是展示内容的实现了。

这里的展示内容是根据当前位置的经纬度坐标,按关键字去搜索周边的兴趣点,而关键字就是底部几个tab所标示的内容。点击底部tab即可以实现关键字的更新,重新发起搜索请求,实现UI更新。

这个过程分为两步,首先是进行定位(这里当然首先要确保获取到定位权限),获取到当前位置;然后根据当前位置和关键字进行POI搜索,将搜索结果呈现出来即可。

关于如何使用百度地图SDK配置AndroidManifest文件,申请key等相关操作,这里不再赘述,具体细节可参考官网

定位实现

首先需要进行定位之前的一些配置


       mLocationClient = new LocationClient(getApplicationContext());     //声明LocationClient类
        mLocationClient.registerLocationListener(this);    //注册监听函数
        LocationClientOption option = new LocationClientOption();
        option.setLocationMode(LocationClientOption.LocationMode.Hight_Accuracy
        );//可选,默认高精度,设置定位模式,高精度,低功耗,仅设备
        option.setCoorType("bd09ll");//可选,默认gcj02,设置返回的定位结果坐标系
        int span = 1000;
        option.setScanSpan(span);//可选,默认0,即仅定位一次,设置发起定位请求的间隔需要大于等于1000ms才是有效的
           .....        (跟多配置信息可参考官网)
       mLocationClient.setLocOption(option);

配置完成后,就可以开始定位操作了,当然不能忘了申请权限

if (ContextCompat.checkSelfPermission(mContext,
                Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
            //没有定位权限则请求
            ActivityCompat.requestPermissions(this, permissons, MY_PERMISSIONS_REQUEST_LOCATION);

        } else {
            mLocationClient.start();
        }

这样,就会开始调用手机的定位功能开始定位,定位成功后,会执行onReceiveLocation回调方法,在这个方法里可以获取到定位后的详细信息。

@Override
    public void onReceiveLocation(BDLocation bdLocation) {
        if (mLocationClient != null && mLocationClient.isStarted()) {
            mLocationClient.stop();
        }

        district.setText(bdLocation.getAddress().district);
        latLng = new LatLng(bdLocation.getLatitude(), bdLocation.getLongitude());
        movie.performClick();
    }

这个方法回调成功后,应该及时关闭定位操作;这里我们只是简单的获取了当前的区域位置,并设置在了顶部,同时获得了当前的经纬度信息。之后通过movie.performClick便开始了POI搜索的内容。

POI搜索实现

和定位功能类似,POI搜索功能开始之前,也需要进行相应的配置

mPoiSearch = PoiSearch.newInstance();
        mPoiSearch.setOnGetPoiSearchResultListener(new MyPoiSearchListener());
        mNearbySearchOption = new PoiNearbySearchOption()
                .radius(5000)
                .pageNum(1)
                .pageCapacity(20)
                .sortType(PoiSortType.distance_from_near_to_far);

接着我们就会按照刚才的movie.performClick 方法,开始执行POI 搜索功能。

if (latLng != null && mNearbySearchOption != null && keyWord != null) {
            mNearbySearchOption.location(latLng).keyword(keyWord);
            mPoiSearch.searchNearby(mNearbySearchOption);
        }

这里将刚才获取到的Latlng 位置信息和keyword关键字信息注入到NearbySearchOption(POI 搜索中,附近位置搜索的配置对象)中,并使用这个NearbySearchOption开始POI搜索。同样,在POI搜索完成后执行一个回调方法,在回调方法里我们可以获取到POI的搜索结果。

@Override
    public void onGetPoiResult(PoiResult poiResult) {
        Log.e("onGetPoiResult", "the poiResult " + poiResult.describeContents());
        EventBus.getDefault().post(poiResult);
    }

顾名思义,返回的参数poiResult 就是POI搜索结果。这里为了减少Activity中代码量,使用EventBus将搜索发送到了Activity中相应的Subscribe方法中。

@Subscribe
    public void onPoiResultEvent(PoiResult poiResult) {

        if (poiResult != null && poiResult.getAllPoi() != null && poiResult.getAllPoi().size() > 0) {
            poiInfos = poiResult.getAllPoi();
            name.setText(poiInfos.get(0).name);
            address.setText(poiInfos.get(0).address);
            phoneNum.setText(poiInfos.get(0).phoneNum);

            index = 1;

            if (refreshView.getVisibility() == View.GONE) {
                new Handler().postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        radar.stopAnim();
                        radar.setVisibility(View.GONE);
                        refreshView.setVisibility(View.VISIBLE);
                        cardShowAnim.start();
                    }
                }, 3000);
            }
        } else {
            radar.stopAnim();
        }


    }

这里,根据搜索结果再次实现最终的UI更新。

到这里,就完成了所有功能。

总结

关于这个微博雷达效果的模仿,从最开始只是模仿雷达扫描效果,最终到整体效果的实现。尝试了不同的方案;不得不承认模仿效果和实际功能差很多。但也算是一个学习的过程中,也踩到了一些一些没注意的坑,也算是有点收获吧。


最后,再次给出源码地址Github,欢迎star & fork。

猜你喜欢

转载自blog.csdn.net/TOYOTA11/article/details/54095456