用RecyclerView做一個小清新的Gallery效果

特雷西多士發表於2017-12-13

一、簡介

RecyclerView現在已經是越來越強大,且不說已經被大家用到滾瓜爛熟的代替ListView的基礎功能,現在RecyclerView還可以取代ViewPager實現Banner效果,當然,以下做的小清新的Gallery效果也是類似於一些輪播圖的效果,如下圖所示,這其中使用到了24.2.0版本後RecyclerView增加的SnapHelper這個輔助類,在實現以下效果起來也是非常簡單。所以這也是為什麼RecyclerView強大之處,因為Google一直在對RecyclerView不斷地進行更新補充,從而它內部的API也是越來越豐富。

小清新的Gallery水平滑動效果

小清新的Gallery垂直滑動效果

210*378

210*378

那麼我們從水平滑動為例,我們細分為以下幾個小問題:

  1. 每一次滑動都讓圖片保持在正中間。
  2. 第一張圖片的左邊距和最後一張的右邊距需要保持和其他照片的左右邊距一樣。
  3. 滑動時,中間圖片滑動到左邊時從大變小,右邊圖片滑動到中間時從小變大。
  4. 背景實現高斯模糊。
  5. 滑動結束時背景有一個漸變效果,從上一張圖片淡入淡出到當前圖片。

二、實現思路

解決以上問題當然也不難,我們分步來講解下實現思路:

(1) 每一次滑動都讓圖片保持在正中間

保持讓圖片保持在正中間,正如簡介中所說,在ToolsVersion24.2.0之後,Google給我們提供了一個SnapHelper的輔助類,它只需要幾行程式碼就能幫助我們實現滑動結束時保持在居中位置:

LinearSnapHelper mLinearySnapHelper = new LinearSnapHelper();
mLinearySnapHelper.attachToRecyclerView(mGalleryRecyclerView);
複製程式碼

LinearSnapHelper類繼承於SnapHelper,當然SnapHelper還有一個子類,叫做PagerSnapHelper。它們之間的區別是,LinearSnapHelper可以使RecyclerView一次滑動越過多個Item,而PagerSnapHelper像ViewPager一樣限制你一次只能滑動一個Item。

(2) 第一張圖片的左邊距和最後一張的右邊距需要保持和其他照片的左右邊距一樣

由於第0個位置,和最後一個位置的圖片比較特殊,其他圖片都預設設定他們的頁邊距左右圖片的可視距離,由於第0頁左邊沒有圖片,所以左邊只有1倍頁邊距,這樣滑動到最左邊時看起來就會比較奇怪,如下圖所示。

用RecyclerView做一個小清新的Gallery效果

讓第0位置的圖片左邊保持和其他圖片一樣的距離,那麼就需要動態設定第0位置圖片的左邊距為2倍頁邊距 + 可視距離。同理,最後一張也是做同樣的操作。

動態修改圖片的LayoutParams,由於RecyclerView對Holder的複用機制,我們最好不要在Adapter裡面動態修改,這樣子首先不夠優雅,這裡感謝@W_BinaryTree的建議,我們給RecyclerView新增一個自定義的Decoration會讓我們的程式碼更加優雅,只需要重寫RecyclerView.ItemDecoration裡面的getItemOffsets(Rect outRect, final View view, final RecyclerView parent, RecyclerView.State state)方法,並在裡面設定每一頁的引數即可,修改如下:

public class GalleryItemDecoration extends RecyclerView.ItemDecoration {
    int mPageMargin = 0;          // 每一個頁面預設頁邊距
    int mLeftPageVisibleWidth = 50; // 中間頁面左右兩邊的頁面可見部分寬度

    public static int mItemComusemX = 0;  // 一頁理論消耗距離


