用 Slice 来做 Android Live Wallpaper 的快捷设置项

好久没写技术实现了,水一篇好了。
Slice 是 Google 做的一套模版化 UI,主要用于 Assistant 相关的界面展示,但系统设置顶部偶尔也会出现相关的应用。B-Reel 和 Google 在 Pixel 4 自带的 Pixel Live Wallpaper 里用 Slice 来做了一组快捷设定项,效果如下:

作为专业 Pixel Live Wallpaper 高仿者(误,做新软件 Diorama 的时候我也试了一把 Slice,效果还是可以的:

至于原理其实很简单,在 API 29 上,wallpaper.xml 可以添加一个新字段 android:settingsSliceUri,如果提供的 Uri 是正确的,动态壁纸 Preview 的底部 panel 会新增一个 customize 的 tab。Diorama 目前的 wallpaper.xml 如下:

<?xml version="1.0" encoding="utf-8"?>
<wallpaper xmlns:android="http://schemas.android.com/apk/res/android"
    android:settingsSliceUri="content://com.justzht.lwp.diorama/settings"
    android:showMetadataInPreview="true"
    android:description="@string/app_wallpaper_description"
    android:author="@string/app_author"
    android:contextUri="@string/app_wallpaper_context_uri"
    android:contextDescription="@string/app_wallpaper_context_description"
    android:label="@string/app_name"
    android:settingsActivity="com.justzht.lwp.diorama.view.activity.MainActivity"
    android:thumbnail="@mipmap/wallpaper_preview"/>

Slice 的实现也很简单,首先 Android Studio 的新建菜单里 New/Other/Slice Provider 这个路径下就已经提供了一个基础的模板,只需要自己拓展 public Slice onBindSlice(Uri sliceUri) 方法返回一个自定义的 Slice 即可。

ListBuilder builder = new ListBuilder(context, sliceUri, ListBuilder.INFINITY);
if ("/settings".equals(sliceUri.getPath())) {
    // add your own implementation here
    return builder.build();
} else {
    return builder.addRow(
        new RowBuilder()
        .setTitle("ERROR: URI " + sliceUri.getPath() + " not found.")
    ).build();
}

Slice 的 Template 里有一些可以交互的 UI 元素,比如 Toggle 或者 Slider。这些交互的触发事件可以通过设定各自元素 Builder 里的 Action 来实现,比如 ListBuilder.InputRangeBuilder 就可以调用 setInputAction(PendingIntent action) 来触发滑动事件,以及 setPrimaryAction(SliceAction action) 来触发整个 row 的点击事件。若是要做一个时间拖动的 Slider:

// init your ListBuilder before this code snippet
int hour = (int) Math.floor(24 * Utils.getVal(SettingsManager.getInstance().getModel().dateTimeManualModeProgress, 0 f)); // Get current set hour from some where
Intent intent = new Intent(context, SliceBroadcastReceiver.class).setAction(context.getString(R.string.intent_change_datetime));
ListBuilder.InputRangeBuilder inputRangeBuilder = new ListBuilder.InputRangeBuilder()
    .setTitle(context.getString(R.string.text_time))
    .setSubtitle("Custom at " + String.format("%02d:00", hour))
    .setInputAction(PendingIntent.getBroadcast(
        getContext(),
        0,
        intent,
        PendingIntent.FLAG_UPDATE_CURRENT
    )) // add a PendingIntent so a BroadcastReceiver can receive the event
    .setValue(hour) // note that value is an int, meaning this slider is a *stepped* seekbar
    .setMax(24)
    .setMin(0);
builder.addInputRange(inputRangeBuilder);
// return builder.build() after this code snippet

然后在 BroadcastReceiver 里就可以获得 Slider 更新的值:

public class SliceBroadcastReceiver extends BroadcastReceiver {
    private static Uri sliceUri = Uri.parse("content://com.justzht.lwp.diorama/settings");
    @Override
    public void onReceive(Context context, Intent intent) {
        String intentAction = intent.getAction();
        if (intentAction != null) {
            if (intentAction.equals(context.getString(R.string.intent_change_datetime)) &&
                intent.getExtras() != null &&
                Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                int hour = intent.getExtras().getInt(EXTRA_RANGE_VALUE, 0); // acquire the updated value using EXTRA_RANGE_VALUE
                Utils.setValIfNotSame(SettingsManager.getInstance().getModel().dateTimeManualModeProgress, hour / 24 f, false); // save this value to somewhere

                context.getContentResolver().notifyChange(sliceUri, null); // notify the app to refresh slice
            }
        }
    }
}

最后记得调用 void notifyChange(Uri uri, ContentObserver observer) 刷新 Slice 即可。