一起擼個朋友圈吧 - 圖片瀏覽(下)【ViewPager優化】

羽翼君發表於2017-12-13

專案地址:github.com/razerdp/Fri… (能弱弱的求個star或者fork麼QAQ)


【ps:評論功能羽翼君我補全了後臺互動了喲,如果您想體驗一下不同的使用者而不是一直都是羽翼君,可以在FriendCircleApp下,在onCreate中,將LocalHostInfo.INSTANCE.setHostId(1001);的id改為1001~1115之間任意一個】

在上一篇,我們實現了朋友圈的圖片瀏覽,在文章的最後,留下了幾個問題,那麼這一片我們解決這些。

本篇需要解決的幾個問題(本篇主要為控制元件的自定義,但相信我,不會很難):

- viewpager如何複用

- 圖片瀏覽viewpager的指示器

本篇圖片預覽如下:

preview

Q1:指示器

我們知道,在微信圖片瀏覽的時候,多張圖下方是有個指示器的,比如這樣

一起擼個朋友圈吧 - 圖片瀏覽(下)【ViewPager優化】

當然,我們可以找庫,但這個如此簡單的控制元件為此花時間去找庫,倒不如我們自己來定製一番對吧。

我們來分析一下,可以如何實現這個指示器功能。

首先可以確認的是,指示器要跟ViewPager聯調,就必須要跟ViewPager的滑動狀態進行關聯。

而對於ViewPager的滑動狀態,使用的最多的就是ViewPager.OnPageChangeListener這個介面。

從圖中我們可以看到,微信下方的指示器滑動的時候,白點並沒有什麼移動動畫,而是直接就跳到另一個點上面了,這樣一來,這個控制元件的實現就更加的容易了。

因此我們可以初步得到思路如下:

  • 首先可以肯定的是,指示器不應該隸屬於ViewPager,否則每次instantiateItem的時候又inflate出來是很不合理的,所以我們的indicator必須跟ViewPager同級,但可以通過ViewPager的滑動狀態來改變。

  • 第二,小點點的數量永遠都是0~9,因為微信的圖片數量最多9張。

  • 第三,小點點都是水平居中,因此我們的indicator可以繼承LinearLayout來實現。

  • 第四,小點點有兩個狀態,一個選中,一個非選中。所以小點點的定製必須要提供改變選中狀態的介面。


Q1 - 程式碼的編寫:

小點點的自定義

既然思路有了,那麼剩下來的也僅僅是用程式碼將我們的思路實現而已。

首先我們來弄小點點。

由於我懶得開啟AE,所以我選擇直接採用Drawable的方式來寫。

來到drawable檔案下,新建一個drawable

首先來定製一個未選中狀態的drawable

<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <size android:width="25dp" android:height="25dp"/>
    <stroke android:color="@color/white" android:width="1dp"/>
</shape>
複製程式碼

程式碼非常簡單,效果也僅僅是一個圓環。

未選中的drawable

而選中的實心圓只是把上述程式碼的stroke換成solid而已,這裡就略過了。

然後我們新建一個類繼承View,叫做**“DotView”**

或許看到繼承View你就會覺得,難道又要重寫onMeasure,onLayout什麼的?煩死了。。。。

其實不用,畢竟我們們用的是drawable。。。

我們的程式碼整體結構如下:

public class DotView extends View {
    private static final String TAG = "DotView";

    //正常狀態下的dot
    Drawable mDotNormal;
    //選中狀態下的dot
    Drawable mDotSelected;

    private boolean isSelected;

    public DotView(Context context) {
        this(context, null);
    }

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

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

    private void init(Context context, AttributeSet attrs) {
        mDotNormal = context.getResources().getDrawable(R.drawable.ic_viewpager_dot_indicator_normal);
        mDotSelected = context.getResources().getDrawable(R.drawable.ic_viewpager_dot_indicator_selected);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

    }

    public void setSelected(boolean selected) {
        this.isSelected = selected;
        invalidate();
    }

    public boolean getSelected() {
        return isSelected;
    }
}
複製程式碼

可以看到,我們只需要實現onDraw方法和提供是否選中的方法而已。其他的都不需要。

在onDraw裡面,我們編寫以下程式碼:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        int width=getWidth();
        int height=getHeight();



        if (isSelected) {
            mDotSelected.setBounds(0,0,width,height);
            mDotSelected.draw(canvas);
        }
        else {
            mDotNormal.setBounds(0,0,width,height);
            mDotNormal.draw(canvas);
        }
    }