	@Override
    public void getItemOffsets(Rect outRect, final View view, final RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
    	// ...

    	// 動態修改頁面的寬度
    	int itemNewWidth = parent.getWidth() - dpToPx(4 * mPageMargin + 2 * mLeftPageVisibleWidth);
    
		// 一頁理論消耗距離
        mItemComusemX = itemNewWidth + OsUtil.dpToPx(2 * mPageMargin);

        // 第0頁和最後一頁沒有左頁面和右頁面,讓他們保持左邊距和右邊距和其他項一樣
        int leftMargin = position == 0 ? dpToPx(mLeftPageVisibleWidth + 2 * mPageMargin) : dpToPx(mPageMargin);
        int rightMargin = position == itemCount - 1 ? dpToPx(mLeftPageVisibleWidth + 2 * mPageMargin) : dpToPx(mPageMargin);
    
    	// 設定引數
    	RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) itemView.getLayoutParams();
        lp.setMargins(leftMargin, 0, rightMargin, 0);
        lp.width = itemWidth;
        itemView.setLayoutParams(lp);
    
    
    	// ...

	}

	public int dpToPx(int dp) {
        return (int) (dp * Resources.getSystem().getDisplayMetrics().density + 0.5f);
    }
}
複製程式碼

然後,把GalleryItemDecoration傳入即可:

mGalleryRecyclerView.addItemDecoration(new GalleryItemDecoration());
複製程式碼

(3) 滑動時,中間圖片滑動到左邊時從大變小,右邊圖片滑動到中間時從小變大

這個問題涉及到比較多的問題。

(a) 獲取滑動過程中當前位置。

首先,RecyclerView當前的API,並不能讓我們在滑動的過程中,簡單地獲取到我們圖中效果中間圖片的位置,或許你會說,可以通過 mGalleryRecyclerView.getLinearLayoutManager().findFirstVisibleItemPosition()能拿到RecyclerView中第一個可見的位置,但是通過效果可以知道,我們每一個張照片(除去第一張和最後一張)左右兩邊都是有前一張照片和最後一張照片的部分內容的,所以需要做區分判斷是否是中間的照片還是第一張亦或最後一張,然後返回mGalleryRecyclerView.getLinearLayoutManager().findFirstVisibleItemPosition() + 1或者其他。 那麼這樣又會引出一個問題,當我們把前後照片展示的寬度設定成可配置,即前後照片的露出部分寬度是可配置,那麼當我們把螢幕不顯示前後照片遺留部分在螢幕的話,那麼我們這一個方法又不能相容了,所以通過這一個方法來獲取,或許不那麼靠譜。

我們可以這樣來計算出比較準確的位置。在RecyclerView中,我們可以監聽它的滑動事件:

// 滑動監聽
mGalleryRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
    @Override
    public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
        super.onScrollStateChanged(recyclerView, newState);
    }

    @Override
    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
        super.onScrolled(recyclerView, dx, dy);

		// 通過dx或者dy來計算位置。 
    }
});
複製程式碼

裡面有一個onScrolled(int dx, int dy)方法,這裡面的dx,dy非常有用。首先,通過判斷dx,dy是否大於0可以判斷它是上、下、左、右滑動,dx > 0右滑,反之左滑,dy > 0 下滑,反之上滑(當然,我這裡的滑動是相對於RecyclerView,即列表的滑動方向,手指的滑動方向和這裡相反)。其次,dx和dy還能監聽每一次滑動在x,y軸上消耗的距離。

舉個例子,當我們迅速至列表右邊時,onScrolled(int dx, int dy)會不斷被呼叫,通過在方法裡面Log輸出,你會看到不斷輸出dx的值,而且他們的大小都是無規律的,而這裡的dx就是每一次onScroll方法呼叫一次,RecyclerView在x軸上的消耗距離。

所以我們可以通過一個全域性變數mConsumeX來累加所有dx,當這樣我們就可以知道當前RecyclerView滑動的總距離。而我們Demo中每移動到下一張照片的距離(即如下圖中所示的移動一頁理論消耗距離)是一定的,那麼就可以通過當前位置 = mConsumeX / 移動一張照片所需要的距離來獲取滑動結束時的位置了。

