Android開發筆記(一百一十八)自定義懸浮窗

湖前琴亭發表於2016-08-10

WindowManager

在前面《Android開發筆記(六十六)自定義對話方塊》中,我們提到每個頁面都是一個Window視窗,許多的Window物件需要一個管家來打理,這個管家我們稱之為WindowManager視窗管理。在手機螢幕上新增或刪除頁面視窗,都可以歸結為WindowManager的操作,下面是該管理類的常用方法說明:
getDefaultDisplay : 獲取預設的螢幕資訊。通常用該方法獲取螢幕解析度,詳情參見《Android開發筆記(三)螢幕解析度》。
addView : 往視窗新增檢視。第二個引數為WindowManager.LayoutParams物件。
updateViewLayout : 更新指定檢視的佈局引數。第二個引數為WindowManager.LayoutParams物件。
removeView : 往視窗移除指定檢視。


下面是視窗布局引數WindowManager.LayoutParams的常用屬性說明:
format : 視窗的畫素點格式。取值見PixelFormat類中的常量定義,一般取值PixelFormat.RGBA_8888。
type : 視窗的顯示型別,常用的型別說明如下:
--TYPE_SYSTEM_ALERT : 系統警告提示。
--TYPE_SYSTEM_ERROR : 系統錯誤提示。
--TYPE_SYSTEM_OVERLAY : 頁面頂層提示。
--TYPE_SYSTEM_DIALOG : 系統對話方塊。
--TYPE_STATUS_BAR : 狀態列
--TYPE_TOAST : 短暫通知Toast
flags : 視窗的行為準則,常用的標誌位如下說明(對於懸浮窗來說,一般只需設定FLAG_NOT_FOCUSABLE):
--FLAG_NOT_FOCUSABLE : 不能搶佔焦點,即不接受任何按鍵或按鈕事件。
--FLAG_NOT_TOUCHABLE : 不接受觸控式螢幕事件。懸浮窗一般不設定該標誌,因為一旦設定該標誌,將無法拖動懸浮窗。
--FLAG_NOT_TOUCH_MODAL : 當視窗允許獲得焦點時(即沒有設定FLAG_NOT_FOCUSALBE標誌),仍然將視窗之外的按鍵事件傳送給後面的視窗處理。否則它將獨佔所有的按鍵事件,而不管它們是不是發生在視窗範圍之內。
-- : 
--FLAG_LAYOUT_IN_SCREEN : 允許視窗占滿整個螢幕。
--FLAG_LAYOUT_NO_LIMITS : 允許視窗擴充套件到螢幕之外。
--FLAG_WATCH_OUTSIDE_TOUCH : 如果設定了FLAG_NOT_TOUCH_MODAL標誌,則當按鍵動作發生在視窗之外時,將接收到一個MotionEvent.ACTION_OUTSIDE事件。
alpha : 視窗的透明度,取值為0-1。
gravity : 取值同View的setGravity方法。
x : 視窗左上角的X座標。
y : 視窗左上角的Y座標。
width : 視窗的寬度。
height : 視窗的高度。


靜態懸浮窗

懸浮窗有點類似對話方塊,它們都是獨立於Activity頁面的視窗,但是懸浮窗又有一些與眾不同的特性,例如:
1、懸浮窗是可以拖動的,對話方塊則不能;
2、懸浮窗不妨礙使用者觸控窗外的區域,對話方塊則不讓使用者操作框外的控制元件;
3、懸浮窗獨立於Activity頁面,即當頁面退出後,懸浮窗仍停留在螢幕上;而對話方塊與Activity頁面是共存關係,一旦頁面退出則對話方塊也消失了;


基於懸浮窗的以上特性,我們要實現視窗的懸浮效果,就不僅僅是呼叫WindowManager的addView方法那麼簡單了,而是需要做一系列的自定義處理,具體步驟如下:
1、在AndroidManifest.xml中宣告系統視窗許可權,即增加下面這句:
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
2、在自定義的懸浮窗控制元件中,要設定觸控監聽器,並根據使用者的手勢滑動來相應調整視窗位置,以實現懸浮窗的拖動功能;
3、合理設定懸浮窗的視窗引數,主要是把視窗引數的顯示型別設定為TYPE_SYSTEM_ALERT或者TYPE_SYSTEM_ERROR,另外要設定標誌位FLAG_NOT_FOCUSABLE;
4、在構造懸浮窗例項時,要傳入Application的上下文Context,這是為了保證即使退出Activity,也不會關閉懸浮窗。因為Application物件在app執行過程中是始終存在著的,而Activity物件只在開啟頁面時有效,一旦退出頁面則Activity的上下文就立刻回收(這會導致依賴於該上下文的懸浮窗也一塊被回收了)。


