自定义view相关Android开发进阶Android学习

一起撸个朋友圈吧 - 图片浏览(中)【图片浏览器】

2016-04-14  本文已影响2549人  Razerdp

项目地址:https://github.com/razerdp/FriendCircle (能弱弱的求个star或者fork么QAQ)
《一起撸个朋友圈吧》 这是本文所处文集,所有更新都会在这个文集里面哦,欢迎关注


上篇链接:http://www.jianshu.com/p/8984efce40ae
下篇链接:http://www.jianshu.com/p/17c51bd5ba70


【Warning】:

本篇完整的从思考->寻找->编写代码->最终完成来阐述我如何实现本篇预览图功能
本篇篇幅较长,请带上一定的耐心
本篇图片较多,流量党请注意
本篇比较抽象,我会尽量形象的阐述

本篇预览图:

preview.gif

前言

正如上一篇文章所说,一个app动人之处在于细节的研磨和富有动感的交互。

在微信的朋友圈,我们点击图片可以感觉到像预览图那样的效果:点击某张图片,然后它会放大到全屏,再点击,则会缩小到原来的那个地方

这种交互看起来非常赞,最起码看得顺眼。

然而很多时候交互动作设计的时候看起来确实很棒,但对于我等程序员来说,设计棒,设计酷往往会让我们摆出一张苦逼脸

—— 臣妾做不到啊,陛下。

但迫于Money的压力下,我们往往不得不硬着头皮上。

正如今天这个效果,确实一开始是没有任何头绪,在思考实现的过程中,我曾经想过如下几种方法:

但实际上,以上的方法貌似都可以,但实际上真要我去干了,就犹豫了,且不说运行效率,但起码可以预测到代码量。。。

然而,一次神奇的发现,让我解决了这个问题,准确的说,是谷歌早就解决了这个问题。


羽翼君探索篇

发现

对于面向搜索引擎编程的我们,其实一直都习惯于有问题找度娘,或者找谷歌。

鉴于度娘找到的技术文章基本都是你抄我,我抄你,于是我只好到谷歌以"android scale a view to full screen"来找答案,奈何找来找去都是关于如何让imageview的图片填充整个屏幕的。

于是换个思路,除了scale,我们不是经常还能接触到"zoom"这个关键词么,于是就继续谷歌"android zoom a view to full screen"

结果第一个结果就是Android开发者文档的train项目:

Zooming a View

点进去一看,瞬间满满的幸福感,原来头疼了好久的问题,人家谷歌早就给出了答案

