Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> 關於Android編程 >> 讓Android控件隨著屏幕旋轉自由轉移至任何地方(附demo)

讓Android控件隨著屏幕旋轉自由轉移至任何地方(附demo)

編輯:關於Android編程

本文主要介紹Android ViewGroup/View的繪制流程,及常用的自定義ViewGroup的方法。在此基礎上介紹動態控制View的位置的三種方法,並給出最佳的一種方法。

一、ViewGroup/View的繪制流程

簡單的說一個View從無到有需要三個步驟,onMeasure、onLayout、onDraw,即測量大小、放置位置、繪制三個步驟。而ViewGroup的onMeasure、onLayout流程裡,又會遍歷每個孩子,並最終調到孩子的measure()、layout()函數裡。與View不同的是,ViewGroup沒有onDraw流程,但有dispatchDraw()流程,該函數最終又調用drawChild()繪制每個孩子,調每個孩子View的onDraw流程。

在onMeasure流程裡是為了獲得控件的高和寬,這塊有個getWidth()和getMeasuredWidth()的概念,前者指寬度,後者是測量寬度。一般來說,一個自定義VIewGroup(如繼承自RelativeLayout)一般要進兩次onMeasure,一次onLayout,一次drawChild()。雖然onMeasure流程是測量大小,且進了兩次。但直到最後一次出去的時候調用getWidth()得到的仍然是0.getWidth()的數值一直到onSizeChanged()的時候才能夠得到正確的,此後進到onLayout裡當然也能正常得到。

下面是我截的一段代碼:

	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		// TODO Auto-generated method stub
		Log.i(TAG, "onMeasure enter...");
		Log.i(TAG, "width = " + getWidth() + " height = " + getHeight());
		Log.i(TAG, "MeasuredWidth = " + getMeasuredWidth() + " MeasuredHeight = " + getMeasuredHeight());

		super.onMeasure(widthMeasureSpec, heightMeasureSpec);
		Log.i(TAG, "00000000000 width = " + getWidth() + " height = " + getHeight());
		Log.i(TAG, "00000000000 MeasuredWidth = " + getMeasuredWidth() + " MeasuredHeight = " + getMeasuredHeight());
		Log.i(TAG, "onMeasure exit...");
	}

打印信息:

Line 355: 01-03 10:15:40.526 I/YanZi   (10793): onMeasure enter...
	Line 357: 01-03 10:15:40.526 I/YanZi   (10793): width = 0 height = 0
	Line 359: 01-03 10:15:40.527 I/YanZi   (10793): MeasuredWidth = 0 MeasuredHeight = 0
	Line 361: 01-03 10:15:40.531 I/YanZi   (10793): 00000000000 width = 0 height = 0
	Line 363: 01-03 10:15:40.532 I/YanZi   (10793): 00000000000 MeasuredWidth = 1080 MeasuredHeight = 1701
	Line 365: 01-03 10:15:40.532 I/YanZi   (10793): onMeasure exit...
	Line 367: 01-03 10:15:40.532 I/YanZi   (10793): onMeasure enter...
	Line 369: 01-03 10:15:40.533 I/YanZi   (10793): width = 0 height = 0
	Line 371: 01-03 10:15:40.533 I/YanZi   (10793): MeasuredWidth = 1080 MeasuredHeight = 1701
	Line 373: 01-03 10:15:40.536 I/YanZi   (10793): 00000000000 width = 0 height = 0
	Line 375: 01-03 10:15:40.536 I/YanZi   (10793): 00000000000 MeasuredWidth = 1080 MeasuredHeight = 1701
	Line 377: 01-03 10:15:40.537 I/YanZi   (10793): onMeasure exit...
	Line 379: 01-03 10:15:40.537 I/YanZi   (10793): onSizeChanged enter...
	Line 381: 01-03 10:15:40.538 I/YanZi   (10793): width = 1080 height = 1701
	Line 383: 01-03 10:15:40.538 I/YanZi   (10793): onSizeChanged exit...
	Line 385: 01-03 10:15:40.538 I/YanZi   (10793): onLayout enter...
	Line 387: 01-03 10:15:40.539 I/YanZi   (10793): width = 1080 height = 1701
	Line 389: 01-03 10:15:40.540 I/YanZi   (10793): onLayout exit...

