Browse Source

feat: 优化阅读弹出菜单样式

HapeLee 3 months ago
parent
commit
c2fb5df928

+ 282 - 84
app/src/main/java/io/legado/app/ui/book/read/TextActionSelectionMenu.kt

@@ -1,8 +1,9 @@
 package io.legado.app.ui.book.read
 
-import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
 import androidx.compose.animation.scaleIn
@@ -24,13 +25,14 @@ import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.layout.widthIn
 import androidx.compose.foundation.layout.wrapContentWidth
 import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.Icon
 import androidx.compose.material3.VerticalDivider
 import androidx.compose.runtime.Composable
@@ -42,7 +44,12 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionOnScreen
 import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalView
 import androidx.compose.ui.platform.LocalWindowInfo
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.IntOffset
@@ -58,11 +65,7 @@ import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.text.AppText
-
-enum class MenuMode {
-    Quick,
-    More
-}
+import kotlin.math.roundToInt
 
 @Composable
 fun TextActionSelectionMenu(
@@ -85,85 +88,180 @@ fun TextActionSelectionMenu(
     val localDensity = LocalDensity.current
     val containerSize = LocalWindowInfo.current.containerSize
     val maxMenuWidth = with(localDensity){ containerSize.width.toDp() } - 32.dp
-    var mode by remember { mutableStateOf(MenuMode.Quick) }
+    val menuShadowPadding = 12.dp
+    val menuCardMaxWidth = maxMenuWidth - menuShadowPadding * 2
+    var showMoreMenu by remember { mutableStateOf(false) }
+    var moreMenuAnchor by remember { mutableStateOf<IntRect?>(null) }
+    val view = LocalView.current
+    val windowLocation = IntArray(2).also(view::getLocationOnScreen)
+    val moreVisibilityState = remember { MutableTransitionState(false) }
+    moreVisibilityState.targetState = showMoreMenu
     LaunchedEffect(menuState) {
-        if (menuState == null) mode = MenuMode.Quick
+        if (menuState == null) showMoreMenu = false
     }
     val draftItems = displayedMenuState.items
-    
+
     val primaryItems = remember(draftItems) { draftItems.filter { it.showState == 0 } }
     val collapsedItems = remember(draftItems) { draftItems.filter { it.showState == 1 } }
     val activeMultiItems = remember(draftItems) { draftItems.filter { it.showState == 0 || it.showState == 1 } }
 
     val density = localDensity.density
-    val positionProvider = remember(displayedMenuState, density) {
+    val shadowPaddingPx = with(localDensity) { menuShadowPadding.roundToPx() }
+    val positionProvider = remember(displayedMenuState, density, shadowPaddingPx) {
         TextMenuPositionProvider(
             density = density,
             startX = displayedMenuState.startX,
             startTopY = displayedMenuState.startTopY,
             startBottomY = displayedMenuState.startBottomY,
             endX = displayedMenuState.endX,
-            endBottomY = displayedMenuState.endBottomY
+            endBottomY = displayedMenuState.endBottomY,
+            shadowPadding = shadowPaddingPx,
         )
     }
 
     Popup(
         popupPositionProvider = positionProvider,
-        onDismissRequest = onDismiss,
+        onDismissRequest = {
+            showMoreMenu = false
+            onDismiss()
+        },
         properties = PopupProperties(
             focusable = false,
-            dismissOnBackPress = true,
-            dismissOnClickOutside = true
+            dismissOnBackPress = !showMoreMenu,
+            dismissOnClickOutside = !showMoreMenu,
         )
     ) {
 
         AnimatedVisibility(
             visibleState = visibilityState,
-            enter = fadeIn() + scaleIn(initialScale = 0.9f),
-            exit = fadeOut() + scaleOut(targetScale = 0.9f),
+            enter = fadeIn(animationSpec = tween(360)) + scaleIn(
+                initialScale = 0.94f,
+                animationSpec = tween(400),
+            ),
+            exit = fadeOut(animationSpec = tween(320)) + scaleOut(
+                targetScale = 0.96f,
+                animationSpec = tween(280),
+            ),
         ) {
-            NormalCard(
-                modifier = Modifier.widthIn(max = maxMenuWidth),
-                containerColor = LegadoTheme.colorScheme.surfaceBright,
-                elevation = 6.dp,
-                cornerRadius = 12.dp,
-            ) {
-                if (expandTextMenu) {
-                    MultiLineMenuView(
-                        items = activeMultiItems,
-                        onItemClick = onItemClick,
-                        onManageClick = {
-                            onDismiss()
-                            onOpenManage()
-                        }
-                    )
-                } else {
-                    AnimatedContent(
-                        targetState = mode,
-                        label = "TextActionMenuLevel",
-                    ) { targetMode ->
-                        if (targetMode == MenuMode.Quick) {
-                            QuickMenuView(
-                                items = primaryItems,
-                                hasMore = collapsedItems.isNotEmpty(),
-                                onItemClick = onItemClick,
-                                onMoreClick = { mode = MenuMode.More },
-                                onSettingsClick = {
-                                    onDismiss()
-                                    onOpenManage()
-                                }
-                            )
-                        } else {
-                            MoreMenuView(
-                                items = collapsedItems,
-                                onItemClick = onItemClick,
-                                onBack = { mode = MenuMode.Quick },
-                                onManageClick = {
-                                    onDismiss()
-                                    onOpenManage()
-                                }
-                            )
+            if (expandTextMenu) {
+                Box(modifier = Modifier.padding(menuShadowPadding)) {
+                    NormalCard(
+                        modifier = Modifier.widthIn(max = menuCardMaxWidth),
+                        containerColor = LegadoTheme.colorScheme.surfaceBright,
+                        elevation = 6.dp,
+                        cornerRadius = 12.dp,
+                    ) {
+                        MultiLineMenuView(
+                            items = activeMultiItems,
+                            onItemClick = onItemClick,
+                            onManageClick = {
+                                onDismiss()
+                                onOpenManage()
+                            }
+                        )
+                    }
+                }
+            } else {
+                val quickMenuScale by animateFloatAsState(
+                    targetValue = if (showMoreMenu) 0.96f else 1f,
+                    animationSpec = tween(durationMillis = 400),
+                    label = "quickMenuScale"
+                )
+                val quickMenuAlpha by animateFloatAsState(
+                    targetValue = if (showMoreMenu) 0.82f else 1f,
+                    animationSpec = tween(durationMillis = 360),
+                    label = "quickMenuAlpha"
+                )
+
+                Box(
+                    modifier = Modifier
+                        .graphicsLayer {
+                            scaleX = quickMenuScale
+                            scaleY = quickMenuScale
+                            alpha = quickMenuAlpha
+                            transformOrigin = TransformOrigin(1f, 0.5f)
                         }
+                        .padding(menuShadowPadding),
+                ) {
+                    NormalCard(
+                        modifier = Modifier.widthIn(max = menuCardMaxWidth),
+                        containerColor = LegadoTheme.colorScheme.surfaceBright,
+                        elevation = 6.dp,
+                        cornerRadius = 12.dp,
+                    ) {
+                        QuickMenuView(
+                            items = primaryItems,
+                            hasMore = collapsedItems.isNotEmpty(),
+                            onItemClick = onItemClick,
+                            onMoreClick = { showMoreMenu = true },
+                            onMoreAnchorChanged = { moreMenuAnchor = it },
+                            onSettingsClick = {
+                                onDismiss()
+                                onOpenManage()
+                            }
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    val anchor = moreMenuAnchor
+    if (!expandTextMenu && anchor != null &&
+        (moreVisibilityState.currentState || moreVisibilityState.targetState)
+    ) {
+        val morePositionProvider = remember(anchor, density, windowLocation[0], windowLocation[1]) {
+            MoreMenuPositionProvider(
+                density = density,
+                anchorOnScreen = anchor,
+                windowXOnScreen = windowLocation[0],
+                windowYOnScreen = windowLocation[1],
+                shadowPadding = shadowPaddingPx,
+            )
+        }
+        Popup(
+            popupPositionProvider = morePositionProvider,
+            onDismissRequest = {
+                showMoreMenu = false
+                onDismiss()
+            },
+            properties = PopupProperties(
+                focusable = false,
+                dismissOnBackPress = true,
+                dismissOnClickOutside = true,
+            )
+        ) {
+            AnimatedVisibility(
+                visibleState = moreVisibilityState,
+                enter = fadeIn(animationSpec = tween(360)) + scaleIn(
+                    initialScale = 0.94f,
+                    transformOrigin = TransformOrigin(1f, 0.5f),
+                    animationSpec = tween(400),
+                ),
+                exit = fadeOut(animationSpec = tween(240)) + scaleOut(
+                    targetScale = 0.96f,
+                    transformOrigin = TransformOrigin(1f, 0.5f),
+                    animationSpec = tween(280),
+                ),
+            ) {
+                Box(
+                    modifier = Modifier.padding(menuShadowPadding),
+                ) {
+                    NormalCard(
+                        modifier = Modifier.widthIn(max = menuCardMaxWidth),
+                        containerColor = LegadoTheme.colorScheme.surfaceBright,
+                        elevation = 8.dp,
+                        cornerRadius = 12.dp,
+                    ) {
+                        MoreMenuView(
+                            items = collapsedItems,
+                            onItemClick = onItemClick,
+                            onBack = { showMoreMenu = false },
+                            onManageClick = {
+                                onDismiss()
+                                onOpenManage()
+                            }
+                        )
                     }
                 }
             }
@@ -181,21 +279,21 @@ private fun MultiLineMenuView(
         modifier = Modifier
             .heightIn(max = 300.dp)
             .verticalScroll(rememberScrollState()),
-        horizontalArrangement = Arrangement.spacedBy(6.dp),
+        horizontalArrangement = Arrangement.spacedBy(8.dp),
         verticalArrangement = Arrangement.spacedBy(0.dp)
     ) {
         items.forEach { item ->
             QuickMenuItem(
                 item = item,
                 onClick = { onItemClick(item) },
-                verticalPadding = 8.dp,
+                verticalPadding = 12.dp,
             )
         }
 
         Row(
             modifier = Modifier
                 .clickable(onClick = onManageClick)
-                .padding(horizontal = 14.dp, vertical = 8.dp),
+                .padding(horizontal = 16.dp, vertical = 12.dp),
             verticalAlignment = Alignment.CenterVertically
         ) {
             Icon(
@@ -219,6 +317,7 @@ private fun QuickMenuView(
     hasMore: Boolean,
     onItemClick: (ActionMenuItem) -> Unit,
     onMoreClick: () -> Unit,
+    onMoreAnchorChanged: (IntRect) -> Unit,
     onSettingsClick: () -> Unit
 ) {
     Row(
@@ -229,8 +328,16 @@ private fun QuickMenuView(
             modifier = Modifier.weight(1f, fill = false),
             verticalAlignment = Alignment.CenterVertically,
         ) {
-            items(items, key = { it.uniqueId }) { item ->
-                QuickMenuItem(item = item, onClick = { onItemClick(item) })
+            itemsIndexed(
+                items = items,
+                key = { _, item -> item.uniqueId },
+            ) { index, item ->
+                QuickMenuItem(
+                    item = item,
+                    onClick = { onItemClick(item) },
+                    startPadding = if (index == 0) 16.dp else 12.dp,
+                    endPadding = 12.dp,
+                )
             }
         }
         if (items.isNotEmpty()) {
@@ -243,8 +350,19 @@ private fun QuickMenuView(
         }
         Box(
             modifier = Modifier
+                .onGloballyPositioned { coordinates ->
+                    val position = coordinates.positionOnScreen()
+                    onMoreAnchorChanged(
+                        IntRect(
+                            left = position.x.roundToInt(),
+                            top = position.y.roundToInt(),
+                            right = position.x.roundToInt() + coordinates.size.width,
+                            bottom = position.y.roundToInt() + coordinates.size.height,
+                        )
+                    )
+                }
                 .clickable(onClick = if (hasMore) onMoreClick else onSettingsClick)
-                .padding(horizontal = 16.dp, vertical = 12.dp),
+                .padding(start = 12.dp, end = 16.dp, top = 12.dp, bottom = 12.dp),
             contentAlignment = Alignment.Center
         ) {
             Icon(
@@ -261,12 +379,19 @@ private fun QuickMenuView(
 private fun QuickMenuItem(
     item: ActionMenuItem,
     onClick: () -> Unit,
+    startPadding: androidx.compose.ui.unit.Dp = 16.dp,
+    endPadding: androidx.compose.ui.unit.Dp = 16.dp,
     verticalPadding: androidx.compose.ui.unit.Dp = 12.dp,
 ) {
     Row(
         modifier = Modifier
             .clickable(onClick = onClick)
-            .padding(horizontal = 16.dp, vertical = verticalPadding),
+            .padding(
+                start = startPadding,
+                end = endPadding,
+                top = verticalPadding,
+                bottom = verticalPadding,
+            ),
         verticalAlignment = Alignment.CenterVertically
     ) {
         if (item.iconDrawable != null) {
@@ -304,17 +429,25 @@ private fun MoreMenuView(
             modifier = Modifier
                 .fillMaxWidth()
                 .clickable(onClick = onBack)
-                .padding(horizontal = 16.dp, vertical = 12.dp),
+                .padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 8.dp),
             contentAlignment = Alignment.CenterStart
         ) {
             Icon(
                 imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                 contentDescription = stringResource(R.string.back),
                 tint = LegadoTheme.colorScheme.onSurface,
-                modifier = Modifier.size(18.dp)
+                modifier = Modifier.size(20.dp)
             )
         }
-        
+
+        HorizontalDivider(
+            color = LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
+            modifier = Modifier
+                .height(1.dp)
+                .fillMaxWidth(0.8f)
+                .align(Alignment.CenterHorizontally)
+        )
+
         Column(
             modifier = Modifier
                 .heightIn(max = 240.dp)
@@ -326,11 +459,19 @@ private fun MoreMenuView(
             }
         }
 
+        HorizontalDivider(
+            color = LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
+            modifier = Modifier
+                .height(1.dp)
+                .fillMaxWidth(0.8f)
+                .align(Alignment.CenterHorizontally)
+        )
+
         Row(
             modifier = Modifier
                 .fillMaxWidth()
                 .clickable(onClick = onManageClick)
-                .padding(horizontal = 16.dp, vertical = 12.dp),
+                .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
             verticalAlignment = Alignment.CenterVertically
         ) {
             Icon(
@@ -338,9 +479,9 @@ private fun MoreMenuView(
                 contentDescription = stringResource(R.string.edit_menu_items),
                 tint = LegadoTheme.colorScheme.onSurface,
                 modifier = Modifier
-                    .size(16.dp)
+                    .size(20.dp)
             )
-            Spacer(modifier = Modifier.width(8.dp))
+            Spacer(modifier = Modifier.width(12.dp))
             AppText(
                 text = stringResource(R.string.edit_menu_items),
                 style = LegadoTheme.typography.labelMedium
@@ -366,7 +507,7 @@ private fun MoreMenuItem(
                 model = item.iconDrawable,
                 contentDescription = item.title,
                 modifier = Modifier
-                    .size(16.dp)
+                    .size(20.dp)
             )
             Spacer(modifier = Modifier.width(12.dp))
         }
@@ -378,13 +519,59 @@ private fun MoreMenuItem(
 }
 
 
+private class MoreMenuPositionProvider(
+    private val density: Float,
+    private val anchorOnScreen: IntRect,
+    private val windowXOnScreen: Int,
+    private val windowYOnScreen: Int,
+    private val shadowPadding: Int,
+) : PopupPositionProvider {
+    override fun calculatePosition(
+        anchorBounds: IntRect,
+        windowSize: IntSize,
+        layoutDirection: LayoutDirection,
+        popupContentSize: IntSize,
+    ): IntOffset {
+        val margin = (16 * density).roundToInt()
+        val anchor = IntRect(
+            left = anchorOnScreen.left - windowXOnScreen,
+            top = anchorOnScreen.top - windowYOnScreen,
+            right = anchorOnScreen.right - windowXOnScreen,
+            bottom = anchorOnScreen.bottom - windowYOnScreen,
+        )
+        val x = (anchor.right - popupContentSize.width + shadowPadding).coerceIn(
+            margin - shadowPadding,
+            (windowSize.width - popupContentSize.width - margin + shadowPadding)
+                .coerceAtLeast(margin - shadowPadding),
+        )
+        val spaceBelow = windowSize.height - anchor.top - margin + shadowPadding
+        val spaceAbove = anchor.bottom - margin + shadowPadding
+        val preferredY = if (
+            popupContentSize.height <= spaceBelow || spaceBelow >= spaceAbove
+        ) {
+            // The first row covers the three-dot button and the menu expands downward.
+            anchor.top - shadowPadding
+        } else {
+            // Near the bottom edge, expand upward with the last row over the button.
+            anchor.bottom - popupContentSize.height + shadowPadding
+        }
+        val y = preferredY.coerceIn(
+            margin - shadowPadding,
+            (windowSize.height - popupContentSize.height - margin + shadowPadding)
+                .coerceAtLeast(margin - shadowPadding),
+        )
+        return IntOffset(x, y)
+    }
+}
+
 private class TextMenuPositionProvider(
     private val density: Float,
     private val startX: Int,
     private val startTopY: Int,
     private val startBottomY: Int,
     private val endX: Int,
-    private val endBottomY: Int
+    private val endBottomY: Int,
+    private val shadowPadding: Int,
 ) : PopupPositionProvider {
     override fun calculatePosition(
         anchorBounds: IntRect,
@@ -399,21 +586,32 @@ private class TextMenuPositionProvider(
         val marginVertical = (12 * density).toInt()
         val textMargin = (4 * density).toInt()
 
-        val isSpaceEnoughAtTop = startTopY > popupContentSize.height + textMargin + marginVertical
-        
+        val cardHeight = popupContentSize.height - shadowPadding * 2
+        val isSpaceEnoughAtTop = startTopY > cardHeight + textMargin + marginVertical
+
         if (isSpaceEnoughAtTop) {
-            x = startX
-            y = startTopY - popupContentSize.height - textMargin
-        } else if (windowSize.height - startBottomY > popupContentSize.height + textMargin + marginVertical) {
-            x = startX
-            y = startBottomY + textMargin
+            x = startX - shadowPadding
+            y = startTopY - popupContentSize.height + shadowPadding - textMargin
+        } else if (windowSize.height - startBottomY > cardHeight + textMargin + marginVertical) {
+            x = startX - shadowPadding
+            y = startBottomY + textMargin - shadowPadding
         } else {
-            x = endX
-            y = endBottomY + textMargin
+            x = endX - shadowPadding
+            y = endBottomY + textMargin - shadowPadding
         }
 
-        val finalX = x.coerceIn(marginHorizontal, (windowSize.width - popupContentSize.width - marginHorizontal).coerceAtLeast(marginHorizontal))
-        val finalY = y.coerceIn(marginVertical, (windowSize.height - popupContentSize.height - marginVertical).coerceAtLeast(marginVertical))
+        val minX = marginHorizontal - shadowPadding
+        val minY = marginVertical - shadowPadding
+        val finalX = x.coerceIn(
+            minX,
+            (windowSize.width - popupContentSize.width - marginHorizontal + shadowPadding)
+                .coerceAtLeast(minX),
+        )
+        val finalY = y.coerceIn(
+            minY,
+            (windowSize.height - popupContentSize.height - marginVertical + shadowPadding)
+                .coerceAtLeast(minY),
+        )
 
         return IntOffset(finalX, finalY)
     }

+ 0 - 94
app/src/test/java/io/legado/app/ui/book/read/config/ClickActionConfigDialogTest.kt

@@ -1,94 +0,0 @@
-package io.legado.app.ui.book.read.config
-
-import android.app.Application
-import android.os.Bundle
-import android.view.View
-import android.view.View.MeasureSpec
-import android.view.ViewGroup
-import android.widget.FrameLayout
-import androidx.appcompat.app.AppCompatActivity
-import io.legado.app.base.BaseOverlayDialogFragment
-import io.legado.app.R
-import org.junit.Assert.assertEquals
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.Robolectric
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.Shadows.shadowOf
-import org.robolectric.annotation.Config
-import splitties.init.injectAsAppCtx
-
-@RunWith(RobolectricTestRunner::class)
-@Config(
-    sdk = [34],
-    application = ClickActionConfigDialogTest.TestApplication::class
-)
-class ClickActionConfigDialogTest {
-
-    @Test
-    fun clickActionConfigDialog_shouldFillWindowHeight() {
-        val activity = Robolectric.buildActivity(TestActivity::class.java)
-            .setup()
-            .get()
-        val dialog = TestFullscreenDialog()
-
-        dialog.show(activity.supportFragmentManager, "click-config")
-        shadowOf(activity.mainLooper).idle()
-
-        val windowHeight = 1920
-        val windowWidth = 1080
-        val decorView = dialog.requireDialog().window!!.decorView
-        layoutView(decorView, windowWidth, windowHeight)
-
-        assertEquals(
-            "ClickActionConfigDialog root view should cover the full window height",
-            windowHeight,
-            dialog.requireView().height
-        )
-    }
-
-    private fun layoutView(view: View, width: Int, height: Int) {
-        view.measure(
-            MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
-            MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
-        )
-        view.layout(0, 0, width, height)
-    }
-
-    class TestApplication : Application() {
-        override fun onCreate() {
-            injectAsAppCtx()
-            super.onCreate()
-        }
-    }
-
-    class TestFullscreenDialog : BaseOverlayDialogFragment(R.layout.dialog_click_action_config) {
-        override fun onStart() {
-            super.onStart()
-            dialog?.window?.run {
-                setBackgroundDrawableResource(R.color.transparent)
-                setLayout(
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                    ViewGroup.LayoutParams.MATCH_PARENT
-                )
-            }
-        }
-
-        override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = Unit
-    }
-
-    class TestActivity : AppCompatActivity() {
-        override fun onCreate(savedInstanceState: Bundle?) {
-            setTheme(R.style.Theme_Base_WH)
-            super.onCreate(savedInstanceState)
-            setContentView(
-                FrameLayout(this).apply {
-                    layoutParams = ViewGroup.LayoutParams(
-                        ViewGroup.LayoutParams.MATCH_PARENT,
-                        ViewGroup.LayoutParams.MATCH_PARENT
-                    )
-                }
-            )
-        }
-    }
-}