RecyclerView距離示意圖

/**
 * 獲取位置
 *
 * @param mConsumeX      實際消耗距離
 * @param shouldConsumeX 移動一頁理論消耗距離
 * @return
 */
private int getPosition(int mConsumeX, int shouldConsumeX) {
    float offset = (float) mConsumeX / (float) shouldConsumeX;
    int position = Math.round(offset);        // 四捨五入獲取位置
    return position;
}
複製程式碼

(b) 根據位置獲取當前頁的滑動偏移率

當我們可以準確拿到當前位置時,我們就需要明確一下幾個概念。

總的偏移距離:意思是從第一個位置移動到現在當前位置偏移的總距離,即dx的累加結果(也就是上述的mConsumX)。

當前頁偏移距離:意思是從上一個位置移動到當前位置偏移距離。

總的偏移率:意思是 總的偏移距離 / 移動一頁理論消耗距離。

當前頁的偏移率:意思是 當前頁偏移距離 / 移動一頁理論消耗距離。

用RecyclerView做一個小清新的Gallery效果

我們都知道,獲取當前位置方法裡面有一個

float offset = (float) mConsumeX / (float) shouldConsumeX;
複製程式碼

它的意思就是總的偏移率,例如圖中我們當前位置是3,我們從3移動到4時,onScroll方法會不斷被呼叫,那麼這個offset就會不斷變化,從3.0逐漸增加一直到4.0,圖中此時的offset大概是3.2左右,我們知道這一個有什麼用呢?試想一下,offset是一個浮點型數,將它向下取整,那就是變成3了,那麼3.2 - 3 = 0.2就是我們當前頁的偏移率了。而我們通過偏移率就可以動態設定圖片的大小,就形成了我們這個問題中所說的圖片大小變化效果。所以這裡的關鍵就是獲取到當前頁的偏移率

@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
	super.onScrolled(recyclerView, dx, dy);

    // ...	


    // 移動一頁理論消耗距離
    int shouldConsumeX = GalleryItemDecoration.mItemComusemX;


    // 獲取當前的位置
    int position = getPosition(mConsumeX, shouldConsumeX);

    // 位置浮點值(即總消耗距離 / 每一頁理論消耗距離 = 一個浮點型的位置值)
    float offset = (float) mConsumeX / (float) shouldConsumeX;     

    // 避免offset值取整時進一,從而影響了percent值
    if (offset >= mGalleryRecyclerView.getLinearLayoutManager().findFirstVisibleItemPosition() + 1 && slideDirct == SLIDE_RIGHT) {
        return;
    }

    // 當前頁的偏移率
    float percent = offset - ((int) offset);


    // 設定動畫變化
    setAnimation(recyclerView, position, percent);

    // ...

}
複製程式碼

(c) 根據偏移率實現動畫

現在我們拿到了偏移率,就可以動態修改它們的尺寸大小了,首先,我們需要拿到當前View,前一個View和後一個View,並同時對它們做Scale伸縮。即上面的setAnimation(recyclerView, position, percent)方法裡面進行動畫操作。

    View mCurView = recyclerView.getLayoutManager().findViewByPosition(position);       // 中間頁
    View mRightView = recyclerView.getLayoutManager().findViewByPosition(position + 1); // 左邊頁
    View mLeftView = recyclerView.getLayoutManager().findViewByPosition(position - 1);  // 右邊頁
複製程式碼

認真觀察圖中變化,兩種變化:

  1. 位置的變化:第一張圖片是從mCurView慢慢變成mLeftView,而第二張圖片是從mRightView慢慢變成mCurView。
  2. 大小變化:第一張圖是從大變小,第二張圖是從小變大。

理解了以上的變化之後,我們就可以做動畫了。

用RecyclerView做一個小清新的Gallery效果

