diff --git a/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt b/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt index 813734c94..1a5c6e5dc 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/DetectDragModifier.kt @@ -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() diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt index e959ce615..361631c6a 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenu.kt @@ -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, 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, + 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 = {}, ) } } diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt index a0a1cbf23..f5ca4551f 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt @@ -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) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e8025732a..446c1d664 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -909,4 +909,5 @@ Drag the header or the actions to this section to hide them Header Back + Reorder and disable actions