Fancy animation for quick affordance option picker

Building on the companion WallpaperPicker2, this CL achieves a refactor
that allows the UI to actually animate user selection of quick
affordance items.

Part of this CL is a refactor that extracts out an "option item
framework" out into the WallpaperPicker2 code repository (please see the
other CL for thate).

Another part of this CL is a heavy refactor of
KeyguardQuickAffordancePickerViewModel which is the main view-model for
the "shortcuts" full-screen experience. Namely, we had to change it to
emit a stable list of option items (one for each lock screen shortcut)
where the _contents_ of it can change (namely, isSelected, onSelected
have both become flows). This required some careful restructuring of the
logic that populate the quickAffordances flow in that class.

Fix: 266116562
Test: existing view-model integration test updated to match new field
types, still passes without logical changes to the test
Test: manually verified the correctness of the UI in Walpaper & style >
Shortcuts
Test: manually verified animations: they do not happen when switching
tabs, they do not happen on initial load. They do happen when I select a
new affordance (both on the newly-selected one and the
previously-selected one).

Change-Id: I985afdfbbc72d0b98859df3378f2c90d2397e7d4
diff --git a/res/drawable/keyguard_quick_affordance_icon_container_background.xml b/res/drawable/keyguard_quick_affordance_icon_container_background.xml
deleted file mode 100644
index 8bd8af4..0000000
--- a/res/drawable/keyguard_quick_affordance_icon_container_background.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-<!--
-     Copyright (C) 2021 The Android Open Source Project
-
-     Licensed under the Apache License, Version 2.0 (the "License");
-     you may not use this file except in compliance with the License.
-     You may obtain a copy of the License at
-
-          http://www.apache.org/licenses/LICENSE-2.0
-
-     Unless required by applicable law or agreed to in writing, software
-     distributed under the License is distributed on an "AS IS" BASIS,
-     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-     See the License for the specific language governing permissions and
-     limitations under the License.
--->
-<shape xmlns:android="http://schemas.android.com/apk/res/android"
-    android:shape="rectangle">
-    <corners android:radius="20dp" />
-    <solid android:color="@color/color_surface_variant" />
-</shape>
diff --git a/res/drawable/keyguard_quick_affordance_icon_container_background_selected.xml b/res/drawable/keyguard_quick_affordance_icon_container_background_selected.xml
deleted file mode 100644
index 6b1ab28..0000000
--- a/res/drawable/keyguard_quick_affordance_icon_container_background_selected.xml
+++ /dev/null
@@ -1,48 +0,0 @@
-<!--
-     Copyright (C) 2021 The Android Open Source Project
-
-     Licensed under the Apache License, Version 2.0 (the "License");
-     you may not use this file except in compliance with the License.
-     You may obtain a copy of the License at
-
-          http://www.apache.org/licenses/LICENSE-2.0
-
-     Unless required by applicable law or agreed to in writing, software
-     distributed under the License is distributed on an "AS IS" BASIS,
-     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-     See the License for the specific language governing permissions and
-     limitations under the License.
--->
-<layer-list
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    >
-
-    <item
-        android:left="6dp"
-        android:top="6dp"
-        android:right="6dp"
-        android:bottom="6dp"
-        >
-        <shape
-            android:layout_width="wrap_content"
-            android:shape="rectangle">
-
-            <solid android:color="@color/color_surface_variant" />
-
-            <corners android:radius="14dp" />
-
-        </shape>
-    </item>
-
-    <item>
-        <shape android:shape="rectangle" >
-
-            <stroke
-                android:width="2dp"
-                android:color="@color/text_color_primary" />
-
-            <corners android:radius="20dp" />
-
-        </shape>
-    </item>
-</layer-list>
diff --git a/res/layout/fragment_lock_screen_quick_affordances.xml b/res/layout/fragment_lock_screen_quick_affordances.xml
index b167b5f..aba9a0c 100644
--- a/res/layout/fragment_lock_screen_quick_affordances.xml
+++ b/res/layout/fragment_lock_screen_quick_affordances.xml
@@ -18,7 +18,8 @@
 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    android:orientation="vertical">
+    android:orientation="vertical"
+    android:clipChildren="false">
 
     <FrameLayout
         android:id="@+id/section_header_container"
@@ -53,7 +54,8 @@
         android:layout_marginBottom="28dp"
         android:background="@drawable/picker_fragment_background"
         android:paddingTop="22dp"
-        android:paddingBottom="62dp">
+        android:paddingBottom="62dp"
+        android:clipChildren="false">
 
         <FrameLayout
             android:layout_width="match_parent"
@@ -91,14 +93,16 @@
 
         <FrameLayout
             android:layout_width="match_parent"
-            android:layout_height="wrap_content">
+            android:layout_height="wrap_content"
+            android:clipChildren="false">
 
             <androidx.recyclerview.widget.RecyclerView
                 android:id="@id/affordances"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:clipToPadding="false"
-                android:paddingHorizontal="16dp" />
+                android:paddingHorizontal="16dp"
+                android:clipChildren="false" />
 
             <!--
             This is just an invisible placeholder put in place so that the parent keeps its height