首先說明一點,大家觀察我的getPosition(mConsumeX, shouldConsumeX)方法,裡面的實現是,當一頁滑動的偏移率超過了0.5之後,position就會自動切換到下一頁。當然你的實現邏輯不一樣,那麼後面你的設定動畫的方法就不一樣。為什麼需要明確這一點呢?因為當我滑動超過圖片超過它的一半寬度之後,上面的mCurView就會切換成下一張圖片了,所以我在設定動畫的方法裡以0.5為一個臨界點,因為0.5臨界點的兩邊,mCurViewmRightViewmLeftView的指向都已經不一樣了。

假如我們定義大小變化因子 float mAnimFactor = 0.2f,它的意思就是控制我們的圖片從1.0伸縮至0.8。以上圖為例,當percent <= 0.5時,mCurView的ScaleX和ScaleY從大慢慢變小,至於這個變化範圍,就根據我們定義的變化因子和percent來修改;而當percent > 0.5時,剛才那個View就變成了mLeftView,此時我們繼續剛才的操作,整個過程我們就實現了第一張圖片的Scale從1.0變化到了0.8。而另外兩張圖片也是同理,大概程式碼邏輯如下:

private void setBottomToTopAnim(RecyclerView recyclerView, int position, float percent) {
    View mCurView = recyclerView.getLayoutManager().findViewByPosition(position);       // 中間頁
    View mRightView = recyclerView.getLayoutManager().findViewByPosition(position + 1); // 左邊頁
    View mLeftView = recyclerView.getLayoutManager().findViewByPosition(position - 1);  // 右邊頁


    if (percent <= 0.5) {
        if (mLeftView != null) {
			// 變大
            mLeftView.setScaleX((1 - mAnimFactor) + percent * mAnimFactor);
            mLeftView.setScaleY((1 - mAnimFactor) + percent * mAnimFactor);
        }
        if (mCurView != null) {
			// 變小
            mCurView.setScaleX(1 - percent * mAnimFactor);
            mCurView.setScaleY(1 - percent * mAnimFactor);
        }
        if (mRightView != null) {
			// 變大
            mRightView.setScaleX((1 - mAnimFactor) + percent * mAnimFactor);
            mRightView.setScaleY((1 - mAnimFactor) + percent * mAnimFactor);
        }
    } else {
        if (mLeftView != null) {
            mLeftView.setScaleX(1 - percent * mAnimFactor);
            mLeftView.setScaleY(1 - percent * mAnimFactor);
        }
        if (mCurView != null) {
            mCurView.setScaleX((1 - mAnimFactor) + percent * mAnimFactor);
            mCurView.setScaleY((1 - mAnimFactor) + percent * mAnimFactor);
        }
        if (mRightView != null) {
            mRightView.setScaleX(1 - percent * mAnimFactor);
            mRightView.setScaleY(1 - percent * mAnimFactor);
        }
    }
}
複製程式碼

(4)背景實現高斯模糊

高斯模糊有挺多種實現方法的,Google一下就出來了。但是還是推薦Native層的實現演算法,因為Java層的實現對效能影響實在太大了,例子裡使用的是RenderScript,當然是參考博主湫水教你一分鐘實現動態模糊效果,大家感興趣可以過去看看,用法也是非常簡單。直接呼叫blurBitmap(Context context, Bitmap image, float blurRadius)方法即可。

public class BlurBitmapUtil {
    //圖片縮放比例
    private static final float BITMAP_SCALE = 0.4f;