而且,不得不说的是,这个项目仅仅是在Android的培训项目,相当于打游戏第一关的新手教程那样吧,具体地址可以点这里(http://developer.android.com/intl/zh-cn/training/index.html

事实上,在完成了这篇文章的效果后,我到官方培训这里看了几次,于是决定,我必须要把这里所有东西弄明白。

这里真的要给谷歌一万个赞。


难点

在得到官方培训这个超级大外挂后,最难的地方其实已经没有什么障碍了,剩下的就是该如何适配到我们的项目中。

从我们日常使用朋友圈的经验看,关于图片点击放大会涉及到这么几个难点:

在官方的demo中,仅仅只有一张图片的浏览,也就是说仅仅是展示了一张图片缩放到全屏的方法,所以我们只有去完全的理解demo,才能继续我们的工程。

不过在真正实现之前,上面的问题我们其实可以回答几个:


原理篇

官方Demo详解

事实上,官方的demo中的注释是十分的清楚的,官方的Demo最主要依靠的是两个东西:

我们都知道,一个View是可以通过getLocationOnScreen或者**getLocationInWindow **得到相对于整个屏幕/相对于父控件的xy位置信息。

getGlobalVisibleRect/getLocalVisibleRect跟上面的这个其实差不多,不过不同的是,它得到的不是xy位置信息,而是得到指定View在屏幕中展示的矩形信息

简单的描述就是前者得到view的原点信息,后者得到view的2D形状信息。

官方的demo则是通过这个得到两个view的Rect:

得到两个view的矩形后,就可以得到双方的缩放比。

通过这个比例,可以做的事情就很多了,官方的demo则是通过这个比例,来计算出以下的参数:

得到这些参数后,就通过ObjectAnimator,操作的对象是隐藏在屏幕中的最终展示的View,通过监听它的数值变化,从而不断的更新展示的View的属性,给人造成原来的view放大的错觉。

或许文字说的有点枯燥,所以,直接上AE,弄出一个动图,相信大家一看就明白:

在图层的结构上如下动图:

组织结构

在点击的之后,会发生如下动作:

过程

总结起来就是:


代码篇

Step 1- 布局/MVP的方法添加

呼呼,又是AE,又是穹妹(手动斜眼)的,终于可以开始弄我们的代码了。

从上面我们知道,要实现这种伪放大效果,最重要的是得到开始和结束两个view的rect,而我们由于是使用viewpager,所以我们穿值传递的是一个数组,这个数组就是当前Item所拥有的imageview的rect数组。

因此回到我们的Activity,由于我们采用MVP模式,所以在View层增加一个方法:

public interface DynamicView {

...之前的方法不变

    // 浏览图片
    void showPhoto(@NonNull ArrayList<String> photoAddress, @NonNull ArrayList<Rect> originViewBounds, int
            curSelectedPos);
}

同样在P层也增加这个方法,这里就不贴上来了。

接下啦到我们朋友圈的布局中,添加一个viewpager。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/photo_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/black"
    android:visibility="invisible">

    <razerdp.friendcircle.widget.HackyViewPager
        android:id="@+id/photo_pager"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</RelativeLayout>

值得注意的是,因为微信朋友圈的大图浏览是有背景(黑色)的,所以我们外层用一个布局包裹。

另外由于我们需要使用PhotoView,所以我们的ViewPager将会使用PhotoView作者给出的解决方法:

HackyViewPager代码如下(因为有LICENSE,所以就完整贴出):

import android.content.Context;
import android.support.v4.view.ViewPager;
import android.util.AttributeSet;
import android.view.MotionEvent;

/**
 * Found at http://stackoverflow.com/questions/7814017/is-it-possible-to-disable-scrolling-on-a-viewpager.
 * Convenient way to temporarily disable ViewPager navigation while interacting with ImageView.
 *
 * Julia Zudikova
 */

/**
 * Hacky fix for Issue #4 and
 * http://code.google.com/p/android/issues/detail?id=18990
 * <p/>
 * ScaleGestureDetector seems to mess up the touch events, which means that
 * ViewGroups which make use of onInterceptTouchEvent throw a lot of
 * IllegalArgumentException: pointerIndex out of range.
 * <p/>
 * There's not much I can do in my code for now, but we can mask the result by
 * just catching the problem and ignoring it.
 *
 * @author Chris Banes
 */
public class HackyViewPager extends ViewPager {

    private boolean isLocked;

    public HackyViewPager(Context context) {
        super(context);
        isLocked = false;
    }

    public HackyViewPager(Context context, AttributeSet attrs) {
        super(context, attrs);
        isLocked = false;
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (!isLocked) {
            try {
                return super.onInterceptTouchEvent(ev);
            } catch (IllegalArgumentException e) {
                e.printStackTrace();
                return false;
            }
        }
        return false;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        return !isLocked && super.onTouchEvent(event);

    }

    public void toggleLock() {
        isLocked = !isLocked;
    }

    public void setLocked(boolean isLocked) {
        this.isLocked = isLocked;
    }

    public boolean isLocked() {
        return isLocked;
    }

}

在布局弄好后,我们将它include到我们的朋友圈activity,于是目前的层次如下:

层次

我们的viewpager在listview的上方


Step 2 - ViewPager的adapter

adapter很明显,就是为了实现我们的所有方法的,在adapter的设计中,我们需要知道几个地方:

因此我们的adapter将会这么设计:

/**
 * Created by 大灯泡 on 2016/4/12.
 * 图片浏览窗口的adapter
 */
public class PhotoBoswerPagerAdapter extends PagerAdapter {
    private static final String TAG = "PhotoBoswerPagerAdapter";

    //=============================================================datas
    private ArrayList<String> photoAddress;
    private ArrayList<Rect> originViewBounds;
    //=============================================================bounds

    private Context mContext;
    private LayoutInflater mLayoutInflater;


    public PhotoBoswerPagerAdapter(Context context) {
        mContext = context;
        mLayoutInflater = LayoutInflater.from(context);

        photoAddress = new ArrayList<>();
        originViewBounds = new ArrayList<>();

    }
 
    public void resetDatas(@NonNull ArrayList<String> newAddress, @NonNull ArrayList<Rect> newOriginViewBounds)
            throws IllegalArgumentException {
        if (newAddress.size() != newOriginViewBounds.size() || newAddress.size() <= 0 ||
                newOriginViewBounds.size() <= 0) {
            throw new IllegalArgumentException("图片地址和图片的位置缓存不对等或某一个为空");
        }

        photoAddress.clear();
        originViewBounds.clear();

        photoAddress.addAll(newAddress);
        originViewBounds.addAll(newOriginViewBounds);
    }

    @Override
    public int getCount() {
        return photoAddress.size();
    }

    @Override
    public Object instantiateItem(ViewGroup container, int position) {
       
        return null;
    }

    int[] pos = new int[1];

    @Override
    public void setPrimaryItem(ViewGroup container, int position, Object object) {
        super.setPrimaryItem(container, position, object);

    }

    @Override
    public void destroyItem(ViewGroup container, int position, Object object) {
        container.removeView((View) object);
    }

    @Override
    public boolean isViewFromObject(View view, Object object) {
        return view == object;
    }

    //=============================================================点击消失的interface

    private OnPhotoViewClickListener mOnPhotoViewClickListener;

    public OnPhotoViewClickListener getOnPhotoViewClickListener() {
        return mOnPhotoViewClickListener;
    }

    public void setOnPhotoViewClickListener(OnPhotoViewClickListener onPhotoViewClickListener) {
        mOnPhotoViewClickListener = onPhotoViewClickListener;
    }

    public interface OnPhotoViewClickListener {
        void onPhotoViewClick(View view, Rect originBound, int curPos);
    }

}

在adapter中,我们存放着这些参数:

然后还有我们内部定义的接口:OnPhotoViewClickListener,这个接口在点击Viewpager里面的PhotoView时会触发。


Step 3 - PhotoPagerManager

在adapter初步结构设计后,我们暂时先不管,接下来我们需要处理的就是缩放动画和点击的事件处理。

由于我们的Activity作为MVP的View,代码量已经比较多了,所以我们将动画的实现和点击事件的处理封装到另一个类里,委托它进行操作。

在设计这个类之前,我们需要确定一下需要的委托管理的东西:

由此,我们初步设计以下结构:

/**
 * Created by 大灯泡 on 2016/4/12.
 * 相册展示的管理类
 */
public class PhotoPagerManager implements PhotoBoswerPagerAdapter.OnPhotoViewClickListener {

    private Context mContext;
    private PhotoBoswerPagerAdapter adapter;
    private HackyViewPager pager;

    private Rect finalBounds;
    private Point globalOffset;

    private View container;

    //私有构造器
    private PhotoPagerManager(Context context, HackyViewPager pager, View container) {
        if (container != null) {
            finalBounds = new Rect();
            globalOffset = new Point();
            this.mContext = context;
            this.container = container;
            this.pager = pager;
            adapter = new PhotoBoswerPagerAdapter(context);
            adapter.setOnPhotoViewClickListener(this);
        }
        else {
            throw new IllegalArgumentException("PhotoPagerManager >>> container不能为空哦");
        }
    }

    //静态工厂
    public static PhotoPagerManager create(Context context, HackyViewPager pager, View container) {
        return new PhotoPagerManager(context, pager, container);
    }

    //共有调用方法,传入图片地址和view的可见矩形数组
    public void showPhoto(
            @NonNull ArrayList<String> photoAddress, @NonNull ArrayList<Rect> originViewBounds, int curSelectedPos) {
        
    }

    //当前正在进行的动画,如果动画没展示完,就将其取消以执行下一个动画
    private AnimatorSet curAnimator;

    //私有showPhoto处理
    private void showPhotoPager(@NonNull ArrayList<Rect> originViewBounds, int curSelectedPos) {
       
    }

    //pager的PhotoView点击回调,用于执行消失时的缩小动画
    @Override
    public void onPhotoViewClick(View view, Rect originBound, int curPos) {
       
    }

    //计算缩放比率
    private float calculateRatio(Rect startBounds, Rect finalBounds) {
        
    }

    //销毁
    public void destroy() {
        adapter.destroy();
        mContext = null;
        adapter = null;
        pager = null;
        finalBounds = null;
        globalOffset = null;
        container = null;
    }
}

可以看得出,我们的重头戏全在showPhoto里面

在私有构造器里面我们将需要的成员进行赋值,同时adapter需要实现我们在第二步定义的接口。

接下来我们补充共有的showPhoto方法:

  public void showPhoto(
            @NonNull ArrayList<String> photoAddress, @NonNull ArrayList<Rect> originViewBounds, int curSelectedPos) {
        adapter.resetDatas(photoAddress, originViewBounds);
        pager.setAdapter(adapter);
        pager.setCurrentItem(curSelectedPos);
        pager.setLocked(photoAddress.size() == 1);
        container.getGlobalVisibleRect(finalBounds, globalOffset);
        showPhotoPager(originViewBounds, curSelectedPos);
    }

每次调用show方法我们都需要刷新adapter的数据,然后使用setAdapter来进行刷新。

接下来判断传进来的图片是否只有一张,如果只有一张,就不允许viewpager滑动,setLocked方法是PhotoView作者给出的解决方案带有的,其原理是在Viewpager的onInterceptTouchEvent里通过locked来决定是否拦截事件。

container.getGlobalVisibleRect(finalBounds, globalOffset);这个在上面的解释里已经有,这里只是直接copy官方demo代码而已。

最后调用私有方法:showPhotoPager

 private void showPhotoPager(@NonNull ArrayList<Rect> originViewBounds, int curSelectedPos) {
        Rect startBounds = originViewBounds.get(curSelectedPos);

        startBounds.offset(-globalOffset.x, -globalOffset.y);
        finalBounds.offset(-globalOffset.x, -globalOffset.y);

        float ratio = calculateRatio(startBounds, finalBounds);

        pager.setPivotX(0);
        pager.setPivotY(0);

        container.setVisibility(View.VISIBLE);
        container.setAlpha(1.0f);

        final AnimatorSet set = new AnimatorSet();
        set.play(ObjectAnimator.ofFloat(pager, View.X, startBounds.left, finalBounds.left))
           .with(ObjectAnimator.ofFloat(pager, View.Y, startBounds.top, finalBounds.top))
           .with(ObjectAnimator.ofFloat(pager, View.SCALE_X, ratio, 1f))
           .with(ObjectAnimator.ofFloat(pager, View.SCALE_Y, ratio, 1f));
        set.setDuration(300);
        set.setInterpolator(new DecelerateInterpolator());
        set.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {
                curAnimator = set;
            }

            @Override
            public void onAnimationEnd(Animator animation) {
                curAnimator = null;
            }

            @Override
            public void onAnimationCancel(Animator animation) {
                curAnimator = null;
            }

            @Override
            public void onAnimationRepeat(Animator animation) {

            }
        });
        set.start();
    }