diff --git a/res/layout/keyguard_quick_affordance.xml b/res/layout/keyguard_quick_affordance.xml
index b3b6893..03b4e14 100644
--- a/res/layout/keyguard_quick_affordance.xml
+++ b/res/layout/keyguard_quick_affordance.xml
@@ -22,15 +22,31 @@
     android:layout_width="@dimen/keyguard_quick_affordance_picker_item_width"
     android:layout_height="wrap_content"
     android:orientation="vertical"
-    android:gravity="center_horizontal">
+    android:gravity="center_horizontal"
+    android:clipChildren="false">
 
     <FrameLayout
-        android:id="@+id/icon_container"
         android:layout_width="@dimen/keyguard_quick_affordance_icon_container_size"
-        android:layout_height="@dimen/keyguard_quick_affordance_icon_container_size" >
+        android:layout_height="@dimen/keyguard_quick_affordance_icon_container_size"
+        android:clipChildren="false">
 
         <ImageView
-            android:id="@+id/icon"
+            android:id="@id/selection_border"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:background="@drawable/option_item_border"
+            android:alpha="0"
+            android:importantForAccessibility="no" />
+
+        <ImageView
+            android:id="@id/background"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:background="@drawable/option_item_background"
+            android:importantForAccessibility="no" />
+
+        <ImageView
+            android:id="@id/foreground"
             android:layout_width="@dimen/keyguard_quick_affordance_icon_size"
             android:layout_height="@dimen/keyguard_quick_affordance_icon_size"
             android:layout_gravity="center"
@@ -43,7 +59,7 @@
         android:layout_height="8dp" />
 
     <TextView
-        android:id="@+id/name"
+        android:id="@id/text"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:textColor="@color/text_color_primary"
@@ -52,4 +68,4 @@
         android:text="Placeholder for stable size calculation, please do not remove."
         tools:ignore="HardcodedText" />
 
-</LinearLayout>
\ No newline at end of file
+</LinearLayout>
diff --git a/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt b/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt
index fbe303b..f154de6 100644
--- a/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt
+++ b/src/com/android/customization/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractor.kt
@@ -63,16 +63,6 @@
         snapshotRestorer.get().storeSnapshot()
     }
 
-    /** Unselects an affordance with the given ID from the slot with the given ID. */
-    suspend fun unselect(slotId: String, affordanceId: String) {
-        client.deleteSelection(
-            slotId = slotId,
-            affordanceId = affordanceId,
-        )
-
-        snapshotRestorer.get().storeSnapshot()
-    }
-
     /** Unselects all affordances from the slot with the given ID. */
     suspend fun unselectAll(slotId: String) {
         client.deleteAllSelections(
diff --git a/src/com/android/customization/picker/quickaffordance/ui/adapter/AffordancesAdapter.kt b/src/com/android/customization/picker/quickaffordance/ui/adapter/AffordancesAdapter.kt
deleted file mode 100644
index e11643a..0000000
--- a/src/com/android/customization/picker/quickaffordance/ui/adapter/AffordancesAdapter.kt
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- */
-
-package com.android.customization.picker.quickaffordance.ui.adapter
-
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.ImageView
-import android.widget.TextView
-import androidx.recyclerview.widget.RecyclerView
-import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceViewModel
-import com.android.wallpaper.R
-
-/** Adapts between lock screen quick affordance items and views. */
-class AffordancesAdapter : RecyclerView.Adapter<AffordancesAdapter.ViewHolder>() {
-
-    private val items = mutableListOf<KeyguardQuickAffordanceViewModel>()
-
-    fun setItems(items: List<KeyguardQuickAffordanceViewModel>) {
-        this.items.clear()
-        this.items.addAll(items)
-        notifyDataSetChanged()
-    }
-
-    class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
-        val iconContainerView: View = itemView.requireViewById(R.id.icon_container)
-        val iconView: ImageView = itemView.requireViewById(R.id.icon)
-        val nameView: TextView = itemView.requireViewById(R.id.name)
-    }
-
-    override fun getItemCount(): Int {
-        return items.size
-    }
-
-    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
-        return ViewHolder(
-            LayoutInflater.from(parent.context)
-                .inflate(
-                    R.layout.keyguard_quick_affordance,
-                    parent,
-                    false,
-                )
-        )
-    }
-
-    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
-        val item = items[position]
-        holder.itemView.alpha =
-            if (item.isEnabled) {
-                ALPHA_ENABLED
-            } else {
-                ALPHA_DISABLED
-            }
-
-        holder.itemView.setOnClickListener(
-            if (item.onClicked != null) {
-                View.OnClickListener { item.onClicked.invoke() }
-            } else {
-                null
-            }
-        )
-        holder.itemView.onLongClickListener =
-            if (item.onLongClicked != null) {
-                View.OnLongClickListener {
-                    item.onLongClicked.invoke()
-                    true
-                }
-            } else {
-                null
-            }
-        holder.iconContainerView.setBackgroundResource(
-            if (item.isSelected) {
-                R.drawable.keyguard_quick_affordance_icon_container_background_selected
-            } else {
-                R.drawable.keyguard_quick_affordance_icon_container_background
-            }
-        )
-        holder.iconView.isSelected = item.isSelected
-        holder.nameView.isSelected = item.isSelected
-        holder.iconView.setImageDrawable(item.icon)
-        holder.nameView.text = item.contentDescription
-        holder.nameView.isSelected = item.isSelected
-    }
-
-    companion object {
-        private const val ALPHA_ENABLED = 1f
-        private const val ALPHA_DISABLED = 0.3f
-    }
-}
diff --git a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt
index efa090b..f03fda3 100644
--- a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt
+++ b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordancePickerBinder.kt
@@ -27,16 +27,20 @@
 import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
 import com.android.customization.picker.common.ui.view.ItemSpacing