複製程式碼

這裡僅僅為了確定drawable的大小並根據不同的狀態進行不同的drawable繪製。非常簡單。

indicator的自定義

在上面的思路里,我們可以通過繼承LinearLayout來實現指示器。

因此我們新建一個類繼承LinearLayout,取名**“DotIndicator”**

在這個指示器中,我們需要確定他擁有的功能:

  • 包含0~9個DotView
  • 通過公有方法來設定當前選中的DotView
  • 通過公有方法來設定當前顯示的DotView的數量

因此我們可以初步設計以下程式碼結構:

package razerdp.friendcircle.widget;

import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.Gravity;
import android.widget.LinearLayout;
import java.util.ArrayList;
import java.util.List;
import razerdp.friendcircle.utils.UIHelper;

/**
 * Created by 大燈泡 on 2016/4/21.
 * viewpager圖片瀏覽器底部的小點點指示器
 */
public class DotIndicator extends LinearLayout {
    private static final String TAG = "DotIndicator";

    List<DotView> mDotViews;

    private int currentSelection = 0;

    private int mDotsNum = 9;

    public DotIndicator(Context context) {
        this(context,null);
    }

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

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

    private void init(Context context, AttributeSet attrs) {
        setOrientation(HORIZONTAL);
        setGravity(Gravity.CENTER);

        buildDotView(context);
    }

    /**
     * 初始化dotview
     * @param context
     */
    private void buildDotView(Context context) {

    }

    /**
     * 當前選中的dotview
     * @param selection
     */
    public void setCurrentSelection(int selection) {
      
    }

    public int getCurrentSelection() {
        return currentSelection;
    }

    /**
     * 當前需要展示的dotview數量
     * @param num
     */
    public void setDotViewNum(int num) {
        
    }

    public int getDotViewNum() {
        return mDotsNum;
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        mDotViews.clear();
        mDotViews=null;
        Log.d(TAG, "清除dotview引用");
    }
}

複製程式碼

在這裡說明一下,由於我們操作不同位置的dotview,所以我們需要有一個列表來存下這些dotview。

另外,我們設定指示器必須是水平的同時Gravity=CENTER

另外注意記得在onDetachedFromWindow清除所有引用哦。否則無法回收就記憶體洩漏了。

接下來我們補全程式碼。

首先是buildDotView

在這裡我們將會進行indicator的初始化,也就是將9個dotView新增進來

/**
     * 初始化dotview
     * @param context
     */
    private void buildDotView(Context context) {
        mDotViews = new ArrayList<>();
        for (int i = 0; i < 9; i++) {
            DotView dotView = new DotView(context);
            dotView.setSelected(false);
            LinearLayout.LayoutParams params = new LayoutParams(UIHelper.dipToPx(context, 10f),
                    UIHelper.dipToPx(context, 10f));
            if (i == 0) {
                params.leftMargin = 0;
            }
            else {
                params.leftMargin = UIHelper.dipToPx(context, 6f);
            }
            addView(dotView,params);
            mDotViews.add(dotView);
        }
    }
複製程式碼

這裡有一個需要注意的是第0個dotview是不需要marginleft的。

接下來補全setCurrentSelection

這個方法我們的思路也很簡單,首先將所有的DotView設定為未選中狀態,然後再設定對應num的DotView為選中狀態。雖然是遍歷了兩次陣列,但因為很少東西,而且CPU的處理速度完全可以在肉眼無法觀察的速度下完成,所以這裡無需過度考慮。

/**
     * 當前選中的dotview
     * @param selection
     */
    public void setCurrentSelection(int selection) {
        this.currentSelection = selection;
        for (DotView dotView : mDotViews) {
            dotView.setSelected(false);
        }
        if (selection >= 0 && selection < mDotViews.size()) {
            mDotViews.get(selection).setSelected(true);
        }
        else {
            Log.e(TAG, "the selection can not over dotViews size");
        }
    }
複製程式碼

值得注意的是,我們需要留意邊界問題

最後我們補全setDotViewNum

這裡的思路跟上面的差不多,首先我們將所有的dotview設定為可見,然後將指定數量之後的dotview設定為GONE,這時候由於LinearLayout的Gravity是CENTER,所以剩餘的dotView會水平居中。

 /**
     * 當前需要展示的dotview數量
     * @param num
     */
    public void setDotViewNum(int num) {
        if (num > 9 || num <= 0) {
            Log.e(TAG, "num必須在1~9之間哦");
            return;
        }

        for (DotView dotView : mDotViews) {
            dotView.setVisibility(VISIBLE);
        }
        this.mDotsNum = num;
        for (int i = num; i < mDotViews.size(); i++) {
            DotView dotView = mDotViews.get(i);
            if (dotView != null) {
                dotView.setSelected(false);
                dotView.setVisibility(GONE);
            }
        }
    }
