Android自定义导览地图组件(一)

原创 cheny  2017-08-21 09:35  阅读 636 次 评论 0 条
摘要:

鉴于Android关于自定义导览地图的相关资料以及开源项目贫乏,应Android同行几位小伙伴们的建议,决定写下这篇文章分享给大家。由于博客篇幅限制,本文将分两到三篇博文叙述。

版权说明 : 《Android自定义导览地图组件(一)于当前乘月网CSDN博客属同一原创,转载请说明出处,谢谢。

 

         鉴于Android关于自定义导览地图的相关资料以及开源项目贫乏,应Android同行几位小伙伴们的建议,决定写下这篇文章分享给大家。由于博客篇幅限制,本文将分两到三篇博文叙述。
进入主题:

先看看下面的效果图:

Android自定义导览地图组件(一) Android 第1张

可以看到,地图组件基本上实现当前主流地图应用(如百度,高德地图)的缩放拖拽移动以及定位图标等功能。

实现思路:
其实一开始想了很多方案:
1. 单纯的自定义一个View然后Draw绘制地图和定位图标,用各种分工逻辑类拆分单元实现。
2. 自定义View绘制地图+自定义View绘制定位图标,用各种分工逻辑类拆分单元实现。
3. 自定一个View,地图是View的背景图+ImageView作为定位图标。
4. 自定义ViewGroup+自定义Imageview作为地图+ImageView作为定位图标。
………….省略后面n种方案,真是绞尽脑汁
由于本文重点在于提供实现路线,最终敲定了最简单的第4种方案:自定义ViewGroup(MapContainer)+自定义ImageView(MapView)+ImageView(定位图标marker)。不过,通过本文的学习,上述其它方案的实现也就变得简单多了。

分析:
1. MapContainer提供地图和定位图标的盛放容器,定位图标可以叠加显示于地图之上。我们知道,地图的缩放(缩放会导致定位位置的改变)和移动,定位图标随之移动,作为ViewGroup肯定可以拿到这两种ImageView的对象,一边监听地图View----MapView的变化(获取变化值),再传递并控制地位图标View--marker的显示位置。
2. 为什么自定义ImageView?因为ImageView可以直接显示地图图片呗,但是缩放移动什么的还得靠自定义来实现。
3. 定位图标没什么难点,直接用ImageView显示坐标icon就好了,剩下的交给ViewGroup动态控制其位置就好了。
通过上述,总结下该方案:核心业务就在MapView上,MapContainer打辅助,定位图标ImageView打酱油。
对于一个图片可移动,缩放的功能实现,自然而然就能想到就是常见的查看大图嘛。
开干!!!

一、 自定义MapView
图片加载完成时,自动对图片进行自适应ViewGroup(或屏幕)缩放。
大多数情况下,图片分辨率都不会是刚好ViewGroup(或屏幕)大小,需要在图片加载时获取其真实显示尺寸并根据当前ViewGroup尺寸做自适应缩放以达到最佳的观感效果。
那么如何知道图片什么时候加载好了?这里可以实现ViewTreeObserver. OnGlobalLayoutListener接口来订阅监听布局变化。当前视图树中,全局布局发生改变或者某个视图的可视状态发生改变时,会得到消息推送(调用订阅者的OnGlobalLayoutListener下的onGlobalLayoutListener方法),图片加载完成后显示到屏幕上便会触发该方法,完整代码如下:

package cn.icheny.guide_map;

import android.content.Context;
import android.os.Build;
import android.util.AttributeSet;
import android.view.ViewTreeObserver;
import android.widget.ImageView;

/**
 * 可手势缩放移动双击缩放ImageView
 *
 * @author www.icheny.cn
 * @date 2017/8/15
 */

public class MapView extends ImageView implements ViewTreeObserver.OnGlobalLayoutListener {

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

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

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

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        //订阅布局监听
        getViewTreeObserver().addOnGlobalLayoutListener(this);
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        //取消订阅
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
            getViewTreeObserver().removeOnGlobalLayoutListener(this);
        }
    }

    /**
     * 订阅者的onGlobalLayout方法,监听布局变化
     */
    @Override
    public void onGlobalLayout() {

    }
}