这里跟官方的代码基本一致,因为官方代码有注释,所以这里就不详细阐述了。

不过值得留意的是,在动画执行之前必须要将container的alpha设回1,因为我们在退出动画里将它设置为0的。

同理,在PhotoView点击回调里,我们也写出差不多的代码:

  @Override
    public void onPhotoViewClick(View view, Rect originBound, int curPos) {
        //如果展开动画没有展示完全就关闭,那么就停止展开动画进而执行退出动画
        if (curAnimator != null) {
            curAnimator.cancel();
        }

        container.getGlobalVisibleRect(finalBounds, globalOffset);

        originBound.offset(-globalOffset.x, -globalOffset.y);
        finalBounds.offset(-globalOffset.x, -globalOffset.y);

        float ratio = calculateRatio(originBound, finalBounds);

        pager.setPivotX(0);
        pager.setPivotY(0);

        final AnimatorSet set = new AnimatorSet();
        set.play(ObjectAnimator.ofFloat(pager, View.X, originBound.left))
           .with(ObjectAnimator.ofFloat(pager, View.Y, originBound.top))
           .with(ObjectAnimator.ofFloat(pager, View.SCALE_X, 1f, ratio))
           .with(ObjectAnimator.ofFloat(pager, View.SCALE_Y, 1f, ratio))
           .with(ObjectAnimator.ofFloat(container, View.ALPHA, 1.0f, 0.0f));

        set.setDuration(300);
        set.setInterpolator(new DecelerateInterpolator());
        set.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {
                curAnimator = set;
            }

            @Override
            public void onAnimationEnd(Animator animation) {
                curAnimator = null;
                container.clearAnimation();
                container.setVisibility(View.INVISIBLE);
            }

            @Override
            public void onAnimationCancel(Animator animation) {
                curAnimator = null;
                container.clearAnimation();
                container.setVisibility(View.INVISIBLE);
            }

            @Override
            public void onAnimationRepeat(Animator animation) {

            }
        });
        set.start();
    }