複製程式碼

同樣需要注意邊界問題。

完成之後,我們回到圖片瀏覽的佈局,將我們的自定義dotindicator新增到佈局,並對其父佈局底部。

xml

最後在我們封裝好的PhotoPagerManager引入DotIndicator

在呼叫showPhoto的時候,先設定dotindicator展示的dotview數量,然後再設定選中的dotview

showphoto

最後在viewpager的pagechangerlistener監聽中設定dotindicator的對應方法就好了

設定當前展示的dotview

【DotIndicator完】


Q2:viewpager複用

在上一篇文章,我們看到當某個動態的圖片數量超過3張,我們點選第四張圖片的時候,會發現放大動畫並不明顯。

這是因為ViewPager的機制,ViewPager預設會快取當前item左右共三個view,當劃到第四個,則會重新執行initItem,對應我們的adapter,就是重新new了一個PhotoView,由於這個PhotoView並沒有圖片,所以放大動畫無法展示。

而我們選擇解決方案就是,在adapter初始化的時候,就直接把9個photoview給new出來放到一個物件池裡面,每次執行到instantiateItem就從池裡面拿出來,這樣就可以防止每次都new,保證放大動畫。

因此我們的改動如下:


/**
 * Created by 大燈泡 on 2016/4/12.
 * 圖片瀏覽視窗的adapter
 */
public class PhotoBoswerPagerAdapter extends PagerAdapter {
    private static final String TAG = "PhotoBoswerPagerAdapter";

    private static ArrayList<MPhotoView> sMPhotoViewPool;
    private static final int sMPhotoViewPoolSize = 10;
	...跟上次一樣

    public PhotoBoswerPagerAdapter(Context context) {
    ...不變
        sMPhotoViewPool = new ArrayList<>();
        //buildProgressTV(context);
        buildMPhotoViewPool(context);
    }

    private void buildMPhotoViewPool(Context context) {
        for (int i = 0; i < sMPhotoViewPoolSize; i++) {
            MPhotoView sPhotoView = new MPhotoView(context);
            sPhotoView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT));
            sMPhotoViewPool.add(sPhotoView);
        }
    }

	...resetDatas()方法不變

    @Override
    public Object instantiateItem(ViewGroup container, int position) {
        MPhotoView mPhotoView = sMPhotoViewPool.get(position);
        if (mPhotoView == null) {
            mPhotoView = new MPhotoView(mContext);
            mPhotoView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT));
        }
        Glide.with(mContext).load(photoAddress.get(position)).into(mPhotoView);
        container.addView(mPhotoView);
        return mPhotoView;
    }
	...setPrimaryItem()方法不變

    @Override
    public void destroyItem(ViewGroup container, int position, Object object) {

        container.removeView((View) object);
    }

	...其餘方法不變
    //=============================================================destroy
    public void destroy(){
        for (MPhotoView photoView : sMPhotoViewPool) {
            photoView.destroy();
        }
        sMPhotoViewPool.clear();
        sMPhotoViewPool=null;
    }
}

複製程式碼

在adapter初始化的時候,我們將物件池new出來,並new出10個photoview新增到池裡面。

在instantiateItem我們直接從池裡面拿出來,如果沒有,才建立。然後跟以前一樣,glide載入。

在destroyItem我們把view給remove掉,這樣可以防止在instantiateItem的時候在池裡拿出的view擁有parent導致了異常的丟擲。

最後記得提供destroy方法來清掉池的引用哦。


Q2 - 關於PhotoView在ViewPager裡面爆出的"ImageView no longer exists. You should not use this PhotoViewAttacher any more."錯誤

如果您細心,會發現我的程式碼裡寫的是MPhotoView而不是PhotoView

原因就是如小標題。

在viewpager中,如果採用物件池的方式結合PhotoView來實現複用,就會因為這個錯誤而導致PhotoView的點選事件無法相應。

要解決這個問題,就必須得檢視PhotoView的原始碼。

首先我們找到這個錯誤的提示位置

錯誤位置

