Android 動畫實戰-仿微博雷達功能

IAM四十二發表於2019-03-03

Android 動畫實戰-仿微博雷達功能

前言

在應用中使用動畫,可以給使用者帶來良好的互動體驗。通過之前對Android動畫的分類總結,嘗試了使用屬性動畫實現支付寶支付效果及購物車新增動畫的效果,今天在這裡模仿一下微博雷達頁面效果

對Android動畫不太熟悉或遺忘的知識,可以通過下面兩篇文章瞭解。

Android 動畫總結Android 動畫實戰

此次模仿新浪微博雷達頁的功能,雖然只有一個Activity,但使用到了很多知識。包括

  • 屬性動畫(雷達效果圖)
  • Android touch 事件傳遞機制
  • Android 6.0 動態許可權判斷
  • 百度LBS/POI 搜尋
  • EventBus

有興趣的同學可以檢視Github 原始碼

效果圖

老習慣,先看看效果圖。

Android 動畫實戰-仿微博雷達功能

至於真實的微博雷達效果是怎樣,玩微博的同學可以對比一下。

功能分析

這裡主要從實現的幾個功能點做一下分析。

雷達效果圖

總的來說,這個雷達效果圖應該是整個微博雷達頁面模仿效果相似度最高的一個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。

相關文章