在退出的动画里,我们需要将SCALE_X和SCALE_Y的动画起始值和目标值替换

最后补全,哦,不,是copy官方的计算比率的方法:

 private float calculateRatio(Rect startBounds, Rect finalBounds) {
        float ratio;
        if ((float) finalBounds.width() / finalBounds.height() > (float) startBounds.width() / startBounds.height()) {
            // Extend start bounds horizontally
            ratio = (float) startBounds.height() / finalBounds.height();
            float startWidth = ratio * finalBounds.width();
            float deltaWidth = (startWidth - startBounds.width()) / 2;
            startBounds.left -= deltaWidth;
            startBounds.right += deltaWidth;
        }
        else {
            // Extend start bounds vertically
            ratio = (float) startBounds.width() / finalBounds.width();
            float startHeight = ratio * finalBounds.height();
            float deltaHeight = (startHeight - startBounds.height()) / 2;
            startBounds.top -= deltaHeight;
            startBounds.bottom += deltaHeight;
        }
        return ratio;
    }

官方的计算方法是这样的:

在这个类完成后,我们在Activity里仅仅需要两句话调用:

/**
 * Created by 大灯泡 on 2016/2/25.
 * 朋友圈demo窗口
 */
public class FriendCircleDemoActivity extends FriendCircleBaseActivity
        implements DynamicView, View.OnClickListener, OnSoftKeyboardChangeListener {
    ... 成员变量略
    
    //图片浏览的pager manager
    private PhotoPagerManager mPhotoPagerManager;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
    ...略
        initView();
    ...略
    }

    private void initView() {
    ...各种findViewById略
    
    //初始化我们的manager
    mPhotoPagerManager = PhotoPagerManager.create(this, (HackyViewPager) findViewById(R.id.photo_pager),
                findViewById(R.id.photo_container));
    }
    ...其他方法略

    @Override
    public void showPhoto(
            @NonNull ArrayList<String> photoAddress, @NonNull ArrayList<Rect> originViewBounds, int curSelectedPos) {
            
        //事件委托给manager      
        mPhotoPagerManager.showPhoto(photoAddress, originViewBounds, curSelectedPos);
    }

}