对于ImageView可缩放,需要设置:setScaleType(ScaleType.MATRIX)属性 ,初始化图片矩阵Matrix 对象以及设定三个缩放比例系数:

……
float SCALE_MIN = 0.5f;//最小缩小比例值系数
float SCALE_ADAPTIVE = 1f;//自适应ViewGroup(或屏幕)缩放比例值
float SCALE_MID = 2f;//中间放大比例值系数,双击一次的放大值
float SCALE_MAX = 5f;//最大放大比例值系数,双击两次的放大值
private Matrix mScaleMatrix;//缩放矩阵

public MapView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    setScaleType(ScaleType.MATRIX);
    mScaleMatrix = new Matrix();
}
……

 

正如上述,onGlobalLayout()在很多情况下都会被触发,然而我们只需要确定一旦图片加载完成便对其自适应屏幕的缩放处理,所以我们需要一个Flag进行判定,并确定能够获取Drawable对象则表示图片刚好加载完成:

……
private boolean isPicLoaded = false;//图片是否已加载

/**
 * 订阅者的onGlobalLayout函数
 */
@Override
public void onGlobalLayout() {
    if (!isPicLoaded) {
        Drawable drawable = getDrawable();
        if (null == drawable) {//图片不存在就继续监听
            return;
        }
        isPicLoaded=true;//图片存在,已加载完成,停止监听

    }
}
……

 

OK,检测到图片加载完成,便可以进行缩放处理。为了少点废话,接下来“代码+注释”为主:

    /**
     * 订阅者的onGlobalLayout函数
     */
    @Override
    public void onGlobalLayout() {
        if (!isPicLoaded) {
            Drawable drawable = getDrawable();
            if (null == drawable) {//图片不存在就继续监听
                return;
            }
            isPicLoaded = true;//图片存在,已加载完成,停止监听
            //获取图片固有的宽高(不是指本身属性:分辨率,因为android系统在加载显示图片前可能对其压缩)
            int iWidth = drawable.getIntrinsicWidth();
            int iHeight = drawable.getIntrinsicHeight();

            //获取当前View(ImageView)的宽高,即父View给予的宽高
            int width = getWidth();
            int height = getHeight();

            //对比图片宽高和当前View的宽高,针对性的缩放
            if (iWidth >= width && iHeight <= height) {//如果图片固宽大于View宽,固高小于View高,
                SCALE_ADAPTIVE = height * 1f / iHeight;   // 那么只需针对高度等比例放大图片(这里有别于查看大图的处理方式)
            } else if (iWidth <= width && iHeight >= height) {//固宽小于View宽,固高大于View高,针对宽度放大
                SCALE_ADAPTIVE = width * 1f / iWidth;
            } else if (iWidth >= width && iHeight >= height || iWidth <= width && iHeight <= height) {//固宽和固高都大于或都小于View的宽高,
                SCALE_ADAPTIVE = Math.max(width * 1f / iWidth, height * 1f / iHeight);//只取对宽和对高之间最小的缩放比例值(这里有别于查看大图的处理方式)
            }

            //先将图片移动到View中心位置
            mScaleMatrix.postTranslate((width - iWidth) * 1f / 2, (height - iHeight) * 1f / 2);
            //再对图片从View的中心点缩放
            mScaleMatrix.postScale(SCALE_ADAPTIVE, SCALE_ADAPTIVE, width * 1f / 2, height * 1f / 2);
            //执行偏移和缩放
            setImageMatrix(mScaleMatrix);

            //根据当前图片的缩放情况,重新调整图片的最大最小缩放值
            SCALE_MAX *= SCALE_ADAPTIVE;
            SCALE_MID *= SCALE_ADAPTIVE;
            SCALE_MIN *= SCALE_ADAPTIVE;
        }
    }