可以看到,在第一次進到onMeasure裡執行完super.onMeasure(widthMeasureSpec, heightMeasureSpec);後就能夠得到MeasureWidth和MeasureHeight了。

至於為啥要進兩次onMeasure,翻遍了網絡麼有找到合理的解釋。有人說是大小發生變化時要進兩次,如Linearlayout裡設置了weight屬性,則第一次測量時得到一個大小,第二次測量時把weight加上得到最終的大小。可是我用Linearlayout把裡面所有的母和子的view大小都寫死,onMeasure還是進了兩次。RelativeLayout就不用說了也是進的兩次。國外文檔也有解釋說,當子view不能夠填滿父控件時,要第二次進到onMeasure裡。經我測試,貌似也是扯淡。我全都match_parent還是進了兩次。

當然在onMeasure裡可以直接setMeasuredDimension(measuredWidth, measuredHeight)設置控件寬和高,這樣不管xml裡咋寫的,最終以此句設置的width和height進行放置、顯示。關於View/ViewGroup繪制原理本文就介紹到這,更詳細請參考:鏈接1 鏈接2 鏈接3 鏈接4 都大同小異,可以看看。

二、常見的兩種自定義ViewGroup的方法

方法一:

c_nanshi_guide.xml布局文件




    <frameLayout
        android:id="@+id/guide_nan_layout"
        android:layout_width="200dp"
        android:layout_height="150dp"
        android:background="@drawable/nan1" >

        
    </frameLayout>

可以看到布局裡並沒出現任何自定義信息。

NanShiGuide.java

package org.yanzi.ui;

import org.yanzi.util.DisplayUtil;

import android.R.color;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Point;
import android.graphics.drawable.Drawable;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.RelativeLayout;
import android.widget.TextView;

import com.example.test1.R;

public class NanShiGuide extends BaseGuideView {
	private static final String TAG = "YanZi";
	int LAYOUT_ID = R.layout.c_nanshi_guide;
	View guideNanLayout;
	TextView guideNanText;
	private Drawable mDrawable;
	private Context mContext = null;
	public NanShiGuide(Context context, GuideViewCallback callback) {
		super(context, callback);
		// TODO Auto-generated constructor stub
		mContext = context;
		initView();
		mDrawable = context.getResources().getDrawable(R.drawable.ong);
	}

	@Override
	protected void initView() {
		// TODO Auto-generated method stub
		Log.i(TAG, "NanShiGuide initView enter...");
		View v = LayoutInflater.from(mContext).inflate(LAYOUT_ID, this, true);
		guideNanLayout = v.findViewById(R.id.guide_nan_layout);
		guideNanText = (TextView) v.findViewById(R.id.guide_nan_text);
		


	}
	

	@Override
	protected void onFinishInflate() {
		// TODO Auto-generated method stub
		Log.i(TAG, "onFinishInflate enter...");

		super.onFinishInflate();
	}