Step 4 - adapter代码补全

实现完manager后,我们就补全我们的adapter代码

在adapter里面,我们主要关注两个方法:

其他方法都是常规方法,就不展示了

初始化的时候,我们的代码非常简单,new一个,add,完。。。

 @Override
    public Object instantiateItem(ViewGroup container, int position) {
      
        PhotoView photoView=new PhotoView(mContext);
        Glide.with(mContext).load(photoAddress.get(position)).into(photoView);
        container.addView(photoView, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
        return photoView;
    }

在setPrimaryItem中,我们为photoView设置回调:

int currentPos;

    @Override
    public void setPrimaryItem(ViewGroup container, int position, Object object) {
        super.setPrimaryItem(container, position, object);
        currentPos=position;
        if (object instanceof PhotoView) {
            PhotoView photoView = (PhotoView) object;
            if (photoView.getOnViewTapListener() == null) {
                photoView.setOnViewTapListener(new PhotoViewAttacher.OnViewTapListener() {
                    @Override
                    public void onViewTap(View view, float x, float y) {
                        if (mOnPhotoViewClickListener != null) {
                            mOnPhotoViewClickListener.onPhotoViewClick(view, originViewBounds.get(currentPos), currentPos);
                        }
                    }
                });
            }
        }
    }

在这里,我们留意到在回调里我们传入的rect就是外部传进来起始View的rect组,这里就回答了我们疑点中的第三个问题:

点击某张图片,滑动到其他图片时,退出的缩小动画如何缩小到对应的起始View中

我们的解决方法就是,把那个View的rect扔给我们的manager让他计算,就好了。


Step 5 - Item里使用

在目前的项目里,事实上也是在微信朋友圈里,图片永远都是0~9,在我们的项目中,因为ListView的Adapter高度抽象化,所以我们可以很轻松的在ViewHolder里处理

在ItemWithImg.java中,我们针对GridView的onItemClick进行处理:

public class ItemWithImg extends BaseItemDelegate implements AdapterView.OnItemClickListener {
    private static final String TAG = "ItemWithImg";

    private NoScrollGridView mNoScrollGridView;
    private GridViewAdapter mGridViewAdapter;

    private ArrayList<String> mUrls = new ArrayList<>();
    private ArrayList<Rect> mRects = new ArrayList<>();
        
    ...略

    @Override
    protected void bindData(int position, @NonNull View v, @NonNull MomentsInfo data, int dynamicType) {
        if (data.content.imgurl == null || data.content.imgurl.size() == 0 || mNoScrollGridView == null) return;
        mUrls.clear();
        mUrls.addAll(data.content.imgurl);
        
        ...数据绑定
    }

    @Override
    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
        final int childCount = parent.getChildCount();
        mRects.clear();
        try {
            if (childCount >= 0) {
                for (int i = 0; i < childCount; i++) {
                    View v = parent.getChildAt(i);
                    Rect bound = new Rect();
                    v.getGlobalVisibleRect(bound);
                    mRects.add(bound);
                }
            }
        } catch (NullPointerException e) {
            Log.e(TAG, "view可能为空哦");
        }
        getPresenter().shoPhoto(mUrls, mRects, position);
    }
}

这里我们需要留意两个地方:

到这里,我们的工作就完成了。


问题

花了那么多时间,终于把这个效果完成了,事实上最麻烦的东西都封到了manager里面,理论上来说要迁移到您的项目中也是非常简单的。

但目前来说,我们仅仅是初步实现了,其实有一些小问题还是存在的:

虽然问题不是很大,但我们也有修复的理由对吧。

所以,在下一篇,我们将会针对这三个问题进行处理,以及关于PhotoView在ViewPager里面爆出的"ImageView no longer exists. You should not use this PhotoViewAttacher any more."错误从而导致PhotoView的点击事件无响应的处理方法。

敬请期待-V-

上一篇下一篇

猜你喜欢

热点阅读