Android自定義導覽地圖組件(一)

版權說明 : Android自定義導覽地圖組件(一)於當前CSDN博客乘月網屬同一原創,轉載請說明出處,謝謝。


         鑑於Android關於自定義導覽地圖的相關資料以及開源項目貧乏,應Android同行幾位小夥伴們的建議,決定寫下這篇文章分享給大家。由於博客篇幅限制,本文將分兩到三篇博文敘述。
進入主題:

         先看看下面的效果圖:

         

         可以看到,地圖組件基本上實現當前主流地圖應用(如百度,高德地圖)的縮放拖拽移動以及定位圖標等功能。
實現思路:
         其實一開始想了很多方案:
         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 = 4f;//最大放大比例值係數,雙擊兩次的放大值
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;
        }
    }

         折騰完自適應,開始折騰手勢縮放,實現縮放手勢探測器接口OnScaleGestureListener來完成圖片隨手勢的動態縮放。初始化ScaleGestureDetector對象並重寫當前View的onTouchEvent()方法,將touch事件交給ScaleGestureDetector處理:

……
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;
    }

          好了,寫了那麼多,上個圖看看運行效果,舒緩下緊張的氣氛:

          

          還是有效果的,滋滋滋~  不過,這個白邊還有圖片中心縮放着縮放着就跑偏了着實讓人強迫症大發。所以,應該在執行縮放前調用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);
            }
        }
    }

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

           

          上面所述的留白,圖片中心移位以及縮小後不能自動恢復自適應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;
}

          好了,幾經周折終於把查看大圖的功能搞定了,這裏就不貼效果圖了,下一篇再見!



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章