	@Override
	protected void onLayout(boolean changed, int l, int t, int r, int b) {
		// TODO Auto-generated method stub
		Log.i(TAG, "onLayout enter...");
		Log.i(TAG, "width = " + getWidth() + " height = " + getHeight());
		
		int transX = 0;
		int transY = 0;
		if(mOrientation == 0){
			guideNanLayout.setRotation(0);
			transX += 0;
			transY += 0;
		}else if(mOrientation == 270){
			guideNanLayout.setRotation(90);
			transX += -DisplayUtil.dip2px(mContext, 25) + DisplayUtil.dip2px(mContext, 210);
			transY += DisplayUtil.dip2px(mContext, 25);
		}else if(mOrientation == 180){
			guideNanLayout.setRotation(180);
			transX += DisplayUtil.dip2px(mContext, 160);
			transY += b - DisplayUtil.dip2px(mContext, 150);
		}else if(mOrientation == 90){
			guideNanLayout.setRotation(270);
			transX += -DisplayUtil.dip2px(mContext, 25);
			transY += b - DisplayUtil.dip2px(mContext, 200 - 25);
		}
		guideNanLayout.setTranslationX(transX);
		guideNanLayout.setTranslationY(transY);

//		this.setTranslationX(transX);
//		this.setTranslationY(transY);
		
		RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) guideNanLayout.getLayoutParams();
		params.leftMargin = 100;
		params.topMargin = 100;
		guideNanLayout.setLayoutParams(params);

		super.onLayout(changed, l, t, r, b);


		Log.i(TAG, "onLayout exit...");
	}

	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		// TODO Auto-generated method stub
		Log.i(TAG, "onMeasure enter...");
		Log.i(TAG, "width = " + getWidth() + " height = " + getHeight());
		Log.i(TAG, "MeasuredWidth = " + getMeasuredWidth() + " MeasuredHeight = " + getMeasuredHeight());

		super.onMeasure(widthMeasureSpec, heightMeasureSpec);
		Log.i(TAG, "00000000000 width = " + getWidth() + " height = " + getHeight());
		Log.i(TAG, "00000000000 MeasuredWidth = " + getMeasuredWidth() + " MeasuredHeight = " + getMeasuredHeight());
		Log.i(TAG, "onMeasure exit...");
	}
	

	@Override
	protected void onSizeChanged(int w, int h, int oldw, int oldh) {
		// TODO Auto-generated method stub
		Log.i(TAG, "onSizeChanged enter...");
		Log.i(TAG, "width = " + getWidth() + " height = " + getHeight());

		super.onSizeChanged(w, h, oldw, oldh);
		Log.i(TAG, "onSizeChanged exit...");
	}

	@Override
	protected void onDraw(Canvas canvas) {
		// TODO Auto-generated method stub
		Log.i(TAG, "onDraw enter...");

		super.onDraw(canvas);
	}

	
	@Override
	protected void dispatchDraw(Canvas canvas) {
		// TODO Auto-generated method stub
		Log.i(TAG, "dispatchDraw enter...");
		super.dispatchDraw(canvas);
	}

	@Override
	protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
		// TODO Auto-generated method stub
		Log.i(TAG, "drawChild enter...");
		int w = getWidth();
		int h = getHeight();
		
		Point centerPoint = new Point(w / 2, h / 2);
		canvas.save();
		mDrawable.setBounds(centerPoint.x - 150, centerPoint.y  - 150, centerPoint.x + 150, centerPoint.y + 150);
		mDrawable.draw(canvas);
		canvas.restore();
		return super.drawChild(canvas, child, drawingTime);
	}
	
	


	
	


}

BaseGuideView.java如下:

package org.yanzi.ui;

import org.yanzi.util.OrientationUtil;

import android.content.Context;
import android.graphics.Canvas;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.widget.RelativeLayout;
import android.widget.TextView;

public abstract  class BaseGuideView extends RelativeLayout implements Rotatable, View.OnClickListener {

	protected int mOrientation = 0;
	protected Context mContext;
	private GuideViewCallback mGuideViewCallback;

	public interface GuideViewCallback{
		public void onGuideViewClick();
	}

	public BaseGuideView(Context context, GuideViewCallback callback) {
		super(context);
		// TODO Auto-generated constructor stub
		mContext = context;
		mGuideViewCallback = callback;
		setOnClickListener(this);
		mOrientation = OrientationUtil.getOrientation();
		
	}