下面是一個靜態懸浮窗的效果截圖:



下面是自定義懸浮窗的示例程式碼:
import android.content.Context;
import android.graphics.PixelFormat;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowManager;

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

	private Context mContext;
	private WindowManager wm;
	private static WindowManager.LayoutParams wmParams;
	public View mContentView;
	private float mRelativeX;
	private float mRelativeY;
	private float mScreenX;
	private float mScreenY;
	private boolean bShow = false;

	public FloatView(Context context) {
		super(context);
		wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
		if (wmParams == null) {
			wmParams = new WindowManager.LayoutParams();
		}
		mContext = context;
	}
	
	public void setLayout(int layout_id) {
		mContentView = LayoutInflater.from(mContext).inflate(layout_id, null);
		mContentView.setOnTouchListener(new OnTouchListener() {
			public boolean onTouch(View v, MotionEvent event) {
				mScreenX = event.getRawX();
				mScreenY = event.getRawY();
				switch (event.getAction()) {
				case MotionEvent.ACTION_DOWN:
					mRelativeX = event.getX();
					mRelativeY = event.getY();
					break;
				case MotionEvent.ACTION_MOVE:
					updateViewPosition();
					break;
				case MotionEvent.ACTION_UP:
					updateViewPosition();
					mRelativeX = mRelativeY = 0;
					break;
				}
				return true;
			}
		});
	}

	private void updateViewPosition() {
		wmParams.x = (int) (mScreenX - mRelativeX);
		wmParams.y = (int) (mScreenY - mRelativeY);
		wm.updateViewLayout(mContentView, wmParams);
	}
	
	public void show() {
		if (mContentView != null) {
			wmParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;
			wmParams.format = PixelFormat.RGBA_8888;
			wmParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
			wmParams.alpha = 1.0f;
			wmParams.gravity = Gravity.LEFT | Gravity.TOP;
			wmParams.x = 0;
			wmParams.y = 0;
			wmParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
			wmParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
			// 顯示自定義懸浮視窗
			wm.addView(mContentView, wmParams);
			bShow = true;
		}
	}

	public void close() {
		if (mContentView != null) {
			wm.removeView(mContentView);
			bShow = false;
		}
	}
	
	public boolean isShow() {
		return bShow;
	}

}


下面是開啟/關閉懸浮窗的頁面程式碼:
import com.example.exmfloat.widget.FloatView;

import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;

public class StaticActivity extends Activity implements OnClickListener {
	
	private FloatView mFloatView;

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_static);

		Button btn_static_open = (Button) findViewById(R.id.btn_static_open);
		Button btn_static_close = (Button) findViewById(R.id.btn_static_close);
		btn_static_open.setOnClickListener(this);
		btn_static_close.setOnClickListener(this);
		
		mFloatView = new FloatView(MainApplication.getInstance());
		mFloatView.setLayout(R.layout.float_static);
	}

	@Override
	public void onClick(View v) {
		if (v.getId() == R.id.btn_static_open) {
			if (mFloatView!=null && mFloatView.isShow()==false) {
				mFloatView.show();
			}
		} else if (v.getId() == R.id.btn_static_close) {
			if (mFloatView!=null && mFloatView.isShow()==true) {
				mFloatView.close();
			}
		}
	}

}


下面是自定義Application的程式碼例子:
import android.app.Application;

public class MainApplication extends Application {

	private static MainApplication mApp;
	
	public static MainApplication getInstance() {
		return mApp;
	}

	@Override
	public void onCreate() {
		super.onCreate();
		mApp = this;
	}
	
}


動態懸浮窗

在實際開發中,懸浮窗的展示內容是變化的,畢竟一個內容不變的懸浮窗對使用者來說沒什麼用處。具體的應用例子有很多,比如說時鐘、天氣、實時流量、股市指數等等,下面就以實時流量與股市指數兩個例子,來詳細說明動態懸浮窗的實際應用。


要想實時重新整理懸浮窗,這得通過服務Service來實現,所以動態懸浮窗要在Service服務中建立和更新,頁面只負責啟動/停止服務。對於手機的實時流量,可以通過TrafficStats類的相關方法計算得到,該類的詳細說明參見《Android開發筆記(七十九)資源與許可權校驗》。


下面是實時流量懸浮窗的效果截圖:



下面是實時流量懸浮窗的頁面程式碼:
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;