……
private ScaleGestureDetector mScaleGestureDetector;//缩放手势探测测器

public MapView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    setScaleType(ScaleType.MATRIX);
    mScaleMatrix = new Matrix();
    mScaleGestureDetector = new ScaleGestureDetector(context, this);
}

/**
 * 缩放手势开始时调用该方法
 *
 * @param detector
 * @return
 */
@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
    //返回为true,则缩放手势事件往下进行,否则到此为止,即不会执行onScale和onScaleEnd方法
    return true;
}

/**
 * 缩放手势进行时调用该方法
 * 缩放控制范围:SCALE_MIN——SCALE_MAX
 *
 * @param detector
 */
@Override
public boolean onScale(ScaleGestureDetector detector) {
    return true;
}

/**
 * 缩放手势完成后调用该方法
 *
 * @param detector
 */
@Override
public void onScaleEnd(ScaleGestureDetector detector) {

}

@Override
public boolean onTouchEvent(MotionEvent event) {
    if (mScaleGestureDetector != null) {
        //绑定缩放手势探测器,由其处理touch事件
        mScaleGestureDetector.onTouchEvent(event);
    }
    return true;
}
……

接下来是处理onScale事件代码:

    /**
     * 缩放手势进行时调用该方法
     * 缩放控制范围:SCALE_MIN——SCALE_MAX
     *
     * @param detector
     */
    @Override
    public boolean onScale(ScaleGestureDetector detector) {
        if (getDrawable() == null) {
            return true;//没有图片就不用折腾了
        }
        //缩放因子(即将缩放的值)
        float scaleFactor = detector.getScaleFactor();
        //当前图片已缩放的值(如果onScale第一次被调用,scale就是自适应后的缩放值:SCALE_ADAPTIVE)
        float scale = getDrawableScale();
        //当前缩放值在最大放大值以内且手势检测缩放因子为缩小手势(小于1),或当前缩放值在最小缩小值以内且缩放因子为放大手势,允许缩放
        if (scale <= SCALE_MAX && scaleFactor < 1 || scale >= SCALE_MIN && scaleFactor > 1) {
            //进一步考虑即将缩小后的缩放比例(scale*scaleFactor)低于规定SCALE_MIN-SCALE_MAX范围的最小值SCALE_MIN
            if (scale * scaleFactor < SCALE_MIN && scaleFactor < 1) {
                //强制锁定缩小后缩放比例为SCALE_MIN(scale*scaleFactor=SCALE_MIN)
                scaleFactor = SCALE_MIN / scale;
            }
            //进一步考虑即将放大后的缩放比例(scale*scaleFactor)高于规定SCALE_MIN-SCALE_MAX范围的最大值SCALE_MAX
            if (scale * scaleFactor > SCALE_MAX && scaleFactor > 1) {
                //强制锁定放大后缩放比例为SCALE_MAX(scale*scaleFactor=SCALE_MAX)
                scaleFactor = SCALE_MAX / scale;
            }
            //设定缩放值和缩放位置,这里缩放位置便是手势焦点的位置
            mScaleMatrix.postScale(scaleFactor, scaleFactor, detector.getFocusX(), detector.getFocusY());
            //执行缩放
            setImageMatrix(mScaleMatrix);
        }
        return true;
    }

好了,写了那么多,上个图看看运行效果,舒缓下紧张的气氛:

Android自定义导览地图组件(一) Android 第2张

还是有效果的,滋滋滋~  不过,这个白边还有图片中心缩放着缩放着就跑偏了着实让人强迫症大发。所以,应该在执行缩放前调用checkBoderAndCenter()方法用于检测并修复留白边,图片中心跑偏的问题:

@Override
public boolean onScale(ScaleGestureDetector detector) {
……
        //设定缩放值和缩放位置,这里缩放位置便是手势焦点的位置
        mScaleMatrix.postScale(scaleFactor, scaleFactor, detector.getFocusX(), detector.getFocusY());

        //检查即将缩放后造成的留空隙和图片不居中的问题,及时调整缩放参数
        checkBoderAndCenter();

        //执行缩放
        setImageMatrix(mScaleMatrix);
    }
    return true;
}