	@Override
	public void setOrientation(int orientation, boolean animation) {
		// TODO Auto-generated method stub
		mOrientation = orientation;
		requestLayout();
	}

	protected abstract void initView();

	@Override
	public boolean onInterceptTouchEvent(MotionEvent ev) {
		// TODO Auto-generated method stub
		return true; //super.onInterceptTouchEvent(ev)
	}

	@Override
	public void onClick(View v) {
		// TODO Auto-generated method stub
		mGuideViewCallback.onGuideViewClick();
	}



	

}

這是一種最常用的方法,核心是initView裡通過LayoutInflater.from(mContext).inflate(LAYOUT_ID, this, true);完成布局xml文件的映射。LayoutInflater使用參見這裡。這種寫法最大的好處是即可以用java語句new一個view add到母布局裡。也可以通過在xml裡使用。個人比較推薦此寫法。動態添加示例:

		if(baseGuideView == null){
			baseGuideView = new NanShiGuide(getApplicationContext(), new GuideViewCallback() {
				
				@Override
				public void onGuideViewClick() {
					// TODO Auto-generated method stub
					hideGuideView();
				}
			});
			guideLayout.addView(baseGuideView);
		}


方法二:不通過LayoutInflater來映射,而是直接使用類名映射

請參考我的前文:http://blog.csdn.net/yanzi1225627/article/details/30763555 的HeadControlPanel.java的封裝方法。這種方法不適合做動態添加,因為它不能new,只能通過在母布局裡include來添加。正因為它是從布局裡加載的,因此會調用onFinishInflate()流程,當執行到此時表示布局已經加載進來了,裡面的孩子view可以實例化了。 但第一種方法是不會調用onFinishInflate的,所以必須用LayoutInflator。 再者,使用第二種方法也就意味著自定義view的構造函數只能是:

public NanShiGuide(Context context, AttributeSet attrs) {
super(context, attrs);
// TODO Auto-generated constructor stub
}

無法再多傳遞其他重要變量。

綜合兩種方法的優缺點,我個人強烈建議使用第一種方式來自定義ViewGroup,但google的部分原生應用裡使用的是第二種方法。本文代碼使用第一種方式。另外,這兩種加載機制不同,所以在對view動態改變位置時也會不同。

三、三種動態改變View位置的方法

方法一:設置LayoutParams,通過params設置四個margin來改變

方法二:通過setX()、setY()這兩個函數直接設置坐標位置。

方法三:通過setTranslationX、setTranslationY來設置相對偏移量,當然是在onLayout流程裡。

這三種方法裡個人最推薦的是第三種,除此外方法1在有些場合下也會用到,方法2比較坑爹一般不用。下面是方法3的示例,先來看一副圖片:

自然狀態下,圖片靠左上頂點擺放:

\

下圖為旋轉了90°後,我在代碼裡guideNanLayout.setRotation()進行旋轉後的。guideNanLayout就是那個圖片的布局。<喎?/kf/ware/vc/" target="_blank" class="keylink">vcD48cD48aW1nIHNyYz0="/uploadfile/Collfiles/20140728/20140728091329142.png" alt="\" />

記View的寬度為W,高度為H。如上圖所示,在旋轉90°後,圖片在x軸和y軸上分別塌縮了Abs(W - H) / 2的像素。為此,我們可以首先把這個“塌縮”給補回來,讓旋轉90°後的view還是以左上頂點為基准點,之後用如下代碼進行平移。

guideNanLayout.setTranslationX(transX);
guideNanLayout.setTranslationY(transY);

最終的onLayout函數如下:

