從零開始打造一個Android 3D立體旋轉容器

本文地址,轉載請注明
代碼下載地址 :https://github.com/ImmortalZ/StereoView

嗯,2個月沒有寫博客,是要好好反省下,趁著放暑假把這兩個月看的東西好好沉淀下。嗯,就立下這個Flag,希望不要自己再打自己臉。

1.概述

回到正題,這次帶來的效果,是一個Android 的3D立體旋轉的效果。
當然靈感的來源,來自早些時間微博上看到的效果圖。
非常酷有木有!作為程序猿我當然要把它加入我的下一個項目中啦!
原效果

這里寫圖片描述

我們實現的效果:

(為了更加可定制化,我在原圖基礎上新增了新的效果)

這里寫圖片描述

可以快速滾動,并且無限循環

這里寫圖片描述

這個是對一些參數的進行設定

這里寫圖片描述

對圖片的包裹效果

這里寫圖片描述

因為本身繼承自ViewGroup,所以基本控件都是可以包裹的

2.分析

因為代碼量有點大,感覺把代碼全部粘貼上來也不現實。所以想了解我的思路的盆友可以先來這里下載代碼。然后邊看代碼邊看我的分析

下載地址 :https://github.com/ImmortalZ/StereoView

通過我們實現的效果圖可以發現:

1.切換的時候是一個3D立體的效果

2.布局中的每一個Item可以自由切換,且無限循環滾動

要解決上面的效果,我們需要什么技術點呢?

1.要想實現一個3D效果,我們可以借助Android中的Camera、Matrix

2.要想實現滾動,毫無疑問,我們需要借助Scroller

當然一切看起來很簡單,其實不然,除此之外,你還需要對于滑動沖突進行處理等等,下面我開始介紹啦。

這就是我們這次項目的大致

這里寫圖片描述

3.實現

因為我們是要打造一個容器類,所以肯定得繼承自 ViewGroup

按照一般的思路,我們肯定是先要進行一些變量的申明,onMeasure,onLayout操作

private void init(Context context) {
    mCamera = new Camera();
    mMatrix = new Matrix();
    if (mScroller == null) {
        mScroller = new Scroller(context);
    }
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    measureChildren(widthMeasureSpec, heightMeasureSpec);
    mWidth = getMeasuredWidth();
    mHeight = getMeasuredHeight();
    //滑動到設置的StartScreen位置
    scrollTo(0, mStartScreen * mHeight);
}

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int childTop = 0;
    for (int i = 0; i < getChildCount(); i++) {
        View child = getChildAt(i);
        if (child.getVisibility() != GONE) {
            child.layout(0, childTop,
                    child.getMeasuredWidth(), childTop + child.getMeasuredHeight());
            childTop = childTop + child.getMeasuredHeight();
        }
    }
}

完成這些操作后,我們需要在onTouchEvent中進行滑動事件的處理

3.1 完成無限循環滑動滾動

我們的item數量是有限的,如何實現無限循環滾動呢?很簡單,以3個item為例子(分別為1,2,3),我們讓屏幕顯示的是2

如此反復,屏幕所在的位置始終是第2個item所在的位置,這樣就實現了我們的無限循環滾動,向下滾動也是如此

QQ截圖20160715190642.png
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(event);
        float y = event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!mScroller.isFinished()) {
                    //當上一次滑動沒有結束時,再次點擊,強制滑動在點擊位置結束
                    mScroller.setFinalY(mScroller.getCurrY());
                    mScroller.abortAnimation();
                    scrollTo(0, getScrollY());
                }
                mDownY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                int realDelta = (int) (mDownY - y);
                mDownY = y;
                if (mScroller.isFinished()) {
                    //因為要循環滾動
                    recycleMove(realDelta);
                }
                break;
            case MotionEvent.ACTION_UP:
                mVelocityTracker.computeCurrentVelocity(1000);
                float yVelocity = mVelocityTracker.getYVelocity();
                //滑動的速度大于規定的速度,或者向上滑動時,上一頁頁面展現出的高度超過1/2。則設定狀態為State.ToPre
                if (yVelocity > standerSpeed || ((getScrollY() + mHeight / 2) / mHeight < mStartScreen)) {
                    mState = State.ToPre;
                } else if (yVelocity < -standerSpeed || ((getScrollY() + mHeight / 2) / mHeight > mStartScreen)) {
                    //滑動的速度大于規定的速度,或者向下滑動時,下一頁頁面展現出的高度超過1/2。則設定狀態為State.ToNext
                    mState = State.ToNext;
                } else {
                    mState = State.Normal;
                }
                //根據mState進行相應的變化
                changeByState(yVelocity);
                if (mVelocityTracker != null) {
                    mVelocityTracker.recycle();
                    mVelocityTracker = null;
                }
                break;
        }
        //返回true,消耗點擊事件
        return true;
    }

