公文書
https://developer.android.com/develop/ui/views/notifications/bubbles#the_bubble_api
バブルを使用すると、ユーザーは会話を簡単に表示して参加できます。
バブルは通知システムに組み込まれています。それらは他のアプリ コンテンツの上に浮かび、ユーザーがどこにいてもフォローします。バブルは展開してアプリの機能と情報を表示でき、使用していないときは折りたたむことができます。
デバイスがロックされているとき、または常時表示がアクティブなときに、通常の通知のようにバブルが表示されます。
バブルはオプトアウト機能です。アプリが最初のバブルを表示すると、次の 2 つの選択肢を持つ許可ダイアログが表示されます。
- アプリ内のすべてのバブルをブロック - 通知はブロックされませんが、バブルとして表示されることはありません
- アプリですべてのバブルを許可 - BubbleMetaData を使用して送信されたすべての通知がバブルとして表示されます
https://developer.android.com/static/images/guide/topics/ui/bubbles-demo.mp4
バブル API
バブルは Notification API 経由で作成されるため、通常どおり通知を送信できます。通知をバブルとして表示する場合は、追加のデータを添付する必要があります。
選択したアクティビティに基づいて、バブルの展開ビューが作成されます。このアクティビティは、バブルとして正しく表示されるように構成する必要があります。アクティビティは、サイズ変更可能で埋め込まれている必要があります。アクティビティがこれらの要件を満たさない場合は常に、通知として表示されます。
次のコードは、単純なバブルを実装する方法を示しています。
<activity
android:name=".bubbles.BubbleActivity"
android:theme="@style/AppTheme.NoActionBar"
android:label="@string/title_activity_bubble"
android:allowEmbedded="true"
android:resizeableActivity="true"
/>
アプリケーションが同じタイプの複数のバブル (異なる連絡先との複数のチャット会話など) を表示する必要がある場合、このアクティビティは複数のインスタンスを開始できる必要があります。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();
バブル コンテンツのライフサイクル
バブルが展開されている場合、コンテンツ アクティビティは通常のプロセス ライフサイクルを完了し、アプリをフォアグラウンド プロセスに移行します (アプリがまだフォアグラウンドで実行されていない場合)。
バブルが崩壊または閉じている場合、システムはアクティビティを破棄します。これにより、アプリで他のフォアグラウンド コンポーネントが実行されているかどうかによっては、システムがプロセスをキャッシュしてから終了する場合があります。
いつバブルを表示するか
ユーザーへの混乱を減らすために、バブルは特定の状況でのみ表示されます。
アプリが Android 11 以降をターゲットにしている場合、ダイアログの要件を満たさない限り、通知はバブルとして表示されません。アプリが Android 10 をターゲットにしている場合、次の条件の 1 つ以上が満たされた場合にのみ、通知がバブルとして表示されます。
- 通知はMessagingStyleを使用し、Personを追加します。
- 通知は、カテゴリCATEGORY_CALLとPersonが追加されたService.startForegroundへの呼び出しから発生します。
- 通知が送信されるとき、アプリはフォアグラウンドで実行されています。
上記の条件のいずれも満たされていない場合、通知はバブルなしで表示されます。
ベストプラクティス
- バブルは画面の領域を占有し、他のアプリ コンテンツを覆い隠します。バブルを表示する必要性が高い場合 (進行中のコミュニケーションなど)、またはユーザーが何かのためにバブルを表示するように明示的に要求した場合にのみ、通知をバブルとして送信します。
- バブルはユーザーが無効にできることに注意してください。この場合、バブル通知は一般的な通知として表示されます。バブル通知が一般的な通知としても機能することを常に確認する必要があります。
- アクティビティやダイアログなど、バブルから開始されたプロセスは、バブル コンテナーに表示されます。これは、バブルがタスク スタックを持つことができることを意味します。バブルに多くの機能やナビゲーションがある場合、事態は複雑になる可能性があります。機能はできるだけ具体的かつ簡潔にすることをお勧めします。
- Bubbles アクティビティでonBackPressed をオーバーライドする場合は、必ず super.onBackPressed を呼び出してください。そうしないと、Bubbles が正しく機能しない可能性があります。
- バブルが折りたたまれた後に更新されたメッセージを受信すると、バブルには未読メッセージがあることを示すフラグ アイコンが表示されます。ユーザーが関連付けられたアプリでメッセージを開くと、次の手順に従います。
- 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);
} ......
}
左ページの 2 つの 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 */);
}
タスクビューを追加
// 在这里初始化{@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 オーダーされ、アクティブな IME とは独立して配置されます (通常、これは、IME の上で 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 Type{@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;
ルートレイアウト BubbleStackView を追加
フルスクリーンのバブル ウィンドウを画面に追加します。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();
}
}
タスクを追加して表示する
タスクを表示できるビュー。
- 初期化が完了したので、TaskView の surfacecontrol 配下に Task に対応する surfacecontrol を直接掛けて表示する
- それ以外の場合は、最初に対応するアクティビティを開始して初期化を完了し、Task が作成されるのを待って、onTaskAppeared メソッドの操作 1 を完了します。
@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();
}
onTaskAppeared コールバックのシーケンス図:
タスクを削除
@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();
}