像Messenger Bubbles这样的Android浮动小部件
如果您曾经使用过Facebook Messenger应用程序,那么无论您当前正在使用哪个应用程序,您都一定会遇到可以在屏幕上看到的聊天气泡。
在本教程中,我们将讨论和实现一个即使在应用程序处于后台时仍会显示在屏幕上的android浮动小部件。
此功能便于进行多任务处理,例如在应用程序之间轻松切换。
Android浮动小部件概念
Android Floating Widget只是在应用程序上绘制的叠加视图。
为了允许在其他应用程序上绘制视图,我们需要在项目的AndroidManifest.xml文件中添加以下权限。
android.permission.SYSTEM_ALERT_WINDOW
要显示android浮动小部件,我们需要启动后台服务并将自定义视图添加到WindowManager的实例中,以便将自定义视图保持在当前屏幕的视图层次结构的顶部。
接下来将要开发的应用程序将具有以下功能:
当应用程序在后台时,将浮动操作按钮显示为叠加视图。
我们将使用CounterFab库。将窗口小部件拖动到屏幕上的任意位置。
让窗口小部件将其自身定位在屏幕的最近边缘(而不是使其挂在中间)。
单击小部件以启动应用程序,并将数据从服务传递到活动。
通过从我们的应用程序中单击一个按钮来添加android浮动小部件。
保持FAB上的徽章计数,显示创建小部件的次数(假设它表示消息的数量)。
Android Floating Widget示例项目结构
该项目包含一个活动和一个后台服务。
Android浮动小部件示例代码
在进入我们的应用程序的业务逻辑之前,我们先来看一下" AndroidManifest.xml"文件。
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="https://schemas.android.com/apk/res/android" package="com.theitroad.floatingchatheads"> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" <uses-permission android:name="TASKS" <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> <activity android:name=".MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" <category android:name="android.intent.category.LAUNCHER" </intent-filter> </activity> <service android:name=".FloatingWidgetService" android:enabled="true" android:exported="false" </application> </manifest>
在项目的build.gradle中添加以下依赖项
compile 'com.github.andremion:counterfab:1.0.1'
下面给出了" activity_main.xml"布局的xml代码。
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="https://schemas.android.com/apk/res/android" xmlns:tools="https://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context="com.theitroad.floatingchatheads.MainActivity"> <TextView android:id="@+id/textView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:text="Hello World!" </RelativeLayout>
overlay_layout.xml
文件中提到了android浮动小部件的布局,如下所示:
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="https://schemas.android.com/apk/res/android" xmlns:app="https://schemas.android.com/apk/res-auto" android:id="@+id/layout" android:layout_width="wrap_content" android:layout_height="wrap_content" android:clickable="true" android:orientation="vertical" android:visibility="visible"> <com.andremion.counterfab.CounterFab android:id="@+id/fabHead" android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@android:drawable/ic_input_add" app:fabSize="normal" </RelativeLayout>
MainActivity.java类的代码如下:
package com.theitroad.floatingchatheads; import android.content.Intent; import android.net.Uri; import android.os.Build; import android.provider.Settings; import android.support.v7.app.AppCompatActivity; import android.os.Bundle; import android.view.View; import android.widget.Button; import android.widget.TextView; import android.widget.Toast; public class MainActivity extends AppCompatActivity { private static final int DRAW_OVER_OTHER_APP_PERMISSION = 123; private Button button; private TextView textView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); askForSystemOverlayPermission(); button = (Button) findViewById(R.id.button); textView = (TextView) findViewById(R.id.textView); int badge_count = getIntent().getIntExtra("badge_count", 0); textView.setText(badge_count + " messages received previously"); button.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(MainActivity.this)) { startService(new Intent(MainActivity.this, FloatingWidgetService.class)); } else { errorToast(); } } }); } private void askForSystemOverlayPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) { //If the draw over permission is not available open the settings screen //to grant the permission. Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + getPackageName())); startActivityForResult(intent, DRAW_OVER_OTHER_APP_PERMISSION); } } @Override protected void onPause() { super.onPause(); //To prevent starting the service if the required permission is NOT granted. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(this)) { startService(new Intent(MainActivity.this, FloatingWidgetService.class).putExtra("activity_background", true)); finish(); } else { errorToast(); } } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == DRAW_OVER_OTHER_APP_PERMISSION) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (!Settings.canDrawOverlays(this)) { //Permission is not available. Display error text. errorToast(); finish(); } } } else { super.onActivityResult(requestCode, resultCode, data); } } private void errorToast() { Toast.makeText(this, "Draw over other app permission not available. Can't start the application without the permission.", Toast.LENGTH_LONG).show(); } }
在上面的代码中,我们检查是否允许在其他应用程序上绘制视图的权限。
当调用onPause()方法(表明应用程序在后台)时,我们启动后台服务意图,即FloatingWidgetService.java。
下面给出了" FloatingWidgetService.java"的代码:
package com.theitroad.floatingchatheads; import android.app.Service; import android.content.Intent; import android.graphics.PixelFormat; import android.graphics.Point; import android.os.IBinder; import android.support.annotation.Nullable; import android.view.Display; import android.view.Gravity; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.WindowManager; import com.andremion.counterfab.CounterFab; /** * Created by anupamchugh on 01/08/17. */ public class FloatingWidgetService extends Service { private WindowManager mWindowManager; private View mOverlayView; CounterFab counterFab; @Nullable @Override public IBinder onBind(Intent intent) { return null; } @Override public void onCreate() { super.onCreate(); setTheme(R.style.AppTheme); mOverlayView = LayoutInflater.from(this).inflate(R.layout.overlay_layout, null); final WindowManager.LayoutParams params = new WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.TYPE_PHONE, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, PixelFormat.TRANSLUCENT); //Specify the view position params.gravity = Gravity.TOP | Gravity.LEFT; //Initially view will be added to top-left corner params.x = 0; params.y = 100; mWindowManager = (WindowManager) getSystemService(WINDOW_SERVICE); mWindowManager.addView(mOverlayView, params); counterFab = (CounterFab) mOverlayView.findViewById(R.id.fabHead); counterFab.setCount(1); counterFab.setOnTouchListener(new View.OnTouchListener() { private int initialX; private int initialY; private float initialTouchX; private float initialTouchY; @Override public boolean onTouch(View v, MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: //remember the initial position. initialX = params.x; initialY = params.y; //get the touch location initialTouchX = event.getRawX(); initialTouchY = event.getRawY(); return true; case MotionEvent.ACTION_UP: //Add code for launching application and positioning the widget to nearest edge. return true; case MotionEvent.ACTION_MOVE: float Xdiff = Math.round(event.getRawX() - initialTouchX); float Ydiff = Math.round(event.getRawY() - initialTouchY); //Calculate the X and Y coordinates of the view. params.x = initialX + (int) Xdiff; params.y = initialY + (int) Ydiff; //Update the layout with new X & Y coordinates mWindowManager.updateViewLayout(mOverlayView, params); return true; } return false; } }); } @Override public void onDestroy() { super.onDestroy(); if (mOverlayView != null) mWindowManager.removeView(mOverlayView); } }
从上面的代码得出的推论很少是:
与活动不同,我们需要使用setTheme()方法在服务中显式设置主题。
否则,将导致IllegalArgumentException。我们已经创建了WindowManager的实例,并在上面的代码中在屏幕的左上方添加了" overlay_layout"。
要沿屏幕拖动浮动窗口小部件,我们已经覆盖了onTouchListener()来监听拖动事件并更改屏幕上叠加视图的X和Y坐标。
我们通过调用方法setCount()将CounterFab类的标志计数设置为1。
上面的代码给我们的输出如下。
剩下的一些事情需要完成以完成应用程序。
自动将小部件定位到屏幕的最近边缘(左/右)。
单击小部件将启动该应用程序。
(我们可能会将数据从服务传递到活动)。在活动内添加按钮以创建android浮动小部件。
(我们不会为每次调用创建新视图,而只是增加徽章计数)。
让我们开始在" activity_main.xml"布局内添加一个按钮,如下所示:
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="https://schemas.android.com/apk/res/android" xmlns:tools="https://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context="com.theitroad.floatingchatheads.MainActivity"> <TextView android:id="@+id/textView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:text="Hello World!" <Button android:id="@+id/button" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_centerHorizontal="true" android:layout_margin="16dp" android:text="ADD FLOATING BUTTON" </RelativeLayout>
下面是FloatingWidgetService.java
类的代码:
package com.theitroad.floatingchatheads; import android.app.Service; import android.content.Intent; import android.graphics.PixelFormat; import android.graphics.Point; import android.os.IBinder; import android.support.annotation.Nullable; import android.view.Display; import android.view.Gravity; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.WindowManager; import com.andremion.counterfab.CounterFab; /** * Created by anupamchugh on 01/08/17. */ public class FloatingWidgetService extends Service { private WindowManager mWindowManager; private View mOverlayView; int mWidth; CounterFab counterFab; boolean activity_background; @Nullable @Override public IBinder onBind(Intent intent) { return null; } @Override public int onStartCommand(Intent intent, int flags, int startId) { if (intent != null) { activity_background = intent.getBooleanExtra("activity_background", false); } if (mOverlayView == null) { mOverlayView = LayoutInflater.from(this).inflate(R.layout.overlay_layout, null); final WindowManager.LayoutParams params = new WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.TYPE_PHONE, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, PixelFormat.TRANSLUCENT); //Specify the view position params.gravity = Gravity.TOP | Gravity.LEFT; //Initially view will be added to top-left corner params.x = 0; params.y = 100; mWindowManager = (WindowManager) getSystemService(WINDOW_SERVICE); mWindowManager.addView(mOverlayView, params); Display display = mWindowManager.getDefaultDisplay(); Point size = new Point(); display.getSize(size); counterFab = (CounterFab) mOverlayView.findViewById(R.id.fabHead); counterFab.setCount(1); final RelativeLayout layout = (RelativeLayout) mOverlayView.findViewById(R.id.layout); ViewTreeObserver vto = layout.getViewTreeObserver(); vto.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { layout.getViewTreeObserver().removeOnGlobalLayoutListener(this); int width = layout.getMeasuredWidth(); //To get the accurate middle of the screen we subtract the width of the android floating widget. mWidth = size.x - width; } }); counterFab.setOnTouchListener(new View.OnTouchListener() { private int initialX; private int initialY; private float initialTouchX; private float initialTouchY; @Override public boolean onTouch(View v, MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: //remember the initial position. initialX = params.x; initialY = params.y; //get the touch location initialTouchX = event.getRawX(); initialTouchY = event.getRawY(); return true; case MotionEvent.ACTION_UP: if (activity_background) { //xDiff and yDiff contain the minor changes in position when the view is clicked. float xDiff = event.getRawX() - initialTouchX; float yDiff = event.getRawY() - initialTouchY; if ((Math.abs(xDiff) < 5) && (Math.abs(yDiff) = middle ? mWidth : 0; params.x = (int) nearestXWall; mWindowManager.updateViewLayout(mOverlayView, params); return true; case MotionEvent.ACTION_MOVE: int xDiff = Math.round(event.getRawX() - initialTouchX); int yDiff = Math.round(event.getRawY() - initialTouchY); //Calculate the X and Y coordinates of the view. params.x = initialX + xDiff; params.y = initialY + yDiff; //Update the layout with new X & Y coordinates mWindowManager.updateViewLayout(mOverlayView, params); return true; } return false; } }); } else { counterFab.increase(); } return super.onStartCommand(intent, flags, startId); } @Override public void onCreate() { super.onCreate(); setTheme(R.style.AppTheme); } @Override public void onDestroy() { super.onDestroy(); if (mOverlayView != null) mWindowManager.removeView(mOverlayView); } }
在上面的代码中,我们已将逻辑从" onCreate()"移至" onStartCommand()"方法。
为什么?我们将多次启动" FloatingWidgetService"。
Service类的onCreate()方法仅在第一次调用。
为了更新小部件并检索意向附加信息,我们需要将代码转换为ʻonStartCommand()`。
我们需要检测活动是否在后台运行。
仅当应用程序在后台运行时,我们才会从服务启动"活动"(如果活动处于前台,则不会启动该活动的另一个实例)。
在不久将要看到的活动中调用onPause()时,将使用activity_background` bundle extra传递的值为true。我们在mOverlayView实例上放置了一个空检查器,以更新CounterFab徽章计数(如果已存在)。
要沿最近的边缘自动定位视图,我们首先需要找到屏幕的宽度并将其存储(" mWidth"是我们使用的变量)。
使用以下代码段即可完成。我们需要从屏幕的显示宽度中减去android浮动小部件的宽度。
我们为此使用GlobalLayoutListener
。
仅在正确放置视图后,才计算视图的宽度。
注意:如果尚未放置视图,则直接在视图上调用getWidth()而不使用诸如layout.getWidth()或者counterFab.getWidth()之类的GlobalLayoutListener会返回0。
仅在正确放置视图之后才触发GlobalLayoutListener
回调
- 更新视图以使其沿最近的边缘,并检测是否单击了该视图,只有当用户从屏幕上抬起手指并触发" MotionEvent.ACTION_UP"时,才会触发这两个功能。
下面提供了ACTION_UP情况的代码:
单击视图时,将另外的badge_count
传递到活动中。
调用stopSelf()终止服务,该服务将调用onDestroy(),在该服务中,浮动小部件已从WindowManager中删除。
下面给出了MainActivity.java类的代码:
if (intent != null) { activity_background = intent.getBooleanExtra("activity_background", false); }
上面的代码现在允许通过单击按钮启动服务。
同样,它显示从" FloatingWidgetService"返回的当前" badge_count"值,默认值为0。