當手從屏幕上移開時,我們來看下這個方法changeByState(yVelocity);

這里寫圖片描述

我們以mState = State.ToPre 為例子來說明

/**
 * mState = State.ToPre 時進行的動作
 * @param yVelocity 豎直方向的速度
 */
private void toPreAction(float yVelocity) {
    int startY;
    int delta;
    int duration;
    mState = State.ToPre;
    addPre();//增加新的頁面
    //計算松手后滑動的item個數
    int flingSpeedCount= (yVelocity - standerSpeed) > 0 ? (int) (yVelocity - standerSpeed) : 0;
    addCount = flingSpeedCount/ flingSpeed + 1;
    //mScroller開始的坐標
    startY = getScrollY() + mHeight;
    setScrollY(startY);
    //mScroller 移動的距離
    delta = -(startY - mStartScreen * mHeight) - (addCount - 1) * mHeight;
    duration = (Math.abs(delta)) * 3;
    mScroller.startScroll(0, startY, 0, delta, duration);
    addCount--;
}

然后會進入addPre方法中

/**
 * 把最后一個item移動到第一個item位置
 */
private void addPre() {
    mCurScreen = ((mCurScreen - 1) + getChildCount()) % getChildCount();
    int childCount = getChildCount();
    View view = getChildAt(childCount - 1);
    removeViewAt(childCount - 1);
    addView(view, 0);
    if (iStereoListener != null) {
        iStereoListener.toPre(mCurScreen);
    }
}

最后mScroller.startScroll(0, startY, 0, delta, duration); 開始執行。
執行的過程中會回調這個函數方法computeScroll

這里寫圖片描述

完成到這一步,我們的無限滑動滾動就算是完成了

3.2 實現3D切換效果。

正常情況下,我們自定義ViewGroup并不需要重寫dispatchDraw 方法。
而這里我們則需要重寫

 @Override
    protected void dispatchDraw(Canvas canvas) {
        if (!isAdding && isCan3D) {
            //當開啟3D效果并且當前狀態不屬于 computeScroll中 addPre() 或者addNext()
            //如果不做這個判斷,addPre() 或者addNext()時頁面會進行閃動一下
            //我當時寫的時候就被這個坑了,后來通過log判斷,原來是computeScroll中的onlayout,和子Child的draw觸發的順序導致的。
            //知道原理的朋友希望可以告知下
            for (int i = 0; i < getChildCount(); i++) {
                drawScreen(canvas, i, getDrawingTime());
            }
        } else {
            isAdding = false;
            super.dispatchDraw(canvas);
        }
    }

好,我們來drawScreen這個方法

private void drawScreen(Canvas canvas, int i, long drawingTime) {
        int curScreenY = mHeight * i;
        //屏幕中不顯示的部分不進行繪制
        if (getScrollY() + mHeight < curScreenY) {
            return;
        }
        if (curScreenY < getScrollY() - mHeight) {
            return;
        }
        float centerX = mWidth / 2;
        float centerY = (getScrollY() > curScreenY) ? curScreenY + mHeight : curScreenY;
        float degree = mAngle * (getScrollY() - curScreenY) / mHeight;
        if (degree > 90 || degree < -90) {
            return;
        }
        canvas.save();

        mCamera.save();
        mCamera.rotateX(degree);
        mCamera.getMatrix(mMatrix);
        mCamera.restore();

        mMatrix.preTranslate(-centerX, -centerY);
        mMatrix.postTranslate(centerX, centerY);
        canvas.concat(mMatrix);
        drawChild(canvas, getChildAt(i), drawingTime);
        canvas.restore();

    }

這里面的關鍵就在于
mCamera.rotateX(degree);
mMatrix.preTranslate(-centerX, -centerY);
mMatrix.postTranslate(centerX, centerY);

對于Camera我們知道我們整個布局都是平鋪的,為什么會產生3D的效果呢?原因就是這個Camera類,人如其名,它就相當于一個相機,它對物體進行拍照。我們把相機正對物體拍攝,拍攝出的效果就是平面的,當我們把相機旋轉了90度再來拍攝原來物體,物體就相當于旋轉了90度。
Camera拍攝完畢后,然后把拍攝的參數值傳到Matrix中,Matrix再和Canvas綁定,由Canvas進行繪制。最終顯示在屏幕中。

那么preTranslate,postTranslate又是怎么一回事呢?
很簡單,我們知道坐標系是以(0,0)作為參照點的。現在我們對拍攝的對象進行的縮放變形操作是在物體的中心。我們需要把物體的中心先移動到(0,0)位置,最后再移動到物體原來中心位置即可。

具體的大家可以參考下這篇文章
http://blog.csdn.net/rav009/article/details/7763223 ( Android postTranslate和preTranslate的理解)

