mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-10-01 00:58:47 +01:00
Additional keyboard polish.
This commit is contained in:
@@ -167,7 +167,9 @@ fun ChatScreen(
|
||||
) {
|
||||
AndroidFragment(
|
||||
clazz = AttachmentKeyboardFragment::class.java,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.navigationBarsCompat)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
+4
-2
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+14
@@ -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 }
|
||||
+180
-149
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+12
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+42
@@ -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>
|
||||
}
|
||||
}
|
||||
+131
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-53
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -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()
|
||||
}
|
||||
|
||||
+64
-30
@@ -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(
|
||||
|
||||
+70
-70
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-68
@@ -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
|
||||
}
|
||||
+115
@@ -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
|
||||
}
|
||||
+43
-45
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user