/**
 * 处理缩放和移动后图片边界与屏幕有间隙或者不居中的问题
 */
private void checkBoderAndCenter() {
    RectF rect = getMatrixRect();
    int width = getWidth();
    int height = getHeight();

    float deltaX = 0;//X轴方向偏移量
    float deltaY = 0;//Y轴方向偏移量

    //图片宽度大于等于View宽
    if (rect.width() >= width) {
        //图片左边坐标大于0,即左边有空隙
        if (rect.left > 0) {
            //向左移动rect.left个单位到View最左边,rect.left=0
            deltaX = -rect.left;
        }
        //图片右边坐标小于width,即右边有空隙
        if (rect.right < width) {
            //向右移动width - rect.left个单位到View最右边,rect.right=width
            deltaX = width - rect.right;
        }
    }
    //图片高度大于等于View高,同理
    if (rect.height() >= height) {
        //图片上面坐标大于0,即上面有空隙
        if (rect.top > 0) {
            //向上移动rect.top个单位到View最上边,rect.top=0
            deltaY = -rect.top;
        }
        //图片下面坐标小于height,即下面有空隙
        if (rect.bottom < height) {
            //向下移动height - rect.bottom个单位到View最下边,rect.bottom=height
            deltaY = height - rect.bottom;
        }
    }

    //图片宽度小于View宽
    if (rect.width() < width) {
        //计算需要移动到X方向View中心的距离
        deltaX = width * 1f / 2 - rect.right + rect.width() * 1f / 2;
    }

    //图片高度小于View高度
    if (rect.height() < height) {
        //计算需要移动到Y方向View中心的距离
        deltaY = height * 1f / 2 - rect.bottom + rect.height() * 1f / 2;
    }
    mScaleMatrix.postTranslate(deltaX, deltaY);
}

/**
 * 根据当前图片矩阵变换成的四个角的坐标,即left,top,right,bottom
 *
 * @return
 */