不過對于Camera的坐標系我還有一點點疑問,我準備有機會寫一篇關于Camera和Matrix文章。

3.3 滑動事件沖突的處理(先看后面的更新說明)

完成上面兩個步驟,那么我們就算Over了嗎?

不!還有很重要的一點,就是事件沖突的處理。 舉個例子:我們把手放到我們的容器上,系統怎么知道我們這個滑動事件是給容器還是要給容器的子類的呢?

(給容器自己,則進行滑動的操作,給容器的子類,則容器的子類可以進行點擊事件的判斷處理)

對于這種情況,我就很大度啦,全部交給容器子類處理!子類不要,OK,那容器你自己拿來玩吧。

————之所以不走尋常路:交給容器處理,容器不需要再交給子類

原因在于:容器拿到滑動事件只需要做滑動操作,而子類則不同,它有點擊事件需要判斷,一個容器有很多子類,而很多子類只有一個共同的容器,如果把控制權交給容器,那么容器怎么可能能夠判斷得出不同的子類到底需不需要這個滑動事件呢?所以,既然這么麻煩,那么統統交給子類處理。

交給子類處理,則容器中onInterceptTouchEvent需要做如下操作

 @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_DOWN) {
            return false;
        }
        return true;
    }

而子類(用CustomEdittext為例)的dispatchTouchEvent需要做如下判斷

@Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                if (!isContain(event)) {
                    //子類不需要,交給容器自己處理
                    getParent().requestDisallowInterceptTouchEvent(false);
                    setFocusable(false);
                } else {
                    //子類自己做操作
                    setFocusableInTouchMode(true);
                }
                break;
            case MotionEvent.ACTION_UP:

                break;
        }
        return super.dispatchTouchEvent(event);
    }

在isContain中,我做的是點擊的坐標是否在Edittext中,在則攔截,子類處理,不在,則交給父類容器

 private boolean isContain(MotionEvent event) {
        region.set(rect);
        if (region.contains((int) event.getX(), (int) event.getY())) {
            return true;
        }
        return false;
    }

當然交給子類這樣也導致了一個問題,就是我如果需要給容器中的子類進行點擊事件,則都需要自定義一個View(例如上面的CustomEdittext 繼承自Edittext)。

例如我就自定義了三個View,不過還是很簡單的,幾分鐘的事就搞定了(在自定義View中dispatchTouchEvent進行判斷)。

具體的可以參考代碼。

這里寫圖片描述

更新說明 2016/8/5

滑動沖突之前我是把控制權交給了子類,這里https://github.com/Y-bao 這位作者提交的pull
request中將事件沖突交給了父類(StereoView)
,我這邊通過了pull,我覺得寫得挺好的,把點擊事件的控制權轉移給父類,就不需要自定義View。
如果你還想查看控制權轉移給子類的代碼(我之前的),可以點擊這里

3.4 點擊水紋波效果

細心的人會發現,我這里還有個RippleView。
沒錯這就是點擊后有水紋波的效果。
Android本身可以在XML中用ripple實現,不過是Android 5.0以上,個人覺得兼容性不太好,就自己隨便寫了一個簡易的,哈哈,效率不能保證,各位看客看看就好啦。

4.應用

4.1 定義的方法

使用方法也和其他的沒有什么區別,我這里自定義了幾個方法,我這里說明下。

自定義的方法

setStartScreen(int startScreen) :設置第一頁展示的頁面 @param startScreen (0,getChildCount-1)

setResistance(float resistance) : 設置滑動阻力 @param resistance (0,...)

setInterpolator(Interpolator mInterpolator) : 設置滾動時interpolator插補器

setAngle(float mAngle):設置滾動時兩個item的夾角度數 [0f,180f]

setCan3D(boolean can3D) : 是否開啟3D效果

setItem(int itemId) : 跳轉到指定的item @param itemId [0,getChildCount-1]

toPre() : 上一頁

toNext() : 下一頁

定義的回調接口

這里寫圖片描述

4.2 使用方法

直接在布局中

這里寫圖片描述

在代碼中

這里寫圖片描述

4.3 缺陷說明

目前容器的item數量需要大于等于3,小于3個滑動時會些問題。設置的最開始展示的item位置不能是第一個或者最后一個,這么做是為了保證第1個或者最后一個被隱藏,從而保證最開始向上滑動或者向下滑動時的正常。

5.下載

如果覺得對你有幫助,歡迎 star,fork,如果對于我感興趣,歡迎follow 我

下載地址 :https://github.com/ImmortalZ/StereoView

參考文章:

http://blog.csdn.net/dawanganban/article/details/38421221

http://blog.csdn.net/rav009/article/details/7763223

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,818評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,185評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,656評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,647評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,446評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,951評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,041評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,189評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,718評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,800評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,419評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,420評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,755評論 2 371

推薦閱讀更多精彩內容