-import com.android.customization.picker.quickaffordance.ui.adapter.AffordancesAdapter
 import com.android.customization.picker.quickaffordance.ui.adapter.SlotTabAdapter
 import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel
 import com.android.wallpaper.R
 import com.android.wallpaper.picker.common.dialog.ui.viewbinder.DialogViewBinder
 import com.android.wallpaper.picker.common.dialog.ui.viewmodel.DialogViewModel
+import com.android.wallpaper.picker.option.ui.adapter.OptionItemAdapter
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.launch
 
+@OptIn(ExperimentalCoroutinesApi::class)
 object KeyguardQuickAffordancePickerBinder {
 
     /** Binds view with view-model for a lock screen quick affordance picker experience. */
@@ -54,7 +58,11 @@
         slotTabView.layoutManager =
             LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false)
         slotTabView.addItemDecoration(ItemSpacing(ItemSpacing.TAB_ITEM_SPACING_DP))
-        val affordancesAdapter = AffordancesAdapter()
+        val affordancesAdapter =
+            OptionItemAdapter(
+                layoutResourceId = R.layout.keyguard_quick_affordance,
+                lifecycleOwner = lifecycleOwner,
+            )
         affordancesView.adapter = affordancesAdapter
         affordancesView.layoutManager =
             LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false)
@@ -73,17 +81,27 @@
                 launch {
                     viewModel.quickAffordances.collect { affordances ->
                         affordancesAdapter.setItems(affordances)
+                    }
+                }
 
-                        // Scroll the view to show the first selected affordance.
-                        val selectedPosition = affordances.indexOfFirst { it.isSelected }
-                        if (selectedPosition != -1) {
-                            // We use "post" because we need to give the adapter item a pass to
-                            // update the view.
-                            affordancesView.post {
-                                affordancesView.smoothScrollToPosition(selectedPosition)
+                launch {
+                    viewModel.quickAffordances
+                        .flatMapLatest { affordances ->
+                            combine(affordances.map { affordance -> affordance.isSelected }) {
+                                selectedFlags ->
+                                selectedFlags.indexOfFirst { it }
                             }
                         }
-                    }
+                        .collect { selectedPosition ->
+                            // Scroll the view to show the first selected affordance.
+                            if (selectedPosition != -1) {
+                                // We use "post" because we need to give the adapter item a pass to
+                                // update the view.
+                                affordancesView.post {
+                                    affordancesView.smoothScrollToPosition(selectedPosition)
+                                }
+                            }
+                        }
                 }
 
                 launch {
diff --git a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceSectionViewBinder.kt b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceSectionViewBinder.kt
index c8880b9..28ad51a 100644
--- a/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceSectionViewBinder.kt
+++ b/src/com/android/customization/picker/quickaffordance/ui/binder/KeyguardQuickAffordanceSectionViewBinder.kt
@@ -27,6 +27,8 @@
 import androidx.lifecycle.lifecycleScope
 import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel
 import com.android.wallpaper.R
+import com.android.wallpaper.picker.common.icon.ui.viewbinder.IconViewBinder
+import com.android.wallpaper.picker.common.text.ui.viewbinder.TextViewBinder
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
 
@@ -48,12 +50,25 @@
             viewModel.summary
                 .flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.RESUMED)
                 .collectLatest { summary ->
-                    descriptionView.text = summary.description
+                    TextViewBinder.bind(
+                        view = descriptionView,
+                        viewModel = summary.description,
+                    )
 
-                    icon1.setImageDrawable(summary.icon1)
+                    if (summary.icon1 != null) {
+                        IconViewBinder.bind(
+                            view = icon1,
+                            viewModel = summary.icon1,
+                        )
+                    }
                     icon1.isVisible = summary.icon1 != null
 
-                    icon2.setImageDrawable(summary.icon2)
+                    if (summary.icon2 != null) {
+                        IconViewBinder.bind(
+                            view = icon2,
+                            viewModel = summary.icon2,
+                        )
+                    }
                     icon2.isVisible = summary.icon2 != null
                 }
         }
diff --git a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt
index 39feec6..d88edfa 100644
--- a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt
+++ b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModel.kt
@@ -38,14 +38,19 @@
 import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon
 import com.android.wallpaper.picker.common.text.ui.viewmodel.Text
 import com.android.wallpaper.picker.customization.ui.viewmodel.ScreenPreviewViewModel
+import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel
 import com.android.wallpaper.util.PreviewUtils
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.suspendCancellableCoroutine
 
@@ -95,8 +100,27 @@
             },
         )
 
+    /** A locally-selected slot, if the user ever switched from the original one. */
     private val _selectedSlotId = MutableStateFlow<String?>(null)