import com.example.exmfloat.service.TrafficService;

public class TrafficActivity extends Activity implements OnClickListener {
	private final static String TAG = "TrafficActivity";
	
	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_traffic);

		Button btn_traffic_open = (Button) findViewById(R.id.btn_traffic_open);
		Button btn_traffic_close = (Button) findViewById(R.id.btn_traffic_close);
		btn_traffic_open.setOnClickListener(this);
		btn_traffic_close.setOnClickListener(this);
	}

	@Override
	public void onClick(View v) {
		if (v.getId() == R.id.btn_traffic_open) {
			Intent intent = new Intent(this, TrafficService.class);
			intent.putExtra("type", TrafficService.OPEN);
			startService(intent);
		} else if (v.getId() == R.id.btn_traffic_close) {
			Intent intent = new Intent(this, TrafficService.class);
			intent.putExtra("type", TrafficService.CLOSE);
			startService(intent);
		}
	}

}


下面是實時流量懸浮窗的服務程式碼:
import com.example.exmfloat.MainApplication;
import com.example.exmfloat.R;
import com.example.exmfloat.util.FlowUtil;
import com.example.exmfloat.widget.FloatView;

import android.app.Service;
import android.content.Intent;
import android.net.TrafficStats;
import android.os.Handler;
import android.os.IBinder;
import android.util.Log;
import android.widget.TextView;

public class TrafficService extends Service {
	private final static String TAG = "TrafficService";
	public static int OPEN = 0;
	public static int CLOSE = 1;
	
	private long curRx;
	private long curTx;
	private FloatView mFloatView;
	private TextView tv_traffic;
	private final int delayTime = 2000;

	private Handler mHandler = new Handler();
	private Runnable mRefresh = new Runnable() {
		public void run() {
			if (mFloatView != null && mFloatView.isShow() == true &&
					(TrafficStats.getTotalRxBytes()>curRx || TrafficStats.getTotalTxBytes()>curTx)) {
				long a = ((TrafficStats.getTotalRxBytes() - curRx) + (TrafficStats
						.getTotalTxBytes() - curTx)) / 2;
				String desc = String.format("當前流量: %s/S", FlowUtil.BToShowStringNoDecimals(a));
				tv_traffic.setText(desc);
				curRx = TrafficStats.getTotalRxBytes();
				curTx = TrafficStats.getTotalTxBytes();
			}
			mHandler.postDelayed(this, delayTime);
		}
	};

	@Override
	public IBinder onBind(Intent intent) {
		return null;
	}

	@Override
	public void onCreate() {
		super.onCreate();
		if (mFloatView == null) {
			mFloatView = new FloatView(MainApplication.getInstance());
			mFloatView.setLayout(R.layout.float_traffic);
			tv_traffic = (TextView) mFloatView.mContentView.findViewById(R.id.tv_traffic);
		}
		curRx = TrafficStats.getTotalRxBytes();
		curTx = TrafficStats.getTotalTxBytes();
		mHandler.postDelayed(mRefresh, 0);
	}

	@Override
	public int onStartCommand(Intent intent, int flags, int startId) {
		if (intent != null) {
			int type = intent.getIntExtra("type", OPEN);
			if (type == OPEN) {
				if (mFloatView != null && mFloatView.isShow() == false) {
					mFloatView.show();
				}
			} else if (type == CLOSE) {
				if (mFloatView != null && mFloatView.isShow() == true) {
					mFloatView.close();
				}
				stopSelf();
			}
		}

		return super.onStartCommand(intent, flags, startId);
	}

	@Override
	public void onDestroy() {
		super.onDestroy();
		mHandler.removeCallbacks(mRefresh);
	}

}


對於股市指數的展示,可以通過呼叫財經網站的實時指數查詢介面得到,比如新浪財經與騰訊財經均提供了上證指數與深圳成指的查詢介面。


下面是實時股指懸浮窗的效果截圖:



下面是實時股指懸浮窗的頁面程式碼:
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;

import com.example.exmfloat.service.StockService;

public class StockActivity extends Activity implements OnClickListener {
	private final static String TAG = "StockActivity";
	
	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_stock);

		Button btn_stock_open = (Button) findViewById(R.id.btn_stock_open);
		Button btn_stock_close = (Button) findViewById(R.id.btn_stock_close);
		btn_stock_open.setOnClickListener(this);
		btn_stock_close.setOnClickListener(this);
	}

	@Override
	public void onClick(View v) {
		if (v.getId() == R.id.btn_stock_open) {
			Intent intent = new Intent(this, StockService.class);
			intent.putExtra("type", StockService.OPEN);
			startService(intent);
		} else if (v.getId() == R.id.btn_stock_close) {
			Intent intent = new Intent(this, StockService.class);
			intent.putExtra("type", StockService.CLOSE);
			startService(intent);
		}
	}

}