@Override
	protected void onLayout(boolean changed, int l, int t, int r, int b) {
		// TODO Auto-generated method stub
		Log.i(TAG, "onLayout enter...");
		Log.i(TAG, "width = " + getWidth() + " height = " + getHeight());
		
		int transX = 0;
		int transY = 0;
		if(mOrientation == 0){
			guideNanLayout.setRotation(0);
			transX += 0;
			transY += 0;
		}else if(mOrientation == 270){
			guideNanLayout.setRotation(90);
			transX += -DisplayUtil.dip2px(mContext, 25) + DisplayUtil.dip2px(mContext, 210);
			transY += DisplayUtil.dip2px(mContext, 25);
		}else if(mOrientation == 180){
			guideNanLayout.setRotation(180);
			transX += DisplayUtil.dip2px(mContext, 160);
			transY += b - DisplayUtil.dip2px(mContext, 150);
		}else if(mOrientation == 90){
			guideNanLayout.setRotation(270);
			transX += -DisplayUtil.dip2px(mContext, 25);
			transY += b - DisplayUtil.dip2px(mContext, 200 - 25);
		}
		guideNanLayout.setTranslationX(transX);
		guideNanLayout.setTranslationY(transY);

//		this.setTranslationX(transX);
//		this.setTranslationY(transY);
		
//		RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) guideNanLayout.getLayoutParams();
//		params.leftMargin = 100;
//		params.topMargin = 100;
//		guideNanLayout.setLayoutParams(params);

		super.onLayout(changed, l, t, r, b);
		Log.i(TAG, "onLayout exit...");
	}
最終旋轉屏幕時效果圖如下:

\

注意這塊我並沒用android自有的讓布局旋轉的那種機制,那個效果不好,轉換太慢。因為onLayout裡設置偏移量是在onDraw前,所以此方法方向變換時不會有殘留。即便一開始就90°拿手機,不會出現那種先是正常顯示再轉過去的現象。每次方向變時就設置下角度,然後調用requestLayout():

@Override
public void setOrientation(int orientation, boolean animation) {
// TODO Auto-generated method stub
mOrientation = orientation;
requestLayout();
}

可以參考這裡,當調用requestLayout時會讓View重新measure、layout。

為什麼不用setX()這種方法呢?查看其api解釋:

    /**
     * Sets the visual x position of this view, in pixels. This is equivalent to setting the
     * {@link #setTranslationX(float) translationX} property to be the difference between
     * the x value passed in and the current {@link #getLeft() left} property.
     *
     * @param x The visual x position of this view, in pixels.
     */
    public void setX(float x) {
        setTranslationX(x - mLeft);
    }

其實setX最終還是調用的setTranslationX,因此不如直接調用setTranslationX。在本文的示例代碼中將:

// guideNanLayout.setTranslationX(transX);
// guideNanLayout.setTranslationY(transY);

換成:

guideNanLayout.setX(transX);
guideNanLayout.setY(transY);

得到的結果是一模一樣的,這是因為這裡的mLeft等於0的原因。
再來看方法1,通過設置LayoutParams來動態改變位置,這有時好用,但有時完全沒有效果。因為要改變LayoutParams首先view要加載進來,才能get得到。2,這種設params的方法一旦rotate後本身的margins就變了,很難計算旋轉後的margins。

而且更嚴重的是,在本例中在onLayout裡通過

// RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) guideNanLayout.getLayoutParams();
// params.leftMargin = 100;
// params.topMargin = 100;
// guideNanLayout.setLayoutParams(params);

是看不到一點效果的,這是個十分詭異的事情。但將其放在initView或onMeasure裡則是ok的。根據這個現象我認為,在onlayout的時候再對子view設置margins已經晚了,不起作用了,要設margins也必須在onlayout進來之前就設好。

另外有個問題,在onlayout裡默認的setX這些都是this.setX()對應的是母布局的設置,如果對裡面的孩子設置前面必須加上孩子的名字。還有,在super.onLayout(changed, l, t, r, b);之前設置好setTranslationX就好了,並不需要再super.onLayout(changed, l, t, r, b);對這裡的五個參數進行改變。