-    val selectedSlotId: StateFlow<String?> = _selectedSlotId.asStateFlow()
+    /** The ID of the selected slot. */
+    val selectedSlotId: StateFlow<String> =
+        combine(
+                quickAffordanceInteractor.slots,
+                _selectedSlotId,
+            ) { slots, selectedSlotIdOrNull ->
+                if (selectedSlotIdOrNull != null) {
+                    slots.first { slot -> slot.id == selectedSlotIdOrNull }
+                } else {
+                    // If we haven't yet selected a new slot locally, default to the first slot.
+                    slots[0]
+                }
+            }
+            .map { selectedSlot -> selectedSlot.id }
+            .stateIn(
+                scope = viewModelScope,
+                started = SharingStarted.WhileSubscribed(),
+                initialValue = "",
+            )
 
     /** View-models for each slot, keyed by slot ID. */
     val slots: Flow<Map<String, KeyguardQuickAffordanceSlotViewModel>> =
@@ -105,95 +129,125 @@
             quickAffordanceInteractor.affordances,
             quickAffordanceInteractor.selections,
             selectedSlotId,
-        ) { slots, affordances, selections, selectedSlotIdOrNull ->
-            slots
-                .mapIndexed { index, slot ->
-                    val selectedAffordanceIds =
-                        selections
-                            .filter { selection -> selection.slotId == slot.id }
-                            .map { selection -> selection.affordanceId }
-                            .toSet()
-                    val selectedAffordances =
-                        affordances.filter { affordance ->
-                            selectedAffordanceIds.contains(affordance.id)
-                        }
-                    val isSelected =
-                        (selectedSlotIdOrNull == null && index == 0) ||
-                            selectedSlotIdOrNull == slot.id
-                    slot.id to
-                        KeyguardQuickAffordanceSlotViewModel(
-                            name = getSlotName(slot.id),
-                            isSelected = isSelected,
-                            selectedQuickAffordances =
-                                selectedAffordances.map { affordanceModel ->
-                                    KeyguardQuickAffordanceViewModel(
-                                        icon = getAffordanceIcon(affordanceModel.iconResourceId),
-                                        contentDescription = affordanceModel.name,
-                                        isSelected = true,
-                                        onClicked = null,
-                                        onLongClicked = null,
-                                        isEnabled = affordanceModel.isEnabled,
-                                    )
-                                },
-                            maxSelectedQuickAffordances = slot.maxSelectedQuickAffordances,
-                            onClicked =
-                                if (isSelected) {
-                                    null
-                                } else {
-                                    { _selectedSlotId.tryEmit(slot.id) }
-                                },
-                        )
-                }
-                .toMap()
+        ) { slots, affordances, selections, selectedSlotId ->
+            slots.associate { slot ->
+                val selectedAffordanceIds =
+                    selections
+                        .filter { selection -> selection.slotId == slot.id }
+                        .map { selection -> selection.affordanceId }
+                        .toSet()
+                val selectedAffordances =
+                    affordances.filter { affordance ->
+                        selectedAffordanceIds.contains(affordance.id)
+                    }
+                val isSelected = selectedSlotId == slot.id
+                slot.id to
+                    KeyguardQuickAffordanceSlotViewModel(
+                        name = getSlotName(slot.id),
+                        isSelected = isSelected,
+                        selectedQuickAffordances =
+                            selectedAffordances.map { affordanceModel ->
+                                OptionItemViewModel(
+                                    key = flowOf("${slot.id}::${affordanceModel.id}"),
+                                    icon =
+                                        Icon.Loaded(
+                                            drawable =
+                                                getAffordanceIcon(affordanceModel.iconResourceId),
+                                            contentDescription = null,
+                                        ),
+                                    text = Text.Loaded(affordanceModel.name),
+                                    isSelected = flowOf(true),
+                                    onClicked = flowOf(null),
+                                    onLongClicked = null,
+                                    isEnabled = true,
+                                )
+                            },
+                        maxSelectedQuickAffordances = slot.maxSelectedQuickAffordances,
+                        onClicked =
+                            if (isSelected) {
+                                null
+                            } else {
+                                { _selectedSlotId.tryEmit(slot.id) }
+                            },
+                    )
+            }
         }
 
