Access editor from long press menu + fix scrolling

This commit is contained in:
Stypox 2025-10-22 01:11:30 +02:00
parent a3af6e20ce
commit 0cc63347af
No known key found for this signature in database
GPG Key ID: 4BDF1B40A49FDD23
4 changed files with 153 additions and 112 deletions

View File

@ -5,6 +5,7 @@ import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.unit.IntOffset
/**
@ -15,31 +16,31 @@ import androidx.compose.ui.unit.IntOffset
* [androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress].
*
* @param beginDragGesture called when the user first touches the screen (down event) with the
* pointer position, should return `true` if the receiver wants to handle this gesture, `false`
* otherwise.
* @param handleDragGestureChange called with the current pointer position, every time the user
* moves the finger after [beginDragGesture] has returned `true`.
* @param endDragGesture called when the drag gesture finishes after [beginDragGesture] has returned
* `true`.
* pointer position.
* @param handleDragGestureChange called with the current pointer position and the difference from
* the last position, every time the user moves the finger after [beginDragGesture] has been called.
* @param endDragGesture called when the drag gesture finishes, after [beginDragGesture] has been
* called.
*/
fun Modifier.detectDragGestures(
beginDragGesture: (IntOffset) -> Boolean,
handleDragGestureChange: (IntOffset) -> Unit,
beginDragGesture: (position: IntOffset) -> Unit,
handleDragGestureChange: (position: IntOffset, positionChange: Offset) -> Unit,
endDragGesture: () -> Unit
): Modifier {
return this.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
val pointerId = down.id
if (!beginDragGesture(down.position.toIntOffset())) {
return@awaitEachGesture
}
beginDragGesture(down.position.toIntOffset())
while (true) {
val change = awaitPointerEvent().changes.find { it.id == pointerId }
if (change == null || !change.pressed) {
break
}
handleDragGestureChange(change.position.toIntOffset())
handleDragGestureChange(
change.position.toIntOffset(),
change.positionChange(),
)
change.consume()
}
endDragGesture()

View File

@ -7,6 +7,7 @@ import android.content.Context
import android.content.res.Configuration
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@ -37,16 +38,15 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SheetState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
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
@ -73,6 +73,7 @@ import coil3.compose.AsyncImage
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.ktx.popFirst
import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar
import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.EnqueueNext
import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.ShowChannelDetails
import org.schabi.newpipe.ui.theme.AppTheme
@ -119,100 +120,127 @@ fun LongPressMenu(
longPressable: LongPressable,
longPressActions: List<LongPressAction>,
onDismissRequest: () -> Unit,
onEditActions: () -> Unit = {}, // TODO handle this menu
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
ModalBottomSheet(
onDismissRequest,
sheetState = sheetState,
dragHandle = { LongPressMenuDragHandle(onEditActions) },
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.padding(start = 6.dp, end = 6.dp, bottom = 16.dp)
var showEditor by rememberSaveable(key = longPressable.url) { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
if (showEditor) {
// we can't put the editor in a bottom sheet, because it relies on dragging gestures
ScaffoldWithToolbar(
title = stringResource(R.string.long_press_menu_actions_editor),
onBackClick = { showEditor = false },
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
LongPressMenuEditor()
}
BackHandler { showEditor = false }
}
} else {
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = onDismissRequest,
dragHandle = { LongPressMenuDragHandle(onEditActions = { showEditor = true }) },
) {
val buttonHeight = MinButtonWidth // landscape aspect ratio, square in the limit
val headerWidthInButtons = 5 // the header is 5 times as wide as the buttons
val buttonsPerRow = (this.maxWidth / MinButtonWidth).toInt()
LongPressMenuContent(
longPressable = longPressable,
longPressActions = longPressActions,
onDismissRequest = onDismissRequest,
)
}
}
}
// the channel icon goes in the menu header, so do not show a button for it
val actions = longPressActions.toMutableList()
val ctx = LocalContext.current
val onUploaderClick = actions.popFirst { it.type == ShowChannelDetails }
?.let { showChannelDetailsAction ->
{
showChannelDetailsAction.action(ctx)
onDismissRequest()
}
@Composable
private fun LongPressMenuContent(
longPressable: LongPressable,
longPressActions: List<LongPressAction>,
onDismissRequest: () -> Unit,
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.padding(start = 6.dp, end = 6.dp, bottom = 16.dp)
) {
val buttonHeight = MinButtonWidth // landscape aspect ratio, square in the limit
val headerWidthInButtons = 5 // the header is 5 times as wide as the buttons
val buttonsPerRow = (this.maxWidth / MinButtonWidth).toInt()
// the channel icon goes in the menu header, so do not show a button for it
val actions = longPressActions.toMutableList()
val ctx = LocalContext.current
val onUploaderClick = actions.popFirst { it.type == ShowChannelDetails }
?.let { showChannelDetailsAction ->
{
showChannelDetailsAction.action(ctx)
onDismissRequest()
}
}
Column {
var actionIndex = -1 // -1 indicates the header
while (actionIndex < actions.size) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
var rowIndex = 0
while (rowIndex < buttonsPerRow) {
if (actionIndex >= actions.size) {
// no more buttons to show, fill the rest of the row with a
// spacer that has the same weight as the missing buttons, so that
// the other buttons don't grow too wide
Spacer(
modifier = Modifier
.height(buttonHeight)
.fillMaxWidth()
.weight((buttonsPerRow - rowIndex).toFloat()),
)
break
} else if (actionIndex >= 0) {
val action = actions[actionIndex]
LongPressMenuButton(
icon = action.type.icon,
text = stringResource(action.type.label),
onClick = {
action.action(ctx)
onDismissRequest()
},
enabled = action.enabled(false),
modifier = Modifier
.height(buttonHeight)
.fillMaxWidth()
.weight(1F),
)
rowIndex += 1
} else if (headerWidthInButtons >= buttonsPerRow) {
// this branch is taken if the header is going to fit on one line
// (i.e. on phones in portrait)
LongPressMenuHeader(
item = longPressable,
onUploaderClick = onUploaderClick,
modifier = Modifier
// leave the height as small as possible, since it's the
// only item on the row anyway
.padding(start = 6.dp, end = 6.dp, bottom = 6.dp)
.fillMaxWidth()
.weight(headerWidthInButtons.toFloat()),
)
rowIndex += headerWidthInButtons
} else {
// this branch is taken if the header will have some buttons to its
// right (i.e. on tablets or on phones in landscape)
LongPressMenuHeader(
item = longPressable,
onUploaderClick = onUploaderClick,
modifier = Modifier
.padding(6.dp)
.heightIn(min = 70.dp)
.fillMaxWidth()
.weight(headerWidthInButtons.toFloat()),
)
rowIndex += headerWidthInButtons
}
actionIndex += 1
Column {
var actionIndex = -1 // -1 indicates the header
while (actionIndex < actions.size) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
var rowIndex = 0
while (rowIndex < buttonsPerRow) {
if (actionIndex >= actions.size) {
// no more buttons to show, fill the rest of the row with a
// spacer that has the same weight as the missing buttons, so that
// the other buttons don't grow too wide
Spacer(
modifier = Modifier
.height(buttonHeight)
.fillMaxWidth()
.weight((buttonsPerRow - rowIndex).toFloat()),
)
break
} else if (actionIndex >= 0) {
val action = actions[actionIndex]
LongPressMenuButton(
icon = action.type.icon,
text = stringResource(action.type.label),
onClick = {
action.action(ctx)
onDismissRequest()
},
enabled = action.enabled(false),
modifier = Modifier
.height(buttonHeight)
.fillMaxWidth()
.weight(1F),
)
rowIndex += 1
} else if (headerWidthInButtons >= buttonsPerRow) {
// this branch is taken if the header is going to fit on one line
// (i.e. on phones in portrait)
LongPressMenuHeader(
item = longPressable,
onUploaderClick = onUploaderClick,
modifier = Modifier
// leave the height as small as possible, since it's the
// only item on the row anyway
.padding(start = 6.dp, end = 6.dp, bottom = 6.dp)
.fillMaxWidth()
.weight(headerWidthInButtons.toFloat()),
)
rowIndex += headerWidthInButtons
} else {
// this branch is taken if the header will have some buttons to its
// right (i.e. on tablets or on phones in landscape)
LongPressMenuHeader(
item = longPressable,
onUploaderClick = onUploaderClick,
modifier = Modifier
.padding(6.dp)
.heightIn(min = 70.dp)
.fillMaxWidth()
.weight(headerWidthInButtons.toFloat()),
)
rowIndex += headerWidthInButtons
}
actionIndex += 1
}
}
}
@ -619,14 +647,12 @@ private fun LongPressMenuPreview(
AppTheme(useDarkTheme = useDarkTheme) {
// longPressable is null when running the preview in an emulator for some reason...
@Suppress("USELESS_ELVIS")
LongPressMenu(
LongPressMenuContent(
longPressable = longPressable ?: LongPressablePreviews().values.first(),
onDismissRequest = {},
longPressActions = LongPressAction.Type.entries
// disable Enqueue actions just to show it off
.map { t -> t.buildAction({ t != EnqueueNext }) { } },
onEditActions = { useDarkTheme = !useDarkTheme },
sheetState = rememberStandardBottomSheetState(), // makes it start out as open
onDismissRequest = {},
)
}
}

View File

@ -20,7 +20,9 @@ package org.schabi.newpipe.ui.components.menu
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.safeDrawingPadding
@ -50,6 +52,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
@ -69,6 +72,7 @@ import org.schabi.newpipe.util.text.FixedHeightCenteredText
private const val ItemNotFound = -1
// TODO implement accessibility for this, to allow using this with a DPAD (e.g. Android TV)
@Composable
fun LongPressMenuEditor() {
// We get the current arrangement once and do not observe on purpose
@ -135,8 +139,8 @@ fun LongPressMenuEditor() {
}
}
fun beginDragGesture(pos: IntOffset): Boolean {
val item = findItemForOffsetOrClosestInRow(pos) ?: return false
fun beginDragGesture(pos: IntOffset) {
val item = findItemForOffsetOrClosestInRow(pos) ?: return
val i = item.index
val enabledActionIndex = indexOfEnabledAction(i)
val hiddenActionIndex = indexOfHiddenAction(i)
@ -149,15 +153,18 @@ fun LongPressMenuEditor() {
activeDragAction = hiddenActions[hiddenActionIndex]
hiddenActions[hiddenActionIndex] = ActionOrMarker.DragMarker
} else {
return false
return
}
activeDragPosition = pos
activeDragSize = item.size
return true
}
fun handleDragGestureChange(pos: IntOffset) {
if (activeDragAction == null) return
fun handleDragGestureChange(pos: IntOffset, posChange: Offset) {
if (activeDragAction == null) {
// when the user clicks outside of any draggable item, let the list be scrolled
gridState.dispatchRawDelta(-posChange.y)
return
}
activeDragPosition = pos
val item = findItemForOffsetOrClosestInRow(pos) ?: return
val i = item.index
@ -223,6 +230,7 @@ fun LongPressMenuEditor() {
),
// same width as the LongPressMenu
columns = GridCells.Adaptive(MinButtonWidth),
userScrollEnabled = false,
state = gridState,
) {
item(span = { GridItemSpan(maxLineSpan) }) {
@ -267,6 +275,11 @@ fun LongPressMenuEditor() {
itemsIndexed(hiddenActions, key = { _, action -> action.stableUniqueKey() }) { _, action ->
ActionOrMarkerUi(modifier = Modifier.animateItem(), action = action)
}
item {
// make the grid size a bit bigger to let items be dragged at the bottom and to give
// the view some space to resizing without jumping up and down
Spacer(modifier = Modifier.height(MinButtonWidth))
}
}
if (activeDragAction != null) {
val size = with(LocalDensity.current) {

View File

@ -909,4 +909,5 @@
<string name="long_press_menu_hidden_actions_description">Drag the header or the actions to this section to hide them</string>
<string name="header">Header</string>
<string name="back">Back</string>
<string name="long_press_menu_actions_editor">Reorder and disable actions</string>
</resources>