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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation 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.draggable
import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
@ -65,13 +68,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.toArgb 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.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isUnspecified
import androidx.core.net.toUri import androidx.core.net.toUri
import de.mm20.launcher2.calendar.CalendarRepository import de.mm20.launcher2.calendar.CalendarRepository
import de.mm20.launcher2.calendar.UserCalendar 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.R
import de.mm20.launcher2.ui.base.LocalAppWidgetHost import de.mm20.launcher2.ui.base.LocalAppWidgetHost
import de.mm20.launcher2.ui.component.BottomSheetDialog 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.LargeMessage
import de.mm20.launcher2.ui.component.MissingPermissionBanner import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.preferences.CheckboxPreference import de.mm20.launcher2.ui.component.preferences.CheckboxPreference
@ -338,111 +346,58 @@ fun ColumnScope.ConfigureAppWidget(
return return
} }
var dragDelta by remember { mutableStateOf(0) }
Column { Column {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(MaterialTheme.shapes.medium) .padding(bottom = 64.dp)
.background(MaterialTheme.colorScheme.surfaceVariant) .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( AppWidgetHost(
widgetInfo = widgetInfo, widgetInfo = widgetInfo,
widgetId = widget.config.widgetId, widgetId = widget.config.widgetId,
modifier = Modifier.fillMaxWidth(), modifier = Modifier
height = widget.config.height + dragDelta, .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, borderless = widget.config.borderless,
useThemeColors = widget.config.themeColors, useThemeColors = widget.config.themeColors,
onLightBackground = (!LocalDarkTheme.current && widget.config.background) || LocalPreferDarkContentOverWallpaper.current onLightBackground = (!LocalDarkTheme.current && widget.config.background) || LocalPreferDarkContentOverWallpaper.current
) )
} DragResizeHandle(
val density = LocalDensity.current alignment = Alignment.TopCenter,
val draggableState = rememberDraggableState { height = resizeHeight,
dragDelta = (dragDelta + it / density.density).roundToInt() width = resizeWidth,
.coerceIn( snapToMeasuredWidth = true,
-widget.config.height + 1, onResize = { w, h ->
500 - widget.config.height resizeWidth = w
) resizeHeight = h
} },
Row( onResizeStopped = {
modifier = Modifier onWidgetUpdated(
.padding(top = 8.dp, bottom = 16.dp) widget.copy(
.padding(horizontal = 8.dp) config = widget.config.copy(
.fillMaxWidth(), height = resizeHeight.value.roundToInt(),
verticalAlignment = Alignment.CenterVertically width = resizeWidth.takeIf { it != Dp.Unspecified }?.value?.roundToInt()
) {
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
)
) )
) )
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 package de.mm20.launcher2.ui.launcher.widgets.clock.clocks
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -32,11 +33,10 @@ fun CustomClock(
AppWidgetHost( AppWidgetHost(
widgetInfo = widgetInfo, widgetInfo = widgetInfo,
widgetId = widgetId, widgetId = widgetId,
height = if (compact) 64 else 200,
useThemeColors = useThemeColor, useThemeColors = useThemeColor,
onLightBackground = darkColors, onLightBackground = darkColors,
borderless = compact, 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.AppWidgetHost
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Warning import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Button import androidx.compose.material3.Button
@ -15,10 +18,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isUnspecified
import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.Banner import de.mm20.launcher2.ui.component.Banner
import de.mm20.launcher2.ui.ktx.toPixels import de.mm20.launcher2.ui.ktx.toPixels
@ -90,14 +95,22 @@ fun AppWidget(
) )
} }
} else { } else {
AppWidgetHost( val width = widget.config.width
widgetId = widget.config.widgetId, Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
widgetInfo = widgetInfo, AppWidgetHost(
modifier = Modifier.fillMaxWidth(), widgetId = widget.config.widgetId,
height = widget.config.height, widgetInfo = widgetInfo,
borderless = widget.config.borderless, modifier = Modifier
useThemeColors = widget.config.themeColors, .then(
onLightBackground = lightBackground, 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 package de.mm20.launcher2.ui.launcher.widgets.external
import android.appwidget.AppWidgetHost
import android.appwidget.AppWidgetProviderInfo import android.appwidget.AppWidgetProviderInfo
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.util.SparseIntArray import android.util.SparseIntArray
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -12,6 +10,7 @@ import android.widget.ListView
import android.widget.ScrollView import android.widget.ScrollView
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -33,7 +32,6 @@ import kotlin.math.roundToInt
fun AppWidgetHost( fun AppWidgetHost(
widgetInfo: AppWidgetProviderInfo, widgetInfo: AppWidgetProviderInfo,
widgetId: Int, widgetId: Int,
height: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
borderless: Boolean = false, borderless: Boolean = false,
useThemeColors: Boolean = false, useThemeColors: Boolean = false,
@ -44,12 +42,15 @@ fun AppWidgetHost(
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val appWidgetHost = LocalAppWidgetHost.current val appWidgetHost = LocalAppWidgetHost.current
BoxWithConstraints { BoxWithConstraints(
modifier = modifier,
) {
val maxWidth = maxWidth val maxWidth = maxWidth
val maxHeight = maxHeight
key(widgetId) { key(widgetId) {
AndroidView( AndroidView(
modifier = modifier modifier = modifier
.height(height.dp), .fillMaxSize(),
factory = { factory = {
val view = appWidgetHost.createView(it.applicationContext, widgetId, widgetInfo) val view = appWidgetHost.createView(it.applicationContext, widgetId, widgetInfo)
enableNestedScroll(view) enableNestedScroll(view)
@ -71,9 +72,9 @@ fun AppWidgetHost(
it.updateAppWidgetSize( it.updateAppWidgetSize(
null, null,
maxWidth.value.roundToInt(), maxWidth.value.roundToInt(),
height, maxHeight.value.roundToInt(),
maxWidth.value.roundToInt(), maxWidth.value.roundToInt(),
height, maxHeight.value.roundToInt(),
) )
it.setPadding(padding) it.setPadding(padding)
// Workaround to force update of the widget view // Workaround to force update of the widget view

View File

@ -15,6 +15,7 @@ import java.util.UUID
data class AppWidgetConfig( data class AppWidgetConfig(
val widgetId: Int, val widgetId: Int,
val height: Int, val height: Int,
val width: Int? = null,
val borderless: Boolean = false, val borderless: Boolean = false,
val background: Boolean = true, val background: Boolean = true,
val themeColors: Boolean = true, val themeColors: Boolean = true,