private RectF getMatrixRect() {
    RectF rect = new RectF();
    Drawable drawable = getDrawable();
    if (drawable != null) {
        rect.set(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
    }
    mScaleMatrix.mapRect(rect);
    return rect;
}

效果图不贴了,节省写博时间。上图我们也可以看到:图片缩小到宽高小于View的宽高后,手指离开屏幕,此时图片无法自动恢复(放大)到自适应View宽高的大小,只能依靠手动缩放恢复,甚是心累。所以,需求来了…...缩放之后,检测这样的情况,再用一个动画让图片平滑恢复自适应View大小,接下来onScaleEnd登场:

    /**
     * 缩放手势完成后调用该方法
     *
     * @param detector
     */
    @Override
    public void onScaleEnd(ScaleGestureDetector detector) {
        Drawable drawable = getDrawable();
        if (drawable == null) return;
        //当前缩放值
        float scale = getDrawableScale();
        //当前缩放值小于自适应缩放缩放比例,即图片小于View宽高
        if (scale < SCALE_ADAPTIVE) {
            postDelayed(new AutoScaleTask(SCALE_ADAPTIVE, getWidth() * 1f / 2, getHeight() * 1f), 2);
        }
    }

    /**
     * 自动缩放任务
     */
    private class AutoScaleTask implements Runnable {
        float targetScale;//目标缩放值
        float x;//缩放焦点的x坐标
        float y;//缩放焦点的y坐标
        static final float TMP_AMPLIFY = 1.05f;//放大梯度
        static final float TMP_SHRINK = 0.96f;//缩小梯度
        float tmpScale = 1.0f;//缩小梯度

        public AutoScaleTask(float targetScale, float x, float y) {
            this.targetScale = targetScale;
            this.x = x;
            this.y = y;
            //当前缩放值小于目标缩放值,目标是放大图片
            if (getDrawableScale() < targetScale) {
                //设定缩放梯度为放大梯度
                tmpScale = TMP_AMPLIFY;
            } else {  //当前缩放值小于(等于可以忽略)目标缩放值,目标是缩小图片
                //设定缩放梯度为缩小梯度
                tmpScale = TMP_SHRINK;
            }
        }

        @Override
        public void run() {
            //设定缩放参数
            mScaleMatrix.postScale(tmpScale, tmpScale, x, y);
            //检查即将缩放后造成的留空隙和图片不居中的问题,及时调整缩放参数
            checkBoderAndCenter();
            setImageMatrix(mScaleMatrix);
            //当前缩放值
            float scale = getDrawableScale();
            //如果tmpScale>1即放大任务状态,且当前缩放值还是小于目标缩放值或
            // tmpScale<1即缩小任务状态,且当前缩放值还是大于目标缩放值就继续执行缩放任务
            if (tmpScale > 1 && scale < targetScale || scale > targetScale && tmpScale < 1) {
                postDelayed(this, 2);
            } else {//缩放的略微过头了,需要强制设定为目标缩放值
                tmpScale = targetScale / scale;
                mScaleMatrix.postScale(tmpScale, tmpScale, x, y);
                checkBoderAndCenter();
                setImageMatrix(mScaleMatrix);
            }
        }
    }

好了,大工造成!来看看效果吧:

Android自定义导览地图组件(一) Android 第3张

上面所述的留白,图片中心移位以及缩小后不能自动恢复自适应View(或屏幕)大小的问题在效果图中已经不复存在了,滋滋滋~

上文代码提到自动缩放任务AutoScaleTask,咱趁热打铁来实现双击缩放功能。一般查看地图(或大图)时,双击,如果当前缩放比例小于一级放大(scale<SCALE_MID)比例就自动放大到一级放大(SCALE_ MID), 如果比例大于等于一级放大(SCALE_ MID)比例且小于二级放大(SCALE_MAX)比例就自动放大到二级放大(SCALE_MAX),如果等于二级放大(SCALE_ MID)比例就缩小到自适应View大小(SCALE_ADAPTIVE),在自动缩放过程中不再响应双击事件,直到自动缩放结束,这就意味着需要一个flag进行锁定。思路已经很清晰,就差Android手势探测器GestureDetector帮我们判断双击手势。嗯,是这样!贴代码:

……
private boolean isAutoScaling = false;//是否处于自动缩放中,用于是否响应双击手势的flag
private GestureDetector mGestureDetector;//手势探测器
public ZoomImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
……
    mGestureDetector = initGestureDetector(context);
}

/**
 * 初始化手势探测器
 *
 * @param context
 * @return GestureDetector
 */
private GestureDetector initGestureDetector(Context context) {
    GestureDetector.SimpleOnGestureListener listner = new GestureDetector.SimpleOnGestureListener() {
        @Override
        public boolean onDoubleTap(MotionEvent e) {
            if (!isAutoScaling) {//如果不在自动缩放
                isAutoScaling = true;
                float x = e.getX();//双击触点x坐标
                float y = e.getY();//双击触点y坐标
                float scale = getDrawableScale();
                if (scale < SCALE_MID) {//当前缩放比例小于一级缩放比例
                    //一级放大
                    postDelayed(new AutoScaleTask(SCALE_MID, x, y), 10);
                } else if (scale >= SCALE_MID && scale < SCALE_MAX) {//当前缩放比例在一级缩放和二级缩放比例之间
                    //二级放大
                    postDelayed(new AutoScaleTask(SCALE_MAX, x, y), 10);
                } else if (scale == SCALE_MAX) {//当前缩放比例等于二级缩放比例
                    //缩小至自适应view比例
                    postDelayed(new AutoScaleTask(SCALE_ADAPTIVE, x, y), 10);
                } else {
                    isAutoScaling = false;
                }
            }
            return super.onDoubleTap(e);
        }
    };
    return new GestureDetector(context, listner);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
……
    if (mGestureDetector != null) {
        //绑定手势探测器,由其处理touch事件
        mGestureDetector.onTouchEvent(event);
    }
    return true;
}