-    /** The list of all available quick affordances for the selected slot. */
-    val quickAffordances: Flow<List<KeyguardQuickAffordanceViewModel>> =
+    /**
+     * The set of IDs of the currently-selected affordances. These change with user selection of new
+     * or different affordances in the currently-selected slot or when slot selection changes.
+     */
+    private val selectedAffordanceIds: Flow<Set<String>> =
         combine(
-            quickAffordanceInteractor.slots,
-            quickAffordanceInteractor.affordances,
-            quickAffordanceInteractor.selections,
-            selectedSlotId,
-        ) { slots, affordances, selections, selectedSlotIdOrNull ->
-            val selectedSlot =
-                selectedSlotIdOrNull?.let { slots.find { slot -> slot.id == it } } ?: slots.first()
-            val selectedAffordanceIds =
+                quickAffordanceInteractor.selections,
+                selectedSlotId,
+            ) { selections, selectedSlotId ->
                 selections
-                    .filter { selection -> selection.slotId == selectedSlot.id }
+                    .filter { selection -> selection.slotId == selectedSlotId }
                     .map { selection -> selection.affordanceId }
                     .toSet()
+            }
+            .shareIn(
+                scope = viewModelScope,
+                started = SharingStarted.WhileSubscribed(),
+                replay = 1,
+            )
+
+    /** The list of all available quick affordances for the selected slot. */
+    val quickAffordances: Flow<List<OptionItemViewModel>> =
+        quickAffordanceInteractor.affordances.map { affordances ->
+            val isNoneSelected = selectedAffordanceIds.map { it.isEmpty() }
             listOf(
                 none(
-                    slotId = selectedSlot.id,
-                    isSelected = selectedAffordanceIds.isEmpty(),
-                )
-            ) +
-                affordances.map { affordance ->
-                    val isSelected = selectedAffordanceIds.contains(affordance.id)
-                    val affordanceIcon = getAffordanceIcon(affordance.iconResourceId)
-                    KeyguardQuickAffordanceViewModel(
-                        icon = affordanceIcon,
-                        contentDescription = affordance.name,
-                        isSelected = isSelected,
-                        onClicked =
-                            if (affordance.isEnabled) {
+                    slotId = selectedSlotId,
+                    isSelected = isNoneSelected,
+                    onSelected =
+                        combine(
+                            isNoneSelected,
+                            selectedSlotId,
+                        ) { isSelected, selectedSlotId ->
+                            if (!isSelected) {
                                 {
                                     viewModelScope.launch {
-                                        if (isSelected) {
-                                            quickAffordanceInteractor.unselect(
-                                                slotId = selectedSlot.id,
-                                                affordanceId = affordance.id,
-                                            )
-                                        } else {
-                                            quickAffordanceInteractor.select(
-                                                slotId = selectedSlot.id,
-                                                affordanceId = affordance.id,
-                                            )
-                                        }
+                                        quickAffordanceInteractor.unselectAll(selectedSlotId)
                                     }
                                 }
                             } else {
-                                {
+                                null
+                            }
+                        }
+                )
+            ) +
+                affordances.map { affordance ->
+                    val affordanceIcon = getAffordanceIcon(affordance.iconResourceId)
+                    val isSelectedFlow: Flow<Boolean> =
+                        selectedAffordanceIds.map { it.contains(affordance.id) }
+                    OptionItemViewModel(
+                        key = selectedSlotId.map { slotId -> "$slotId::${affordance.id}" },
+                        icon = Icon.Loaded(drawable = affordanceIcon, contentDescription = null),
+                        text = Text.Loaded(affordance.name),
+                        isSelected = isSelectedFlow,
+                        onClicked =
+                            if (affordance.isEnabled) {
+                                combine(
+                                    isSelectedFlow,
+                                    selectedSlotId,
+                                ) { isSelected, selectedSlotId ->
+                                    if (!isSelected) {
+                                        {
+                                            viewModelScope.launch {
+                                                quickAffordanceInteractor.select(
+                                                    slotId = selectedSlotId,
+                                                    affordanceId = affordance.id,
+                                                )
+                                            }
+                                        }
+                                    } else {
+                                        null
+                                    }
+                                }
+                            } else {
+                                flowOf {
                                     showEnablementDialog(
                                         icon = affordanceIcon,
                                         name = affordance.name,
@@ -233,7 +287,10 @@
                 description = toDescriptionText(context, slots),
                 icon1 = icon1
                         ?: if (icon2 == null) {
-                            context.getDrawable(R.drawable.link_off)
+                            Icon.Resource(
+                                res = R.drawable.link_off,
+                                contentDescription = null,
+                            )
                         } else {
                             null
                         },
@@ -300,17 +357,21 @@
             )
     }
 
+    /** Returns a view-model for the special "None" option. */
     @SuppressLint("UseCompatLoadingForDrawables")
     private fun none(
-        slotId: String,
-        isSelected: Boolean,
-    ): KeyguardQuickAffordanceViewModel {
-        return KeyguardQuickAffordanceViewModel.none(
-            context = applicationContext,
+        slotId: Flow<String>,
+        isSelected: Flow<Boolean>,
+        onSelected: Flow<(() -> Unit)?>,
+    ): OptionItemViewModel {
+        return OptionItemViewModel(
+            key = slotId.map { "$it::none" },
+            icon = Icon.Resource(res = R.drawable.link_off, contentDescription = null),
+            text = Text.Resource(res = R.string.keyguard_affordance_none),
             isSelected = isSelected,
-            onSelected = {
-                viewModelScope.launch { quickAffordanceInteractor.unselectAll(slotId) }
-            },
+            onClicked = onSelected,
+            onLongClicked = null,
+            isEnabled = true,
         )
     }
 
@@ -356,30 +417,31 @@
     private fun toDescriptionText(
         context: Context,
         slots: Map<String, KeyguardQuickAffordanceSlotViewModel>,
-    ): String {
+    ): Text {
         val bottomStartAffordanceName =
             slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START]
                 ?.selectedQuickAffordances
                 ?.firstOrNull()
-                ?.contentDescription
+                ?.text
         val bottomEndAffordanceName =
             slots[KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END]
                 ?.selectedQuickAffordances
                 ?.firstOrNull()
-                ?.contentDescription
+                ?.text
 
         return when {
-            !bottomStartAffordanceName.isNullOrEmpty() &&
-                !bottomEndAffordanceName.isNullOrEmpty() -> {
-                context.getString(
-                    R.string.keyguard_quick_affordance_two_selected_template,
-                    bottomStartAffordanceName,
-                    bottomEndAffordanceName,
+            bottomStartAffordanceName != null && bottomEndAffordanceName != null -> {
+                Text.Loaded(
+                    context.getString(
+                        R.string.keyguard_quick_affordance_two_selected_template,
+                        bottomStartAffordanceName.asString(context),
+                        bottomEndAffordanceName.asString(context),
+                    )
                 )
             }
-            !bottomStartAffordanceName.isNullOrEmpty() -> bottomStartAffordanceName
-            !bottomEndAffordanceName.isNullOrEmpty() -> bottomEndAffordanceName
-            else -> context.getString(R.string.keyguard_quick_affordance_none_selected)
+            bottomStartAffordanceName != null -> bottomStartAffordanceName
+            bottomEndAffordanceName != null -> bottomEndAffordanceName
+            else -> Text.Resource(R.string.keyguard_quick_affordance_none_selected)
         }
     }
 
diff --git a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt
index bb9b29b..6d8195a 100644
--- a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt
+++ b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSlotViewModel.kt
@@ -17,6 +17,8 @@
 
 package com.android.customization.picker.quickaffordance.ui.viewmodel
 
+import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel
+
 /** Models UI state for a single lock screen quick affordance slot in a picker experience. */
 data class KeyguardQuickAffordanceSlotViewModel(
     /** User-visible name for the slot. */
@@ -30,7 +32,7 @@
      *
      * Useful for preview.
      */
-    val selectedQuickAffordances: List<KeyguardQuickAffordanceViewModel>,
+    val selectedQuickAffordances: List<OptionItemViewModel>,
 
     /**
      * The maximum number of quick affordances that can be selected for this slot.
diff --git a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSummaryViewModel.kt b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSummaryViewModel.kt
index d5fc79b..ee89d3e 100644
--- a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSummaryViewModel.kt
+++ b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceSummaryViewModel.kt
@@ -17,10 +17,11 @@
 
 package com.android.customization.picker.quickaffordance.ui.viewmodel
 
-import android.graphics.drawable.Drawable
+import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon
+import com.android.wallpaper.picker.common.text.ui.viewmodel.Text
 
 data class KeyguardQuickAffordanceSummaryViewModel(
-    val description: String,
-    val icon1: Drawable?,
-    val icon2: Drawable?,
+    val description: Text,
+    val icon1: Icon?,
+    val icon2: Icon?,
 )
diff --git a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceViewModel.kt b/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceViewModel.kt
deleted file mode 100644
index 8f6d327..0000000
--- a/src/com/android/customization/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordanceViewModel.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- */
-
-package com.android.customization.picker.quickaffordance.ui.viewmodel
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.graphics.drawable.Drawable
-import com.android.wallpaper.R
-
-/** Models UI state for a single lock screen quick affordance in a picker experience. */
-data class KeyguardQuickAffordanceViewModel(
-    /** An icon for the quick affordance. */
-    val icon: Drawable,
-
-    /** A content description for the icon. */
-    val contentDescription: String,
-
-    /** Whether this quick affordance is selected in its slot. */
-    val isSelected: Boolean,
-
-    /** Whether this quick affordance is enabled. */
-    val isEnabled: Boolean,
-
-    /** Notifies that the quick affordance has been clicked by the user. */
-    val onClicked: (() -> Unit)?,
-
-    /** Notifies that the quick affordance has been long-clicked by the user. */
-    val onLongClicked: (() -> Unit)?,
-) {
-    companion object {
-        @SuppressLint("UseCompatLoadingForDrawables")
-        fun none(
-            context: Context,
-            isSelected: Boolean,
-            onSelected: () -> Unit,
-        ): KeyguardQuickAffordanceViewModel {
-            return KeyguardQuickAffordanceViewModel(
-                icon = checkNotNull(context.getDrawable(R.drawable.link_off)),
-                contentDescription = context.getString(R.string.keyguard_affordance_none),
-                isSelected = isSelected,
-                onClicked =
-                    if (isSelected) {
-                        null
-                    } else {
-                        onSelected
-                    },
-                onLongClicked = null,
-                isEnabled = true,
-            )
-        }
-    }
-}
diff --git a/tests/src/com/android/customization/model/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractorTest.kt b/tests/src/com/android/customization/model/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractorTest.kt
index 4879fb0..fea94dc 100644
--- a/tests/src/com/android/customization/model/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractorTest.kt
+++ b/tests/src/com/android/customization/model/picker/quickaffordance/domain/interactor/KeyguardQuickAffordancePickerInteractorTest.kt
@@ -115,23 +115,6 @@
         }
 
     @Test
-    fun unselect() =
-        testScope.runTest {
-            val selections = collectLastValue(underTest.selections)
-            underTest.select(
-                slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START,
-                affordanceId = FakeCustomizationProviderClient.AFFORDANCE_1,
-            )
-
-            underTest.unselect(
-                slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START,
-                affordanceId = FakeCustomizationProviderClient.AFFORDANCE_1,
-            )
-
-            assertThat(selections()).isEmpty()
-        }
-
-    @Test
     fun unselectAll() =
         testScope.runTest {
             client.setSlotCapacity(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END, 3)
diff --git a/tests/src/com/android/customization/model/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModelTest.kt b/tests/src/com/android/customization/model/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModelTest.kt
index fd03285..6044724 100644
--- a/tests/src/com/android/customization/model/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModelTest.kt
+++ b/tests/src/com/android/customization/model/picker/quickaffordance/ui/viewmodel/KeyguardQuickAffordancePickerViewModelTest.kt
@@ -27,13 +27,14 @@
 import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordancePickerViewModel
 import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceSlotViewModel
 import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceSummaryViewModel
-import com.android.customization.picker.quickaffordance.ui.viewmodel.KeyguardQuickAffordanceViewModel
 import com.android.systemui.shared.customization.data.content.CustomizationProviderClient
 import com.android.systemui.shared.customization.data.content.FakeCustomizationProviderClient
 import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots
+import com.android.wallpaper.R
 import com.android.wallpaper.module.InjectorProvider
 import com.android.wallpaper.picker.common.icon.ui.viewmodel.Icon
 import com.android.wallpaper.picker.common.text.ui.viewmodel.Text
+import com.android.wallpaper.picker.option.ui.viewmodel.OptionItemViewModel
 import com.android.wallpaper.testing.FakeSnapshotStore
 import com.android.wallpaper.testing.TestCurrentWallpaperInfoFactory
 import com.android.wallpaper.testing.TestInjector
@@ -131,7 +132,7 @@
             )
 
             // Select "affordance 1" for the first slot.
-            quickAffordances()?.get(1)?.onClicked?.invoke()
+            selectAffordance(quickAffordances, 1)
             assertPickerUiState(
                 slots = slots(),
                 affordances = quickAffordances(),
@@ -152,7 +153,7 @@
             // First, switch to the second slot:
             slots()?.get(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END)?.onClicked?.invoke()
             // Second, select the "affordance 3" affordance:
-            quickAffordances()?.get(3)?.onClicked?.invoke()
+            selectAffordance(quickAffordances, 3)
             assertPickerUiState(
                 slots = slots(),
                 affordances = quickAffordances(),
@@ -171,7 +172,7 @@
             )
 
             // Select a different affordance for the second slot.
-            quickAffordances()?.get(2)?.onClicked?.invoke()
+            selectAffordance(quickAffordances, 2)
             assertPickerUiState(
                 slots = slots(),
                 affordances = quickAffordances(),
@@ -197,17 +198,17 @@
             val quickAffordances = collectLastValue(underTest.quickAffordances)
 
             // Select "affordance 1" for the first slot.
-            quickAffordances()?.get(1)?.onClicked?.invoke()
+            selectAffordance(quickAffordances, 1)
             // Select an affordance for the second slot.
             // First, switch to the second slot:
             slots()?.get(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END)?.onClicked?.invoke()
             // Second, select the "affordance 3" affordance:
-            quickAffordances()?.get(3)?.onClicked?.invoke()
+            selectAffordance(quickAffordances, 3)
 
             // Switch back to the first slot:
             slots()?.get(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START)?.onClicked?.invoke()
             // Select the "none" affordance, which is always in position 0:
-            quickAffordances()?.get(0)?.onClicked?.invoke()
+            selectAffordance(quickAffordances, 0)
 
             assertPickerUiState(
                 slots = slots(),
@@ -253,7 +254,7 @@
                 )
 
             // Lets try to select that disabled affordance:
-            quickAffordances()?.get(affordanceIndex + 1)?.onClicked?.invoke()
+            selectAffordance(quickAffordances, affordanceIndex + 1)
 
             // We expect there to be a dialog that should be shown:
             assertThat(dialog()?.icon)
@@ -304,21 +305,23 @@
             val summary = collectLastValue(underTest.summary)
 
             // Select "affordance 1" for the first slot.
-            quickAffordances()?.get(1)?.onClicked?.invoke()
+            selectAffordance(quickAffordances, 1)
             // Select an affordance for the second slot.
             // First, switch to the second slot:
             slots()?.get(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END)?.onClicked?.invoke()
             // Second, select the "affordance 3" affordance:
-            quickAffordances()?.get(3)?.onClicked?.invoke()
+            selectAffordance(quickAffordances, 3)
 
             assertThat(summary())
                 .isEqualTo(
                     KeyguardQuickAffordanceSummaryViewModel(
                         description =
-                            "${FakeCustomizationProviderClient.AFFORDANCE_1}," +
-                                " ${FakeCustomizationProviderClient.AFFORDANCE_3}",
-                        icon1 = FakeCustomizationProviderClient.ICON_1,
-                        icon2 = FakeCustomizationProviderClient.ICON_3,
+                            Text.Loaded(
+                                "${FakeCustomizationProviderClient.AFFORDANCE_1}," +
+                                    " ${FakeCustomizationProviderClient.AFFORDANCE_3}"
+                            ),
+                        icon1 = Icon.Loaded(FakeCustomizationProviderClient.ICON_1, null),
+                        icon2 = Icon.Loaded(FakeCustomizationProviderClient.ICON_3, null),
                     )
                 )
         }
@@ -331,13 +334,13 @@
             val summary = collectLastValue(underTest.summary)
 
             // Select "affordance 1" for the first slot.
-            quickAffordances()?.get(1)?.onClicked?.invoke()
+            selectAffordance(quickAffordances, 1)
 
             assertThat(summary())
                 .isEqualTo(
                     KeyguardQuickAffordanceSummaryViewModel(
-                        description = FakeCustomizationProviderClient.AFFORDANCE_1,
-                        icon1 = FakeCustomizationProviderClient.ICON_1,
+                        description = Text.Loaded(FakeCustomizationProviderClient.AFFORDANCE_1),
+                        icon1 = Icon.Loaded(FakeCustomizationProviderClient.ICON_1, null),
                         icon2 = null,
                     )
                 )
@@ -354,14 +357,14 @@
             // First, switch to the second slot:
             slots()?.get(KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_END)?.onClicked?.invoke()
             // Second, select the "affordance 3" affordance:
-            quickAffordances()?.get(3)?.onClicked?.invoke()
+            selectAffordance(quickAffordances, 3)
 
             assertThat(summary())
                 .isEqualTo(
                     KeyguardQuickAffordanceSummaryViewModel(
-                        description = FakeCustomizationProviderClient.AFFORDANCE_3,
+                        description = Text.Loaded(FakeCustomizationProviderClient.AFFORDANCE_3),
                         icon1 = null,
-                        icon2 = FakeCustomizationProviderClient.ICON_3,
+                        icon2 = Icon.Loaded(FakeCustomizationProviderClient.ICON_3, null),
                     )
                 )
         }
@@ -369,15 +372,28 @@
     @Test
     fun `summary - no affordances selected`() =
         testScope.runTest {
-            val slots = collectLastValue(underTest.slots)
-            val quickAffordances = collectLastValue(underTest.quickAffordances)
             val summary = collectLastValue(underTest.summary)
 
-            assertThat(summary()?.description).isEqualTo("None")
+            assertThat(summary()?.description)
+                .isEqualTo(Text.Resource(R.string.keyguard_quick_affordance_none_selected))
             assertThat(summary()?.icon1).isNotNull()
             assertThat(summary()?.icon2).isNull()
         }
 
+    /** Simulates a user selecting the affordance at the given index, if that is clickable. */
+    private fun TestScope.selectAffordance(
+        affordances: () -> List<OptionItemViewModel>?,
+        index: Int,
+    ) {
+        val onClickedFlow = affordances()?.get(index)?.onClicked
+        val onClickedLastValueOrNull: (() -> (() -> Unit)?)? =
+            onClickedFlow?.let { collectLastValue(it) }
+        onClickedLastValueOrNull?.let { onClickedLastValue ->
+            val onClickedOrNull: (() -> Unit)? = onClickedLastValue()
+            onClickedOrNull?.let { onClicked -> onClicked() }
+        }
+    }
+
     /**
      * Asserts the entire picker UI state is what is expected. This includes the slot tabs and the
      * affordance list.
@@ -387,9 +403,9 @@
      * @param selectedSlotText The text of the slot that's expected to be selected
      * @param selectedAffordanceText The text of the affordance that's expected to be selected
      */
-    private fun assertPickerUiState(
+    private fun TestScope.assertPickerUiState(
         slots: Map<String, KeyguardQuickAffordanceSlotViewModel>?,
-        affordances: List<KeyguardQuickAffordanceViewModel>?,
+        affordances: List<OptionItemViewModel>?,
         selectedSlotText: String,
         selectedAffordanceText: String,
     ) {
@@ -407,12 +423,18 @@
         var foundSelectedAffordance = false
         assertThat(affordances).isNotNull()
         affordances?.forEach { affordance ->
-            val nameMatchesSelectedName = affordance.contentDescription == selectedAffordanceText
-            assertWithMessage(
-                    "Expected affordance with name \"${affordance.contentDescription}\" to have" +
-                        " isSelected=$nameMatchesSelectedName but it was ${affordance.isSelected}"
+            val nameMatchesSelectedName =
+                Text.evaluationEquals(
+                    context,
+                    affordance.text,
+                    Text.Loaded(selectedAffordanceText),
                 )
-                .that(affordance.isSelected)
+            val isSelected: Boolean? = collectLastValue(affordance.isSelected).invoke()
+            assertWithMessage(
+                    "Expected affordance with name \"${affordance.text}\" to have" +
+                        " isSelected=$nameMatchesSelectedName but it was $isSelected"
+                )
+                .that(isSelected)
                 .isEqualTo(nameMatchesSelectedName)
             foundSelectedAffordance = foundSelectedAffordance || nameMatchesSelectedName
         }
@@ -449,13 +471,12 @@
         assertThat(slots).isNotNull()
         slots?.forEach { (slotId, slotViewModel) ->
             val expectedAffordanceName = expectedAffordanceNameBySlotId[slotId]
-            val actualAffordanceName =
-                slotViewModel.selectedQuickAffordances.firstOrNull()?.contentDescription
+            val actualAffordanceName = slotViewModel.selectedQuickAffordances.firstOrNull()?.text
             assertWithMessage(
                     "At slotId=\"$slotId\", expected affordance=\"$expectedAffordanceName\" but" +
-                        " was \"$actualAffordanceName\"!"
+                        " was \"${actualAffordanceName?.asString(context)}\"!"
                 )
-                .that(actualAffordanceName)
+                .that(actualAffordanceName?.asString(context))
                 .isEqualTo(expectedAffordanceName)
         }
     }