https://developer.android.com/develop/ui/views/notifications/bubbles#the_bubble_api
气泡使用户可以轻松查看和参与对话。
气泡内置于通知系统中。 它们漂浮在其他应用程序内容之上,无论用户走到哪里都会跟随他们。 气泡可以展开以显示应用程序功能和信息,并且可以在不使用时折叠。
当设备被锁定或始终显示处于活动状态时,气泡会像通常的通知一样出现。
气泡是一种选择退出功能。 当应用程序显示第一个气泡时,会显示一个权限对话框,其中提供两种选择:
- 阻止您应用中的所有气泡 - 通知不会被阻止,但它们永远不会显示为气泡
- 允许您应用中的所有气泡 - 所有使用 BubbleMetaData 发送的通知都将显示为气泡
https://developer.android.com/static/images/guide/topics/ui/bubbles-demo.mp4
气泡API
气泡是通过 Notification API 创建的,因此您可以照常发送通知。如果您希望让通知显示为气泡,则需要为其附加一些额外的数据。
气泡的展开视图是根据您选择的 Activity 创建的。此 Activity 需要经过配置才能正确显示为气泡。此 Activity 必须可以调整大小且是嵌入式的。只要 Activity 不满足其中任何一项要求,都会显示为通知。
以下代码演示了如何实现简单的气泡:
<activity
android:name=".bubbles.BubbleActivity"
android:theme="@style/AppTheme.NoActionBar"
android:label="@string/title_activity_bubble"
android:allowEmbedded="true"
android:resizeableActivity="true"
/>
如果您的应用需要显示多个相同类型的气泡(例如与不同联系人的多个聊天对话),此 Activity 必须能够启动多个实例。在搭载 Android 10 的设备上,除非您将 documentLaunchMode 明确设置为 “always”,否则通知不会显示为气泡。从 Android 11 开始,您无需明确设置此值,因为系统会自动将所有对话的 documentLaunchMode 设置为 “always”。
如需发送气泡,请按照以下步骤操作:
- 按照常规方式创建通知。
- 调用 BubbleMetadata.Builder(PendingIntent, Icon) 或 BubbleMetadata.Builder(String) 以创建 BubbleMetadata 对象。
- 使用 setBubbleMetadata() 将元数据添加到通知中。
- 如果以 Android 11 或更高版本为目标平台,对话泡元数据或通知必须引用共享快捷方式。
注意:第一次发送通知以显示气泡时,它必须位于 IMPORTANCE_MIN 或更高级别的通知渠道中。
如果在发送气泡时您的应用程序在前台,重要性将被忽略并且您的气泡将始终显示(除非用户已阻止来自您的应用程序的气泡或通知)。
// Create bubble intent
Intent target = new Intent(mContext, BubbleActivity.class);
PendingIntent bubbleIntent =
PendingIntent.getActivity(mContext, 0, target, 0 /* flags */);
private val CATEGORY_TEXT_SHARE_TARGET =
"com.example.category.IMG_SHARE_TARGET"
Person chatPartner = new Person.Builder()
.setName("Chat partner")
.setImportant(true)
.build();
// 创建共享快捷方式
private String shortcutId = generateShortcutId();
ShortcutInfo shortcut =
new ShortcutInfo.Builder(mContext, shortcutId)
.setCategories(Collections.singleton(CATEGORY_TEXT_SHARE_TARGET))
.setIntent(Intent(Intent.ACTION_DEFAULT))
.setLongLived(true)
.setShortLabel(chatPartner.getName())
.build();
// Create bubble metadata
Notification.BubbleMetadata bubbleData =
new Notification.BubbleMetadata.Builder(bubbleIntent,
Icon.createWithResource(context, R.drawable.icon))
.setDesiredHeight(600)
.build();
// Create notification, referencing the sharing shortcut
Notification.Builder builder =
new Notification.Builder(mContext, CHANNEL_ID)
.setContentIntent(contentIntent)
.setSmallIcon(smallIcon)
.setBubbleMetadata(bubbleData)
.setShortcutId(shortcutId)
.addPerson(chatPartner);
创建展开的气泡
您可以将气泡配置为自动以展开状态显示。我们建议您仅在用户执行会导致显示气泡的操作(例如点按按钮以开始新的聊天)时才使用此功能。在这种情况下,还有必要禁止显示在创建气泡时发送的初始通知。
您可以使用以下方法设置启用这些行为的标志:setAutoExpandBubble() 和setSuppressNotification()。
Notification.BubbleMetadata bubbleData =
new Notification.BubbleMetadata.Builder()
.setDesiredHeight(600)
.setIntent(bubbleIntent)
.setAutoExpandBubble(true)
.setSuppressNotification(true)
.build();
气泡内容生命周期
如果展开气泡,内容 Activity 会完成常规进程生命周期,这会使应用成为前台进程(如果应用尚未在前台运行)。
如果收起或关闭气泡,系统会销毁此 Activity。这可能导致系统缓存此进程,然后将其终止,具体取决于应用是否有任何其他前台组件正在运行。
何时显示气泡
为减少对用户的干扰,气泡仅在特定情况下显示。
如果应用以 Android 11 或更高版本为目标平台,那么除非通知符合对话要求,否则将不会显示为气泡。如果应用以 Android 10 为目标平台,那么仅在满足以下一个或多个条件时,通知才会显示为气泡:
- 通知使用 MessagingStyle,并添加了 Person。
- 通知来自对 Service.startForeground 的调用,类别为 CATEGORY_CALL,并添加了 Person。
- 发送通知时,应用在前台运行。
如果上述条件均不满足,系统就会显示通知而不显示气泡。
最佳做法
- 气泡会占用屏幕空间并遮盖其他应用内容。仅当非常需要显示气泡(例如对于进行中的通信)或用户明确要求为某些内容显示气泡时,才将通知发送为气泡。
- 请注意,用户可以停用气泡。在这种情况下,气泡通知会显示为一般通知。您应该始终确保您的气泡通知也可以作为一般通知使用。
- 从气泡启动的进程(例如 activity 和对话框)会显示在气泡容器中。这意味着气泡可以有任务堆栈。如果您的气泡中有很多功能或导航,情况就会变得很复杂。建议您尽量让功能保持具体且简明。
- 确保在气泡 Activity 中替换 onBackPressed 时调用 super.onBackPressed;否则,气泡可能会无法正常运行。
- 当气泡在收起后收到更新的消息时,气泡会显示一个标志图标,表示有未读消息。当用户在关联的应用中打开消息时,请按以下步骤操作:
- 更新 BubbleMetadata 以抑制通知的显示。调用 BubbleMetadata.Builder.setSupressNotification()。这会移除标志图标以表示用户处理过信息。
- 将 Notification.Builder.setOnlyAlertOnce() 设置为 true 可禁止涉及 BubbleMetadata 更新的声音或振动。
示例应用
People 示例应用是一个使用气泡的简单对话式应用。出于演示目的,此应用使用聊天机器人。在真实的应用中,气泡应仅用于人类发送的消息,而不用于聊天机器人发送的消息。
气泡是一种特殊类型的内容,可以“漂浮”在其他应用程序或系统 UI 之上。
可以展开气泡以显示更多内容。
控制器管理屏幕上气泡的添加、移除和可见状态。
在根布局BubbleStackView添加时先创建添加BubbleExpandedView并初始化添加BubbleOverflowContainerView
// 在这里初始化{@link BubbleController}和{@link BubbleStackView},
// 这个方法必须在view inflate之后调用。
void initialize(BubbleController controller, BubbleStackView stackView, boolean isOverflow) {
mController = controller;
mStackView = stackView;
mIsOverflow = isOverflow;
mPositioner = mController.getPositioner();
// 首次先添加右侧的BubbleOverflowContainerView相关的
if (mIsOverflow) {
mOverflowView = (BubbleOverflowContainerView) LayoutInflater.from(getContext()).inflate(
R.layout.bubble_overflow_container, null /* root */);
mOverflowView.setBubbleController(mController);
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT);
mExpandedViewContainer.addView(mOverflowView, lp);
mExpandedViewContainer.setLayoutParams(
new LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT));
bringChildToFront(mOverflowView);
mManageButton.setVisibility(GONE);
} ......
}
左侧页面的两个BadgedImageView加载和BubbleExpandedView添加
@VisibleForTesting
@Nullable
public static BubbleViewInfo populate(Context c, BubbleController controller,
BubbleStackView stackView, BubbleIconFactory iconFactory,
BubbleBadgeIconFactory badgeIconFactory, Bubble b,
boolean skipInflation) {
BubbleViewInfo info = new BubbleViewInfo();
// View inflation: only should do this once per bubble
if (!skipInflation && !b.isInflated()) {
LayoutInflater inflater = LayoutInflater.from(c);
info.imageView = (BadgedImageView) inflater.inflate(
R.layout.bubble_view, stackView, false /* attachToRoot */);
info.imageView.initialize(controller.getPositioner());
info.expandedView = (BubbleExpandedView) inflater.inflate(
R.layout.bubble_expanded_view, stackView, false /* attachToRoot */);
info.expandedView.initialize(controller, stackView, false /* isOverflow */);
}
添加TaskView
// 在这里初始化{@link BubbleController}和{@link BubbleStackView},
// 这个方法必须在view inflate之后调用。
void initialize(BubbleController controller, BubbleStackView stackView, boolean isOverflow) {
mController = controller;
mStackView = stackView;
mIsOverflow = isOverflow;
mPositioner = mController.getPositioner();
// 首次先添加右侧的BubbleOverflowContainerView相关的
if (mIsOverflow) {
......
} else {
// 添加TaskView
mTaskView = new TaskView(mContext, mController.getTaskOrganizer(),
mController.getTaskViewTransitions(), mController.getSyncTransactionQueue());
mTaskView.setListener(mController.getMainExecutor(), mTaskViewListener);
mExpandedViewContainer.addView(mTaskView);
bringChildToFront(mTaskView);
}
}
窗口类型:应用程序覆盖窗口显示在所有活动窗口上方({@link #FIRST_APPLICATION_WINDOW} 和 {@link #LAST_APPLICATION_WINDOW} 之间的类型)但在状态栏或 IME 等关键系统窗口下方。
系统可以随时更改这些窗口的位置、大小或可见性,以减少用户的视觉混乱并管理资源。
需要 {@link android.Manifest.permission#SYSTEM_ALERT_WINDOW} 权限。
系统将调整具有这种窗口类型的进程的重要性,以减少低内存杀手杀死它们的机会。
在多用户系统中,仅在拥有用户的屏幕上显示。
public static final int TYPE_APPLICATION_OVERLAY = FIRST_SYSTEM_WINDOW + 38;
窗口标志:此窗口永远不会获得按键输入焦点,因此用户无法向其发送按键或其他按钮事件。 那些将转到它后面的任何可聚焦窗口。 此标志还将启用 {@link #FLAG_NOT_TOUCH_MODAL},无论是否明确设置。
设置此标志还意味着窗口不需要与软输入法交互,因此它将独立于任何活动输入法进行 Z 排序和定位(通常这意味着它在输入法之上获得 Z 排序, 因此它可以使用全屏显示内容并在需要时覆盖输入法。您可以使用 {@link #FLAG_ALT_FOCUSABLE_IM} 修改此行为。
public static final int FLAG_NOT_FOCUSABLE = 0x00000008;
窗口标志:即使此窗口可聚焦(其 {@link #FLAG_NOT_FOCUSABLE} 未设置),也允许将窗口外的任何指针事件发送到其后面的窗口。 否则它将自己消耗所有指针事件,无论它们是否在窗口内。
public static final int FLAG_NOT_TOUCH_MODAL = 0x00000020;
指定窗口应被视为受信任的系统覆盖。 在考虑输入调度期间窗口是否被遮挡时,可信系统覆盖将被忽略。 需要 {@link android.Manifest.permission#INTERNAL_SYSTEM_WINDOW} 权限。
{@see android.view.MotionEvent#FLAG_WINDOW_IS_OBSCURED}
{@see android.view.MotionEvent#FLAG_WINDOW_IS_PARTIALLY_OBSCURED}
public void setTrustedOverlay() {
privateFlags |= PRIVATE_FLAG_TRUSTED_OVERLAY;
}
指定此窗口在布局期间应避免重叠的插图类型。
@param 类型{@link WindowInsets.Type} 此窗口应避免的插入。
该对象的初始值包括所有系统栏。
public void setFitInsetsTypes(@InsetsType int types) {
mFitInsetsTypes = types;
privateFlags |= PRIVATE_FLAG_FIT_INSETS_CONTROLLED;
}
{@link #softInputMode} 的调整选项:设置为允许在显示输入法时调整窗口大小,使其内容不被输入法覆盖。 这不能与 {@link #SOFT_INPUT_ADJUST_PAN} 结合使用; 如果这些都没有设置,那么系统将根据窗口的内容尝试选择一个或另一个。 如果窗口的布局参数标志包括 {@link #FLAG_FULLSCREEN},{@link #softInputMode} 的这个值将被忽略; 窗口不会调整大小,但会保持全屏。
@deprecated 使用 {@code false} 调用 {@link Window#setDecorFitsSystemWindows(boolean)} 并在适合类型 {@link Type#ime()} 的插入的根内容视图上安装 {@link OnApplyWindowInsetsListener}。
@Deprecated
public static final int SOFT_INPUT_ADJUST_RESIZE = 0x10;
始终允许窗口延伸到屏幕所有边缘的 {@link DisplayCutout} 区域。
窗口必须确保没有重要内容与 {@link DisplayCutout} 重叠。
在此模式下,无论窗口是否隐藏系统栏,窗口都会在纵向和横向显示的所有边缘上的切口下延伸。
public static final int LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS = 3;
添加全屏的气泡窗口到屏幕上,mStackView是一个FrameLayout。
BubbleStackView 是在第一次添加 Bubble 时通过此方法延迟创建的。 此方法初始化堆栈视图并将其添加到窗口管理器。
private void ensureStackViewCreated() {
if (mStackView == null) {
// 创建bubble根布局
mStackView = new BubbleStackView(
mContext, this, mBubbleData, mSurfaceSynchronizer, mFloatingContentCoordinator,
mMainExecutor);
mStackView.onOrientationChanged();
if (mExpandListener != null) {
mStackView.setExpandListener(mExpandListener);
}
mStackView.setUnbubbleConversationCallback(mSysuiProxy::onUnbubbleConversation);
}
addToWindowManagerMaybe();
}
/** Adds the BubbleStackView to the WindowManager if it's not already there. */
private void addToWindowManagerMaybe() {
// If the stack is null, or already added, don't add it.
if (mStackView == null || mAddedToWindowManager) {
return;
}
mWmLayoutParams = new WindowManager.LayoutParams(
// 填满屏幕,以便我们可以使用平移动画来定位气泡堆栈。
// 我们将使用可触摸区域来忽略不在气泡本身上的触摸。
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
| WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
PixelFormat.TRANSLUCENT);
mWmLayoutParams.setTrustedOverlay();
mWmLayoutParams.setFitInsetsTypes(0);
mWmLayoutParams.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
mWmLayoutParams.token = new Binder();
mWmLayoutParams.setTitle("Bubbles!");
mWmLayoutParams.packageName = mContext.getPackageName();
mWmLayoutParams.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
mWmLayoutParams.privateFlags |= WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS;
try {
mAddedToWindowManager = true;
registerBroadcastReceiver();
mBubbleData.getOverflow().initialize(this);
mWindowManager.addView(mStackView, mWmLayoutParams);
mStackView.setOnApplyWindowInsetsListener((view, windowInsets) -> {
if (!windowInsets.equals(mWindowInsets)) {
mWindowInsets = windowInsets;
mBubblePositioner.update();
mStackView.onDisplaySizeChanged();
}
return windowInsets;
});
} catch (IllegalStateException e) {
// This means the stack has already been added. This shouldn't happen...
e.printStackTrace();
}
}
View that can display a task.
@Override
public void surfaceCreated(SurfaceHolder holder) {
mSurfaceCreated = true;
if (mListener != null && !mIsInitialized) {
mIsInitialized = true;
// 未初始化,先启动activity
mListenerExecutor.execute(() -> {
mListener.onInitialized();
});
}
mShellExecutor.execute(() -> {
if (mTaskToken == null) {
// Nothing to update, task is not yet available
return;
}
if (isUsingShellTransitions()) {
mTaskViewTransitions.setTaskViewVisible(this, true /* visible */);
return;
}
// 将Task的surfacecontrol从先有层级剥离,挂在TaskView(SurfaceView的子类)下面
// Reparent the task when this surface is created
mTransaction.reparent(mTaskLeash, getSurfaceControl())
.show(mTaskLeash)
.apply();
updateTaskVisibility();
});
}
展开气泡视图的容器,处理呈现插入符号和设置图标。
@Override
public void onInitialized() {
if (mDestroyed || mInitialized) {
return;
}
// 自定义选项,因此没有activity过渡动画
ActivityOptions options = ActivityOptions.makeCustomAnimation(getContext(),0 /* enterResId */, 0 /* exitResId */);
Rect launchBounds = new Rect();
mTaskView.getBoundsOnScreen(launchBounds);
// TODO: I notice inconsistencies in lifecycle
// Post to keep the lifecycle normal
post(() -> {
try {
options.setTaskAlwaysOnTop(true);
options.setLaunchedFromBubble(true);
if (!mIsOverflow && mBubble.hasMetadataShortcutId()) {
options.setApplyActivityFlagsForBubbles(true);
mTaskView.startShortcutActivity(mBubble.getShortcutInfo(),
options, launchBounds);
} else {
Intent fillInIntent = new Intent();
// Apply flags to make behaviour match documentLaunchMode=always.
fillInIntent.addFlags(FLAG_ACTIVITY_NEW_DOCUMENT);
fillInIntent.addFlags(FLAG_ACTIVITY_MULTIPLE_TASK);
if (mBubble != null) {
mBubble.setIntentActive();
}
mTaskView.startActivity(mPendingIntent, fillInIntent, options,
launchBounds);
}
} catch (RuntimeException e) {
Log.w(TAG, "Exception while displaying bubble: " + getBubbleKey()
+ ", " + e.getMessage() + "; removing bubble");
mController.removeBubble(getBubbleKey(), Bubbles.DISMISS_INVALID_INTENT);
}
});
mInitialized = true;
}
@Override
public void onTaskAppeared(ActivityManager.RunningTaskInfo taskInfo,
SurfaceControl leash) {
if (isUsingShellTransitions()) {
// Everything else handled by enter transition.
return;
}
mTaskInfo = taskInfo;
mTaskToken = taskInfo.token;
mTaskLeash = leash;
if (mSurfaceCreated) {
// Surface is ready, so just reparent the task to this surface control
mTransaction.reparent(mTaskLeash, getSurfaceControl())
.show(mTaskLeash)
.apply();
} else {
// The surface has already been destroyed before the task has appeared,
// so go ahead and hide the task entirely
updateTaskVisibility();
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
mSurfaceCreated = false;
mShellExecutor.execute(() -> {
if (mTaskToken == null) {
// Nothing to update, task is not yet available
return;
}
if (isUsingShellTransitions()) {
mTaskViewTransitions.setTaskViewVisible(this, false /* visible */);
return;
}
// Unparent the task when this surface is destroyed
mTransaction.reparent(mTaskLeash, null).apply();
updateTaskVisibility();
});
}
@Override
public void onTaskVanished(ActivityManager.RunningTaskInfo taskInfo) {
// 与出现时不同,我们还不能保证消失会在我们知道的转换中发生——所以即使启用了 shell 转换,也请将清理留在这里。
if (mTaskToken == null || !mTaskToken.equals(taskInfo.token)) return;
if (mListener != null) {
final int taskId = taskInfo.taskId;
mListenerExecutor.execute(() -> {
mListener.onTaskRemovalStarted(taskId);
});
}
mTaskOrganizer.setInterceptBackPressedOnTaskRoot(mTaskToken, false);
// Unparent the task when this surface is destroyed
mTransaction.reparent(mTaskLeash, null).apply();
resetTaskInfo();
}