From 1a45ae90037c85a6b5c6d6de5a860b4b3e801ae2 Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Thu, 18 Apr 2024 00:42:03 +0200 Subject: [PATCH] Add horizontal appwidget resizing --- .../ui/component/DragResizeHandle.kt | 183 ++++++++++++++++++ .../launcher/sheets/ConfigureWidgetSheet.kt | 137 +++++-------- .../widgets/clock/clocks/CustomClock.kt | 4 +- .../ui/launcher/widgets/external/AppWidget.kt | 31 ++- .../widgets/external/AppWidgetHost.kt | 15 +- .../de/mm20/launcher2/widgets/AppWidget.kt | 1 + 6 files changed, 262 insertions(+), 109 deletions(-) create mode 100644 app/ui/src/main/java/de/mm20/launcher2/ui/component/DragResizeHandle.kt diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/DragResizeHandle.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/DragResizeHandle.kt new file mode 100644 index 00000000..f1d54479 --- /dev/null +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/DragResizeHandle.kt @@ -0,0 +1,183 @@ +package de.mm20.launcher2.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.UnfoldMore +import androidx.compose.material3.Icon +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.rotate +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.coerceIn +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isUnspecified + +enum class ResizeAxis { + Horizontal, + Vertical, + Both, +} + +@Composable +fun DragResizeHandle( + resizeAxis: ResizeAxis = ResizeAxis.Both, + alignment: Alignment = Alignment.TopStart, + minWidth: Dp = 0.dp, + minHeight: Dp = 0.dp, + maxWidth: Dp = Dp.Infinity, + maxHeight: Dp = Dp.Infinity, + snapToMeasuredWidth: Boolean = false, + snapToMeasuredHeight: Boolean = false, + width: Dp = Dp.Unspecified, + height: Dp = Dp.Unspecified, + onResize: (width: Dp, height: Dp) -> Unit, + onResizeStopped: () -> Unit = { }, +) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize() + ) { + val measuredWidth = this.maxWidth + val measuredHeight = this.maxHeight + + val density = LocalDensity.current + + val hapticFeedback = LocalHapticFeedback.current + + Box( + modifier = Modifier + .then(if (width.isUnspecified) Modifier.fillMaxWidth() else Modifier.requiredWidth(width)) + .then(if (height.isUnspecified) Modifier.fillMaxHeight() else Modifier.requiredHeight(height)) + .align(alignment) + .border(1.dp, color = MaterialTheme.colorScheme.primary, MaterialTheme.shapes.small) + ) { + if (resizeAxis == ResizeAxis.Both || resizeAxis == ResizeAxis.Horizontal) { + val horizontalDragState = rememberDraggableState { + val currentWidth = if (width.isUnspecified) measuredWidth else width + val dragDelta = + when (alignment) { + Alignment.Center, Alignment.TopCenter, Alignment.BottomCenter -> it * 2 + else -> it + } + + val newWidth = (currentWidth + with(density) { dragDelta.toDp() }).coerceIn( + minWidth, + maxWidth + ) + + if (snapToMeasuredWidth && + maxWidth >= measuredWidth && + newWidth > measuredWidth - 16.dp && + dragDelta > 0 + ) { + if (!width.isUnspecified) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onResize(Dp.Unspecified, height) + } + } else { + onResize(newWidth, height) + } + + } + Box( + Modifier + .align(Alignment.CenterEnd) + .draggable( + state = horizontalDragState, + orientation = Orientation.Horizontal, + onDragStopped = { + onResizeStopped() + }, + startDragImmediately = true, + ) + .requiredSize(128.dp) + .offset(x = 64.dp) + ) { + Icon( + modifier = Modifier + .background(MaterialTheme.colorScheme.primary, CircleShape) + .padding(8.dp) + .rotate(90f) + .align(Alignment.Center), + imageVector = Icons.Rounded.UnfoldMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } + + if (resizeAxis == ResizeAxis.Both || resizeAxis == ResizeAxis.Vertical) { + val verticalDragState = rememberDraggableState { + val currentHeight = if (height.isUnspecified) measuredHeight else height + val dragDelta = + when (alignment) { + Alignment.Center, Alignment.CenterStart, Alignment.CenterEnd -> it * 2 + else -> it + } + val newHeight = (currentHeight + with(density) { dragDelta.toDp() }).coerceIn( + minHeight, + maxHeight + ) + + if (snapToMeasuredHeight && + maxHeight >= measuredHeight && + newHeight > measuredHeight - 16.dp && + dragDelta > 0 + ) { + if (!height.isUnspecified) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onResize(width, Dp.Unspecified) + } + } else { + onResize(width, newHeight) + } + } + Box( + Modifier + .align(Alignment.BottomCenter) + .draggable( + state = verticalDragState, + orientation = Orientation.Vertical, + onDragStopped = { + onResizeStopped() + }, + startDragImmediately = true, + ) + .requiredSize(128.dp) + .offset(y = 64.dp) + ) { + Icon( + modifier = Modifier + .background(MaterialTheme.colorScheme.primary, CircleShape) + .padding(8.dp) + .align(Alignment.Center), + imageVector = Icons.Rounded.UnfoldMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/ConfigureWidgetSheet.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/ConfigureWidgetSheet.kt index 5dd31be9..38121699 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/ConfigureWidgetSheet.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/ConfigureWidgetSheet.kt @@ -15,6 +15,8 @@ import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.layout.Box @@ -26,6 +28,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions @@ -65,13 +68,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isUnspecified import androidx.core.net.toUri import de.mm20.launcher2.calendar.CalendarRepository import de.mm20.launcher2.calendar.UserCalendar @@ -82,6 +89,7 @@ import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.base.LocalAppWidgetHost import de.mm20.launcher2.ui.component.BottomSheetDialog +import de.mm20.launcher2.ui.component.DragResizeHandle import de.mm20.launcher2.ui.component.LargeMessage import de.mm20.launcher2.ui.component.MissingPermissionBanner import de.mm20.launcher2.ui.component.preferences.CheckboxPreference @@ -338,111 +346,58 @@ fun ColumnScope.ConfigureAppWidget( return } - var dragDelta by remember { mutableStateOf(0) } Column { Box( modifier = Modifier .fillMaxWidth() - .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(bottom = 64.dp) + .background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.medium) ) { + var resizeWidth by remember { + mutableStateOf(widget.config.width?.dp ?: Dp.Unspecified) + } + + var resizeHeight by remember { + mutableStateOf(widget.config.height.dp) + } + AppWidgetHost( widgetInfo = widgetInfo, widgetId = widget.config.widgetId, - modifier = Modifier.fillMaxWidth(), - height = widget.config.height + dragDelta, + modifier = Modifier + .clip(MaterialTheme.shapes.medium) + .then(if (resizeWidth.isUnspecified) Modifier.fillMaxWidth() else Modifier.requiredWidth(resizeWidth)) + .height(resizeHeight) + .align(Alignment.TopCenter) + .pointerInput(Unit) { + awaitEachGesture { + val event = awaitFirstDown(pass = PointerEventPass.Initial) + event.consume() + } + }, borderless = widget.config.borderless, useThemeColors = widget.config.themeColors, onLightBackground = (!LocalDarkTheme.current && widget.config.background) || LocalPreferDarkContentOverWallpaper.current ) - } - val density = LocalDensity.current - val draggableState = rememberDraggableState { - dragDelta = (dragDelta + it / density.density).roundToInt() - .coerceIn( - -widget.config.height + 1, - 500 - widget.config.height - ) - } - Row( - modifier = Modifier - .padding(top = 8.dp, bottom = 16.dp) - .padding(horizontal = 8.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - val scope = rememberCoroutineScope() - val tooltipState = rememberTooltipState() - TooltipBox( - state = tooltipState, - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { - PlainTooltip { - Text(stringResource(R.string.widget_config_appwidget_resize_hint)) - } - } - ) { - Box( - modifier = Modifier - .padding(end = 16.dp) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.primaryContainer) - .height(36.dp) - .width(48.dp) - .draggable( - state = draggableState, - orientation = Orientation.Vertical, - onDragStopped = { - onWidgetUpdated( - widget.copy( - config = widget.config.copy( - height = widget.config.height + dragDelta - ) - ) - ) - dragDelta = 0 - } - ) - .clickable { - scope.launch { - tooltipState.show() - } - }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Rounded.UnfoldMore, - contentDescription = null, - ) - } - - } - var textFieldValue by remember(widget.config.height) { mutableStateOf(widget.config.height.toString()) } - OutlinedTextField( - modifier = Modifier - .weight(1f) - .padding(bottom = 8.dp), - value = (widget.config.height + dragDelta).toString(), - onValueChange = { - val intValue = it.toIntOrNull() - if (intValue == null) textFieldValue = "" - else if (intValue in 1..1000) { - onWidgetUpdated( - widget.copy( - config = widget.config.copy( - height = intValue - ) + DragResizeHandle( + alignment = Alignment.TopCenter, + height = resizeHeight, + width = resizeWidth, + snapToMeasuredWidth = true, + onResize = { w, h -> + resizeWidth = w + resizeHeight = h + }, + onResizeStopped = { + onWidgetUpdated( + widget.copy( + config = widget.config.copy( + height = resizeHeight.value.roundToInt(), + width = resizeWidth.takeIf { it != Dp.Unspecified }?.value?.roundToInt() ) ) - textFieldValue = intValue.toString() - } - }, - label = { Text(stringResource(R.string.widget_config_appwidget_height)) }, - suffix = { Text("dp") }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done - ), + ) + } ) } } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/CustomClock.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/CustomClock.kt index 5b22d662..c7341c6b 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/CustomClock.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/clock/clocks/CustomClock.kt @@ -1,6 +1,7 @@ package de.mm20.launcher2.ui.launcher.widgets.clock.clocks import android.appwidget.AppWidgetManager +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -32,11 +33,10 @@ fun CustomClock( AppWidgetHost( widgetInfo = widgetInfo, widgetId = widgetId, - height = if (compact) 64 else 200, useThemeColors = useThemeColor, onLightBackground = darkColors, borderless = compact, - modifier = Modifier.widthIn(max = 250.dp) + modifier = Modifier.widthIn(max = 250.dp).height(if (compact) 64.dp else 200.dp) ) } } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/external/AppWidget.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/external/AppWidget.kt index 96c3e609..c58d2676 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/external/AppWidget.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/external/AppWidget.kt @@ -2,8 +2,11 @@ package de.mm20.launcher2.ui.launcher.widgets.external import android.appwidget.AppWidgetHost import android.appwidget.AppWidgetManager +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.Button @@ -15,10 +18,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isUnspecified import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.component.Banner import de.mm20.launcher2.ui.ktx.toPixels @@ -90,14 +95,22 @@ fun AppWidget( ) } } else { - AppWidgetHost( - widgetId = widget.config.widgetId, - widgetInfo = widgetInfo, - modifier = Modifier.fillMaxWidth(), - height = widget.config.height, - borderless = widget.config.borderless, - useThemeColors = widget.config.themeColors, - onLightBackground = lightBackground, - ) + val width = widget.config.width + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + AppWidgetHost( + widgetId = widget.config.widgetId, + widgetInfo = widgetInfo, + modifier = Modifier + .then( + if (width == null) Modifier.fillMaxWidth() else Modifier.requiredWidth( + width.dp + ) + ) + .height(widget.config.height.dp), + borderless = widget.config.borderless, + useThemeColors = widget.config.themeColors, + onLightBackground = lightBackground, + ) + } } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/external/AppWidgetHost.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/external/AppWidgetHost.kt index e84a9e79..7f68005e 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/external/AppWidgetHost.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/external/AppWidgetHost.kt @@ -1,10 +1,8 @@ package de.mm20.launcher2.ui.launcher.widgets.external -import android.appwidget.AppWidgetHost import android.appwidget.AppWidgetProviderInfo import android.os.Build import android.os.Bundle -import android.util.Log import android.util.SparseIntArray import android.view.View import android.view.ViewGroup @@ -12,6 +10,7 @@ import android.widget.ListView import android.widget.ScrollView import androidx.annotation.RequiresApi import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme @@ -33,7 +32,6 @@ import kotlin.math.roundToInt fun AppWidgetHost( widgetInfo: AppWidgetProviderInfo, widgetId: Int, - height: Int, modifier: Modifier = Modifier, borderless: Boolean = false, useThemeColors: Boolean = false, @@ -44,12 +42,15 @@ fun AppWidgetHost( val colorScheme = MaterialTheme.colorScheme val appWidgetHost = LocalAppWidgetHost.current - BoxWithConstraints { + BoxWithConstraints( + modifier = modifier, + ) { val maxWidth = maxWidth + val maxHeight = maxHeight key(widgetId) { AndroidView( modifier = modifier - .height(height.dp), + .fillMaxSize(), factory = { val view = appWidgetHost.createView(it.applicationContext, widgetId, widgetInfo) enableNestedScroll(view) @@ -71,9 +72,9 @@ fun AppWidgetHost( it.updateAppWidgetSize( null, maxWidth.value.roundToInt(), - height, + maxHeight.value.roundToInt(), maxWidth.value.roundToInt(), - height, + maxHeight.value.roundToInt(), ) it.setPadding(padding) // Workaround to force update of the widget view diff --git a/data/widgets/src/main/java/de/mm20/launcher2/widgets/AppWidget.kt b/data/widgets/src/main/java/de/mm20/launcher2/widgets/AppWidget.kt index 3dd22bc0..25e735f4 100644 --- a/data/widgets/src/main/java/de/mm20/launcher2/widgets/AppWidget.kt +++ b/data/widgets/src/main/java/de/mm20/launcher2/widgets/AppWidget.kt @@ -15,6 +15,7 @@ import java.util.UUID data class AppWidgetConfig( val widgetId: Int, val height: Int, + val width: Int? = null, val borderless: Boolean = false, val background: Boolean = true, val themeColors: Boolean = true,