Add horizontal appwidget resizing

This commit is contained in:
MM20 2024-04-18 00:42:03 +02:00
parent 7056c5963a
commit 1a45ae9003
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
6 changed files with 262 additions and 109 deletions

View File

@ -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,
)
}
}
}
}
}

View File

@ -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
),
)
}
)
}
}

View File

@ -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)
)
}
}

View File

@ -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,
)
}
}
}

View File

@ -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

View File

@ -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,