Additional keyboard polish.

This commit is contained in:
Alex Hart
2026-09-29 15:57:47 -03:00
parent ce29d21e9d
commit 82aa78bc50
24 changed files with 795 additions and 432 deletions
@@ -167,7 +167,9 @@ fun ChatScreen(
) {
AndroidFragment(
clazz = AttachmentKeyboardFragment::class.java,
modifier = Modifier.fillMaxSize()
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.navigationBarsCompat)
)
}
},
@@ -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)
}
@@ -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)
}
@@ -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<ExoPlayer> = AppDependencies.exoPlayerPool
}
@@ -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
@@ -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> { Modifier }
@@ -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<Float>(
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<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = 700f
)
/** Puts the sheet away, quicker than it comes up. */
private val SHEET_HIDE_MOTION = spring<Float>(
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<KeyboardSheetKey?>(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()
}
}
}
}
@@ -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,
+2
View File
@@ -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
@@ -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<ExoPlayer>(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
}
}
@@ -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<ExoPlayer> = exoPlayerPool
}
)
repository = DemoMediaKeyboardRepository(this)
}
}
+1
View File
@@ -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 ->
@@ -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<MediaKeyboardState>,
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
)
}
@@ -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<ExoPlayer>
get() = _provider.provideExoPlayerPool()
interface Provider {
fun provideExoPlayerPool(): ExoPlayerPool<ExoPlayer>
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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()
}
@@ -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(
@@ -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<Player?>(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)
)
}
}
}
@@ -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<String, ExoPlayer>()
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
}
@@ -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<ExoPlayer>) {
private val players = mutableStateMapOf<String, ExoPlayer>()
fun playerFor(id: String): Player? = players[id]
@MainThread
fun setPlaying(playing: Map<String, Uri>) {
(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<Int> {
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
}
@@ -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
)
}
}
}
}
@@ -13,7 +13,7 @@
<string name="MediaKeyboard__gifs">GIFs</string>
<!-- Search -->
<!-- Placeholder text in the media keyboard search field while the emoji tab is showing. -->
<!-- Placeholder text in the search field at the top of the emoji page, and in the media keyboard search field while the emoji tab is showing. -->
<string name="MediaKeyboard__search_emoji">Search emoji</string>
<!-- Placeholder text in the search field at the top of the sticker page, and in the media keyboard search field while the sticker tab is showing. -->
<string name="MediaKeyboard__search_stickers">Search stickers</string>
@@ -75,7 +75,6 @@ fun <T> 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 {