其實看setLayoutParams(params)的流程可以知道:

    public void setLayoutParams(ViewGroup.LayoutParams params) {
        if (params == null) {
            throw new NullPointerException("Layout parameters cannot be null");
        }
        mLayoutParams = params;
        resolveLayoutParams();
        if (mParent instanceof ViewGroup) {
            ((ViewGroup) mParent).onSetLayoutParams(this, params);
        }
        requestLayout();
    }

設完參數後最終調的是requestLayout(),即請求對自身重新measure和layout.從這個角度講,通過params來改變位置比較低效,還需要再走一遍自己的流程。而在母布局裡的onLayout裡setTranslateX則不額外增加流程。至於為啥在onLayout裡設置子view的params無效,這個著實無從查起,個人猜測是母布局onLayout的時候不額外獲取子view的其他參數,僅僅從xml裡讀的。但是在上面介紹自定義VIewGroup的時候,裡面的方法2是可以在onlayout裡通過設置margin來動態布局子view的。參見我的前文:Android應用經典主界面框架之一:仿QQ (使用Fragment, 附源碼)裡的layoutItems()函數。

至此旋轉搞好了,接下來是如何獲得角度:

mOrientationEvent= new OrientationEventListener(this) {
			
			@Override
			public void onOrientationChanged(int orientation) {
				// TODO Auto-generated method stub
				if(orientation == OrientationEventListener.ORIENTATION_UNKNOWN){
					return;
				}
				mOrientation = RoundUtil.roundOrientation(orientation, mOrientation);
				  int orientationCompensation = (mOrientation + RoundUtil
			                .getDisplayRotation(MainActivity.this)) % 360;
				
				  if(mOrientationCompensation != orientationCompensation){
					  mOrientationCompensation = orientationCompensation;
					  Log.i("YanZi", "mOrientationCompensation = " + mOrientationCompensation);
					  OrientationUtil.setOrientation(mOrientationCompensation == -1 ? 0 :
						  mOrientationCompensation);
					  setOrientation(OrientationUtil.getOrientation(), false);
				  }
			}

	@Override
	protected void onResume() {
		// TODO Auto-generated method stub
		super.onResume();
		mOrientationEvent.enable();
	}
	

	@Override
	protected void onPause() {
		// TODO Auto-generated method stub
		super.onPause();
		mOrientationEvent.disable();
	}
用到的RoundUtil:

package org.yanzi.util;

import android.app.Activity;
import android.view.OrientationEventListener;
import android.view.Surface;

public class RoundUtil {
	public static final int ORIENTATION_HYSTERESIS = 5;

	public static int roundOrientation(int orientation, int orientationHistory) {
		boolean changeOrientation = false;
		if (orientationHistory == OrientationEventListener.ORIENTATION_UNKNOWN) {
			changeOrientation = true;
		} else {
			int dist = Math.abs(orientation - orientationHistory);
			dist = Math.min( dist, 360 - dist );
			changeOrientation = ( dist >= 45 + ORIENTATION_HYSTERESIS );
		}
		if (changeOrientation) {
			return ((orientation + 45) / 90 * 90) % 360;
		}
		return orientationHistory;
	}
    public static int getDisplayRotation(Activity activity) {
        int rotation = activity.getWindowManager().getDefaultDisplay()
                .getRotation();
        switch (rotation) {
            case Surface.ROTATION_0: return 0;
            case Surface.ROTATION_90: return 90;
            case Surface.ROTATION_180: return 180;
            case Surface.ROTATION_270: return 270;
        }
        return 0;
    }
}

注:這個獲得角度是正確的,且只有在該變量到一定程度時才通知更新view,比我之前的博文要嚴謹。

最後,一個view通過rotate()無論怎麼轉都是以自身的中心點進行旋轉的,只要母布局麼有旋轉,坐標系原點就是屏幕左上角,且x、y軸不交換。

源碼下載:http://download.csdn.net/detail/yanzi1225627/7681731

--------------------本文系原創,轉載請注明作者yanzi1225627



  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved