好记性不如烂笔头之 App widgets(一)_禽兽先生不禽兽的博客-CSDN博客
之前记录了 AppWidgets 的基本用法,当我的小组件中需要展示列表的时候,发现它的方式也跟普通的列表控件不一样,而且在使用 AppWidgetProvider 时还有更需要注意的地方,在此特意也记录一下。
一、定义 ListDemoWidget
跟之前小组件一样,我们需要一个 AppWidgetProvider 的实现类:
package com.qinshou.appwidgetsdemo
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.widget.RemoteViews
import org.json.JSONArray
import org.json.JSONObject
class ListDemoWidget : AppWidgetProvider() {
companion object {
private const val TAG = "ListDemoWidget"
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
Log.i(TAG, "onReceive : " + "action--->" + intent.action)
}
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
Log.i(TAG, "onUpdate")
// 保存一个 json 串,模拟数据
val jsonArray = JSONArray()
for (i in 0 until 20) {
val jsonObject = JSONObject()
jsonObject.put("done", false)
jsonObject.put("content", "待办事项${i}")
jsonArray.put(jsonObject)
}
val sharedPreferences = context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putString("todo", jsonArray.toString())
editor.apply()
// 为小组件添加列表控件
for (appWidgetId in appWidgetIds) {
val remoteViews = RemoteViews(context.packageName, R.layout.widget_list_demo)
val intent = Intent(context, ListDemoService::class.java).apply {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
}
remoteViews.setRemoteAdapter(R.id.lv_demo, intent)
appWidgetManager.updateAppWidget(appWidgetId, remoteViews)
}
}
}
这里重点关注 remoteViews.setRemoteAdapter(R.id.lv_demo, intent) 这一行代码,该方法需要传递两个参数,第一个参数为列表控件的 id,第二个参数为一个 Intent 对象。
既然是列表视图,一定要有一个列表控件,看这个 id 能够猜出来使用的是 ListView。之前说过小组件布局中使用的控件一定是 @RemoteView 注解修饰的控件,所以很遗憾不能使用现在主流的 RecyclerView 了,所以我们选择了 ListView。
记得之前使用 ListView 我们会定义一个 Adapter,而且为了复用还需要定义一个 ViewHolder 类去提高 ListView 的效率,但是在小组件中使用 ListView 又有些不同,不是使用 Adapter 去填充数据,而是需要一个 RemoteViewsService 和 RemoteViewsFactory 去实现,所以第二个参数 intent 就是用来启动这个填充数据的 Service 的。
二、定义 ListDemoService
这个 Service 就是重点了,它继承自 RemoteViewsService,点进源码发现 RemoteViewsService 是一个抽象类,只需要我们实现 public abstract RemoteViewsService.RemoteViewsFactory onGetViewFactory(Intent var1) 这个方法,该方法需要返回一个 RemoteViewsService.RemoteViewsFactory 对象。接着看发现 RemoteViewsService.RemoteViewsFactory 是一个接口,定义了如下方法需要我们去实现:
- void onCreate():RemoteViewsFactory 创建时回调,可以在该方法中初始化数据。
- void onDataSetChanged():调用 AppWidgetManager 的 notifyAppWidgetViewDataChanged() 方法时回调,表示数据发生改变,可以在该方法中更新数据。
- void onDestroy():RemoteViewsFactory 销毁时回调,可以在该方法中释放数据。
- int getCount():返回数据个数。
- RemoteViews getViewAt(int var1):返回每一个 item 的视图,重要。
- RemoteViews getLoadingView():加载中布局,如果不需要则返回 null。
- int getViewTypeCount():返回视图类型数量,通常不需要多布局的话返回 1。
- long getItemId(int var1):返回每一个 item 的独立 id,通常返回对应 position 就行。
- boolean hasStableIds():Item 是否是稳定 id,没太明白该方法的具体作用,通常返回 true。
因此我们可以这样理解,RemoteViewsService 是用于小组件与适配器通信的一个服务,RemoteViewsService.RemoteViewsFactory 则是用于填充数据,相当于 ListView 的 Adapter 的角色。
上代码:
package com.qinshou.appwidgetsdemo
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
import android.widget.RemoteViewsService
import org.json.JSONArray
class ListDemoService : RemoteViewsService() {
override fun onGetViewFactory(intent: Intent?): RemoteViewsFactory {
return ListDemoRemoteViewsFactory(applicationContext)
}
private class ListDemoRemoteViewsFactory(private val context: Context) : RemoteViewsFactory {
private var datum: MutableList<Pair<Boolean?, String?>> = ArrayList()
override fun onCreate() {
datum.clear()
val sharedPreferences = context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE)
val json = sharedPreferences.getString("todo", null) ?: return
val jsonArray = JSONArray(json)
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
val done = jsonObject.optBoolean("done", false)
val content = jsonObject.optString("content", "")
datum.add(Pair(done, content))
}
}
override fun onDataSetChanged() {
datum.clear()
val sharedPreferences = context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE)
val json = sharedPreferences.getString("todo", null) ?: return
val jsonArray = JSONArray(json)
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
val done = jsonObject.optBoolean("done", false)
val content = jsonObject.optString("content", "")
datum.add(Pair(done, content))
}
}
override fun onDestroy() {
datum.clear()
}
override fun getCount(): Int {
return datum.size
}
override fun getViewAt(position: Int): RemoteViews {
val item = datum[position]
// 创建在当前索引位置要显示的View
val remoteViews = RemoteViews(context.packageName, R.layout.item_lv_demo)
// 设置要显示的内容
remoteViews.setImageViewResource(R.id.iv_done, if (item.first != null && item.first!!) android.R.drawable.checkbox_on_background
else android.R.drawable.checkbox_off_background)
remoteViews.setTextViewText(R.id.tv_content, item.second)
return remoteViews
}
override fun getLoadingView(): RemoteViews? {
return null
}
override fun getViewTypeCount(): Int {
return 1
}
override fun getItemId(position: Int): Long {
return position.toLong()
}
override fun hasStableIds(): Boolean {
return true
}
}
}
三、小组件的布局
布局很简单没什么好说的。
widget_list_demo.xml:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FFFFE165"
android:orientation="vertical"
android:padding="15dp"
tools:context=".ListDemoWidget">
<ListView
android:id="@+id/lv_demo"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:overScrollMode="never"
android:scrollbars="none" />
</LinearLayout>
item 的布局:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/ll_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/iv_done"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@android:drawable/checkbox_on_background" />
<TextView
android:id="@+id/tv_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#FF000000"
android:textSize="15sp"
tools:text="测试文字" />
</LinearLayout>
四、小组件的配置
这个也没什么好说的,跟之前的定义基本差不多:
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialKeyguardLayout="@layout/widget_demo"
android:initialLayout="@layout/widget_demo"
android:minWidth="250dp"
android:minHeight="110dp"
android:previewImage="@mipmap/ic_launcher"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen">
</appwidget-provider>
五、小组件的声明
声明时需要注意一下,处理声明小组件的 receiver 之外,还需要声明 RemoteViewsService 的实现类,并且这个 service 还需要声明 "android.permission.BIND_REMOTEVIEWS" 权限,官方说明增加该权限是为了防止其他应用自由访问你的数据,这个权限一定别忘了加,否则数据显示不出来,还挺难排查的。
<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
<application
...>
...
<service
android:name=".ListDemoService"
android:enabled="true"
android:exported="true"
android:permission="android.permission.BIND_REMOTEVIEWS" />
...
<receiver
android:name=".ListDemoWidget"
android:exported="true"
android:label="ListDemo小组件">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_config_list_demo" />
</receiver>
</application>
</manifest>
至此,我们就可以添加一个使用列表的小组件了:
六、Item 响应点击事件
列表中的 item 设置点击事件跟之前设置 button 的点击事件也不一样,一共需要设置两个地方。
首先修改 ListDemoWidget 的 onUpdate() 方法:
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
Log.i(TAG, "onUpdate")
// 保存一个 json 串,模拟数据
val jsonArray = JSONArray()
for (i in 0 until 20) {
val jsonObject = JSONObject()
jsonObject.put("done", false)
jsonObject.put("content", "待办事项${i}")
jsonArray.put(jsonObject)
}
val sharedPreferences = context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putString("todo", jsonArray.toString())
editor.apply()
// 为小组件添加列表控件
for (appWidgetId in appWidgetIds) {
val remoteViews = RemoteViews(context.packageName, R.layout.widget_list_demo)
val intent = Intent(context, ListDemoService::class.java).apply {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
}
remoteViews.setRemoteAdapter(R.id.lv_demo, intent)
// 为集合设置待定 intent
val itemClickIntent = Intent(context, ListDemoWidget::class.java).apply {
action = ACTION_NOTIFY_ITEM_DONE_CHANGED
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
}
val pendingIntentTemplate = PendingIntent.getBroadcast(context, 0, itemClickIntent, PendingIntent.FLAG_UPDATE_CURRENT)
remoteViews.setPendingIntentTemplate(R.id.lv_demo, pendingIntentTemplate)
appWidgetManager.updateAppWidget(appWidgetId, remoteViews)
}
}
然后修改 ListDemoRemoteViewsFactory 的 getViewAt() 方法:
override fun getViewAt(position: Int): RemoteViews {
val item = datum[position]
// 创建在当前索引位置要显示的View
val remoteViews = RemoteViews(context.packageName, R.layout.item_lv_demo)
// 设置要显示的内容
remoteViews.setImageViewResource(R.id.iv_done, if (item.first != null && item.first!!) android.R.drawable.checkbox_on_background
else android.R.drawable.checkbox_off_background)
remoteViews.setTextViewText(R.id.tv_content, item.second)
// 为 item 设置填充 intent,响应点击事件
val intent = Intent().apply {
putExtra(ListDemoWidget.POSITION, position)
}
remoteViews.setOnClickFillInIntent(R.id.ll_root, intent)
return remoteViews
}
主要就是设置 intent 的地方,设置列表 Adapter 的时候是调用 setPendingIntentTemplate() 方法设置的是待定 Intent,设置 item 视图的时候是调用 setOnClickFillInIntent() 方法设置的是填充 Intent。
最后在 onReceive() 方法中接收广播,处理逻辑:
companion object {
private const val TAG = "ListDemoWidget"
const val POSITION = "position"
const val ACTION_NOTIFY_ITEM_DONE_CHANGED = "notifyItemDoneChanged"
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
Log.i(TAG, "onReceive : " + "action--->" + intent.action)
when (intent.action) {
ACTION_NOTIFY_ITEM_DONE_CHANGED -> {
val position = intent.getIntExtra(POSITION, -1)
if (position == -1) {
return
}
val sharedPreferences = context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE)
val json = sharedPreferences.getString("todo", null) ?: return
val jsonArray = JSONArray(json)
val jsonObject = jsonArray.getJSONObject(position)
jsonObject.put("done", !jsonObject.optBoolean("done", false))
val editor = sharedPreferences.edit()
editor.putString("todo", jsonArray.toString())
editor.apply()
val appWidgetManager = AppWidgetManager.getInstance(context)
appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetManager.getAppWidgetIds(ComponentName(context, ListDemoWidget::class.java)), R.id.lv_demo)
}
}
}
至此,就实现点击 item,改变数据的 done 字段,复选框根据 done 字段显示不同的状态(复选框是通过 ImageView 实现的,因为 CheckBox 控件是在 API 31 以上才支持的)。效果如下(需要移除小组件,重新添加后才能看到效果):
七、总结
在小组件中使用列表还是挺麻烦的,小组件中操作控件已经比普通方式麻烦一些了,使用列表区别于 ListView 就更加麻烦,不过从我记录的这个示例来看,做一个类似 todo 的应用还是没有问题,用小组件操作 todo,比起打开应用进入界面操作方便简直不要太多。