    /**
     * 模糊圖片的具體方法
     *
     * @param context 上下文物件
     * @param image   需要模糊的圖片
     * @return 模糊處理後的圖片
     */
    public static Bitmap blurBitmap(Context context, Bitmap image, float blurRadius) {
        // 計算圖片縮小後的長寬
        int width = Math.round(image.getWidth() * BITMAP_SCALE);
        int height = Math.round(image.getHeight() * BITMAP_SCALE);

        // 將縮小後的圖片做為預渲染的圖片
        Bitmap inputBitmap = Bitmap.createScaledBitmap(image, width, height, false);
        // 建立一張渲染後的輸出圖片
        Bitmap outputBitmap = Bitmap.createBitmap(inputBitmap);

        // 建立RenderScript核心物件
        RenderScript rs = RenderScript.create(context);
        // 建立一個模糊效果的RenderScript的工具物件
        ScriptIntrinsicBlur blurScript = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs));

        // 由於RenderScript並沒有使用VM來分配記憶體,所以需要使用Allocation類來建立和分配記憶體空間
        // 建立Allocation物件的時候其實記憶體是空的,需要使用copyTo()將資料填充進去
        Allocation tmpIn = Allocation.createFromBitmap(rs, inputBitmap);
        Allocation tmpOut = Allocation.createFromBitmap(rs, outputBitmap);

        // 設定渲染的模糊程度, 25f是最大模糊度
        blurScript.setRadius(blurRadius);
        // 設定blurScript物件的輸入記憶體
        blurScript.setInput(tmpIn);
        // 將輸出資料儲存到輸出記憶體中
        blurScript.forEach(tmpOut);

        // 將資料填充到Allocation中
        tmpOut.copyTo(outputBitmap);

        return outputBitmap;
    }
}
複製程式碼

這個方法只要傳入Context,Bitmap,和一個模糊程度即可,然後返回一個高斯模糊後的Bitmap給我們,我們只需要將RecyclerView的父佈局設定背景為這個Bitmap即可。

(5)滑動結束時背景有一個漸變效果,從上一張圖片淡入淡出到當前圖片

實現這個效果最好不要使用Tween動畫,因為它的實現效果比較生硬,使用TransitionDrawable會讓效果更佳接近淡入淡出效果。那我們怎麼記錄前後兩個位置的照片呢?方法很多種,這裡就使用了一個Map<String, Drwable>來記錄每一次顯示的圖片,在它切換到下一個圖片時,便從上一次記錄的圖片淡入淡出到本次的圖片。

// 獲取當前位置的圖片資源ID
int resourceId = ((RecyclerAdapter) mRecyclerView.getAdapter()).getResId(mRecyclerView.getScrolledPosition());
// 將該資源圖片轉為Bitmap
Bitmap resBmp = BitmapFactory.decodeResource(getResources(), resourceId);
// 將該Bitmap高斯模糊後返回到resBlurBmp
Bitmap resBlurBmp = BlurBitmapUtil.blurBitmap(mRecyclerView.getContext(), resBmp, 15f);
// 再將resBlurBmp轉為Drawable
Drawable resBlurDrawable = new BitmapDrawable(resBlurBmp);
// 獲取前一頁的Drawable
Drawable preBlurDrawable = mTSDraCacheMap.get(KEY_PRE_DRAW) == null ? resBlurDrawable : mTSDraCacheMap.get(KEY_PRE_DRAW);

/* 以下為淡入淡出效果 */
Drawable[] drawableArr = {preBlurDrawable, resBlurDrawable};
TransitionDrawable transitionDrawable = new TransitionDrawable(drawableArr);
mContainer.setBackgroundDrawable(transitionDrawable);
transitionDrawable.startTransition(500);

// 存入到cache中
mTSDraCacheMap.put(KEY_PRE_DRAW, resBlurDrawable);
複製程式碼

更多

以上所講的都是實現的一個思路,雖然效果和小清新搭不上關係哈,但是配了幾張小清新的圖片還是讓我們的程式設計師生活增添一絲精彩。其實大家實現了基礎效果之後,還可以深挖更多輔助功能,例如不同的切換效果,支援橫屏,動態修改滑動速度等,相信這個過程可以讓你收穫良多。

Github:Recyclerview-Gallery

相關文章