Merge changes from topic "picker-a-dnd" into main

* changes:
  Listen to and handle drag and drop for homescreen and lockscreen widgets in widget picker activity wrapper.
  Implement tap-to-add and drag and drop in the compose widget picker
  Pass down the widget interaction callback and flag on whether to support drag shadow
  Add widget picker drag and add item listeners.
This commit is contained in:
Shamali Patwa
2025-06-02 21:21:18 -07:00
committed by Android (Google) Code Review
34 changed files with 1458 additions and 129 deletions
@@ -17,6 +17,8 @@
package com.android.launcher3.compose.core.widgetpicker
import com.android.launcher3.widgetpicker.WidgetPickerActivity
import com.android.launcher3.widgetpicker.WidgetPickerConfig
import javax.annotation.Nonnull
import javax.inject.Inject
/**
@@ -24,7 +26,11 @@ import javax.inject.Inject
* widget picker in [WidgetPickerActivity] when compose is enabled via build flag.
*/
interface WidgetPickerComposeWrapper {
fun showAllWidgets(activity: WidgetPickerActivity)
fun showAllWidgets(
activity: WidgetPickerActivity,
@Nonnull
widgetPickerConfig: WidgetPickerConfig
)
}
/**
@@ -32,7 +38,11 @@ interface WidgetPickerComposeWrapper {
* don't involve widget picker e.g. launcher preview OR when compose is disabled via build flag.
*/
class NoOpWidgetPickerComposeWrapper @Inject constructor() : WidgetPickerComposeWrapper {
override fun showAllWidgets(activity: WidgetPickerActivity) {
override fun showAllWidgets(
activity: WidgetPickerActivity,
@Nonnull
widgetPickerConfig: WidgetPickerConfig
) {
error("Widget picker with compose is not supported")
}
}
@@ -16,25 +16,32 @@
package com.android.launcher3.widgetpicker
import android.app.Activity.RESULT_OK
import android.content.Context
import android.content.Intent
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalView
import com.android.launcher3.Launcher
import com.android.launcher3.R
import com.android.launcher3.widgetpicker.WidgetPickerActivity
import com.android.launcher3.compose.ComposeFacade
import com.android.launcher3.compose.core.widgetpicker.WidgetPickerComposeWrapper
import com.android.launcher3.concurrent.annotations.BackgroundContext
import com.android.launcher3.dagger.ApplicationContext
import com.android.launcher3.widgetpicker.WidgetPickerComponent
import com.android.launcher3.widgetpicker.WidgetPickerEventListeners
import com.android.launcher3.util.ApiWrapper
import com.android.launcher3.widgetpicker.WidgetPickerConfig.Companion.EXTRA_IS_PENDING_WIDGET_DRAG
import com.android.launcher3.widgetpicker.WidgetPickerConfig.Companion.asHostConstraints
import com.android.launcher3.widgetpicker.data.repository.WidgetAppIconsRepository
import com.android.launcher3.widgetpicker.data.repository.WidgetUsersRepository
import com.android.launcher3.widgetpicker.data.repository.WidgetsRepository
import com.android.launcher3.widgetpicker.listeners.WidgetPickerAddItemListener
import com.android.launcher3.widgetpicker.listeners.WidgetPickerDragItemListener
import com.android.launcher3.widgetpicker.shared.model.WidgetHostInfo
import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo
import com.android.launcher3.widgetpicker.ui.WidgetPickerEventListeners
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Provider
@@ -42,7 +49,7 @@ import kotlin.coroutines.CoroutineContext
/**
* An helper that bootstraps widget picker UI (from [WidgetPickerComponent]) in to
* [WidgetPickerActivity].
* [WidgetPickerActivity] when compose is available and widget picker refactor flags are on.
*
* Sets up the bindings necessary for widget picker component.
*/
@@ -55,19 +62,19 @@ class WidgetPickerComposeWrapperImpl @Inject constructor(
private val backgroundContext: CoroutineContext,
@ApplicationContext
private val appContext: Context,
private val apiWrapper: ApiWrapper,
) : WidgetPickerComposeWrapper {
override fun showAllWidgets(
activity: WidgetPickerActivity,
widgetPickerConfig: WidgetPickerConfig
) {
val widgetPickerComponent = newWidgetPickerComponent()
val callbacks = object : WidgetPickerEventListeners {
override fun onClose() {
activity.finish()
}
}
val widgetPickerComponent = newWidgetPickerComponent(widgetPickerConfig)
val callbacks = activity.buildEventListeners(widgetPickerConfig, apiWrapper)
val fullWidgetsCatalog = widgetPickerComponent.getFullWidgetsCatalog()
val composeView = ComposeFacade.initComposeView(activity.asContext()) as ComposeView
composeView.apply {
setContent {
val scope = rememberCoroutineScope()
@@ -90,20 +97,27 @@ class WidgetPickerComposeWrapperImpl @Inject constructor(
}
}
activity.dragLayer?.addView(composeView)
checkNotNull(activity.dragLayer).addView(composeView)
}
private fun newWidgetPickerComponent(): WidgetPickerComponent =
widgetPickerComponentProvider.get()
private fun newWidgetPickerComponent(
widgetPickerConfig: WidgetPickerConfig
): WidgetPickerComponent {
return widgetPickerComponentProvider.get()
.build(
widgetsRepository = widgetsRepository,
widgetUsersRepository = widgetUsersRepository,
widgetAppIconsRepository = widgetAppIconsRepository,
widgetHostInfo = WidgetHostInfo(
appContext.resources.getString(R.string.widget_button_text)
title = widgetPickerConfig.title
?: appContext.resources.getString(R.string.widget_button_text),
description = widgetPickerConfig.description,
constraints = widgetPickerConfig.asHostConstraints(),
showDragShadow = !widgetPickerConfig.isForHomeScreen
),
backgroundContext = backgroundContext
)
}
private fun initializeRepositories() {
widgetsRepository.initialize()
@@ -116,4 +130,100 @@ class WidgetPickerComposeWrapperImpl @Inject constructor(
widgetUsersRepository.cleanUp()
widgetAppIconsRepository.cleanUp()
}
companion object {
private const val HOME_SCREEN_WIDGET_INTERACTION_REASON_STRING =
"WidgetPickerActivity.OnWidgetInteraction"
private fun WidgetPickerActivity.buildEventListeners(
widgetPickerConfig: WidgetPickerConfig,
apiWrapper: ApiWrapper
) = object : WidgetPickerEventListeners {
override fun onClose() {
finish()
}
override fun onWidgetInteraction(widgetInteractionInfo: WidgetInteractionInfo) {
if (widgetPickerConfig.isForHomeScreen) {
handleWidgetInteractionForHomeScreen(widgetInteractionInfo, apiWrapper)
} else {
handleWidgetInteractionForExternalHost(widgetInteractionInfo)
}
}
}
/**
* Handles communication with the home screen about the "add" and "drag" interactions on
* widgets within widget picker.
*
* For home screen, we register a listener that is called back when home screen is shown;
* - WidgetPickerDragItemListener: bootstraps the drag helper that displays the shadow and
* handles the drag until completion.
* - WidgetPickerAddItemListener: once launcher is shown, triggers the flow to add the
* widget to workspace.
*/
private fun WidgetPickerActivity.handleWidgetInteractionForHomeScreen(
interactionInfo: WidgetInteractionInfo,
apiWrapper: ApiWrapper
) {
val interactionListener = when (interactionInfo) {
is WidgetInteractionInfo.WidgetDragInfo ->
WidgetPickerDragItemListener(
mimeType = interactionInfo.mimeType,
appWidgetProviderInfo = interactionInfo.providerInfo,
widgetPreview = interactionInfo.previewInfo,
previewRect = interactionInfo.bounds,
previewWidth = interactionInfo.widthPx
)
is WidgetInteractionInfo.WidgetAddInfo ->
WidgetPickerAddItemListener(interactionInfo.providerInfo)
}
Launcher.ACTIVITY_TRACKER.registerCallback(
interactionListener,
HOME_SCREEN_WIDGET_INTERACTION_REASON_STRING
)
startActivity(
/*intent=*/ Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_HOME)
.setPackage(packageName)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
/*options=*/ apiWrapper.createFadeOutAnimOptions().toBundle()
)
finish()
}
/**
* Handles communication with the external host about the "add" and "drag" interactions on
* widgets within widget picker.
*
* - In case of drag and drop, finishes the activity with result indicating that there is a
* pending drag [EXTRA_IS_PENDING_WIDGET_DRAG] (that would contain the widget info as part
* of clip data) that the host should be handling.
* - In case of add, finishes the activity with result containing extra information about
* the widget being added (namely [Intent.EXTRA_COMPONENT_NAME] and [Intent.EXTRA_USER].
*/
private fun WidgetPickerActivity.handleWidgetInteractionForExternalHost(
widgetInteractionInfo: WidgetInteractionInfo,
) {
when (widgetInteractionInfo) {
is WidgetInteractionInfo.WidgetDragInfo ->
setResult(
RESULT_OK,
Intent().putExtra(EXTRA_IS_PENDING_WIDGET_DRAG, true)
)
is WidgetInteractionInfo.WidgetAddInfo -> {
val providerInfo = widgetInteractionInfo.providerInfo
setResult(
RESULT_OK, Intent()
.putExtra(Intent.EXTRA_COMPONENT_NAME, providerInfo.provider)
.putExtra(Intent.EXTRA_USER, providerInfo.profile)
)
}
}
finish()
}
}
}
@@ -0,0 +1,58 @@
/*
* Copyright (C) 2025 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.launcher3.widgetpicker.listeners
import android.appwidget.AppWidgetProviderInfo
import android.view.View
import com.android.launcher3.Launcher
import com.android.launcher3.LauncherSettings.Favorites.CONTAINER_WIDGETS_TRAY
import com.android.launcher3.logging.StatsLogManager.LauncherEvent
import com.android.launcher3.util.ContextTracker.SchedulerCallback
import com.android.launcher3.widget.LauncherAppWidgetProviderInfo
import com.android.launcher3.widget.PendingAddWidgetInfo
/**
* A callback listener (for tap-to-add flow) that handles adding a widget from a separate widget
* picker activity. Invoked once widget picker is closed and home screen is showing / ready.
*
* Also logs to stats logger once widget is added.
*/
class WidgetPickerAddItemListener(private val providerInfo: AppWidgetProviderInfo) :
SchedulerCallback<Launcher> {
override fun init(launcher: Launcher?, isHomeStarted: Boolean): Boolean {
checkNotNull(launcher)
val launcherProviderInfo =
LauncherAppWidgetProviderInfo.fromProviderInfo(launcher, providerInfo)
val pendingAddWidgetInfo =
PendingAddWidgetInfo(launcherProviderInfo, CONTAINER_WIDGETS_TRAY)
val view = View(launcher)
view.tag = pendingAddWidgetInfo
launcher.accessibilityDelegate?.addToWorkspace(
/*item=*/ pendingAddWidgetInfo,
/*accessibility=*/ false
) {
launcher.statsLogManager
.logger()
.withItemInfo(pendingAddWidgetInfo)
.log(LauncherEvent.LAUNCHER_WIDGET_ADD_BUTTON_TAP)
}
return false // don't receive any more callbacks as we got launcher and handled it
}
}
@@ -0,0 +1,87 @@
/*
* Copyright (C) 2025 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.launcher3.widgetpicker.listeners
import android.appwidget.AppWidgetProviderInfo
import android.graphics.Rect
import android.view.View
import com.android.launcher3.LauncherSettings.Favorites.CONTAINER_WIDGETS_TRAY
import com.android.launcher3.dragndrop.BaseItemDragListener
import com.android.launcher3.widget.DatabaseWidgetPreviewLoader.WidgetPreviewInfo
import com.android.launcher3.widget.LauncherAppWidgetProviderInfo
import com.android.launcher3.widget.PendingAddWidgetInfo
import com.android.launcher3.widget.PendingItemDragHelper
import com.android.launcher3.widgetpicker.shared.model.WidgetPreview
/**
* A callback listener of type [BaseItemDragListener] that handles widget drag and drop from widget
* picker hosted in a separate activity than home screen.
*
* Responsible for initializing the [PendingItemDragHelper] that then handles the rest of the
* drag and drop (including showing a drag shadow for the widget).
*
* @param mimeType a mime type used by widget picker when attaching this listener for a specific
* widget's drag and drop session.
* @param appWidgetProviderInfo provider info of the widget being dragged
* @param previewRect the bounds of widget's preview offset by the point of long press
* @param previewWidth width of the preview as it appears in the widget picker.
*/
class WidgetPickerDragItemListener(
private val mimeType: String,
private val appWidgetProviderInfo: AppWidgetProviderInfo,
private val widgetPreview: WidgetPreview,
previewRect: Rect,
previewWidth: Int
) : BaseItemDragListener(previewRect, previewWidth, previewWidth) {
override fun getMimeType(): String = mimeType
override fun createDragHelper(): PendingItemDragHelper {
val launcherProviderInfo =
LauncherAppWidgetProviderInfo.fromProviderInfo(mLauncher, appWidgetProviderInfo)
val pendingAddWidgetInfo =
PendingAddWidgetInfo(launcherProviderInfo, CONTAINER_WIDGETS_TRAY)
val view = View(mLauncher)
view.tag = pendingAddWidgetInfo
val dragHelper = PendingItemDragHelper(view)
val info = WidgetPreviewInfo()
when (widgetPreview) {
is WidgetPreview.BitmapWidgetPreview -> {
info.previewBitmap = widgetPreview.bitmap
info.providerInfo = appWidgetProviderInfo
}
is WidgetPreview.ProviderInfoWidgetPreview -> {
info.providerInfo = widgetPreview.providerInfo
}
is WidgetPreview.RemoteViewsWidgetPreview -> {
info.remoteViews = widgetPreview.remoteViews
info.providerInfo = appWidgetProviderInfo
}
else -> throw IllegalStateException(
"Unsupported preview type when dropping widget to launcher"
)
}
dragHelper.setWidgetPreviewInfo(info)
return dragHelper
}
}
+16 -1
View File
@@ -136,13 +136,16 @@ android_library {
"src/com/android/launcher3/widgetpicker/ui/components/Strings.kt",
"src/com/android/launcher3/widgetpicker/ui/components/WidgetDetails.kt",
"src/com/android/launcher3/widgetpicker/ui/components/WidgetPreview.kt",
"src/com/android/launcher3/widgetpicker/ui/components/DragAndDrop.kt",
"src/com/android/launcher3/widgetpicker/ui/components/WidgetPreviewHostView.kt",
"src/com/android/launcher3/widgetpicker/ui/components/WidgetsGrid.kt",
"src/com/android/launcher3/widgetpicker/ui/components/ExpandCollapseIndicator.kt",
"src/com/android/launcher3/widgetpicker/ui/components/WidgetAppsListHeader.kt",
"src/com/android/launcher3/widgetpicker/ui/components/WidgetAppsList.kt",
"src/com/android/launcher3/widgetpicker/ui/components/TestTag.kt",
],
static_libs: [
"widget_picker_event_listeners",
"widget_picker_ui_data_types",
"androidx.compose.foundation_foundation",
"androidx.compose.foundation_foundation-layout",
@@ -162,6 +165,18 @@ android_library {
],
}
android_library {
name: "widget_picker_event_listeners",
sdk_version: "current",
min_sdk_version: min_launcher3_sdk_version,
srcs: [
"src/com/android/launcher3/widgetpicker/ui/WidgetPickerEventListeners.kt",
],
static_libs: [
"widget_picker_shared_data_types",
],
}
android_library {
name: "widget_picker_ui",
sdk_version: "current",
@@ -177,10 +192,10 @@ android_library {
"src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenViewModel.kt",
"src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/search/SearchScreen.kt",
"src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/search/SearchScreenViewModel.kt",
"src/com/android/launcher3/widgetpicker/ui/WidgetPickerEventListeners.kt",
],
static_libs: [
"widget_picker_ui_components",
"widget_picker_event_listeners",
"widget_picker_ui_data_types",
"widget_picker_domain_layer",
"dagger2",
+1
View File
@@ -122,6 +122,7 @@ dependencies {
// Shared testing libs
testImplementation(project(":RobolectricLib"))
testImplementation(project(":SharedTestLib"))
androidTestImplementation(project(":SharedTestLib"))
androidTestImplementation(project(":PlatformParameterizedLib"))
androidTestImplementation(project(":ScreenshotLib"))
+14 -1
View File
@@ -27,8 +27,21 @@
<!-- The format string for the cell dimensions of a widget in the widget picker e.g. 2x2 -->
<!-- There is a special version of this format string for Farsi (%1$dx%2$d) -->
<string name="widget_span_dimensions_format">%1$d \u00d7 %2$d</string>
<!-- Accessibility spoken message format for the cell dimensions of a widget in the widget picker -->
<!-- Accessibility spoken message format for the cell dimensions of a widget in the widget picker [CHAR_LIMIT=none] -->
<string name="widget_span_dimensions_accessible_format">%1$d wide by %2$d high</string>
<!-- Accessibility spoken message format for the widget's details shown in the widget picker. [CHAR_LIMIT=none] -->
<string name="widget_details_accessibility_label">
<xliff:g id="widget_name" example="Calendar month view">%1$s</xliff:g> widget, %2$d wide by %3$d high
</string>
<!-- Accessibility label on the widget preview that on click (if add button is hidden) shows the button to add widget to the home screen. [CHAR_LIMIT=none] -->
<string name="widget_tap_to_show_add_button_label">Show add button</string>
<!-- Accessibility label on the widget preview that on click (if add button is showing) hides the button to add widget to the home screen. [CHAR_LIMIT=none] -->
<string name="widget_tap_to_hide_add_button_label">Hide add button</string>
<!-- Text on the button that adds a widget to the home screen. [CHAR_LIMIT=15] -->
<string name="widget_tap_to_add_button_label">Add</string>
<!-- Accessibility content description for the button that adds a widget to the home screen. The
placeholder text is the widget name. [CHAR_LIMIT=none] -->
<string name="widget_tap_to_add_button_content_description">Add <xliff:g id="widget_name" example="Calendar month view">%1$s</xliff:g> widget</string>
<!-- Tab / list header label. A user can tap this to access recommended (or featured) widgets.
[CHAR_LIMIT=25] -->
@@ -25,11 +25,15 @@ import android.os.UserHandle
* @param description an optional 1-2 line description to be shown below the title. If not set, no
* description is shown.
* @param constraints constraints around which widgets can be shown in the picker.
* @param showDragShadow indicates whether to show drag shadow for the widgets when dragging them;
* can be set to false if host manages drag shadow on its own (e.g. home screen to animate the
* shadow with actual content)
*/
data class WidgetHostInfo(
val title: String? = null,
val description: String? = null,
val constraints: List<HostConstraint> = emptyList(),
val showDragShadow: Boolean = true
)
/** Various constraints for the widget host. */
@@ -14,7 +14,11 @@
* limitations under the License.
*/
package com.android.launcher3.widgetpicker
package com.android.launcher3.widgetpicker.ui
import android.appwidget.AppWidgetProviderInfo
import android.graphics.Rect
import com.android.launcher3.widgetpicker.shared.model.WidgetPreview
/**
* General interface that clients can implement to listen to events from different types of
@@ -23,4 +27,38 @@ package com.android.launcher3.widgetpicker
interface WidgetPickerEventListeners {
/** Called when the widget picker is dismissed. */
fun onClose()
/** Called when a widget is being dragged or added from picker. */
fun onWidgetInteraction(widgetInteractionInfo: WidgetInteractionInfo)
}
/** Information passed in event listener when a widget is dragged or added from picker. */
sealed class WidgetInteractionInfo {
/**
* Information passed in event listener when a widget is dragged.
*
* @param providerInfo metadata for the provider of the widget being dragged.
* @param bounds current bounds of the widget's preview considering the drag offset and scale.
* @param widthPx measured width of the preview.
* @param heightPx measured height of the preview.
* @param previewInfo information necessary to render a preview within host
* @param mimeType a unique mime type set on clip data for the drag session
*/
data class WidgetDragInfo(
val providerInfo: AppWidgetProviderInfo,
val bounds: Rect,
val widthPx: Int,
val heightPx: Int,
val previewInfo: WidgetPreview,
val mimeType: String,
) : WidgetInteractionInfo()
/**
* Information passed in event listener when a widget is added using tap to add.
*
* @param providerInfo metadata for the provider of the widget being added.
*/
data class WidgetAddInfo(
val providerInfo: AppWidgetProviderInfo
) : WidgetInteractionInfo()
}
@@ -0,0 +1,131 @@
/*
* Copyright (C) 2025 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.launcher3.widgetpicker.ui.components
import android.appwidget.AppWidgetProviderInfo
import android.content.ClipData
import android.content.ClipDescription
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Point
import android.graphics.Rect
import android.view.View
import android.view.View.DragShadowBuilder
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.core.graphics.drawable.RoundedBitmapDrawable
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
import java.util.UUID
/**
* Information about the image's dimensions post scaling.
*/
data class ImageScaledDimensions(
val scale: Float,
val scaledSizeDp: DpSize,
val scaledSizePx: IntSize,
val scaledRadiusDp: Dp,
val scaledRadiusPx: Float
)
/**
* A [DragShadowBuilder] that draws drag shadow using the provided bitmap and image dimensions.
*/
class ImageBitmapDragShadowBuilder(
context: Context,
bitmap: Bitmap,
imageScaledDimensions: ImageScaledDimensions
) : DragShadowBuilder() {
private val shadowWidth = imageScaledDimensions.scaledSizePx.width
private val shadowHeight = imageScaledDimensions.scaledSizePx.height
private val shadowDrawable: RoundedBitmapDrawable =
RoundedBitmapDrawableFactory.create(context.resources, bitmap)
.apply { cornerRadius = imageScaledDimensions.scaledRadiusPx }
override fun onProvideShadowMetrics(outShadowSize: Point?, outShadowTouchPoint: Point?) {
outShadowSize?.set(shadowWidth, shadowHeight)
// Set the touch point's position to be in the middle of the drag shadow.
outShadowTouchPoint?.set(shadowWidth / 2, shadowHeight / 2)
}
override fun onDrawShadow(canvas: Canvas) {
// The Drawable's native bounds may be different than the source ImageView. Change it to
// to the needed size.
val oldBounds: Rect = shadowDrawable.copyBounds()
shadowDrawable.setBounds(0, 0, shadowWidth, shadowHeight)
canvas.let { shadowDrawable.draw(it) }
shadowDrawable.bounds = oldBounds
}
}
/**
* A [DragShadowBuilder] that draws a transparent drag shadow; useful for cases when the actual drag
* shadow is displayed by the host.
*/
object TransparentDragShadowBuilder : DragShadowBuilder() {
private const val SHADOW_SIZE = 10
override fun onDrawShadow(canvas: Canvas) {}
override fun onProvideShadowMetrics(outShadowSize: Point, outShadowTouchPoint: Point) {
outShadowSize.set(SHADOW_SIZE, SHADOW_SIZE);
outShadowTouchPoint.set(SHADOW_SIZE / 2, SHADOW_SIZE / 2);
}
}
/** State containing information to start a drag for a widget. */
class DragState(
private val widgetInfo: AppWidgetProviderInfo,
private val dragShadowBuilder: DragShadowBuilder
) {
private val uniqueId = UUID.randomUUID().toString()
val pickerMimeType = "com.android.launcher3.widgetpicker.drag_and_drop/$uniqueId"
fun startDrag(view: View) {
val clipData = ClipData(
ClipDescription(
// not displayed anywhere; so, set to empty.
/* label= */ "",
arrayOf(
// unique picker specific mime type.
pickerMimeType,
// indicates that the clip item contains an intent (with extras about widget
// info).
ClipDescription.MIMETYPE_TEXT_INTENT
)
),
ClipData.Item(
Intent()
.putExtra(Intent.EXTRA_USER, widgetInfo.profile)
.putExtra(
Intent.EXTRA_COMPONENT_NAME,
widgetInfo.provider
)
)
)
view.startDragAndDrop(
clipData,
/*shadowBuilder=*/ dragShadowBuilder,
/*myLocalState=*/ null,
View.DRAG_FLAG_GLOBAL
)
}
}
@@ -0,0 +1,20 @@
/*
* Copyright (C) 2025 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.launcher3.widgetpicker.ui.components
/** Builds a test tag prefixed with the widget picker package name. */
fun widgetPickerTestTag(id: String): String = "com.android.launcher3.widgetpicker:id/$id"
@@ -28,7 +28,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
@@ -68,7 +67,7 @@ private fun HighResAppIcon(
Box(modifier = Modifier.size(size.iconSize)) {
Icon(
bitmap = icon.bitmap.asImageBitmap(),
modifier = Modifier.fillMaxSize().clip(CircleShape),
modifier = Modifier.fillMaxSize(),
contentDescription = null,
tint = Color.Unspecified,
)
@@ -36,6 +36,7 @@ import com.android.launcher3.widgetpicker.shared.model.WidgetAppIcon
import com.android.launcher3.widgetpicker.shared.model.WidgetAppId
import com.android.launcher3.widgetpicker.shared.model.WidgetId
import com.android.launcher3.widgetpicker.shared.model.WidgetPreview
import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo
import com.android.launcher3.widgetpicker.ui.model.DisplayableWidgetApp
/**
@@ -53,6 +54,8 @@ fun WidgetAppsList(
onWidgetAppClick: (DisplayableWidgetApp) -> Unit,
appIcons: Map<WidgetAppId, WidgetAppIcon>,
widgetPreviews: Map<WidgetId, WidgetPreview>,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
showDragShadow: Boolean,
headerDescriptionStyle: AppHeaderDescriptionStyle = AppHeaderDescriptionStyle.WIDGETS_COUNT,
) {
val listState = rememberLazyListState()
@@ -95,6 +98,8 @@ fun WidgetAppsList(
description = description,
widgetPreviews = widgetPreviews,
onWidgetAppClick = onWidgetAppClick,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
@@ -124,6 +129,8 @@ private fun ExpandableWidgetAppHeader(
description: String,
widgetPreviews: Map<WidgetId, WidgetPreview>,
onWidgetAppClick: (DisplayableWidgetApp) -> Unit,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
showDragShadow: Boolean,
) {
val expandedContent: @Composable () -> Unit =
remember(widgetApp, widgetPreviews) {
@@ -133,7 +140,8 @@ private fun ExpandableWidgetAppHeader(
showAllWidgetDetails = true,
previews = widgetPreviews,
modifier =
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceBright,
shape =
@@ -142,6 +150,8 @@ private fun ExpandableWidgetAppHeader(
else -> WidgetAppsListDimensions.smallShape
},
),
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
}
@@ -156,6 +166,7 @@ private fun ExpandableWidgetAppHeader(
onClick = { onWidgetAppClick(widgetApp) },
shape =
when {
isFirst && isLast && !expanded -> WidgetAppsListDimensions.largeShape
isFirst -> WidgetAppsListDimensions.topLargeShape
isLast && !expanded -> WidgetAppsListDimensions.bottomLargeShape
else -> WidgetAppsListDimensions.smallShape
@@ -16,20 +16,42 @@
package com.android.launcher3.widgetpicker.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
@@ -39,6 +61,11 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.android.launcher3.widgetpicker.R
import com.android.launcher3.widgetpicker.shared.model.PickableWidget
import com.android.launcher3.widgetpicker.shared.model.WidgetId
import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo
import com.android.launcher3.widgetpicker.ui.components.AddButtonDefaults.TOGGLE_ANIMATION_DURATION
import com.android.launcher3.widgetpicker.ui.components.WidgetDetailsDimensions.INVISIBLE_ALPHA
import com.android.launcher3.widgetpicker.ui.components.WidgetDetailsDimensions.VISIBLE_ALPHA
/**
* Displays the details of the widget that can be shown below their previews.
@@ -48,6 +75,8 @@ import com.android.launcher3.widgetpicker.shared.model.PickableWidget
* app's context e.g. in recommendations.
* @param showAllDetails when set, besides the widget label, also shows widget spans and 1-3 line
* long description
* @param showAddButton when set, displays the add button instead of details.
* @param onWidgetAddClick callback when user clicks on the add button to add the widget
* @param modifier modifier for the top level composable.
*/
@Composable
@@ -55,20 +84,118 @@ fun WidgetDetails(
widget: PickableWidget,
appIcon: (@Composable () -> Unit)?,
showAllDetails: Boolean,
showAddButton: Boolean,
onWidgetAddClick: (WidgetInteractionInfo.WidgetAddInfo) -> Unit,
onAddButtonToggle: (WidgetId) -> Unit,
modifier: Modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = modifier.fillMaxSize().padding(
horizontal = WidgetDetailsDimension.horizontalPadding,
vertical = WidgetDetailsDimension.verticalPadding
),
val haptic = LocalHapticFeedback.current
val interactionSource = remember { MutableInteractionSource() }
val contentDescription = stringResource(
R.string.widget_details_accessibility_label,
widget.label,
widget.sizeInfo.spanX,
widget.sizeInfo.spanY
)
val detailsAlpha: Float by animateFloatAsState(
targetValue = if (showAddButton) INVISIBLE_ALPHA else VISIBLE_ALPHA,
animationSpec = tween(durationMillis = TOGGLE_ANIMATION_DURATION),
label = "detailsAlphaAnimation"
)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.fillMaxSize()
.clickable(
onClickLabel = if (showAddButton) {
stringResource(R.string.widget_tap_to_hide_add_button_label)
} else {
stringResource(R.string.widget_tap_to_show_add_button_label)
},
interactionSource = interactionSource,
indication = null
) {
haptic.performHapticFeedback(HapticFeedbackType.VirtualKey)
onAddButtonToggle(
widget.id
)
}
.padding(
horizontal = WidgetDetailsDimensions.horizontalPadding,
vertical = WidgetDetailsDimensions.verticalPadding
)
) {
WidgetLabel(label = widget.label, appIcon = appIcon, modifier = Modifier)
if (showAllDetails) {
WidgetSpanSizeLabel(spanX = widget.sizeInfo.spanX, spanY = widget.sizeInfo.spanY)
widget.description?.let { WidgetDescription(it) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.clearAndSetSemantics { this.contentDescription = contentDescription }
.minimumInteractiveComponentSize()
.graphicsLayer { alpha = detailsAlpha }
.fillMaxSize()
) {
WidgetLabel(
label = widget.label,
appIcon = appIcon,
modifier = Modifier
)
if (showAllDetails) {
WidgetSpanSizeLabel(
spanX = widget.sizeInfo.spanX,
spanY = widget.sizeInfo.spanY
)
widget.description?.let { WidgetDescription(it) }
}
}
AnimatedVisibility(
visible = showAddButton,
modifier = Modifier.fillMaxSize(),
enter = AddButtonDefaults.enterTransition,
exit = AddButtonDefaults.exitTransition
) {
AddButton(
widget = widget,
onClick = {
onWidgetAddClick(
WidgetInteractionInfo.WidgetAddInfo(
widget.appWidgetProviderInfo
)
)
haptic.performHapticFeedback(HapticFeedbackType.Confirm)
},
)
}
}
}
@Composable
private fun AddButton(
widget: PickableWidget,
onClick: () -> Unit,
) {
val accessibleDescription =
stringResource(R.string.widget_tap_to_add_button_content_description, widget.label)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Button(
modifier = Modifier.minimumInteractiveComponentSize(),
contentPadding = AddButtonDimensions.paddingValues,
colors = AddButtonDefaults.colors,
onClick = onClick,
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = null // decorative
)
Text(
modifier = Modifier.semantics { this.contentDescription = accessibleDescription },
text = stringResource(R.string.widget_tap_to_add_button_label)
)
}
}
}
@@ -76,12 +203,18 @@ fun WidgetDetails(
/** The label / short title of the widget provided by the developer in the manifest. */
@Composable
private fun WidgetLabel(label: String, appIcon: (@Composable () -> Unit)?, modifier: Modifier) {
Row(modifier = modifier, horizontalArrangement = Arrangement.Center) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
if (appIcon != null) {
appIcon()
Spacer(
modifier =
Modifier.width(WidgetDetailsDimension.appIconLabelSpacing).fillMaxHeight()
Modifier
.width(WidgetDetailsDimensions.appIconLabelSpacing)
.fillMaxHeight()
)
}
Text(
@@ -136,8 +269,32 @@ private fun WidgetSpanSizeLabel(spanX: Int, spanY: Int) {
)
}
private object WidgetDetailsDimension {
private object WidgetDetailsDimensions {
val horizontalPadding: Dp = 4.dp
val verticalPadding: Dp = 12.dp
val appIconLabelSpacing = 8.dp
const val VISIBLE_ALPHA = 1f
const val INVISIBLE_ALPHA = 0f
}
private object AddButtonDimensions {
val paddingValues = PaddingValues(
start = 8.dp,
top = 11.dp,
end = 16.dp,
bottom = 11.dp
)
}
private object AddButtonDefaults {
const val TOGGLE_ANIMATION_DURATION = 400
val enterTransition = fadeIn(animationSpec = tween(TOGGLE_ANIMATION_DURATION))
val exitTransition = fadeOut(animationSpec = tween(TOGGLE_ANIMATION_DURATION))
val colors: ButtonColors
@Composable get() = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
}
@@ -18,9 +18,16 @@ package com.android.launcher3.widgetpicker.ui.components
import android.appwidget.AppWidgetProviderInfo
import android.graphics.Bitmap
import android.graphics.Rect
import android.view.View
import android.view.View.DragShadowBuilder
import android.widget.FrameLayout
import android.widget.RemoteViews
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
@@ -32,31 +39,51 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.coerceAtMost
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.android.launcher3.widgetpicker.shared.model.WidgetId
import com.android.launcher3.widgetpicker.shared.model.WidgetPreview
import com.android.launcher3.widgetpicker.shared.model.WidgetSizeInfo
import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo
import kotlin.math.roundToInt
/** Renders a different types of preview for an appwidget. */
@Composable
fun WidgetPreview(
id: WidgetId,
sizeInfo: WidgetSizeInfo,
preview: WidgetPreview,
appwidgetInfo: AppWidgetProviderInfo,
modifier: Modifier = Modifier,
showDragShadow: Boolean,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
onAddButtonToggle: (WidgetId) -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
val haptic = LocalHapticFeedback.current
val widgetRadius = dimensionResource(android.R.dimen.system_app_widget_background_radius)
val density = LocalDensity.current
@@ -65,7 +92,17 @@ fun WidgetPreview(
DpSize(sizeInfo.containerWidthPx.toDp(), sizeInfo.containerHeightPx.toDp())
}
Box(modifier = modifier.wrapContentSize()) {
Box(
modifier = modifier
.wrapContentSize()
.clickable(
interactionSource = interactionSource,
// no ripples for preview taps that toggle the add button.
indication = null
) {
haptic.performHapticFeedback(HapticFeedbackType.VirtualKey)
onAddButtonToggle(id)
}) {
when (preview) {
is WidgetPreview.PlaceholderWidgetPreview ->
PlaceholderWidgetPreview(size = containerSize, widgetRadius = widgetRadius)
@@ -75,6 +112,9 @@ fun WidgetPreview(
bitmap = preview.bitmap,
size = containerSize,
widgetRadius = widgetRadius,
widgetInfo = appwidgetInfo,
showDragShadow = showDragShadow,
onWidgetInteraction = onWidgetInteraction,
)
is WidgetPreview.RemoteViewsWidgetPreview ->
@@ -83,6 +123,8 @@ fun WidgetPreview(
widgetInfo = appwidgetInfo,
sizeInfo = sizeInfo,
widgetRadius = widgetRadius,
showDragShadow = showDragShadow,
onWidgetInteraction = onWidgetInteraction,
)
is WidgetPreview.ProviderInfoWidgetPreview ->
@@ -91,6 +133,8 @@ fun WidgetPreview(
widgetInfo = appwidgetInfo,
sizeInfo = sizeInfo,
widgetRadius = widgetRadius,
showDragShadow = showDragShadow,
onWidgetInteraction = onWidgetInteraction,
)
}
}
@@ -101,7 +145,8 @@ private fun PlaceholderWidgetPreview(size: DpSize, widgetRadius: Dp) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier.width(size.width)
Modifier
.width(size.width)
.height(size.height)
.background(
color = MaterialTheme.colorScheme.secondaryContainer,
@@ -113,54 +158,142 @@ private fun PlaceholderWidgetPreview(size: DpSize, widgetRadius: Dp) {
}
@Composable
private fun BitmapWidgetPreview(bitmap: Bitmap, size: DpSize, widgetRadius: Dp) {
private fun BitmapWidgetPreview(
bitmap: Bitmap,
size: DpSize,
widgetInfo: AppWidgetProviderInfo,
widgetRadius: Dp,
showDragShadow: Boolean,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
) {
val context = LocalContext.current
val density = LocalDensity.current
val haptic = LocalHapticFeedback.current
val imageScale by
remember(bitmap) {
derivedStateOf {
with(density) {
val bitmapHeight = bitmap.height.toDp()
val bitmapWidth = bitmap.width.toDp()
val bitmapAspectRatio = bitmapWidth / bitmapHeight
val containerAspectRatio: Float = size.width / size.height
val scaledBitmapDimensions by remember(bitmap, density, size) {
derivedStateOf { bitmap.calculateScaledDimensions(density, size, widgetRadius) }
}
// Scale by width if image has larger aspect ratio than the container else by
// height; and avoid cropping the previews.
if (bitmapAspectRatio > containerAspectRatio) {
size.width / bitmapWidth
} else {
size.height / bitmapHeight
}
val dragState by remember(widgetInfo, showDragShadow) {
derivedStateOf {
DragState(
widgetInfo,
if (showDragShadow) {
ImageBitmapDragShadowBuilder(context, bitmap, scaledBitmapDimensions)
} else {
TransparentDragShadowBuilder
}
}
}
val imageSize by
remember(imageScale) {
derivedStateOf {
with(density) {
val bitmapHeight = bitmap.height.toDp()
val bitmapWidth = bitmap.width.toDp()
DpSize(bitmapWidth * imageScale, bitmapHeight * imageScale)
}
}
}
val scaledCornerRadius by
remember(imageScale) {
derivedStateOf { (widgetRadius * imageScale).coerceAtMost(widgetRadius) }
)
}
}
var imagePositionInParent by remember { mutableStateOf(Offset.Zero) }
// A view to start drag and drop; the compose drag and drop doesn't provide pre-drag hooks.
// So, we simulate a drag and drop with a backing view.
val dragView: View = remember(widgetInfo) { FrameLayout(context) }
AndroidView(factory = { dragView })
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null, // only visual (widget details provides the readable info)
contentScale = ContentScale.FillBounds,
modifier =
Modifier.width(imageSize.width)
.height(imageSize.height)
.clip(shape = RoundedCornerShape(scaledCornerRadius)),
Modifier
.onGloballyPositioned { coordinates ->
imagePositionInParent = coordinates.positionInParent()
}
.pointerInput(bitmap) {
detectDragGesturesAfterLongPress(
onDrag = { change, _ -> change.consume() },
onDragStart = { offset ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
dragState.startDrag(dragView)
val bounds =
calculateImageDragBounds(
scaledBitmapDimensions = scaledBitmapDimensions,
imagePositionInParent = imagePositionInParent,
offset = offset
)
onWidgetInteraction(
WidgetInteractionInfo.WidgetDragInfo(
mimeType = dragState.pickerMimeType,
providerInfo = widgetInfo,
bounds = bounds,
widthPx = scaledBitmapDimensions.scaledSizePx.width,
heightPx = scaledBitmapDimensions.scaledSizePx.height,
previewInfo = WidgetPreview.BitmapWidgetPreview(
bitmap = bitmap,
),
)
)
}
)
}
.width(scaledBitmapDimensions.scaledSizeDp.width)
.height(scaledBitmapDimensions.scaledSizeDp.height)
.clip(shape = RoundedCornerShape(scaledBitmapDimensions.scaledRadiusDp)),
)
}
/** Returns the visual bounds of image offset by the touch point represented by [offset]. */
private fun calculateImageDragBounds(
scaledBitmapDimensions: ImageScaledDimensions,
imagePositionInParent: Offset,
offset: Offset
): Rect {
val bounds = Rect()
bounds.left = 0
bounds.top = 0
bounds.right = scaledBitmapDimensions.scaledSizePx.width
bounds.bottom = scaledBitmapDimensions.scaledSizePx.height
val xOffset: Int =
(imagePositionInParent.x - offset.x).roundToInt()
val yOffset: Int =
(imagePositionInParent.y - offset.y).roundToInt()
bounds.offset(xOffset, yOffset)
return bounds
}
private fun Bitmap.calculateScaledDimensions(
density: Density,
size: DpSize,
widgetRadius: Dp
) =
with(density) {
val bitmapSize = DpSize(width = width.toDp(), height = height.toDp())
val bitmapAspectRatio = bitmapSize.width / bitmapSize.height
val containerAspectRatio: Float = size.width / size.height
// Scale by width if image has larger aspect ratio than the container else by
// height; and avoid cropping the previews.
val scale = if (bitmapAspectRatio > containerAspectRatio) {
size.width / bitmapSize.width
} else {
size.height / bitmapSize.height
}
val scaledDpSize = DpSize(
width = bitmapSize.width * scale,
height = bitmapSize.height * scale
)
val scaledPxSize = IntSize(
width = scaledDpSize.width.roundToPx(),
height = scaledDpSize.height.roundToPx()
)
val scaledRadius = (widgetRadius * scale).coerceAtMost(widgetRadius).value.roundToInt().dp
ImageScaledDimensions(
scale = scale,
scaledSizePx = scaledPxSize,
scaledSizeDp = scaledDpSize,
scaledRadiusDp = scaledRadius,
scaledRadiusPx = scaledRadius.toPx()
)
}
@Composable
private fun RemoteViewsWidgetPreview(
remoteViews: RemoteViews? = null,
@@ -168,22 +301,74 @@ private fun RemoteViewsWidgetPreview(
widgetInfo: AppWidgetProviderInfo,
sizeInfo: WidgetSizeInfo,
widgetRadius: Dp,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
showDragShadow: Boolean,
) {
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
val appWidgetHostView by
remember(sizeInfo) {
derivedStateOf {
WidgetPreviewHostView(context).apply {
setContainerSizePx(
IntSize(sizeInfo.containerWidthPx, sizeInfo.containerHeightPx)
)
}
remember(sizeInfo, widgetInfo) {
derivedStateOf {
WidgetPreviewHostView(context).apply {
setContainerSizePx(
IntSize(sizeInfo.containerWidthPx, sizeInfo.containerHeightPx)
)
}
}
}
key(sizeInfo) {
val dragState by remember {
derivedStateOf {
DragState(
widgetInfo = widgetInfo,
dragShadowBuilder = if (showDragShadow) {
DragShadowBuilder(appWidgetHostView)
} else {
TransparentDragShadowBuilder
}
)
}
}
key(appWidgetHostView) {
AndroidView(
modifier = Modifier.wrapContentSize().clip(RoundedCornerShape(widgetRadius)),
modifier = Modifier
.pointerInput(appWidgetHostView) {
detectDragGesturesAfterLongPress(
onDrag = { change, _ -> change.consume() },
onDragStart = { offset ->
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
dragState.startDrag(appWidgetHostView)
onWidgetInteraction(
WidgetInteractionInfo.WidgetDragInfo(
mimeType = dragState.pickerMimeType,
providerInfo = widgetInfo,
bounds = appWidgetHostView.getDragBoundsForOffset(offset),
widthPx = appWidgetHostView.measuredWidth,
heightPx = appWidgetHostView.measuredHeight,
previewInfo = when {
remoteViews != null ->
WidgetPreview.RemoteViewsWidgetPreview(
remoteViews = remoteViews,
)
previewLayoutProviderInfo != null ->
WidgetPreview.ProviderInfoWidgetPreview(
providerInfo = previewLayoutProviderInfo
)
else ->
throw IllegalStateException("No preview during drag")
}
)
)
},
)
}
.wrapContentSize()
.clip(RoundedCornerShape(widgetRadius)),
factory = { appWidgetHostView },
update = { view ->
// if preview.remoteViews is null, initial layout will render.
@@ -18,8 +18,10 @@ package com.android.launcher3.widgetpicker.ui.components
import android.appwidget.AppWidgetHostView
import android.content.Context
import android.graphics.Rect
import android.view.MotionEvent
import android.view.View
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import kotlin.math.roundToInt
@@ -30,6 +32,7 @@ import kotlin.math.roundToInt
*/
class WidgetPreviewHostView(context: Context) : AppWidgetHostView(context) {
private var previewContainerSizePx: IntSize? = null
private var contentScale = 1f
init {
clipToPadding = false
@@ -64,6 +67,7 @@ class WidgetPreviewHostView(context: Context) : AppWidgetHostView(context) {
child.scaleX = scale
child.scaleY = scale
contentScale = scale
setMeasuredDimension(
(scale * childWidth).roundToInt(),
@@ -100,4 +104,22 @@ class WidgetPreviewHostView(context: Context) : AppWidgetHostView(context) {
measureChild(child, widgetSpec, heightSpec)
return Pair(child.measuredWidth.toFloat(), child.measuredHeight.toFloat())
}
/**
* Returns visual bounds of this preview offset by the provided [offset] and considering the
* scale of preview.
*/
fun getDragBoundsForOffset(offset: Offset): Rect {
val width: Int = (measuredWidth)
val height: Int = (measuredHeight)
val bounds = Rect(0, 0, width, height)
val xOffset: Int =
left - (offset.x * contentScale).toInt()
val yOffset: Int =
top - (offset.y * contentScale).toInt()
bounds.offset(xOffset, yOffset)
return bounds
}
}
@@ -21,7 +21,10 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
@@ -30,6 +33,7 @@ import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
@@ -42,6 +46,7 @@ import com.android.launcher3.widgetpicker.shared.model.WidgetAppIcon
import com.android.launcher3.widgetpicker.shared.model.WidgetAppId
import com.android.launcher3.widgetpicker.shared.model.WidgetId
import com.android.launcher3.widgetpicker.shared.model.WidgetPreview
import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo
import com.android.launcher3.widgetpicker.ui.components.WidgetGridDimensions.MAX_ITEMS_PER_ROW
import com.android.launcher3.widgetpicker.ui.model.WidgetSizeGroup
import kotlin.math.max
@@ -55,6 +60,11 @@ import kotlin.math.max
* label.
* @param appIcons optional map containing app icons to show in the widget details besides the label
* (when showing the widgets outside of app context e.g. recommendations)
* @param showDragShadow indicates if in a drag and drop session, widget picker should show drag
* shadow containing the preview; if not set, a transparent shadow is rendered and host should
* manage providing a shadow on its own.
* @param onWidgetInteraction callback invoked when a widget is being dragged and picker has started
* global drag and drop session.
* @param modifier modifier with parent constraints and additional modifications
*/
@Composable
@@ -64,7 +74,11 @@ fun WidgetsGrid(
previews: Map<WidgetId, WidgetPreview>,
modifier: Modifier,
appIcons: Map<WidgetAppId, WidgetAppIcon> = emptyMap(),
showDragShadow: Boolean,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
) {
var addButtonWidgetId by remember { mutableStateOf<WidgetId?>(null) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.padding(vertical = WidgetGridDimensions.gridVerticalPadding),
@@ -75,6 +89,16 @@ fun WidgetsGrid(
showAllWidgetDetails = showAllWidgetDetails,
appIcons = appIcons,
previews = previews,
showDragShadow = showDragShadow,
addButtonWidgetId = addButtonWidgetId,
onWidgetInteraction = onWidgetInteraction,
onAddButtonToggle = { id ->
addButtonWidgetId = if (id != addButtonWidgetId) {
id
} else {
null
}
}
)
}
}
@@ -102,8 +126,12 @@ fun WidgetsGrid(
private fun WidgetsFlowRow(
widgetSizeGroup: WidgetSizeGroup,
showAllWidgetDetails: Boolean,
addButtonWidgetId: WidgetId?,
appIcons: Map<WidgetAppId, WidgetAppIcon>,
previews: Map<WidgetId, WidgetPreview>,
showDragShadow: Boolean,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
onAddButtonToggle: (WidgetId) -> Unit,
cellHorizontalPadding: Dp = WidgetGridDimensions.cellHorizontalPadding,
rowVerticalSpacing: Dp = WidgetGridDimensions.rowVerticalSpacing,
minItemWidth: Dp = WidgetGridDimensions.minItemWidth,
@@ -111,8 +139,25 @@ private fun WidgetsFlowRow(
val items = widgetSizeGroup.widgets
WidgetsFlowRowLayout(
widgetPreviews = { Previews(items, previews) },
widgetDetails = { Details(showAllWidgetDetails, items, appIcons) },
widgetPreviews = {
Previews(
widgets = items,
previews = previews,
showDragShadow = showDragShadow,
onWidgetInteraction = onWidgetInteraction,
onAddButtonToggle = onAddButtonToggle,
)
},
widgetDetails = {
Details(
showAllWidgetDetails = showAllWidgetDetails,
widgets = items,
appIcons = appIcons,
addButtonWidgetId = addButtonWidgetId,
onWidgetInteraction = onWidgetInteraction,
onAddButtonToggle = onAddButtonToggle
)
},
previewContainerWidthPx = widgetSizeGroup.previewContainerWidthPx,
cellHorizontalPadding = cellHorizontalPadding,
rowVerticalSpacing = rowVerticalSpacing,
@@ -121,7 +166,13 @@ private fun WidgetsFlowRow(
}
@Composable
private fun Previews(widgets: List<PickableWidget>, previews: Map<WidgetId, WidgetPreview>) {
private fun Previews(
widgets: List<PickableWidget>,
previews: Map<WidgetId, WidgetPreview>,
showDragShadow: Boolean,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
onAddButtonToggle: (WidgetId) -> Unit,
) {
widgets.forEachIndexed { index, widgetItem ->
val id = widgetItem.id
@@ -133,12 +184,21 @@ private fun Previews(widgets: List<PickableWidget>, previews: Map<WidgetId, Widg
Box(
contentAlignment = Alignment.BottomCenter,
modifier =
Modifier.fillMaxSize().clearAndSetSemantics { traversalIndex = index.toFloat() },
Modifier
.fillMaxSize()
.clearAndSetSemantics {
traversalIndex = index.toFloat()
testTag = WIDGET_PREVIEW_TEST_TAG
},
) {
WidgetPreview(
id = widgetItem.id,
sizeInfo = widgetItem.sizeInfo,
preview = widgetPreview,
appwidgetInfo = widgetItem.appWidgetProviderInfo,
showDragShadow = showDragShadow,
onWidgetInteraction = onWidgetInteraction,
onAddButtonToggle = onAddButtonToggle
)
}
}
@@ -148,7 +208,10 @@ private fun Previews(widgets: List<PickableWidget>, previews: Map<WidgetId, Widg
private fun Details(
showAllWidgetDetails: Boolean,
widgets: List<PickableWidget>,
addButtonWidgetId: WidgetId?,
appIcons: Map<WidgetAppId, WidgetAppIcon>,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
onAddButtonToggle: (WidgetId) -> Unit
) {
widgets.forEachIndexed { index, widgetItem ->
val appId = widgetItem.appId
@@ -159,7 +222,10 @@ private fun Details(
WidgetDetails(
widget = widgetItem,
showAllDetails = showAllWidgetDetails,
showAddButton = addButtonWidgetId == widgetItem.id,
appIcon = appIcon,
onWidgetAddClick = onWidgetInteraction,
onAddButtonToggle = onAddButtonToggle,
modifier =
Modifier.semantics(mergeDescendants = true) { traversalIndex = index.toFloat() },
)
@@ -310,8 +376,8 @@ private fun Placeable.PlacementScope.placeRows(
// Move to next row
yPosition +=
measuredRow.tallestPreviewHeight +
measuredRow.tallestDetailsHeight +
rowVerticalSpacingPx
measuredRow.tallestDetailsHeight +
rowVerticalSpacingPx
}
}
@@ -377,3 +443,5 @@ private object WidgetGridDimensions {
const val MAX_ITEMS_PER_ROW = 3
val minItemWidth = 100.dp
}
private val WIDGET_PREVIEW_TEST_TAG = widgetPickerTestTag("widget_preview")
@@ -21,7 +21,7 @@ import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import com.android.launcher3.widgetpicker.WidgetPickerEventListeners
import com.android.launcher3.widgetpicker.ui.WidgetPickerEventListeners
import com.android.launcher3.widgetpicker.ui.components.ModalBottomSheetHeightStyle
import com.android.launcher3.widgetpicker.ui.components.TitledBottomSheet
import com.android.launcher3.widgetpicker.ui.components.TitledBottomSheetDefaults
@@ -62,7 +62,7 @@ class FullWidgetsCatalog @Inject constructor(
TitledBottomSheet(
title = viewModel.title.takeIf { !isCompactHeight },
modifier = Modifier,
description = null,
description = viewModel.description,
heightStyle = ModalBottomSheetHeightStyle.FILL_HEIGHT,
showDragHandle = true,
onDismissRequest = { eventListeners.onClose() },
@@ -72,6 +72,8 @@ class FullWidgetsCatalog @Inject constructor(
LandingScreen(
isCompact = isCompactWidth,
onEnterSearchMode = { viewModel.onActiveScreenChange(Screen.SEARCH) },
onWidgetInteraction = eventListeners::onWidgetInteraction,
showDragShadow = viewModel.showDragShadow,
viewModel = viewModel.landingScreenViewModel,
)
}
@@ -80,6 +82,8 @@ class FullWidgetsCatalog @Inject constructor(
SearchScreen(
isCompact = isCompactWidth,
onExitSearchMode = { viewModel.onActiveScreenChange(Screen.LANDING) },
onWidgetInteraction = eventListeners::onWidgetInteraction,
showDragShadow = viewModel.showDragShadow,
viewModel = viewModel.searchScreenViewModel,
)
}
@@ -45,6 +45,7 @@ class FullWidgetsCatalogViewModel @AssistedInject constructor(
val title: String? = hostInfo.title
val description: String? = hostInfo.description
val showDragShadow: Boolean = hostInfo.showDragShadow
var activeScreen by mutableStateOf(Screen.LANDING)
private set
@@ -18,12 +18,14 @@ package com.android.launcher3.widgetpicker.ui.fullcatalog.screens.landing
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.android.launcher3.widgetpicker.shared.model.WidgetAppId
import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo
import com.android.launcher3.widgetpicker.ui.components.WidgetsGrid
import com.android.launcher3.widgetpicker.ui.components.WidgetsSearchBar
@@ -32,12 +34,17 @@ import com.android.launcher3.widgetpicker.ui.components.WidgetsSearchBar
*
* @param isCompact indicates whether to show the compact single pane layout or the two pane layout.
* @param onEnterSearchMode callback for when user focuses on the search bar.
* @param onWidgetInteraction callback for when user interacts with a widget.
* @param showDragShadow indicates whether to show the drag shadow when user long presses on a
* widget to drag it.
* @param viewModel the view model backing the state and data for the landing screen.
*/
@Composable
fun LandingScreen(
isCompact: Boolean,
onEnterSearchMode: () -> Unit,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
showDragShadow: Boolean,
viewModel: LandingScreenViewModel,
) {
val browseState = viewModel.browseWidgetsState
@@ -74,6 +81,8 @@ fun LandingScreen(
onPersonalWidgetAppToggle = viewModel::onSelectedPersonalAppToggle,
selectedWorkWidgetAppId = viewModel.selectedWorkAppId,
onWorkWidgetAppToggle = viewModel::onSelectedWorkAppToggle,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
}
@@ -92,16 +101,21 @@ private fun LandingScreen(
onPersonalWidgetAppToggle: (WidgetAppId?) -> Unit,
selectedWorkWidgetAppId: WidgetAppId?,
onWorkWidgetAppToggle: (WidgetAppId?) -> Unit,
) {
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
showDragShadow: Boolean,
) {
val featuredWidgetsContent: @Composable () -> Unit = {
WidgetsGrid(
modifier = Modifier
.fillMaxSize()
.wrapContentSize()
.verticalScroll(rememberScrollState()),
widgetSizeGroups = featuredWidgetsState.sizeGroups,
showAllWidgetDetails = false,
previews = featuredWidgetPreviewsState.previews,
appIcons = widgetAppIconsState.icons
appIcons = widgetAppIconsState.icons,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
@@ -117,7 +131,9 @@ private fun LandingScreen(
selectedPersonalWidgetAppId = selectedPersonalWidgetAppId,
onPersonalWidgetAppToggle = onPersonalWidgetAppToggle,
selectedWorkWidgetAppId = selectedWorkWidgetAppId,
onWorkWidgetAppToggle = onWorkWidgetAppToggle
onWorkWidgetAppToggle = onWorkWidgetAppToggle,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
else ->
@@ -133,6 +149,8 @@ private fun LandingScreen(
onPersonalWidgetAppToggle = onPersonalWidgetAppToggle,
selectedWorkWidgetAppId = selectedWorkWidgetAppId,
onWorkWidgetAppToggle = onWorkWidgetAppToggle,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
}
@@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
import com.android.launcher3.widgetpicker.R
import com.android.launcher3.widgetpicker.shared.model.WidgetAppId
import com.android.launcher3.widgetpicker.shared.model.WidgetUserProfile
import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo
import com.android.launcher3.widgetpicker.ui.components.AppHeaderDescriptionStyle
import com.android.launcher3.widgetpicker.ui.components.LeadingIconToolbarTab
import com.android.launcher3.widgetpicker.ui.components.ScrollableFloatingToolbar
@@ -77,6 +78,8 @@ fun LandingScreenSinglePane(
onPersonalWidgetAppToggle: (WidgetAppId) -> Unit,
selectedWorkWidgetAppId: WidgetAppId?,
onWorkWidgetAppToggle: (WidgetAppId) -> Unit,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
showDragShadow: Boolean,
) {
val hasWorkProfile = remember(browseWidgetsState) { browseWidgetsState.workProfile != null }
@@ -133,7 +136,9 @@ fun LandingScreenSinglePane(
onPersonalWidgetAppToggle(widgetApp.id)
},
appIcons = widgetAppIconsState.icons,
widgetPreviews = personalWidgetPreviewsState.previews
widgetPreviews = personalWidgetPreviewsState.previews,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
}
@@ -150,7 +155,9 @@ fun LandingScreenSinglePane(
onWorkWidgetAppToggle(widgetApp.id)
},
appIcons = widgetAppIconsState.icons,
widgetPreviews = workWidgetPreviewsState.previews
widgetPreviews = workWidgetPreviewsState.previews,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
}
@@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp
import com.android.launcher3.widgetpicker.R
import com.android.launcher3.widgetpicker.shared.model.WidgetAppId
import com.android.launcher3.widgetpicker.shared.model.WidgetUserProfile
import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo
import com.android.launcher3.widgetpicker.ui.components.AppHeaderDescriptionStyle
import com.android.launcher3.widgetpicker.ui.components.LeadingIconToolbarTab
import com.android.launcher3.widgetpicker.ui.components.ScrollableFloatingToolbar
@@ -63,7 +64,6 @@ import com.android.launcher3.widgetpicker.ui.fullcatalog.screens.landing.Landing
import com.android.launcher3.widgetpicker.ui.fullcatalog.screens.landing.LandingScreenTwoPaneDimens.pagerItemsSpacing
import kotlinx.coroutines.launch
/**
* A composable function that provides a two pane layout for landing screen of the full catalog
* of widgets in the widget picker.
@@ -83,7 +83,9 @@ fun LandingScreenTwoPane(
onPersonalWidgetAppToggle: (WidgetAppId?) -> Unit,
selectedWorkWidgetAppId: WidgetAppId?,
onWorkWidgetAppToggle: (WidgetAppId?) -> Unit,
) {
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
showDragShadow: Boolean,
) {
val hasWorkProfile = remember(browseWidgetsState) { browseWidgetsState.workProfile != null }
var isFeaturedSectionShowing by rememberSaveable { mutableStateOf(true) }
val pageCount = remember {
@@ -128,6 +130,8 @@ fun LandingScreenTwoPane(
isFeaturedSectionShowing = false
onWorkWidgetAppToggle(id)
},
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
},
rightPaneTitle = rightPaneTitle(
@@ -148,6 +152,8 @@ fun LandingScreenTwoPane(
widgetAppIconsState = widgetAppIconsState,
selectedWorkWidgetAppId = selectedWorkWidgetAppId,
workWidgetPreviewsState = workWidgetPreviewsState,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
)
@@ -203,8 +209,10 @@ private fun RightPaneContent(
personalWidgetPreviewsState: PreviewsState,
widgetAppIconsState: AppIconsState,
selectedWorkWidgetAppId: WidgetAppId?,
workWidgetPreviewsState: PreviewsState
) {
workWidgetPreviewsState: PreviewsState,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
showDragShadow: Boolean,
) {
when {
isFeaturedSectionSelected -> featuredWidgets()
@@ -227,7 +235,9 @@ private fun RightPaneContent(
showAllWidgetDetails = true,
widgetSizeGroups = selectedPersonalWidgets,
previews = personalWidgetPreviewsState.previews,
appIcons = widgetAppIconsState.icons
appIcons = widgetAppIconsState.icons,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
}
@@ -251,7 +261,9 @@ private fun RightPaneContent(
showAllWidgetDetails = true,
widgetSizeGroups = selectedWorkWidgets,
previews = workWidgetPreviewsState.previews,
appIcons = widgetAppIconsState.icons
appIcons = widgetAppIconsState.icons,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
}
@@ -273,7 +285,9 @@ private fun LeftPaneContent(
selectedWorkWidgetAppId: WidgetAppId?,
workWidgetPreviewsState: PreviewsState,
onWorkWidgetAppToggle: (WidgetAppId) -> Unit,
) {
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
showDragShadow: Boolean,
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
SelectableSuggestionsHeader(
@@ -303,7 +317,9 @@ private fun LeftPaneContent(
selectedPersonalWidgetAppId = selectedPersonalWidgetAppId,
widgetAppIconsState = widgetAppIconsState,
personalWidgetPreviewsState = personalWidgetPreviewsState,
onPersonalWidgetAppToggle = onPersonalWidgetAppToggle
onPersonalWidgetAppToggle = onPersonalWidgetAppToggle,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
@@ -313,7 +329,9 @@ private fun LeftPaneContent(
selectedWorkWidgetAppId = selectedWorkWidgetAppId,
widgetAppIconsState = widgetAppIconsState,
workWidgetPreviewsState = workWidgetPreviewsState,
onWorkWidgetAppToggle = onWorkWidgetAppToggle
onWorkWidgetAppToggle = onWorkWidgetAppToggle,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
}
@@ -337,8 +355,10 @@ private fun PersonalSection(
selectedPersonalWidgetAppId: WidgetAppId?,
widgetAppIconsState: AppIconsState,
personalWidgetPreviewsState: PreviewsState,
onPersonalWidgetAppToggle: (WidgetAppId) -> Unit
) {
onPersonalWidgetAppToggle: (WidgetAppId) -> Unit,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
showDragShadow: Boolean,
) {
Box(modifier = Modifier.fillMaxSize()) {
WidgetAppsList(
modifier = Modifier.fillMaxSize(),
@@ -351,6 +371,8 @@ private fun PersonalSection(
onWidgetAppClick = { widgetApp ->
onPersonalWidgetAppToggle(widgetApp.id)
},
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
}
@@ -361,8 +383,10 @@ private fun WorkSection(
selectedWorkWidgetAppId: WidgetAppId?,
widgetAppIconsState: AppIconsState,
workWidgetPreviewsState: PreviewsState,
onWorkWidgetAppToggle: (WidgetAppId) -> Unit
) {
onWorkWidgetAppToggle: (WidgetAppId) -> Unit,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
showDragShadow: Boolean,
) {
Box(modifier = Modifier.fillMaxSize()) {
WidgetAppsList(
modifier = Modifier.fillMaxSize(),
@@ -375,6 +399,8 @@ private fun WorkSection(
onWidgetAppClick = { widgetApp ->
onWorkWidgetAppToggle(widgetApp.id)
},
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
}
@@ -28,6 +28,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.android.launcher3.widgetpicker.R
import com.android.launcher3.widgetpicker.shared.model.WidgetAppId
import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo
import com.android.launcher3.widgetpicker.ui.components.AppHeaderDescriptionStyle
import com.android.launcher3.widgetpicker.ui.components.SinglePaneLayout
import com.android.launcher3.widgetpicker.ui.components.TwoPaneLayout
@@ -45,7 +46,9 @@ import com.android.launcher3.widgetpicker.ui.fullcatalog.screens.landing.Preview
fun SearchScreen(
isCompact: Boolean,
onExitSearchMode: () -> Unit,
viewModel: SearchScreenViewModel,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
showDragShadow: Boolean,
viewModel: SearchScreenViewModel
) {
SearchScreen(
isCompact = isCompact,
@@ -57,6 +60,8 @@ fun SearchScreen(
onSearch = viewModel::onQueryChange,
onSelectedWidgetAppToggle = viewModel::onSelectedWidgetAppToggle,
onExitSearchMode = onExitSearchMode,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
@@ -71,6 +76,8 @@ private fun SearchScreen(
onSearch: (String) -> Unit,
onSelectedWidgetAppToggle: (id: WidgetAppId) -> Unit,
onExitSearchMode: () -> Unit,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
showDragShadow: Boolean,
) {
val searchBar: @Composable () -> Unit = {
WidgetsSearchBar(
@@ -92,7 +99,9 @@ private fun SearchScreen(
selectedWidgetAppId = selectedWidgetAppId,
appIconsState = appIconsState,
widgetPreviewsState = widgetPreviewsState,
onSelectedWidgetAppChange = onSelectedWidgetAppToggle
onSelectedWidgetAppChange = onSelectedWidgetAppToggle,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
} else {
SearchScreenTwoPane(
@@ -101,7 +110,9 @@ private fun SearchScreen(
selectedWidgetAppId = selectedWidgetAppId,
appIconsState = appIconsState,
widgetPreviewsState = widgetPreviewsState,
onSelectedWidgetAppChange = onSelectedWidgetAppToggle
onSelectedWidgetAppChange = onSelectedWidgetAppToggle,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
}
@@ -113,7 +124,9 @@ private fun SearchScreenSinglePane(
selectedWidgetAppId: WidgetAppId?,
appIconsState: AppIconsState,
widgetPreviewsState: PreviewsState,
onSelectedWidgetAppChange: (id: WidgetAppId) -> Unit
onSelectedWidgetAppChange: (id: WidgetAppId) -> Unit,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
showDragShadow: Boolean,
) {
SinglePaneLayout(
searchBar = searchBar,
@@ -129,7 +142,9 @@ private fun SearchScreenSinglePane(
onSelectedWidgetAppChange(widgetApp.id)
},
appIcons = appIconsState.icons,
widgetPreviews = widgetPreviewsState.previews
widgetPreviews = widgetPreviewsState.previews,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
}
@@ -143,7 +158,9 @@ fun SearchScreenTwoPane(
selectedWidgetAppId: WidgetAppId?,
appIconsState: AppIconsState,
widgetPreviewsState: PreviewsState,
onSelectedWidgetAppChange: (id: WidgetAppId) -> Unit
onSelectedWidgetAppChange: (id: WidgetAppId) -> Unit,
onWidgetInteraction: (WidgetInteractionInfo) -> Unit,
showDragShadow: Boolean,
) {
TwoPaneLayout(
searchBar = searchBar,
@@ -160,6 +177,8 @@ fun SearchScreenTwoPane(
onWidgetAppClick = { widgetApp ->
onSelectedWidgetAppChange(widgetApp.id)
},
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
},
@@ -186,7 +205,9 @@ fun SearchScreenTwoPane(
showAllWidgetDetails = true,
widgetSizeGroups = selectedWidgets,
previews = widgetPreviewsState.previews,
appIcons = appIconsState.icons
appIcons = appIconsState.icons,
onWidgetInteraction = onWidgetInteraction,
showDragShadow = showDragShadow,
)
}
},
+1
View File
@@ -96,6 +96,7 @@ filegroup {
"multivalentTests/src/com/android/launcher3/widgetpicker/repository/FakeWidgetAppIconsRepository.kt",
"multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenSinglePaneTest.kt",
"multivalentTests/src/com/android/launcher3/widgetpicker/ui/fullcatalog/screens/landing/LandingScreenTwoPaneTest.kt",
"multivalentTests/src/com/android/launcher3/widgetpicker/ui/components/WidgetInteractionsTest.kt",
],
}
@@ -138,8 +138,10 @@ private fun GridPreview(
WidgetsGrid(
widgetSizeGroups = groups,
showAllWidgetDetails = true,
showDragShadow = false,
previews = previews,
modifier = Modifier.fillMaxWidth().wrapContentHeight(),
onWidgetInteraction = {}
)
}
}
@@ -19,11 +19,15 @@ package com.android.launcher3.widgetpicker
import android.appwidget.AppWidgetProviderInfo
import android.appwidget.AppWidgetProviderInfo.WIDGET_CATEGORY_HOME_SCREEN
import android.content.ComponentName
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.os.UserHandle
import com.android.launcher3.widgetpicker.shared.model.PickableWidget
import com.android.launcher3.widgetpicker.shared.model.WidgetApp
import com.android.launcher3.widgetpicker.shared.model.WidgetAppId
import com.android.launcher3.widgetpicker.shared.model.WidgetId
import com.android.launcher3.widgetpicker.shared.model.WidgetPreview
import com.android.launcher3.widgetpicker.shared.model.WidgetSizeInfo
import com.android.launcher3.widgetpicker.shared.model.WidgetUserProfile
import com.android.launcher3.widgetpicker.shared.model.WidgetUserProfileType
@@ -114,7 +118,10 @@ object TestUtils {
appId = finalWidgetAppId,
label = providerClassName,
description = null,
appWidgetProviderInfo = AppWidgetProviderInfo().apply { widgetCategory = category },
appWidgetProviderInfo = AppWidgetProviderInfo().apply {
widgetCategory = category
provider = ComponentName.createRelative(PACKAGE_NAME, providerClassName)
},
sizeInfo =
WidgetSizeInfo(
spanX = 2,
@@ -128,4 +135,15 @@ object TestUtils {
),
)
}
fun createBitmapPreview(
width: Int = 200,
height: Int = 200,
color: Int = Color.RED,
): WidgetPreview.BitmapWidgetPreview {
val bitmap: Bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
Canvas(bitmap).drawColor(color)
return WidgetPreview.BitmapWidgetPreview(bitmap = bitmap)
}
}
@@ -0,0 +1,179 @@
/*
* Copyright (C) 2025 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.launcher3.widgetpicker.ui.components
import android.platform.test.rule.AllowedDevices
import android.platform.test.rule.DeviceProduct
import android.platform.test.rule.LimitDevicesRule
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.isNotDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.launcher3.widgetpicker.TestUtils
import com.android.launcher3.widgetpicker.TestUtils.PERSONAL_TEST_APPS
import com.android.launcher3.widgetpicker.ui.WidgetInteractionInfo
import com.android.launcher3.widgetpicker.ui.model.WidgetSizeGroup
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/** Tests for widget interactions e.g. tap to add. */
@RunWith(AndroidJUnit4::class)
@AllowedDevices(allowed = [DeviceProduct.CF_PHONE])
class WidgetInteractionsTest {
@get:Rule
val limitDevicesRule = LimitDevicesRule()
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun tapPreview_andClickAdd() {
composeTestRule.setContent { TapToAddTestComposable() }
composeTestRule.waitForIdle()
// tap on preview for widget 1
composeTestRule.onAllNodesWithTag(PREVIEW_TEST_TAG)
.assertCountEquals(2)
.onFirst()
.performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(WIDGET_ONE.label).isNotDisplayed() // label text not shown
composeTestRule.onAllNodesWithText(ADD_BUTTON_TEXT).assertCountEquals(1)
composeTestRule.onNodeWithContentDescription(WIDGET_ONE_ADD_BUTTON_CONTENT_DESC)
.assertExists()
.performClick()
composeTestRule.waitForIdle()
// widget interaction callback invoked and correct provider info returned.
composeTestRule.onNodeWithText(WIDGET_ONE.appWidgetProviderInfo.provider.toString())
.assertExists()
// tap again on preview for widget 1
composeTestRule.onAllNodesWithTag(PREVIEW_TEST_TAG)
.assertCountEquals(2)
.onFirst()
.performClick()
// No add button
composeTestRule.onAllNodesWithText(ADD_BUTTON_TEXT).assertCountEquals(0)
// label text shown
composeTestRule.onNodeWithText(WIDGET_ONE.label).isDisplayed()
}
@Test
fun tapPreview_togglesAddButton_onlyOneShownAtATime() {
composeTestRule.setContent { TapToAddTestComposable() }
composeTestRule.waitForIdle()
// tap on preview for widget 1
composeTestRule.onAllNodesWithTag(PREVIEW_TEST_TAG)
.assertCountEquals(2)
.onFirst()
.performClick()
composeTestRule.waitForIdle()
// widget 1 - label text not shown, widget 2 - label still shown
composeTestRule.onNodeWithText(WIDGET_ONE.label).isNotDisplayed()
composeTestRule.onNodeWithText(WIDGET_TWO.label).isNotDisplayed()
composeTestRule.onAllNodesWithText(ADD_BUTTON_TEXT).assertCountEquals(1)
composeTestRule.onNodeWithContentDescription(WIDGET_ONE_ADD_BUTTON_CONTENT_DESC)
.assertExists()
// tap on preview for widget 2
composeTestRule.onAllNodesWithTag(PREVIEW_TEST_TAG)
.assertCountEquals(2)
.onLast()
.performClick()
composeTestRule.waitForIdle()
// now, widget 1 - label text shown & widget 2 - label not shown
composeTestRule.onNodeWithText(WIDGET_ONE.label).isDisplayed()
composeTestRule.onNodeWithText(WIDGET_TWO.label).isNotDisplayed()
composeTestRule.onAllNodesWithText(ADD_BUTTON_TEXT).assertCountEquals(1)
composeTestRule.onNodeWithContentDescription(WIDGET_TWO_ADD_BUTTON_CONTENT_DESC)
.assertExists()
}
@Composable
fun TapToAddTestComposable() {
var provider by remember { mutableStateOf("invalid") }
Column(modifier = Modifier.fillMaxSize()) {
Text(provider)
WidgetsGrid(
widgetSizeGroups = listOf(TEST_WIDGET_GROUP),
showAllWidgetDetails = false,
previews = PREVIEWS,
modifier = Modifier.weight(1f),
appIcons = emptyMap(),
showDragShadow = false,
onWidgetInteraction = { widgetInteractionInfo ->
if (widgetInteractionInfo is WidgetInteractionInfo.WidgetAddInfo) {
provider = widgetInteractionInfo.providerInfo.provider.toString()
}
},
)
}
}
companion object {
private val WIDGET_ONE = PERSONAL_TEST_APPS[0].widgets[0]
private val WIDGET_TWO = PERSONAL_TEST_APPS[1].widgets[0]
private val TEST_WIDGET_GROUP = WidgetSizeGroup(
previewContainerHeightPx = 200,
previewContainerWidthPx = 200,
widgets = listOf(WIDGET_ONE, WIDGET_TWO)
)
private val PREVIEWS = mapOf(
WIDGET_ONE.id to TestUtils.createBitmapPreview(),
WIDGET_TWO.id to TestUtils.createBitmapPreview()
)
private val PREVIEW_TEST_TAG = widgetPickerTestTag("widget_preview")
private const val ADD_BUTTON_TEXT = "Add"
private val WIDGET_ONE_ADD_BUTTON_CONTENT_DESC = "Add ${WIDGET_ONE.label} widget"
private val WIDGET_TWO_ADD_BUTTON_CONTENT_DESC = "Add ${WIDGET_TWO.label} widget"
}
}
@@ -26,6 +26,7 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.hasTextExactly
import androidx.compose.ui.test.junit4.createAndroidComposeRule
@@ -65,6 +66,7 @@ import org.junit.runner.RunWith
class LandingScreenSinglePaneTest {
@get:Rule
val limitDevicesRule = LimitDevicesRule()
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@@ -123,6 +125,8 @@ class LandingScreenSinglePaneTest {
LandingScreen(
isCompact = true,
onEnterSearchMode = {},
onWidgetInteraction = {},
showDragShadow = true,
viewModel = viewModel,
)
}
@@ -141,8 +145,10 @@ class LandingScreenSinglePaneTest {
composeTestRule.onNode(hasTextExactly(PERSONAL_LABEL)).assertExists().assertIsNotSelected()
composeTestRule.onNode(hasTextExactly(WORK_LABEL)).assertExists().assertIsNotSelected()
// Featured Widgets state
composeTestRule.onNode(hasTextExactly(featuredWidgetA.label)).assertExists()
composeTestRule.onNode(hasTextExactly(featuredWidgetB.label)).assertExists()
composeTestRule.onNode(hasContentDescription(featuredWidgetA.label, substring = true))
.assertExists()
composeTestRule.onNode(hasContentDescription(featuredWidgetB.label, substring = true))
.assertExists()
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -163,7 +169,8 @@ class LandingScreenSinglePaneTest {
.assertIsNotSelected()
composeTestRule.onNode(hasTextExactly(WORK_LABEL)).assertExists().assertIsNotSelected()
// No recommendations showing
composeTestRule.onNode(hasTextExactly(featuredWidgetA.label)).assertIsNotDisplayed()
composeTestRule.onNode(hasContentDescription(featuredWidgetA.label, substring = true))
.assertIsNotDisplayed()
// Has list of personal apps showing
composeTestRule.onNode(hasText(PERSONAL_TEST_APPS[0].title!!.toString()))
.assertExists()
@@ -196,7 +203,8 @@ class LandingScreenSinglePaneTest {
composeTestRule.onNode(hasTextExactly(browseTabLabel)).assertExists().assertIsNotSelected()
composeTestRule.onNode(hasTextExactly(browseTabLabel)).onSiblings().assertCountEquals(1)
// featured widgets showing
composeTestRule.onNode(hasTextExactly(featuredWidgetA.label)).assertIsDisplayed()
composeTestRule.onNode(hasContentDescription(featuredWidgetA.label, substring = true))
.assertIsDisplayed()
// Widget apps list is not showing.
composeTestRule.onNode(hasText(PERSONAL_TEST_APPS[0].title!!.toString()))
.assertIsNotDisplayed()
@@ -29,6 +29,7 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.hasTextExactly
import androidx.compose.ui.test.isSelected
@@ -68,6 +69,7 @@ import org.junit.runner.RunWith
class LandingScreenTwoPaneTest {
@get:Rule
val limitDevicesRule = LimitDevicesRule()
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@@ -126,6 +128,8 @@ class LandingScreenTwoPaneTest {
LandingScreen(
isCompact = false,
onEnterSearchMode = {},
onWidgetInteraction = {},
showDragShadow = true,
viewModel = viewModel,
)
}
@@ -144,8 +148,10 @@ class LandingScreenTwoPaneTest {
composeTestRule.onNode(hasTextExactly(PERSONAL_LABEL)).assertExists().assertIsSelected()
composeTestRule.onNode(hasTextExactly(WORK_LABEL)).assertExists().assertIsNotSelected()
// Featured Widgets state
composeTestRule.onNode(hasTextExactly(featuredWidgetA.label)).assertExists()
composeTestRule.onNode(hasTextExactly(featuredWidgetB.label)).assertExists()
composeTestRule.onNode(hasContentDescription(featuredWidgetA.label, substring = true))
.assertExists()
composeTestRule.onNode(hasContentDescription(featuredWidgetB.label, substring = true))
.assertExists()
// List on left showing personal apps
composeTestRule.onNode(hasText(PERSONAL_TEST_APPS[0].title!!.toString()))
.assertExists().assertIsNotSelected()
@@ -175,7 +181,8 @@ class LandingScreenTwoPaneTest {
// to click on specific app header to see the widgets.
composeTestRule.onNode(hasText(featuredTabLabel)).assertIsSelected()
// No recommendations showing
composeTestRule.onNode(hasText(featuredWidgetA.label)).assertIsDisplayed()
composeTestRule.onNode(hasContentDescription(featuredWidgetA.label, substring = true))
.assertIsDisplayed()
// Has list of work apps showing
composeTestRule.onNode(hasText(WORK_TEST_APPS[0].title!!.toString()))
.assertExists()
@@ -213,7 +220,12 @@ class LandingScreenTwoPaneTest {
composeTestRule.onNode(hasText(appToSelect)).assertIsSelected()
composeTestRule.onNode(hasText(featuredTabLabel)).assertIsNotSelected()
// widgets for the selected app are showing
composeTestRule.onNode(hasText(PERSONAL_TEST_APPS[1].widgets[0].label))
composeTestRule.onNode(
hasContentDescription(
PERSONAL_TEST_APPS[1].widgets[0].label,
substring = true
)
)
.assertIsDisplayed()
val rightPaneTitleAfter = context.resources.getString(
R.string.widget_picker_right_pane_accessibility_label,
@@ -248,11 +260,16 @@ class LandingScreenTwoPaneTest {
// No toolbar i.e. browse tab
composeTestRule.onNode(hasTextExactly(browseTabLabel)).assertDoesNotExist()
// featured widgets showing
composeTestRule.onNode(hasTextExactly(featuredWidgetA.label))
composeTestRule.onNode(hasContentDescription(featuredWidgetA.label, substring = true))
.assertExists()
.assertIsDisplayed()
// But not other widgets
composeTestRule.onNode(hasText(PERSONAL_TEST_APPS[1].widgets[0].label))
composeTestRule.onNode(
hasContentDescription(
PERSONAL_TEST_APPS[1].widgets[0].label,
substring = true
)
)
.assertDoesNotExist()
// Widget apps list shown on left
composeTestRule.onNode(hasText(PERSONAL_TEST_APPS[0].title!!.toString()))
@@ -114,7 +114,9 @@ class SearchScreenTest {
SearchScreen(
isCompact = isCompact,
onExitSearchMode = {},
viewModel = viewModel,
onWidgetInteraction = {},
showDragShadow = true,
viewModel = viewModel
)
}
@@ -20,6 +20,7 @@ import static android.view.View.MeasureSpec.EXACTLY;
import static android.view.View.MeasureSpec.makeMeasureSpec;
import static com.android.launcher3.LauncherAnimUtils.VIEW_ALPHA;
import static com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APPWIDGET;
import static com.android.launcher3.icons.FastBitmapDrawable.getDisabledColorFilter;
import static com.android.launcher3.util.Executors.MODEL_EXECUTOR;
@@ -58,6 +59,7 @@ import androidx.dynamicanimation.animation.SpringAnimation;
import androidx.dynamicanimation.animation.SpringForce;
import com.android.app.animation.Interpolators;
import com.android.launcher3.Flags;
import com.android.launcher3.R;
import com.android.launcher3.Utilities;
import com.android.launcher3.graphics.ThemeManager;
@@ -116,6 +118,7 @@ public abstract class DragView<T extends Context & ActivityContext> extends Fram
private SpringFloatValue mTranslateX, mTranslateY;
private Path mScaledMaskPath;
private Drawable mBadge;
private int mItemType;
public DragView(T launcher, Drawable drawable, int registrationX,
int registrationY, final float initialScale, final float scaleOnDrop,
@@ -244,6 +247,7 @@ public abstract class DragView<T extends Context & ActivityContext> extends Fram
*/
@TargetApi(Build.VERSION_CODES.O)
public void setItemInfo(final ItemInfo info) {
mItemType = info.itemType;
// Load the adaptive icon on a background thread and add the view in ui thread.
MODEL_EXECUTOR.getHandler().postAtFrontOfQueue(() -> {
ThemeManager themeManager = ThemeManager.INSTANCE.get(getContext());
@@ -627,7 +631,13 @@ public abstract class DragView<T extends Context & ActivityContext> extends Fram
for (int i = dragLayer.getChildCount() - 1; i >= 0; i--) {
View child = dragLayer.getChildAt(i);
if (child instanceof DragView) {
dragLayer.removeView(child);
// Widgets uses a listener to remove views.
// When widgets are dropped from another window, we don't want to remove the
// dragView on resume of launcher.
if (Flags.enableWidgetPickerRefactor()
&& ((DragView<?>) child).mItemType != ITEM_TYPE_APPWIDGET) {
dragLayer.removeView(child);
}
}
}
}
@@ -29,6 +29,7 @@ import android.view.View;
import android.view.View.MeasureSpec;
import android.widget.RemoteViews;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.launcher3.DeviceProfile;
@@ -44,6 +45,9 @@ import com.android.launcher3.icons.BaseIconFactory;
import com.android.launcher3.icons.FastBitmapDrawable;
import com.android.launcher3.icons.LauncherIcons;
import com.android.launcher3.icons.RoundDrawableWrapper;
import com.android.launcher3.widget.DatabaseWidgetPreviewLoader.WidgetPreviewInfo;
import java.util.Objects;
/**
* Extension of {@link DragPreviewProvider} with logic specific to pending widgets/shortcuts
@@ -57,6 +61,7 @@ public class PendingItemDragHelper extends DragPreviewProvider {
private int[] mEstimatedCellSize;
@Nullable private RemoteViews mRemoteViewsPreview;
@Nullable private WidgetPreviewInfo mWidgetPreviewInfo;
private float mRemoteViewsPreviewScale = 1f;
@Nullable private NavigableAppWidgetHostView mAppWidgetHostViewPreview;
private final float mEnforcedRoundedCornersForWidget;
@@ -68,6 +73,13 @@ public class PendingItemDragHelper extends DragPreviewProvider {
view.getContext());
}
/**
* Set the necessary information about the preview, so a preview can be built for drag and drop.
*/
public void setWidgetPreviewInfo(@NonNull WidgetPreviewInfo previewInfo) {
mWidgetPreviewInfo = previewInfo;
}
/**
* Sets a {@link RemoteViews} which shows an app widget preview provided by app developers in
* the pin widget flow.
@@ -115,7 +127,30 @@ public class PendingItemDragHelper extends DragPreviewProvider {
int[] previewSizeBeforeScale = new int[1];
if (mRemoteViewsPreview != null) {
if (mWidgetPreviewInfo != null) {
if (mWidgetPreviewInfo.previewBitmap != null) {
Drawable drawable = new FastBitmapDrawable(mWidgetPreviewInfo.previewBitmap);
drawable = new RoundDrawableWrapper(drawable, mEnforcedRoundedCornersForWidget);
preview = drawable;
if (drawable.getIntrinsicWidth() > 0
&& drawable.getIntrinsicHeight() > 0) {
previewSizeBeforeScale[0] = drawable.getIntrinsicWidth();
}
} else {
mAppWidgetHostViewPreview = new LauncherAppWidgetHostView(launcher);
mAppWidgetHostViewPreview.setAppWidget(/* appWidgetId= */ -1,
mWidgetPreviewInfo.providerInfo);
mAppWidgetHostViewPreview.setClipChildren(false);
mAppWidgetHostViewPreview.setClipToPadding(false);
mAppWidgetHostViewPreview.updateAppWidget(/* remoteViews= */
mWidgetPreviewInfo.remoteViews);
DeviceProfile deviceProfile = launcher.getDeviceProfile();
Size widgetSizes = getWidgetSizePx(deviceProfile, mAddInfo.spanX,
mAddInfo.spanY);
measureAndUpdateAppWidgetHostViewScale(widgetSizes);
}
} else if (mRemoteViewsPreview != null) {
mAppWidgetHostViewPreview = new LauncherAppWidgetHostView(launcher);
mAppWidgetHostViewPreview.setAppWidget(/* appWidgetId= */ -1,
((PendingAddWidgetInfo) mAddInfo).info);
@@ -215,6 +250,38 @@ public class PendingItemDragHelper extends DragPreviewProvider {
}
}
private void measureAndUpdateAppWidgetHostViewScale(Size widgetSizes) {
Objects.requireNonNull(mAppWidgetHostViewPreview).measure(
MeasureSpec.makeMeasureSpec(widgetSizes.getWidth(),
MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(widgetSizes.getHeight(),
MeasureSpec.EXACTLY));
// Scale the preview to fit the widget's size. Not all widgets fill bounds, so we need to
// scale them.
if (mAppWidgetHostViewPreview.getChildCount() == 1) {
View content = mAppWidgetHostViewPreview.getChildAt(0);
float contentWidth = content.getMeasuredWidth();
float contentHeight = content.getMeasuredHeight();
if (contentWidth > 0 && contentHeight > 0) {
// Take the content width based on the edge furthest from the center, so that when
// scaling the hostView, the farthest edge is still visible.
contentWidth = 2 * Math.max(contentWidth / 2 - content.getLeft(),
content.getRight() - contentWidth / 2);
contentHeight = 2 * Math.max(contentHeight / 2 - content.getTop(),
content.getBottom() - contentHeight / 2);
if (contentWidth > 0 && contentHeight > 0) {
float pWidth = widgetSizes.getWidth();
float pHeight = widgetSizes.getHeight();
mAppWidgetHostViewPreview.setScaleToFit(
Math.min(pWidth / contentWidth, pHeight / contentHeight));
}
}
}
}
@Override
protected Bitmap convertPreviewToAlphaBitmap(Bitmap preview) {
if (mAddInfo instanceof PendingAddShortcutInfo || mEstimatedCellSize == null) {
@@ -50,7 +50,7 @@ open class WidgetPickerActivity : BaseActivity() {
checkNotNull(_dragLayer).recreateControllers()
if (Flags.enableWidgetPickerRefactor() && isComposeAvailable()) {
component.widgetPickerComposeWrapper.showAllWidgets(this)
component.widgetPickerComposeWrapper.showAllWidgets(this, widgetPickerConfig)
}
}
@@ -17,6 +17,7 @@
package com.android.launcher3.widgetpicker
import android.os.UserHandle
import com.android.launcher3.widgetpicker.shared.model.HostConstraint
/**
* Possible parameters sent over by the widget host when launching the widget picker activity.
@@ -57,5 +58,23 @@ data class WidgetPickerConfig(
const val EXTRA_IS_PENDING_WIDGET_DRAG = "is_pending_widget_drag"
const val HOMESCREEN_WIDGETS_UI_SURFACE = "widgets"
/** Builds the host constraints to provide to the widget picker module. */
fun WidgetPickerConfig.asHostConstraints() =
buildList {
if (filteredUsers.isNotEmpty()) {
add(HostConstraint.HostUserConstraint(filteredUsers))
}
if (categoryInclusionFilter != 0
|| categoryExclusionFilter != 0
) {
add(
HostConstraint.HostCategoryConstraint(
categoryInclusionMask = categoryInclusionFilter,
categoryExclusionMask = categoryExclusionFilter,
)
)
}
}
}
}