private class AutoScaleTask implements Runnable {
……
    @Override
    public void run() {
     ……
        if (tmpScale > 1 && scale < targetScale || scale > targetScale && tmpScale < 1) {
            ……
        } else {//缩放的略微过头了,需要强制设定为目标缩放值
            ……
            isAutoScaling = false;
        }
    }
}

终于进入最后一个功能实现:手势移动。这里依然借助系统给我们算好的拖拽临界值ScaledTouchSlop决定是拖动图片。重写onTouchEvent()处理touch事件判断用户拖动(ActionMove)值是否达到临界值,是则从手势触点(Point)的中心点(因为可能不止一个手指在拖动,需要求中心点)出发移动图片:

@Override
public boolean onTouchEvent(MotionEvent event) {
……
    //不在自动缩放中才可以拖动图片(这个判断可有可无,根据需求来)
    if (!isAutoScaling) {
        //绑定touch事件,处理移动图片逻辑
        moveByTouchEvent(event);
    }
    return true;
}

/**
 * 通过Touch事件移动图片
 *
 * @param event
 */
private void moveByTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_MOVE://手势移动
            RectF rect = getMatrixRect();
            if (rect.width() <= getWidth() && rect.height() <= getHeight()) {
                //图片宽高小于等于View宽高,即图片可以完全显示于屏幕中,那就没必要拖动了
                return;
            }
            //计算多个触点的中心坐标
            int x = 0;
            int y = 0;
            int pointerCount = event.getPointerCount();//获取触点数(手指数)
            for (int i = 0; i < pointerCount; i++) {
                x += event.getX(i);
                y += event.getY(i);
            }
            //得到最终的中心坐标
            x /= pointerCount;
            y /= pointerCount;

            //如果触点数(手指数)发生变化,需要重置上一次中心坐标和数量的参考值
            if (mLastPointCount != pointerCount) {
                mLastX = x;
                mLastY = y;
                mLastPointCount = pointerCount;
            }
            int deltaX = x - mLastX;//X方向的位移
            int deltaY = y - mLastY;//Y方向的位移
            //如果可以拖拽
            if (isCanDrag(deltaX, deltaY)) {

                //图片宽小于等于view宽,则X方向不需要移动
                if (rect.width() <= getWidth()) {
                    deltaX = 0;
                }
                //图片高小于等于view高,则Y方向不需要移动
                if (rect.height() <= getHeight()) {
                    deltaY = 0;
                }
                //完成缩放
                mScaleMatrix.postTranslate(deltaX, deltaY);
                checkBoderAndCenter();
                setImageMatrix(mScaleMatrix);
            }
            //交换中心坐标值,作为下次移动事件的参考值
            mLastX = x;
            mLastY = y;
            break;
        case MotionEvent.ACTION_CANCEL://取消
        case MotionEvent.ACTION_UP://释放
            mLastPointCount = 0;//触点数置零,便于下次判断是否重置mLastX和mLastY
            break;
    }
}

//上一次触点中心坐标
int mLastX;//上一次拖动图片的触点数(手指数)
int mLastY;
//上一次拖动图片的触点数(手指数)
int mLastPointCount;

/**
 * 是否可以移动图片
 *
 * @param deltaX
 * @param deltaY
 */
private boolean isCanDrag(int deltaX, int deltaY) {
    return Math.sqrt(deltaX * deltaX + deltaY * deltaY) >= mTouchSlop;
}

好了,几经周折终于把查看大图的功能搞定了,这里就不贴效果图了,下一篇再见!

本文地址:https://www.icheny.cn/archives/256
关注公众号“我的IT小地方”:扫二维码乘月网的公众号 or 搜索公众号:it_place
版权声明:本文为原创文章,版权归 cheny 所有,欢迎分享本文,转载请保留出处!

发表评论


表情