From 82aa78bc50e810aa88757761a9b86d1b191caec4 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 29 Sep 2026 15:04:00 -0300 Subject: [PATCH] Additional keyboard polish. --- .../securesms/conversation/v2/ChatScreen.kt | 4 +- .../conversation/v2/ConversationFragment.kt | 6 +- .../securesms/dependencies/AppDependencies.kt | 2 + .../MediaKeyboardDependenciesProvider.kt | 14 + .../keyboard/KeyboardSheetController.kt | 9 + .../keyboard/KeyboardSheetDragRegion.kt | 31 ++ .../compose/keyboard/KeyboardSheetScaffold.kt | 329 ++++++++++-------- .../ui/compose/keyboard/KeyboardSheetScope.kt | 4 +- demo/media-keyboard/build.gradle.kts | 2 + .../mediakeyboard/demo/DemoExoPlayerPool.kt | 25 ++ .../demo/MediaKeyboardDemoApplication.kt | 12 + feature/media-keyboard/build.gradle.kts | 1 + .../org/signal/mediakeyboard/MediaKeyboard.kt | 33 +- .../MediaKeyboardDependencies.kt | 42 +++ .../screens/CollapsingHeaderLayout.kt | 131 +++++++ .../screens/MediaKeyboardSearchField.kt | 56 +-- .../mediakeyboard/screens/PinnedRailLayout.kt | 18 +- .../screens/emoji/EmojiPageScreen.kt | 94 +++-- .../screens/gif/GifPageScreen.kt | 140 ++++---- .../screens/gif/GifPlayerPool.kt | 68 ---- .../mediakeyboard/screens/gif/GifPlayers.kt | 115 ++++++ .../screens/sticker/StickerPageScreen.kt | 88 +++-- .../src/main/res/values/strings.xml | 2 +- .../org/signal/glide/compose/GlideImage.kt | 1 - 24 files changed, 795 insertions(+), 432 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/dependencies/MediaKeyboardDependenciesProvider.kt create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetDragRegion.kt create mode 100644 demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/DemoExoPlayerPool.kt create mode 100644 feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboardDependencies.kt create mode 100644 feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/CollapsingHeaderLayout.kt delete mode 100644 feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/gif/GifPlayerPool.kt create mode 100644 feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/gif/GifPlayers.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt index e4a25bc439..cf961bdf1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt @@ -167,7 +167,9 @@ fun ChatScreen( ) { AndroidFragment( clazz = AttachmentKeyboardFragment::class.java, - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.navigationBarsCompat) ) } }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 6da1b6f337..fe92fc21ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -3722,7 +3722,7 @@ class ConversationFragment : val range = computeVerticalScrollRange() - computeVerticalScrollExtent() val delta = range - offset - delta <= IS_SCROLLED_TO_BOTTOM_THRESHOLD + delta <= IS_SCROLLED_TO_BOTTOM_THRESHOLD + mediaKeyboardController.expandedOverlapPx } } @@ -3756,7 +3756,9 @@ class ConversationFragment : } private fun scrollToBottom() { - layoutManager.scrollToPositionWithOffset(0, 0) + // An expanded media keyboard covers the bottom of the list without the list's height changing, so the newest + // message has to be lifted clear of it or it lands behind the sheet. + layoutManager.scrollToPositionWithOffset(0, mediaKeyboardController.expandedOverlapPx) scrollListener?.onScrolled(binding.conversationItemRecycler, 0, 0) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index 6d290445ad..47ddb72025 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -20,6 +20,7 @@ import org.signal.glide.SignalGlideDependencies import org.signal.libsignal.net.Network import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations +import org.signal.mediakeyboard.MediaKeyboardDependencies import org.signal.mediasend.MediaSendDependencies import org.signal.network.api.AccountApiV2 import org.signal.network.api.ArchiveApi @@ -131,6 +132,7 @@ object AppDependencies { SignalGlideDependencies.init(application, SignalGlideDependenciesProvider) CameraDependencies.init(application, CameraDependenciesProvider) MediaSendDependencies.init(application, MediaSendDependenciesProvider) + MediaKeyboardDependencies.init(application, MediaKeyboardDependenciesProvider) EmojiDependencies.init(application, EmojiDependenciesProvider) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/MediaKeyboardDependenciesProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/MediaKeyboardDependenciesProvider.kt new file mode 100644 index 0000000000..8121041dfd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/MediaKeyboardDependenciesProvider.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.dependencies + +import androidx.media3.exoplayer.ExoPlayer +import org.signal.mediakeyboard.MediaKeyboardDependencies +import org.signal.video.exo.ExoPlayerPool + +object MediaKeyboardDependenciesProvider : MediaKeyboardDependencies.Provider { + override fun provideExoPlayerPool(): ExoPlayerPool = AppDependencies.exoPlayerPool +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetController.kt b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetController.kt index 9e869fe6d2..7d8cce3c0f 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetController.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetController.kt @@ -54,6 +54,14 @@ class KeyboardSheetController(initialKeyboardHeightPx: Int = 0) { var isExpanded: Boolean by mutableStateOf(false) internal set + /** + * How much of the content behind the sheet an expanded sheet covers. The content keeps the height it had at + * keyboard height while the sheet grows past it, so this is room the host still believes it has and must leave + * clear itself when it puts something where the user has to see it. Zero unless the sheet is past keyboard height. + */ + var expandedOverlapPx: Int by mutableStateOf(0) + internal set + /** * Whether a window of the host's own -- a dialog with a field of its own, say -- is up in front of * the sheet and owns the system keyboard while it is there, so the sheet stays put rather than @@ -148,6 +156,7 @@ class KeyboardSheetController(initialKeyboardHeightPx: Int = 0) { private fun reset(key: KeyboardSheetKey?, awaitingSystem: Boolean = false) { awaitingSystemKeyboard = awaitingSystem + expandedOverlapPx = 0 current = key _isEnteringText.value = false expansionTarget = false diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetDragRegion.kt b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetDragRegion.kt new file mode 100644 index 0000000000..d403f7624c --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetDragRegion.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose.keyboard + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier + +/** + * Lets an expandable keyboard's sheet be dragged from anywhere in [content], not just its handle. + * + * Taps and horizontal drags still reach [content]; only a vertical drag past touch slop is taken for the sheet. Meant + * for chrome, since anything in [content] that scrolls vertically keeps its drags for itself. + * + * Does nothing outside a [KeyboardSheetScaffold]'s keyboard content, or for a keyboard that is not expandable. + */ +@Composable +fun KeyboardSheetDragRegion( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Box(modifier = modifier.then(LocalKeyboardSheetDragRegion.current)) { + content() + } +} + +internal val LocalKeyboardSheetDragRegion = staticCompositionLocalOf { Modifier } diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScaffold.kt b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScaffold.kt index 2cc0218a96..0dbc8f29e0 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScaffold.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScaffold.kt @@ -11,6 +11,12 @@ import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.AnchoredDraggableDefaults +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.gestures.animateTo import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,16 +29,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imeAnimationTarget +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material3.BottomSheetScaffold -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue -import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -50,9 +52,9 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.BlurEffect -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.graphicsLayer @@ -66,6 +68,7 @@ import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged @@ -73,7 +76,6 @@ import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import org.signal.core.ui.compose.BottomSheets -import org.signal.core.ui.compose.navigationBarsCompat import org.signal.core.ui.compose.safeDrawingCompat import org.signal.core.ui.compose.systemBarsCompat import org.signal.core.ui.getWindowSizeClass @@ -95,7 +97,24 @@ private val BACK_SETTLE_MOTION = spring( private val SYSTEM_KEYBOARD_ARRIVAL_TIMEOUT = 1.seconds private val SHEET_POSITIONAL_THRESHOLD = 56.dp -private val SHEET_VELOCITY_THRESHOLD = 125.dp + +/** Moves the sheet between heights. Never bouncy, since an overshoot would lift the sheet off the bottom of the window. */ +private val SHEET_MOTION = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = 700f +) + +/** Puts the sheet away, quicker than it comes up. */ +private val SHEET_HIDE_MOTION = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = 3800f +) + +private enum class SheetAnchor { + Hidden, + PartiallyExpanded, + Expanded +} /** How far an expandable sheet dims what is behind it once at full height. */ private const val EXPANDED_SCRIM_ALPHA = 0.32f @@ -119,7 +138,7 @@ private const val EXPANDED_HEIGHT_MULTIPLIER = 2f * @param keyboardHeight Bounds on how tall a keyboard may be. * @param adjustContentForInput False to let a keyboard cover [content] rather than resize it. */ -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalLayoutApi::class) @Composable fun KeyboardSheetScaffold( controller: KeyboardSheetController, @@ -266,16 +285,13 @@ fun KeyboardSheetScaffold( // that built the sheet, and keying the remember on density instead would rebuild the sheet at its // initial value, putting a keyboard away because the font scale changed. val currentDensity by rememberUpdatedState(density) - val sheetState = remember { - SheetState( - skipPartiallyExpanded = false, - positionalThreshold = { with(currentDensity) { SHEET_POSITIONAL_THRESHOLD.toPx() } }, - velocityThreshold = { with(currentDensity) { SHEET_VELOCITY_THRESHOLD.toPx() } }, - initialValue = SheetValue.Hidden, - skipHiddenState = false - ) - } - val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = sheetState) + val sheetState = remember { AnchoredDraggableState(initialValue = SheetAnchor.Hidden) } + val positionalThreshold: (Float) -> Float = remember { { with(currentDensity) { SHEET_POSITIONAL_THRESHOLD.toPx() } } } + val sheetFlingBehavior = AnchoredDraggableDefaults.flingBehavior( + state = sheetState, + positionalThreshold = positionalThreshold, + animationSpec = SHEET_MOTION + ) // This just makes sure the previously visible state doesn't go away too early while we're mid swap. var visibleKey by remember { mutableStateOf(null) } @@ -294,8 +310,8 @@ fun KeyboardSheetScaffold( val expandable = registry.isExpandable(visibleKey) - // BottomSheetScaffold measures its anchors from the sheet, so the sheet is always as tall as it - // could ever need to be and the part below the fold simply hangs off the bottom of the window. + // The sheet is always as tall as it could ever need to be and the part below the fold simply hangs + // off the bottom of the window. var scaffoldHeightPx by remember { mutableIntStateOf(0) } val layoutHeightPx = if (scaffoldHeightPx > 0) scaffoldHeightPx else windowHeightPx val minimumContentPx = with(density) { keyboardHeight.minimumContentVisible.roundToPx() } @@ -304,6 +320,22 @@ fun KeyboardSheetScaffold( val sheetHeightPx = if (expandable) expandedHeightPx else heightPx val sheetHeight = with(density) { sheetHeightPx.toDp() } + // Where the top of the sheet sits in the scaffold for each height it can rest at. + val sheetAnchors = remember(layoutHeightPx, heightPx, sheetHeightPx) { + DraggableAnchors { + SheetAnchor.Hidden at layoutHeightPx.toFloat() + SheetAnchor.PartiallyExpanded at (layoutHeightPx - heightPx).toFloat() + if (sheetHeightPx > heightPx) { + SheetAnchor.Expanded at (layoutHeightPx - sheetHeightPx).toFloat() + } + } + } + + SideEffect { + val target = sheetState.targetValue.takeIf { sheetAnchors.hasPositionFor(it) } ?: SheetAnchor.PartiallyExpanded + sheetState.updateAnchors(sheetAnchors, target) + } + /** * How much of the sheet the window actually shows. Reads the live sheet offset, so anything that * calls it from layout or draw follows a drag frame by frame. @@ -335,9 +367,9 @@ fun KeyboardSheetScaffold( * asked for or given up. */ val sheetTarget = when { - activeKey == null -> SheetValue.Hidden - expandable && controller.expansionTarget -> SheetValue.Expanded - else -> SheetValue.PartiallyExpanded + activeKey == null -> SheetAnchor.Hidden + expandable && controller.expansionTarget -> SheetAnchor.Expanded + else -> SheetAnchor.PartiallyExpanded } // The one place the sheet is driven from. Keyed on the target, so every change comes through here @@ -352,7 +384,7 @@ fun KeyboardSheetScaffold( // A gesture may already have carried it off screen; hold that until the hide completes. handingOverFromSystemKeyboard = false - sheetState.hide() + sheetState.animateTo(SheetAnchor.Hidden, SHEET_HIDE_MOTION) backProgress.snapTo(0f) visibleKey = null currentOnAction(KeyboardSheetAction.KeyboardHidden) @@ -370,10 +402,10 @@ fun KeyboardSheetScaffold( backProgress.snapTo(0f) } - when { - sheetTarget == SheetValue.Expanded && sheetState.hasExpandedState -> sheetState.expand() - sheetState.currentValue == SheetValue.Hidden -> sheetState.show() - else -> sheetState.partialExpand() + if (sheetTarget == SheetAnchor.Expanded && sheetState.anchors.hasPositionFor(SheetAnchor.Expanded)) { + sheetState.animateTo(SheetAnchor.Expanded, SHEET_MOTION) + } else { + sheetState.animateTo(SheetAnchor.PartiallyExpanded, SHEET_MOTION) } if (arriving) { @@ -382,29 +414,26 @@ fun KeyboardSheetScaffold( } } - // Picks up drags as well as requests, so the controller reports where the sheet actually went. + // Picks up drags as well as requests, so the controller reports where the sheet is heading. LaunchedEffect(sheetState) { - snapshotFlow { sheetState.targetValue == SheetValue.Expanded } + snapshotFlow { sheetState.targetValue == SheetAnchor.Expanded } .distinctUntilChanged() .collect { expanded -> controller.isExpanded = expanded - - if (expanded) { - controller.expansionTarget = true - } else { - controller.endTextEntry() - } + controller.expandedOverlapPx = if (expanded) sheetHeightPx - heightPx else 0 } } - // A swipe can carry the sheet away without anyone having asked it to, leaving the controller - // believing a keyboard is still up and content still holding space for one. + // Requests follow a drag only once it lets go. Changing them mid-drag would change sheetTarget, and driving the + // sheet there would take it out from under the finger. A drag can also carry the sheet away without anyone having + // asked it to, which would otherwise leave the controller believing a keyboard is still up. LaunchedEffect(sheetState) { - snapshotFlow { sheetState.currentValue } - .filter { it == SheetValue.Hidden } - .collect { - if (controller.isShowing) { - controller.hide() + snapshotFlow { sheetState.settledValue } + .collect { settled -> + when (settled) { + SheetAnchor.Expanded -> controller.expansionTarget = true + SheetAnchor.PartiallyExpanded -> controller.collapse() + SheetAnchor.Hidden -> if (controller.isShowing) controller.hide() } } } @@ -462,6 +491,12 @@ fun KeyboardSheetScaffold( } // Keyed, because expansionFraction closes over this composition's heights and visible key; an // unkeyed remember would hold the first composition's, from before anything was up. + val dragRegion = if (expandable) { + Modifier.anchoredDraggable(state = sheetState, orientation = Orientation.Vertical, flingBehavior = sheetFlingBehavior) + } else { + Modifier + } + val scrimShowing by remember(expandable, heightPx, sheetHeightPx, layoutHeightPx, visibleKey) { derivedStateOf { expansionFraction() > 0f } } @@ -471,121 +506,117 @@ fun KeyboardSheetScaffold( .fillMaxSize() .onSizeChanged { scaffoldHeightPx = it.height } ) { - BottomSheetScaffold( - scaffoldState = scaffoldState, - sheetPeekHeight = height, - sheetShape = sheetShape, - sheetDragHandle = null, - sheetSwipeEnabled = expandable, - sheetContainerColor = Color.Transparent, - sheetTonalElevation = 0.dp, - sheetShadowElevation = 0.dp, - containerColor = Color.Transparent, - sheetContent = { - Box( + Box( + modifier = Modifier + .fillMaxSize() + .onConsumedWindowInsetsChanged { ancestorConsumedBottomPx = it.getBottom(density) } + .windowInsetsPadding(windowInsets) + .layout { measurable, constraints -> + // Window insets are already out of these constraints; take only the excess claim. + val windowBottomPx = (safeDrawingInsets.getBottom(this) - ancestorConsumedBottomPx).coerceAtLeast(0) + val extraPx = if (adjustContentForInput) (claimedBottomPx() - windowBottomPx).coerceAtLeast(0) else 0 + + val available = (constraints.maxHeight - extraPx).coerceAtLeast(0) + val placeable = measurable.measure(constraints.copy(minHeight = available, maxHeight = available)) + layout(constraints.maxWidth, constraints.maxHeight) { + placeable.place(0, 0) + } + } + ) { + content() + } + + if (expandable) { + Box( + modifier = Modifier + .fillMaxSize() + .drawBehind { drawRect(color = scrimColor, alpha = EXPANDED_SCRIM_ALPHA * expansionFraction()) } + .then( + if (scrimShowing) { + // Collapses rather than dismisses: the dim means the sheet has grown past keyboard + // height, and keyboard height is where it belongs the rest of the time, so undoing + // the growth is what the gesture is for. Dismissing would also be indiscriminate, + // since this covers the whole of [content] -- a host's own text field included. + Modifier.pointerInput(Unit) { detectTapGestures { controller.collapse() } } + } else { + Modifier + } + ) + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(sheetHeight) + .offset { + val offset = sheetState.offset + IntOffset(0, if (offset.isNaN()) layoutHeightPx else offset.roundToInt()) + } + .clip(sheetShape) + // Taken here so a touch on the sheet never falls through to [content] behind it. + .pointerInput(Unit) {} + // Off the bottom by whatever is actually on screen, which an expanded sheet outgrows. + .graphicsLayer { translationY = backProgress.value * visibleSheetHeightPx() } + .background(containerColor) + ) { + val overlay = registry.overlayFor(visibleKey) + val blurRadius = registry.blurRadiusFor(visibleKey) + + Box( + modifier = Modifier.layout { measurable, constraints -> + // Only the part of the sheet the window shows is worth laying content out in, and + // never less than a keyboard: below that the sheet is on its way out, not resizing. + val visible = visibleSheetHeightPx().coerceIn(heightPx, constraints.maxHeight) + val placeable = measurable.measure(constraints.copy(minHeight = visible, maxHeight = visible)) + layout(constraints.maxWidth, constraints.maxHeight) { + placeable.place(0, 0) + } + } + ) { + Column( modifier = Modifier - .fillMaxWidth() - .height(sheetHeight) - // Off the bottom by whatever is actually on screen, which an expanded sheet outgrows. - .graphicsLayer { translationY = backProgress.value * visibleSheetHeightPx() } - .background(containerColor) + .fillMaxSize() + .graphicsLayer { + val radiusPx = blurRadius().toPx() + if (radiusPx > 0f) { + renderEffect = BlurEffect(radiusPx, radiusPx, TileMode.Clamp) + clip = true + } else { + renderEffect = null + clip = false + } + } ) { - val overlay = registry.overlayFor(visibleKey) - val blurRadius = registry.blurRadiusFor(visibleKey) + if (expandable) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .then(dragRegion) + ) { + BottomSheets.Handle() + } + } Box( - modifier = Modifier.layout { measurable, constraints -> - // Only the part of the sheet the window shows is worth laying content out in, and - // never less than a keyboard: below that the sheet is on its way out, not resizing. - val visible = visibleSheetHeightPx().coerceIn(heightPx, constraints.maxHeight) - val placeable = measurable.measure(constraints.copy(minHeight = visible, maxHeight = visible)) - layout(constraints.maxWidth, constraints.maxHeight) { - placeable.place(0, 0) - } - } + modifier = Modifier + .weight(1f) + .fillMaxWidth() ) { - Column( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - val radiusPx = blurRadius().toPx() - if (radiusPx > 0f) { - renderEffect = BlurEffect(radiusPx, radiusPx, TileMode.Clamp) - clip = true - } else { - renderEffect = null - clip = false - } - } - .windowInsetsPadding(WindowInsets.navigationBarsCompat) + CompositionLocalProvider( + LocalKeyboardSheetController provides controller, + LocalKeyboardSheetDragRegion provides dragRegion ) { - if (expandable) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() - ) { - BottomSheets.Handle() - } - } - - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - ) { - CompositionLocalProvider(LocalKeyboardSheetController provides controller) { - registry.contentFor(visibleKey)?.invoke() - } - } - } - - if (overlay != null) { - CompositionLocalProvider(LocalKeyboardSheetController provides controller) { - overlay() - } + registry.contentFor(visibleKey)?.invoke() } } } - } - ) { _ -> - Box(modifier = Modifier.fillMaxSize()) { - Box( - modifier = Modifier - .fillMaxSize() - .onConsumedWindowInsetsChanged { ancestorConsumedBottomPx = it.getBottom(density) } - .windowInsetsPadding(windowInsets) - .layout { measurable, constraints -> - // Window insets are already out of these constraints; take only the excess claim. - val windowBottomPx = (safeDrawingInsets.getBottom(this) - ancestorConsumedBottomPx).coerceAtLeast(0) - val extraPx = if (adjustContentForInput) (claimedBottomPx() - windowBottomPx).coerceAtLeast(0) else 0 - val available = (constraints.maxHeight - extraPx).coerceAtLeast(0) - val placeable = measurable.measure(constraints.copy(minHeight = available, maxHeight = available)) - layout(constraints.maxWidth, constraints.maxHeight) { - placeable.place(0, 0) - } - } - ) { - content() - } - - if (expandable) { - Box( - modifier = Modifier - .fillMaxSize() - .drawBehind { drawRect(color = scrimColor, alpha = EXPANDED_SCRIM_ALPHA * expansionFraction()) } - .then( - if (scrimShowing) { - // Collapses rather than dismisses: the dim means the sheet has grown past keyboard - // height, and keyboard height is where it belongs the rest of the time, so undoing - // the growth is what the gesture is for. Dismissing would also be indiscriminate, - // since this covers the whole of [content] -- a host's own text field included. - Modifier.pointerInput(Unit) { detectTapGestures { controller.collapse() } } - } else { - Modifier - } - ) - ) + if (overlay != null) { + CompositionLocalProvider(LocalKeyboardSheetController provides controller) { + overlay() + } } } } diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScope.kt b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScope.kt index 6f67791930..a714cd7eed 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScope.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/keyboard/KeyboardSheetScope.kt @@ -26,7 +26,9 @@ interface KeyboardSheetScope { * @param blurRadius How much to blur the sheet beneath [overlay]. Read as the sheet draws, so it * can animate without recomposing anything. Below API 31 the sheet is never blurred, so * [overlay] needs a background opaque enough to stand on its own there. - * @param content The keyboard itself. + * @param content The keyboard itself. Laid out to the bottom of the window rather than above the navigation bar, so + * content that should not sit under the bar holds it clear itself. Scrolling content usually wants to draw under + * it and pad its own ends instead. */ fun keyboard( key: KeyboardSheetKey, diff --git a/demo/media-keyboard/build.gradle.kts b/demo/media-keyboard/build.gradle.kts index 400e3cc0ee..6d8bac6eed 100644 --- a/demo/media-keyboard/build.gradle.kts +++ b/demo/media-keyboard/build.gradle.kts @@ -42,6 +42,8 @@ dependencies { implementation(project(":core:ui")) implementation(project(":core:util")) implementation(project(":lib:glide")) + implementation(project(":lib:video")) + implementation(libs.androidx.media3.exoplayer) implementation(libs.glide.glide) // Core AndroidX diff --git a/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/DemoExoPlayerPool.kt b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/DemoExoPlayerPool.kt new file mode 100644 index 0000000000..ed1bd6f59e --- /dev/null +++ b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/DemoExoPlayerPool.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediakeyboard.demo + +import android.content.Context +import androidx.media3.exoplayer.ExoPlayer +import org.signal.video.exo.ExoPlayerPool + +/** + * Plain pool for the demo app, which has none of the main app's media source wiring. + */ +class DemoExoPlayerPool(context: Context) : ExoPlayerPool(0) { + private val context = context.applicationContext + + override fun createPlayer(): ExoPlayer = ExoPlayer.Builder(this.context).build() + + override fun getMaxSimultaneousPlayback(): Int = MAX_SIMULTANEOUS_PLAYBACK + + companion object { + private const val MAX_SIMULTANEOUS_PLAYBACK = 3 + } +} diff --git a/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/MediaKeyboardDemoApplication.kt b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/MediaKeyboardDemoApplication.kt index 3dd3934f99..d9d61183c7 100644 --- a/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/MediaKeyboardDemoApplication.kt +++ b/demo/media-keyboard/src/main/java/org/signal/mediakeyboard/demo/MediaKeyboardDemoApplication.kt @@ -7,12 +7,15 @@ package org.signal.mediakeyboard.demo import android.app.Application import android.content.Context +import androidx.media3.exoplayer.ExoPlayer import com.bumptech.glide.Glide import com.bumptech.glide.Registry import org.signal.core.ui.CoreUiDependencies import org.signal.core.util.logging.AndroidLogger import org.signal.core.util.logging.Log +import org.signal.mediakeyboard.MediaKeyboardDependencies import org.signal.mediakeyboard.demo.data.DemoMediaKeyboardRepository +import org.signal.video.exo.ExoPlayerPool import org.thoughtcrime.securesms.mms.RegisterGlideComponents import org.thoughtcrime.securesms.mms.SignalGlideModule @@ -39,6 +42,15 @@ class MediaKeyboardDemoApplication : Application() { } ) + MediaKeyboardDependencies.init( + this, + object : MediaKeyboardDependencies.Provider { + private val exoPlayerPool = DemoExoPlayerPool(this@MediaKeyboardDemoApplication) + + override fun provideExoPlayerPool(): ExoPlayerPool = exoPlayerPool + } + ) + repository = DemoMediaKeyboardRepository(this) } } diff --git a/feature/media-keyboard/build.gradle.kts b/feature/media-keyboard/build.gradle.kts index 2b3ceb4d25..8fb0a9c816 100644 --- a/feature/media-keyboard/build.gradle.kts +++ b/feature/media-keyboard/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(project(":core:util")) implementation(project(":core:ui")) implementation(project(":lib:glide")) + implementation(project(":lib:video")) // Compose BOM platform(libs.androidx.compose.bom).let { composeBom -> diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboard.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboard.kt index 8ffafb840c..fb32f486c2 100644 --- a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboard.kt +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboard.kt @@ -52,6 +52,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.flow.StateFlow import org.signal.core.ui.compose.CollectActions +import org.signal.core.ui.compose.keyboard.KeyboardSheetDragRegion import org.signal.core.ui.compose.keyboard.LocalKeyboardSheetController import org.signal.mediakeyboard.data.MediaKeyboardRepository import org.signal.mediakeyboard.screens.LocalMediaKeyboardExpanded @@ -127,13 +128,15 @@ fun MediaKeyboard( if (searching) { MediaKeyboardSearchBar(state = state, onEvent = viewModel::onEvent, onCloseSearch = host::endTextEntry) } else { - MediaKeyboardTopBar( - state = state, - onEvent = viewModel::onEvent, - onAction = onAction, - onOpenSearch = host::beginTextEntry, - searchFieldRevealed = searchFieldRevealed - ) + KeyboardSheetDragRegion { + MediaKeyboardTopBar( + state = state, + onEvent = viewModel::onEvent, + onAction = onAction, + onOpenSearch = host::beginTextEntry, + searchFieldRevealed = searchFieldRevealed + ) + } } Box( @@ -143,7 +146,13 @@ fun MediaKeyboard( ) { if (state.initialized) { when (state.selectedTab) { - MediaKeyboardTab.EMOJI -> EmojiTab(repository, viewModel.state, onAction) + MediaKeyboardTab.EMOJI -> EmojiTab( + repository = repository, + parentStateFlow = viewModel.state, + onAction = onAction, + onSearchClicked = host::beginTextEntry, + onSearchFieldRevealedChange = { searchFieldRevealed = it } + ) MediaKeyboardTab.STICKER -> StickerTab( repository = repository, onAction = onAction, @@ -277,7 +286,9 @@ private fun TabPill( private fun EmojiTab( repository: MediaKeyboardRepository, parentStateFlow: StateFlow, - onAction: (MediaKeyboardAction) -> Unit + onAction: (MediaKeyboardAction) -> Unit, + onSearchClicked: () -> Unit, + onSearchFieldRevealedChange: (Boolean) -> Unit ) { val viewModel: EmojiPageViewModel = viewModel( key = "media-keyboard-emoji", @@ -290,7 +301,9 @@ private fun EmojiTab( EmojiPageScreen( state = state, onEvent = viewModel::onEvent, - getEmojiDrawable = repository.emoji::getEmojiDrawable + getEmojiDrawable = repository.emoji::getEmojiDrawable, + onSearchClicked = onSearchClicked, + onSearchFieldRevealedChange = onSearchFieldRevealedChange ) } diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboardDependencies.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboardDependencies.kt new file mode 100644 index 0000000000..39d4eb20de --- /dev/null +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/MediaKeyboardDependencies.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediakeyboard + +import android.app.Application +import androidx.media3.exoplayer.ExoPlayer +import org.signal.video.exo.ExoPlayerPool + +/** + * Media keyboard feature module dependencies. + */ +object MediaKeyboardDependencies { + private lateinit var _application: Application + private lateinit var _provider: Provider + + @Synchronized + fun init(application: Application, provider: Provider) { + if (this::_application.isInitialized || this::_provider.isInitialized) { + return + } + + _application = application + _provider = provider + } + + val application + get() = _application + + /** + * The app-wide pool of players. Shared with every other surface that plays video, because the number of + * simultaneous decoders is a device limit rather than a per-screen one. + */ + val exoPlayerPool: ExoPlayerPool + get() = _provider.provideExoPlayerPool() + + interface Provider { + fun provideExoPlayerPool(): ExoPlayerPool + } +} diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/CollapsingHeaderLayout.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/CollapsingHeaderLayout.kt new file mode 100644 index 0000000000..288439e4d7 --- /dev/null +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/CollapsingHeaderLayout.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediakeyboard.screens + +import androidx.compose.animation.core.animate +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.Velocity +import kotlin.math.roundToInt + +/** Past this, in pixels per second, a release is taken as heading somewhere rather than just letting go. */ +private const val SETTLE_VELOCITY_THRESHOLD = 300f + +/** + * Lays out [header] above [content], collapsed to nothing until the content is pulled down past its start. + * + * Like an app bar in a CoordinatorLayout, the header is something the user pulls down to rather than something + * occupying a row's worth of room. It starts collapsed, so it never shows before content arrives and then has to be + * scrolled away, and it takes up only as much room as it has been pulled open by. + * + * @param onRevealedChange Reports whether the header is fully open, for hosts that offer the same thing elsewhere + * while it is closed. + * @param resetKey Closes the header again whenever this changes, for content that is being replaced wholesale. + */ +@Composable +internal fun CollapsingHeaderLayout( + header: @Composable () -> Unit, + onRevealedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + resetKey: Any? = Unit, + content: @Composable () -> Unit +) { + var headerHeightPx by remember { mutableIntStateOf(0) } + var revealedPx by remember(resetKey) { mutableFloatStateOf(0f) } + + val scrollConnection = remember(headerHeightPx) { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (available.y >= 0f || revealedPx <= 0f) { + return Offset.Zero + } + + // Dragging up closes the header before the content underneath moves at all. + val taken = -minOf(-available.y, revealedPx) + revealedPx += taken + return Offset(0f, taken) + } + + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + if (available.y <= 0f || revealedPx >= headerHeightPx) { + return Offset.Zero + } + + // Whatever downward drag the content had no room for opens the header instead. + val taken = minOf(available.y, headerHeightPx - revealedPx) + revealedPx += taken + return Offset(0f, taken) + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val height = headerHeightPx + if (height <= 0 || revealedPx <= 0f || revealedPx >= height) { + return Velocity.Zero + } + + // Released part way, so the header goes to whichever end the drag was heading for, or the nearer one when it + // was not really heading anywhere. Scrollable cancels this if the user takes hold again. + val target = when { + available.y > SETTLE_VELOCITY_THRESHOLD -> height.toFloat() + available.y < -SETTLE_VELOCITY_THRESHOLD -> 0f + revealedPx >= height / 2f -> height.toFloat() + else -> 0f + } + + animate(initialValue = revealedPx, targetValue = target, initialVelocity = available.y) { value, _ -> + revealedPx = value + } + + return Velocity(0f, available.y) + } + } + } + + val revealed by remember { derivedStateOf { headerHeightPx > 0 && revealedPx >= headerHeightPx } } + + LaunchedEffect(revealed) { + onRevealedChange(revealed) + } + + Layout( + contents = listOf( + { Box(modifier = Modifier.onSizeChanged { headerHeightPx = it.height }) { header() } }, + content + ), + modifier = modifier + .fillMaxSize() + .clipToBounds() + .nestedScroll(scrollConnection) + ) { (headerMeasurables, contentMeasurables), constraints -> + // Measured at full height even while collapsed, so the header's own size is what decides how far it can open. + val headerPlaceable = headerMeasurables.first().measure(constraints.copy(minHeight = 0)) + val reveal = revealedPx.roundToInt().coerceIn(0, headerPlaceable.height) + val contentPlaceable = contentMeasurables.first().measure( + constraints.copy(minHeight = 0, maxHeight = (constraints.maxHeight - reveal).coerceAtLeast(0)) + ) + + layout(constraints.maxWidth, constraints.maxHeight) { + headerPlaceable.placeRelative(0, reveal - headerPlaceable.height) + contentPlaceable.placeRelative(0, reveal) + } + } +} diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/MediaKeyboardSearchField.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/MediaKeyboardSearchField.kt index 10f67da8da..a576986c11 100644 --- a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/MediaKeyboardSearchField.kt +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/MediaKeyboardSearchField.kt @@ -18,17 +18,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.unit.dp @@ -46,6 +40,9 @@ import org.signal.mediakeyboard.R */ internal val GRID_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 4.dp) +/** Gap between the search field and the content it sits above. */ +internal val SEARCH_FIELD_SPACING = 8.dp + /** * Looks like a field but is not one: tapping it hands search off to the host, which has a whole * screen to give it rather than the strip of keyboard available here. @@ -96,50 +93,3 @@ private fun MediaKeyboardSearchFieldPreview() { onClick = {} ) } - -/** - * Opens a page's grid scrolled past its [MediaKeyboardSearchField] and reports when the user has - * pulled back down to it, so the top bar can drop its now-redundant search icon. - * - * Effects only. The two pages hold different kinds of grid state, which share no supertype, so the - * grid is reached through lambdas rather than passed in. - * - * @param hasContent Whether the grid has anything to scroll yet; until it does there is nothing to - * hide the field behind. - * @param firstVisibleItemIndex The grid's first visible item. Read inside a [derivedStateOf] so - * that scrolling does not recompose the page. - * @param scrollPastField Scrolls the grid to an item and offset. - * @param onRevealedChange Receives whether the field is showing. - * @param resetKey Hides the field again whenever this changes, for a grid whose contents are - * replaced and whose scroll goes back to the top with them. - */ -@Composable -internal fun SearchFieldReveal( - hasContent: Boolean, - firstVisibleItemIndex: () -> Int, - scrollPastField: suspend (index: Int, offset: Int) -> Unit, - onRevealedChange: (Boolean) -> Unit, - resetKey: Any? = Unit -) { - // Scrolling to an item stops at the leading content padding rather than the top of the viewport, - // and that gap is just enough to leave the bottom of the field showing, so it goes too. - val topContentPaddingPx = with(LocalDensity.current) { GRID_CONTENT_PADDING.calculateTopPadding().roundToPx() } - - var scrolledPast by remember(resetKey) { mutableStateOf(false) } - - LaunchedEffect(resetKey, hasContent) { - if (hasContent && !scrolledPast) { - scrollPastField(1, topContentPaddingPx) - scrolledPast = true - } - } - - val currentFirstVisibleItemIndex by rememberUpdatedState(firstVisibleItemIndex) - val revealed by remember { derivedStateOf { currentFirstVisibleItemIndex() == 0 } } - - // Held back until the grid has settled where it opens, or the field would be reported as showing - // for the frames before that first scroll and the top bar's icon would blink out and back. - LaunchedEffect(revealed, scrolledPast) { - onRevealedChange(scrolledPast && revealed) - } -} diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/PinnedRailLayout.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/PinnedRailLayout.kt index ec9a370a5d..1f9d7c6733 100644 --- a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/PinnedRailLayout.kt +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/PinnedRailLayout.kt @@ -7,9 +7,12 @@ package org.signal.mediakeyboard.screens import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidableCompositionLocal @@ -30,6 +33,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.navigationBarsCompat /** * Whether the media keyboard is (or is settling to) full height. Bottom rails re-show themselves @@ -44,6 +48,11 @@ private val SCROLL_DIRECTION_THRESHOLD = 4.dp * * Like browser chrome, the rail slides away when the content is scrolled down and returns when it * is scrolled back up or the keyboard grows to full height. + * + * The rail holds the navigation bar clear on the keyboard's behalf, painting behind it while keeping its own contents + * above it, so [content] is free to fill the sheet to the bottom of the window. A hidden rail takes the bar's room + * with it, which is what lets the content run edge to edge once it is scrolled away. With no rail to do that, this + * layout keeps the bar clear itself, since nothing else in the sheet does. */ @Composable internal fun PinnedRailLayout( @@ -92,10 +101,16 @@ internal fun PinnedRailLayout( .clipToBounds() .nestedScroll(scrollConnection) ) { + val contentBottomPadding = if (rail != null) { + with(density) { (railHeight * (1f - railHiddenFraction)).toDp() } + } else { + WindowInsets.navigationBarsCompat.asPaddingValues().calculateBottomPadding() + } + Box( modifier = Modifier .fillMaxSize() - .padding(bottom = with(density) { (railHeight * (1f - railHiddenFraction)).toDp() }) + .padding(bottom = contentBottomPadding) ) { content() } @@ -107,6 +122,7 @@ internal fun PinnedRailLayout( .fillMaxWidth() .onSizeChanged { measuredRailHeight = it.height } .graphicsLayer { translationY = railHeight * railHiddenFraction } + .windowInsetsPadding(WindowInsets.navigationBarsCompat) ) { rail() } diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/emoji/EmojiPageScreen.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/emoji/EmojiPageScreen.kt index ee9ce8a70d..1d747a2495 100644 --- a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/emoji/EmojiPageScreen.kt +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/emoji/EmojiPageScreen.kt @@ -68,15 +68,33 @@ import org.signal.mediakeyboard.R import org.signal.mediakeyboard.data.EmojiCategoryPage import org.signal.mediakeyboard.data.EmojiKeyboardCategory import org.signal.mediakeyboard.data.KeyboardEmoji +import org.signal.mediakeyboard.screens.CollapsingHeaderLayout import org.signal.mediakeyboard.screens.GRID_CONTENT_PADDING +import org.signal.mediakeyboard.screens.MediaKeyboardSearchField import org.signal.mediakeyboard.screens.PinnedRailLayout +import org.signal.mediakeyboard.screens.SEARCH_FIELD_SPACING +/** + * Emoji are drawn at the size the design calls for, in cells big enough to tap. The gutters the design shows are the + * room left over in each cell around its emoji rather than gaps between the cells, so the whole cell takes the tap + * while the emoji still sit the designed distance apart. + */ +private val EMOJI_SIZE = 30.dp +private val EMOJI_CELL_MIN_SIZE = 46.dp + +/** + * @param onSearchClicked Opens search in place, which the host owns since it needs the system keyboard. + * @param onSearchFieldRevealedChange Reports whether the grid is scrolled far enough up to show the + * search field, so the top bar can drop its now-redundant search icon. + */ @Composable fun EmojiPageScreen( state: EmojiPageState, onEvent: (EmojiPageScreenEvents) -> Unit, modifier: Modifier = Modifier, - getEmojiDrawable: (String) -> Drawable? = { null } + getEmojiDrawable: (String) -> Drawable? = { null }, + onSearchClicked: () -> Unit = {}, + onSearchFieldRevealedChange: (Boolean) -> Unit = {} ) { val searching = state.searchResults != null @@ -98,7 +116,9 @@ fun EmojiPageScreen( EmojiGrid( state = state, onEvent = onEvent, - getEmojiDrawable = getEmojiDrawable + getEmojiDrawable = getEmojiDrawable, + onSearchClicked = onSearchClicked, + onSearchFieldRevealedChange = onSearchFieldRevealedChange ) } } @@ -154,6 +174,8 @@ private fun EmojiGrid( state: EmojiPageState, onEvent: (EmojiPageScreenEvents) -> Unit, getEmojiDrawable: (String) -> Drawable?, + onSearchClicked: () -> Unit, + onSearchFieldRevealedChange: (Boolean) -> Unit, modifier: Modifier = Modifier ) { val gridState = rememberLazyGridState() @@ -195,35 +217,47 @@ private fun EmojiGrid( visibleCategory?.let { onEvent(EmojiPageScreenEvents.VisibleCategoryChanged(it)) } } - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 44.dp), - state = gridState, - contentPadding = GRID_CONTENT_PADDING, - modifier = modifier.fillMaxWidth() + CollapsingHeaderLayout( + header = { + MediaKeyboardSearchField( + hint = stringResource(R.string.MediaKeyboard__search_emoji), + onClick = onSearchClicked, + modifier = Modifier.padding(bottom = SEARCH_FIELD_SPACING) + ) + }, + onRevealedChange = onSearchFieldRevealedChange, + modifier = modifier ) { - state.pages.forEach { page -> - item(key = "header:${page.category.key}", span = { GridItemSpan(maxLineSpan) }) { - Text( - text = stringResource(page.category.label), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp, top = 12.dp, bottom = 4.dp) - ) - } - - page.emoji.forEachIndexed { index, emoji -> - val cellKey = "${page.category.key}:$index" - item(key = cellKey) { - EmojiCell( - emoji = emoji, - display = state.displayEmoji(emoji), - cellKey = cellKey, - showVariationSelector = state.variationSelector?.cellKey == cellKey, - onEvent = onEvent, - getEmojiDrawable = getEmojiDrawable, - modifier = Modifier.aspectRatio(1f) + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = EMOJI_CELL_MIN_SIZE), + state = gridState, + contentPadding = GRID_CONTENT_PADDING, + modifier = Modifier.fillMaxWidth() + ) { + state.pages.forEach { page -> + item(key = "header:${page.category.key}", span = { GridItemSpan(maxLineSpan) }) { + Text( + text = stringResource(page.category.label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp, top = 12.dp, bottom = 4.dp) ) } + + page.emoji.forEachIndexed { index, emoji -> + val cellKey = "${page.category.key}:$index" + item(key = cellKey) { + EmojiCell( + emoji = emoji, + display = state.displayEmoji(emoji), + cellKey = cellKey, + showVariationSelector = state.variationSelector?.cellKey == cellKey, + onEvent = onEvent, + getEmojiDrawable = getEmojiDrawable, + modifier = Modifier.aspectRatio(1f) + ) + } + } } } } @@ -253,7 +287,7 @@ private fun EmojiSearchResults( } } else { LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 44.dp), + columns = GridCells.Adaptive(minSize = EMOJI_CELL_MIN_SIZE), contentPadding = GRID_CONTENT_PADDING, modifier = Modifier.fillMaxSize() ) { @@ -365,7 +399,7 @@ private fun EmojiImage( Image( painter = rememberDrawablePainter(drawable), contentDescription = emoji, - modifier = modifier.size(26.dp) + modifier = modifier.size(EMOJI_SIZE) ) } else { Text( diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/gif/GifPageScreen.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/gif/GifPageScreen.kt index bf8020a395..f793311bab 100644 --- a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/gif/GifPageScreen.kt +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/gif/GifPageScreen.kt @@ -7,8 +7,10 @@ package org.signal.mediakeyboard.screens.gif import androidx.annotation.OptIn import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -26,11 +28,9 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -51,18 +51,21 @@ import org.signal.glide.compose.GlideImage import org.signal.glide.compose.GlideImageScaleType import org.signal.mediakeyboard.R import org.signal.mediakeyboard.data.KeyboardGif -import org.signal.mediakeyboard.screens.GRID_CONTENT_PADDING +import org.signal.mediakeyboard.screens.CollapsingHeaderLayout import org.signal.mediakeyboard.screens.MediaKeyboardSearchField import org.signal.mediakeyboard.screens.PinnedRailLayout -import org.signal.mediakeyboard.screens.SearchFieldReveal - -/** The search field ahead of the gifs, which item indices have to be shifted back past. */ -private const val SEARCH_FIELD_ITEMS = 1 +import org.signal.mediakeyboard.screens.SEARCH_FIELD_SPACING /** * @param onSearchFieldRevealedChange Reports whether the grid is scrolled far enough up to show the * search field, so the top bar can drop its now-redundant search icon. */ +private const val GIF_COLUMN_COUNT = 2 +private val GIF_SPACING = 8.dp + +/** Its own, rather than the shared grid padding, because the design insets the gifs further than the other pages. */ +private val GIF_GRID_CONTENT_PADDING = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + @Composable fun GifPageScreen( state: GifPageState, @@ -168,21 +171,26 @@ private fun GifGrid( onSearchFieldRevealedChange: (Boolean) -> Unit ) { val gridState = rememberLazyStaggeredGridState() - val playerPool = rememberGifPlayerPool() + val gifPlayers = rememberGifPlayers() + val maxSimultaneous = rememberMaxSimultaneousGifs() + val playbackSet = rememberGifPlaybackSet(gridState, maxSimultaneous) + + LaunchedEffect(playbackSet, state.gifs) { + gifPlayers.setPlaying( + playbackSet + .mapNotNull { itemIndex -> + val gif = state.gifs.getOrNull(itemIndex) + gif?.mp4PreviewUri?.let { gif.id to it } + } + .toMap() + ) + } // Keyed on the quick search, since switching one empties the grid and takes the scroll back to // the top with it. - SearchFieldReveal( - hasContent = state.gifs.isNotEmpty(), - firstVisibleItemIndex = { gridState.firstVisibleItemIndex }, - scrollPastField = gridState::scrollToItem, - onRevealedChange = onSearchFieldRevealedChange, - resetKey = state.selectedQuickSearch - ) - val shouldLoadMore by remember(state.gifs.size) { derivedStateOf { - val lastVisibleGif = (gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) - SEARCH_FIELD_ITEMS + val lastVisibleGif = gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 lastVisibleGif >= state.gifs.size - 8 } } @@ -193,42 +201,47 @@ private fun GifGrid( } } - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Fixed(2), - state = gridState, - verticalItemSpacing = 4.dp, - contentPadding = GRID_CONTENT_PADDING, - modifier = Modifier.fillMaxSize() - ) { - item(key = "search", span = StaggeredGridItemSpan.FullLine) { + CollapsingHeaderLayout( + header = { MediaKeyboardSearchField( hint = stringResource(R.string.MediaKeyboard__search_gifs), - onClick = { onEvent(GifPageScreenEvents.SearchClicked) } + onClick = { onEvent(GifPageScreenEvents.SearchClicked) }, + modifier = Modifier.padding(bottom = SEARCH_FIELD_SPACING) ) - } + }, + onRevealedChange = onSearchFieldRevealedChange, + resetKey = state.selectedQuickSearch + ) { + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(GIF_COLUMN_COUNT), + state = gridState, + verticalItemSpacing = GIF_SPACING, + horizontalArrangement = Arrangement.spacedBy(GIF_SPACING), + contentPadding = GIF_GRID_CONTENT_PADDING, + modifier = Modifier.fillMaxSize() + ) { + items( + count = state.gifs.size, + key = { index -> "${state.gifs[index].id}:$index" } + ) { index -> + val gif = state.gifs[index] + GifCell( + gif = gif, + player = gifPlayers.playerFor(gif.id), + onClick = { onEvent(GifPageScreenEvents.GifClicked(gif)) } + ) + } - items( - count = state.gifs.size, - key = { index -> "${state.gifs[index].id}:$index" } - ) { index -> - val gif = state.gifs[index] - GifCell( - gif = gif, - playerPool = playerPool, - onClick = { onEvent(GifPageScreenEvents.GifClicked(gif)) }, - modifier = Modifier.padding(horizontal = 4.dp) - ) - } - - if (state.isLoadingMore) { - item(key = "loading-more", span = StaggeredGridItemSpan.FullLine) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - CircularProgressIndicator() + if (state.isLoadingMore) { + item(key = "loading-more", span = StaggeredGridItemSpan.FullLine) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + CircularProgressIndicator() + } } } } @@ -239,7 +252,7 @@ private fun GifGrid( @Composable private fun GifCell( gif: KeyboardGif, - playerPool: GifPlayerPool, + player: Player?, onClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -256,27 +269,14 @@ private fun GifCell( modifier = Modifier.fillMaxSize() ) - val mp4PreviewUri = gif.mp4PreviewUri - if (mp4PreviewUri != null) { - var player by remember(gif.id) { mutableStateOf(null) } + if (player != null) { + val presentationState = rememberPresentationState(player) - DisposableEffect(gif.id) { - player = playerPool.acquire(gif.id, mp4PreviewUri) - onDispose { - playerPool.release(gif.id) - player = null - } - } - - player?.let { - val presentationState = rememberPresentationState(it) - - PlayerSurface( - player = it, - surfaceType = SURFACE_TYPE_TEXTURE_VIEW, - modifier = Modifier.resizeWithContentScale(ContentScale.Crop, presentationState.videoSizeDp) - ) - } + PlayerSurface( + player = player, + surfaceType = SURFACE_TYPE_TEXTURE_VIEW, + modifier = Modifier.resizeWithContentScale(ContentScale.Crop, presentationState.videoSizeDp) + ) } } } diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/gif/GifPlayerPool.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/gif/GifPlayerPool.kt deleted file mode 100644 index 728c259526..0000000000 --- a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/gif/GifPlayerPool.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.mediakeyboard.screens.gif - -import android.content.Context -import android.net.Uri -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.exoplayer.ExoPlayer - -/** - * A small pool of muted, looping [ExoPlayer]s used to autoplay gif previews in the grid. - * At most [maxSimultaneous] players exist at once; cells beyond that render their still image. - */ -class GifPlayerPool( - private val context: Context, - private val maxSimultaneous: Int -) { - - private val active = mutableMapOf() - - fun acquire(id: String, uri: Uri): Player? { - active[id]?.let { return it } - - if (active.size >= maxSimultaneous) { - return null - } - - val player = ExoPlayer.Builder(context).build().apply { - volume = 0f - repeatMode = Player.REPEAT_MODE_ALL - setMediaItem(MediaItem.fromUri(uri)) - prepare() - playWhenReady = true - } - - active[id] = player - return player - } - - fun release(id: String) { - active.remove(id)?.release() - } - - fun releaseAll() { - active.values.forEach { it.release() } - active.clear() - } -} - -@Composable -fun rememberGifPlayerPool(maxSimultaneous: Int = 3): GifPlayerPool { - val context = LocalContext.current.applicationContext - val pool = remember(context, maxSimultaneous) { GifPlayerPool(context, maxSimultaneous) } - - DisposableEffect(pool) { - onDispose { pool.releaseAll() } - } - - return pool -} diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/gif/GifPlayers.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/gif/GifPlayers.kt new file mode 100644 index 0000000000..835fe51018 --- /dev/null +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/gif/GifPlayers.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediakeyboard.screens.gif + +import android.net.Uri +import androidx.annotation.MainThread +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import org.signal.core.util.logging.Log +import org.signal.mediakeyboard.MediaKeyboardDependencies +import org.signal.video.exo.ExoPlayerPool +import kotlin.math.abs + +private val TAG = Log.tag(GifPlayers::class) + +/** + * Holds the players currently assigned to gif cells, borrowed from the app-wide [ExoPlayerPool]. + * + * Players are handed out per gif id rather than per cell, and a cell renders its still image whenever it has none. + * [setPlaying] returns players the grid no longer wants before asking for new ones, so scrolling reassigns the same + * few players instead of stranding cells that lost the first race for them. + */ +class GifPlayers(private val pool: ExoPlayerPool) { + + private val players = mutableStateMapOf() + + fun playerFor(id: String): Player? = players[id] + + @MainThread + fun setPlaying(playing: Map) { + (players.keys - playing.keys).forEach { id -> + players.remove(id)?.let { pool.pool(it) } + } + + playing.forEach { (id, uri) -> + if (!players.containsKey(id)) { + val player = pool.get(TAG) + if (player == null) { + Log.d(TAG, "No player available for $id, leaving it still.") + return@forEach + } + + players[id] = player.apply { + // get() configures for ordinary video playback, which is neither muted nor looping. + volume = 0f + repeatMode = Player.REPEAT_MODE_ALL + setMediaItem(MediaItem.fromUri(uri)) + prepare() + playWhenReady = true + } + } + } + } + + @MainThread + fun releaseAll() { + players.values.forEach { pool.pool(it) } + players.clear() + } +} + +@Composable +fun rememberGifPlayers(): GifPlayers { + val pool = MediaKeyboardDependencies.exoPlayerPool + val players = remember(pool) { GifPlayers(pool) } + + DisposableEffect(players) { + onDispose { players.releaseAll() } + } + + return players +} + +/** + * How many gifs may play at once: the whole unreserved pool, as the pre-Compose keyboard also used. + */ +@Composable +fun rememberMaxSimultaneousGifs(): Int { + val pool = MediaKeyboardDependencies.exoPlayerPool + return remember(pool) { pool.getPoolStats().maxUnreserved } +} + +/** + * The item indices that should be playing: the ones nearest the middle of the viewport, which are the gifs the user is + * most likely looking at. Mirrors what the pre-Compose keyboard's playback controller worked out from adapter + * positions, in terms of where items actually sit instead, since a staggered grid's index order is not its visual one. + */ +@Composable +fun rememberGifPlaybackSet(gridState: LazyStaggeredGridState, maxSimultaneous: Int): Set { + val playbackSet by remember(gridState, maxSimultaneous) { + derivedStateOf { + val layoutInfo = gridState.layoutInfo + val viewportCenter = (layoutInfo.viewportStartOffset + layoutInfo.viewportEndOffset) / 2 + + layoutInfo.visibleItemsInfo + .sortedBy { abs(it.offset.y + it.size.height / 2 - viewportCenter) } + .take(maxSimultaneous) + .map { it.index } + .toSet() + } + } + + return playbackSet +} diff --git a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageScreen.kt b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageScreen.kt index f4994a3dd1..68cde4a4d9 100644 --- a/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageScreen.kt +++ b/feature/media-keyboard/src/main/java/org/signal/mediakeyboard/screens/sticker/StickerPageScreen.kt @@ -62,10 +62,11 @@ import org.signal.mediakeyboard.R import org.signal.mediakeyboard.data.KeyboardSticker import org.signal.mediakeyboard.data.KeyboardStickerPack import org.signal.mediakeyboard.data.StickerKeyboardRepository +import org.signal.mediakeyboard.screens.CollapsingHeaderLayout import org.signal.mediakeyboard.screens.GRID_CONTENT_PADDING import org.signal.mediakeyboard.screens.MediaKeyboardSearchField import org.signal.mediakeyboard.screens.PinnedRailLayout -import org.signal.mediakeyboard.screens.SearchFieldReveal +import org.signal.mediakeyboard.screens.SEARCH_FIELD_SPACING private const val STICKER_COLUMN_COUNT = 5 private val STICKER_CELL_SPACING = 12.dp @@ -184,9 +185,8 @@ private fun StickerGrid( ) { val gridState = rememberLazyGridState() - // The search field is the first item, so everything else starts one along from it. val headerIndices = remember(state.packs) { - var index = 1 + var index = 0 buildMap { state.packs.forEach { pack -> put(pack.id, index) @@ -195,13 +195,6 @@ private fun StickerGrid( } } - SearchFieldReveal( - hasContent = state.packs.isNotEmpty(), - firstVisibleItemIndex = { gridState.firstVisibleItemIndex }, - scrollPastField = gridState::scrollToItem, - onRevealedChange = onSearchFieldRevealedChange - ) - LaunchedEffect(state.scrollTargetPackId) { val target = state.scrollTargetPackId ?: return@LaunchedEffect headerIndices[target]?.let { gridState.scrollToItem(it) } @@ -221,43 +214,48 @@ private fun StickerGrid( visiblePackId?.let { onEvent(StickerPageScreenEvents.VisiblePackChanged(it)) } } - // Each cell decodes its sticker at the cell's size, which the grid does not report, so work it out the way the grid - // will and hand it down. - BoxWithConstraints(modifier = modifier.fillMaxWidth()) { - val layoutDirection = LocalLayoutDirection.current - val rowWidth = maxWidth - - GRID_CONTENT_PADDING.calculateStartPadding(layoutDirection) - - GRID_CONTENT_PADDING.calculateEndPadding(layoutDirection) - val cellSize = (rowWidth - STICKER_CELL_SPACING * (STICKER_COLUMN_COUNT - 1)) / STICKER_COLUMN_COUNT + CollapsingHeaderLayout( + header = { + MediaKeyboardSearchField( + hint = stringResource(R.string.MediaKeyboard__search_stickers), + onClick = { onEvent(StickerPageScreenEvents.SearchClicked) }, + modifier = Modifier.padding(bottom = SEARCH_FIELD_SPACING) + ) + }, + onRevealedChange = onSearchFieldRevealedChange, + modifier = modifier + ) { + // Each cell decodes its sticker at the cell's size, which the grid does not report, so work it out the way the + // grid will and hand it down. + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val layoutDirection = LocalLayoutDirection.current + val rowWidth = maxWidth - + GRID_CONTENT_PADDING.calculateStartPadding(layoutDirection) - + GRID_CONTENT_PADDING.calculateEndPadding(layoutDirection) + val cellSize = (rowWidth - STICKER_CELL_SPACING * (STICKER_COLUMN_COUNT - 1)) / STICKER_COLUMN_COUNT - LazyVerticalGrid( - columns = GridCells.Fixed(STICKER_COLUMN_COUNT), - state = gridState, - contentPadding = GRID_CONTENT_PADDING, - horizontalArrangement = Arrangement.spacedBy(STICKER_CELL_SPACING), - verticalArrangement = Arrangement.spacedBy(STICKER_CELL_SPACING), - modifier = Modifier.fillMaxWidth() - ) { - item(key = "search", span = { GridItemSpan(maxLineSpan) }) { - MediaKeyboardSearchField( - hint = stringResource(R.string.MediaKeyboard__search_stickers), - onClick = { onEvent(StickerPageScreenEvents.SearchClicked) } - ) - } + LazyVerticalGrid( + columns = GridCells.Fixed(STICKER_COLUMN_COUNT), + state = gridState, + contentPadding = GRID_CONTENT_PADDING, + horizontalArrangement = Arrangement.spacedBy(STICKER_CELL_SPACING), + verticalArrangement = Arrangement.spacedBy(STICKER_CELL_SPACING), + modifier = Modifier.fillMaxWidth() + ) { + state.packs.forEach { pack -> + item(key = "header:${pack.id}", span = { GridItemSpan(maxLineSpan) }) { + StickerPackHeader(pack = pack, onEvent = onEvent) + } - state.packs.forEach { pack -> - item(key = "header:${pack.id}", span = { GridItemSpan(maxLineSpan) }) { - StickerPackHeader(pack = pack, onEvent = onEvent) - } - - pack.stickers.forEachIndexed { index, sticker -> - item(key = "${pack.id}:${sticker.stickerId}:$index") { - StickerCell( - sticker = sticker, - allowAnimation = state.allowAnimation, - cellSize = cellSize, - onEvent = onEvent - ) + pack.stickers.forEachIndexed { index, sticker -> + item(key = "${pack.id}:${sticker.stickerId}:$index") { + StickerCell( + sticker = sticker, + allowAnimation = state.allowAnimation, + cellSize = cellSize, + onEvent = onEvent + ) + } } } } diff --git a/feature/media-keyboard/src/main/res/values/strings.xml b/feature/media-keyboard/src/main/res/values/strings.xml index 96951fb73f..512ad09a32 100644 --- a/feature/media-keyboard/src/main/res/values/strings.xml +++ b/feature/media-keyboard/src/main/res/values/strings.xml @@ -13,7 +13,7 @@ GIFs - + Search emoji Search stickers diff --git a/lib/glide/src/main/java/org/signal/glide/compose/GlideImage.kt b/lib/glide/src/main/java/org/signal/glide/compose/GlideImage.kt index 89ad5a0658..d660d8af9d 100644 --- a/lib/glide/src/main/java/org/signal/glide/compose/GlideImage.kt +++ b/lib/glide/src/main/java/org/signal/glide/compose/GlideImage.kt @@ -75,7 +75,6 @@ fun GlideImage( .fallback(fallback) .error(error) .diskCacheStrategy(diskCacheStrategy) - // ApngOptions.ANIMATE defaults to true, so a caller that did not ask for animation has to say so. .set(ApngOptions.ANIMATE, enableApngAnimation) .skipMemoryCache(skipMemoryCache) .apply {