首先PhotoView的實現跟我們PhotoPagerMananger的實現思路差不多,都是將事件的處理委託給另一個物件,這樣的好處是可以降低耦合度,其他的控制元件想實現類似功能會更簡單。

在getImageView中,如果imageview==null,就會log出這個錯誤。

我們看看imageview的引用,在PhotoViewAttacher中,imageview是屬於弱引用,這樣可以更快的被回收。

而imageview的清理則是在cleanup中

/**
     * Clean-up the resources attached to this object. This needs to be called when the ImageView is
     * no longer used. A good example is from {@link android.view.View#onDetachedFromWindow()} or
     * from {@link android.app.Activity#onDestroy()}. This is automatically called if you are using
     * {@link uk.co.senab.photoview.PhotoView}.
     */
    @SuppressWarnings("deprecation")
    public void cleanup() {
        if (null == mImageView) {
            return; // cleanup already done
        }

        final ImageView imageView = mImageView.get();

        if (null != imageView) {
            // Remove this as a global layout listener
            ViewTreeObserver observer = imageView.getViewTreeObserver();
            if (null != observer && observer.isAlive()) {
                observer.removeGlobalOnLayoutListener(this);
            }

            // Remove the ImageView's reference to this
            imageView.setOnTouchListener(null);

            // make sure a pending fling runnable won't be run
            cancelFling();
        }

        if (null != mGestureDetector) {
            mGestureDetector.setOnDoubleTapListener(null);
        }

        // Clear listeners too
        mMatrixChangeListener = null;
        mPhotoTapListener = null;
        mViewTapListener = null;

        // Finally, clear ImageView
        mImageView = null;
    }
複製程式碼

那麼現在問題的出現就很明顯了,爆出這個錯誤是因為imageview==null,也就是說兩個可能:

  • 要麼被執行了cleanup
  • 要麼就是引用的物件被銷燬了

第二點我們可以排除,因為我們有個list來引用著photoview,所以只可能是第一個問題。

最終,我們在PhotoView的onDetachedFromWindow找到了cleanup方法的呼叫

cleanup

還記得在ViewPager中我們的destroyItem嗎,那裡我們執行的是container.remove(View),一個View在被remove的時候會回撥onDetachedFromWindow。

而在PhotoView中,回撥的時候就會執行attacher.cleanup,也就是說attacher已經沒有了imageview的引用,然而我們的photoview卻是在我們的池裡面。

這樣導致的結果就是在下一次instantiateItem時,從池裡拿出的photoview裡面的attacher根本就沒有imageview的引用,所以就會log出那個錯誤。

所以我們的解決方法就很明瞭了:

把photoview的程式碼copy,註釋掉onDetachedFromWindow中的mattacher.cleanup,然後提供cleanup方法來手動進行attacher.cleanup,這樣就可以避免這個錯誤了。

大概程式碼如下:

/**
 * Created by 大燈泡 on 2016/4/14.
 *
 * 針對onDetachedFromWindow
 *
 * 因為PhotoView在這裡會導致attacher.cleanup,從而導致attacher的imageview=null
 * 最終無法在viewpager響應onPhotoViewClick
 *
 * 這裡將cleanup註釋掉,把cleanup移到手動呼叫方法中
 */
public class MPhotoView extends ImageView implements IPhotoView {
    private PhotoViewAttacher mAttacher;

    private ScaleType mPendingScaleType;

    public MPhotoView(Context context) {
        this(context, null);
    }

    public MPhotoView(Context context, AttributeSet attr) {
        this(context, attr, 0);
    }

    public MPhotoView(Context context, AttributeSet attr, int defStyle) {
        super(context, attr, defStyle);
        super.setScaleType(ScaleType.MATRIX);
        init();
    }

    protected void init() {
        if (null == mAttacher || null == mAttacher.getImageView()) {
            mAttacher = new PhotoViewAttacher(this);
        }

        if (null != mPendingScaleType) {
            setScaleType(mPendingScaleType);
            mPendingScaleType = null;
        }
    }

...copy from photoview

    @Override
    protected void onDetachedFromWindow() {
        //mAttacher.cleanup();
        super.onDetachedFromWindow();
    }

    @Override
    protected void onAttachedToWindow() {
        init();
        super.onAttachedToWindow();
    }

    public void destroy(){
        setImageBitmap(null);
        mAttacher.cleanup();
        onDetachedFromWindow();
    }

}

複製程式碼

至此,我們上一篇留下來的問題全部解決。

下一篇。。。暫時沒想到做什麼好,大家有沒有什麼提議的

相關文章