下面是實時股指懸浮窗的服務程式碼:
import com.example.exmfloat.MainApplication;
import com.example.exmfloat.R;
import com.example.exmfloat.http.HttpReqData;
import com.example.exmfloat.http.HttpRespData;
import com.example.exmfloat.http.HttpUrlUtil;
import com.example.exmfloat.widget.FloatView;

import android.app.Service;
import android.content.Intent;
import android.graphics.Color;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.widget.TextView;

public class StockService extends Service {
	private final static String TAG = "StockService";
	public static int OPEN = 0;
	public static int CLOSE = 1;

	private FloatView mFloatView;
	private TextView tv_sh_stock, tv_sz_stock;
	private final int delayTime = 5000;

	private Handler mHandler = new Handler() {
		@Override
		public void handleMessage(Message msg) {
			//上證指數,3019.9873,-5.6932,-0.19,1348069,14969598
			String desc = (String) msg.obj;
			String[] array = desc.split(",");
			String stock = array[1];
			float distance = Float.parseFloat(array[2]);
			String range = array[3];
			String text = String.format("%s  %s%%", stock, range);
			int type = msg.what;
			if (type == SHANGHAI) {
				tv_sh_stock.setText(text);
				if (distance > 0) {
					tv_sh_stock.setTextColor(Color.RED);
				} else {
					tv_sh_stock.setTextColor(Color.GREEN);
				}
			} else if (type == SHENZHEN) {
				tv_sz_stock.setText(text);
				if (distance > 0) {
					tv_sz_stock.setTextColor(Color.RED);
				} else {
					tv_sz_stock.setTextColor(Color.GREEN);
				}
			}
		}
	};
	
	private Runnable mRefresh = new Runnable() {
		@Override
		public void run() {
			if (mFloatView != null && mFloatView.isShow() == true ) {
				new StockThread(SHANGHAI).start();
				new StockThread(SHENZHEN).start();
			}
			mHandler.postDelayed(this, delayTime);
		}
	};
	
	private static int SHANGHAI = 0;
	private static int SHENZHEN = 1;
	private class StockThread extends Thread {
		private int mType;
		public StockThread(int type) {
			mType = type;
		}
		
		@Override
		public void run() {
			HttpReqData req_data = new HttpReqData();
			if (mType == SHANGHAI) {
				req_data.url = "http://hq.sinajs.cn/list=s_sh000001";
			} else if (mType == SHENZHEN) {
				req_data.url = "http://hq.sinajs.cn/list=s_sz399001";
			}
			HttpRespData resp_data = HttpUrlUtil.getData(req_data);
			//var hq_str_s_sh000001="上證指數,3019.9873,-5.6932,-0.19,1348069,14969598";
			String desc = resp_data.content;
			Message msg = Message.obtain();
			msg.what = mType;
			msg.obj = desc.substring(desc.indexOf("\"")+1, desc.lastIndexOf("\""));
			mHandler.sendMessage(msg);
		}
	}

	@Override
	public IBinder onBind(Intent intent) {
		return null;
	}

	@Override
	public void onCreate() {
		super.onCreate();
		if (mFloatView == null) {
			mFloatView = new FloatView(MainApplication.getInstance());
			mFloatView.setLayout(R.layout.float_stock);
			tv_sh_stock = (TextView) mFloatView.mContentView.findViewById(R.id.tv_sh_stock);
			tv_sz_stock = (TextView) mFloatView.mContentView.findViewById(R.id.tv_sz_stock);
		}
		mHandler.postDelayed(mRefresh, 0);
	}

	@Override
	public int onStartCommand(Intent intent, int flags, int startId) {
		if (intent != null) {
			int type = intent.getIntExtra("type", OPEN);
			if (type == OPEN) {
				if (mFloatView != null && mFloatView.isShow() == false) {
					mFloatView.show();
				}
			} else if (type == CLOSE) {
				if (mFloatView != null && mFloatView.isShow() == true) {
					mFloatView.close();
				}
				stopSelf();
			}
		}

		return super.onStartCommand(intent, flags, startId);
	}

	@Override
	public void onDestroy() {
		super.onDestroy();
		mHandler.removeCallbacks(mRefresh);
	}

}






點此檢視Android開發筆記的完整目錄

相關文章