Improve long press menu performance.

This commit is contained in:
Alex Hart
2026-09-10 15:56:51 -03:00
committed by GitHub
parent d0bba759e8
commit 89090592ee
11 changed files with 196 additions and 205 deletions
@@ -11,8 +11,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.first
/**
* Asks a [MediaKeyboardScaffold] for a keyboard.
@@ -30,18 +28,10 @@ class MediaKeyboardController(initialKeyboardHeightPx: Int = 0) {
var current: MediaKeyboardKey? by mutableStateOf(null)
private set
/** Whether the system keyboard is up, as reported by the target of the IME inset animation. */
/** Whether the system keyboard is up, from the target of the IME inset animation. */
var isSystemKeyboardVisible: Boolean by mutableStateOf(false)
internal set
/**
* True while the system keyboard is on its way in or out. [isSystemKeyboardVisible] reads the
* target of that animation, so it goes false the moment a hide is asked for, well before the
* space is handed back.
*/
var isSystemKeyboardAnimating: Boolean by mutableStateOf(false)
internal set
/**
* True while the system keyboard has been asked for in place of one of ours but has yet to settle.
* Holds the space across that gap, which the IME service round trip would otherwise leave empty.
@@ -55,18 +45,6 @@ class MediaKeyboardController(initialKeyboardHeightPx: Int = 0) {
val isShowing: Boolean get() = current != null
/** True when no keyboard is up, on its way out, or being held space for. */
val isSettled: Boolean get() = current == null && !isSystemKeyboardVisible && !isSystemKeyboardAnimating && !awaitingSystemKeyboard
/**
* Suspends until [isSettled], so a caller can act on a content area that has been handed all of
* its space back. Reads the same snapshot state the scaffold writes, so there is no settle to miss
* for a keyboard that goes away without animating.
*/
suspend fun awaitSettled() {
snapshotFlow { isSettled }.first { it }
}
fun show(key: MediaKeyboardKey) {
current = key
awaitingSystemKeyboard = false
@@ -33,6 +33,7 @@ import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
@@ -132,11 +133,16 @@ fun MediaKeyboardScaffold(
derivedStateOf { imeInsets.getBottom(density) != imeAnimationTarget.getBottom(density) }
}
// Written together, so nothing waiting on the controller can see a keyboard that is neither
// visible nor still animating out.
SideEffect {
controller.isSystemKeyboardVisible = systemKeyboardVisible
controller.isSystemKeyboardAnimating = systemKeyboardAnimating
}
// The controller outlives us. current is left set so a rebuilt view restores its keyboard.
DisposableEffect(controller) {
onDispose {
controller.isSystemKeyboardVisible = false
controller.awaitingSystemKeyboard = false
}
}
var hasReportedKeyboardVisibility by remember { mutableStateOf(false) }
@@ -204,10 +204,15 @@ public final class ConversationReactionOverlay extends FrameLayout {
@NonNull PointF lastSeenDownPoint,
boolean isMessageOnLeft)
{
// A hide can land between show() and this layout pass.
if (overlayState == OverlayState.HIDDEN) {
return;
}
contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(conversationMessage));
conversationItem.setX(selectedConversationModel.getSnapshotMetrics().getSnapshotOffset());
conversationItem.setY(selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() - statusBarHeight);
conversationItem.setX(selectedConversationModel.getBubbleX());
conversationItem.setY(selectedConversationModel.getBubbleY());
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth();
@@ -215,7 +220,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
int overlayHeight = getHeight() - bottomNavigationBarHeight;
int bubbleWidth = selectedConversationModel.getBubbleWidth();
float endX = selectedConversationModel.getSnapshotMetrics().getSnapshotOffset();
float endX = selectedConversationModel.getBubbleX();
float endY = conversationItem.getY();
float endApparentTop = endY;
float endScale = 1f;
@@ -251,8 +256,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
boolean everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.getHeight() + menuPadding + spaceForReactionBar < overlayHeight;
if (everythingFitsVertically) {
float bubbleBottom = selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
boolean menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight;
float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
boolean menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight;
if (menuFitsBelowItem) {
if (conversationItem.getY() < 0) {
@@ -289,8 +294,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
boolean fitsVertically = menuHeight + conversationItem.getHeight() + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight;
if (fitsVertically) {
float bubbleBottom = selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
boolean menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight;
float bubbleBottom = selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
boolean menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight;
if (menuFitsBelowItem) {
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
@@ -346,7 +351,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
float offsetX = isMessageOnLeft ? scrubberRight + menuPadding : scrubberX - contextMenu.getMaxWidth() - menuPadding;
contextMenu.show((int) offsetX, (int) Math.min(backgroundView.getY(), overlayHeight - contextMenu.getMaxHeight()));
} else {
float contentX = selectedConversationModel.getSnapshotMetrics().getContextMenuPadding();
float contentX = selectedConversationModel.getContextMenuX();
float offsetX = isMessageOnLeft ? contentX : -contextMenu.getMaxWidth() + contentX + bubbleWidth;
float menuTop = endApparentTop + (conversationItemSnapshot.getHeight() * endScale);
@@ -423,6 +428,10 @@ public final class ConversationReactionOverlay extends FrameLayout {
if (onHideListener != null) {
onHideListener.onHide();
}
if (overlayState == OverlayState.HIDDEN) {
releaseSelection();
}
}
});
@@ -431,6 +440,13 @@ public final class ConversationReactionOverlay extends FrameLayout {
}
}
/** Drops the snapshot bitmap, model and menu the last long press left behind. */
private void releaseSelection() {
selectedConversationModel = null;
contextMenu = null;
conversationItem.setBackground(null);
}
public boolean isShowing() {
return overlayState != OverlayState.HIDDEN;
}
@@ -843,16 +859,21 @@ public final class ConversationReactionOverlay extends FrameLayout {
itemScaleYAnim.setDuration(duration);
animators.add(itemScaleYAnim);
// Where the row is now, not where the press started. Null once it is gone.
PointF returnPosition = selectedConversationModel.getReturnPosition().get();
float returnX = returnPosition != null ? returnPosition.x : selectedConversationModel.getBubbleX();
float returnY = returnPosition != null ? returnPosition.y : selectedConversationModel.getBubbleY();
ObjectAnimator itemXAnim = new ObjectAnimator();
itemXAnim.setProperty(View.X);
itemXAnim.setFloatValues(selectedConversationModel.getSnapshotMetrics().getSnapshotOffset());
itemXAnim.setFloatValues(returnX);
itemXAnim.setTarget(conversationItem);
itemXAnim.setDuration(duration);
animators.add(itemXAnim);
ObjectAnimator itemYAnim = new ObjectAnimator();
itemYAnim.setProperty(View.Y);
itemYAnim.setFloatValues(selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() - statusBarHeight);
itemYAnim.setFloatValues(returnY);
itemYAnim.setTarget(conversationItem);
itemYAnim.setDuration(duration);
animators.add(itemYAnim);
@@ -1,22 +1,35 @@
package org.thoughtcrime.securesms.conversation
import android.graphics.Bitmap
import android.graphics.PointF
import android.net.Uri
import android.view.View
import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement
/**
* Contains information on a single selected conversation item. This is used when transitioning
* between selected and unselected states.
*
* Coordinates are in the reaction overlay's space, not the list's.
*
* @param bubbleX Left edge of the captured snapshot.
* @param bubbleY Top edge of the captured bubble.
* @param contextMenuX Left edge the context menu lines up with.
*/
data class SelectedConversationModel(
val bitmap: Bitmap,
val itemX: Float,
val itemY: Float,
val bubbleX: Float,
val bubbleY: Float,
val bubbleWidth: Int,
val contextMenuX: Float,
val audioUri: Uri? = null,
val isOutgoing: Boolean,
val focusedView: View?,
val snapshotMetrics: InteractiveConversationElement.SnapshotMetrics
)
val returnPosition: ReturnPosition
) {
/** Where the snapshot animates back to, read on dismiss so a list that moved is followed. */
fun interface ReturnPosition {
/** @return The row's current position, or null if it is gone. */
fun get(): PointF?
}
}
@@ -8,15 +8,9 @@ package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import android.view.View
import android.widget.EditText
import kotlinx.coroutines.withTimeoutOrNull
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardController
import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardKey
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.awaitAfterNextLayout
import kotlin.time.Duration.Companion.milliseconds
/** Longest a keyboard's exit is waited on. Comfortably past the platform's own hide animation. */
private val SETTLE_TIMEOUT = 500.milliseconds
/**
* Adapts [MediaKeyboardController] to the conversation's view code, which asks for keyboards from
@@ -35,6 +29,9 @@ class ChatInputController(
private val listeners: MutableSet<Listener> = mutableSetOf()
private val keyboardStateListeners: MutableSet<KeyboardStateListener> = mutableSetOf()
/** What [runAfterAllHidden] is waiting on. */
private var pendingHiddenAction: (() -> Unit)? = null
val isInputShowing: Boolean
get() = controller.isShowing
@@ -61,12 +58,17 @@ class ChatInputController(
fun clearListeners() {
listeners.clear()
keyboardStateListeners.clear()
pendingHiddenAction = null
}
fun onKeyboardVisibilityChanged(visible: Boolean) {
keyboardStateListeners.toList().forEach {
if (visible) it.onKeyboardShown() else it.onKeyboardHidden()
}
if (!visible) {
runPendingHiddenAction()
}
}
fun onKeyboardAnimationEnded() {
@@ -79,6 +81,7 @@ class ChatInputController(
fun onInputHidden() {
listeners.toList().forEach { it.onInputHidden() }
runPendingHiddenAction()
}
/** @param imeTarget The field to bring the system keyboard up for, which need not be the input panel's. */
@@ -106,54 +109,25 @@ class ChatInputController(
ViewUtil.hideKeyboard(context, imeTarget)
}
fun runAfterAllHidden(imeTarget: EditText, onHidden: () -> Unit) {
if (isInputShowing || isKeyboardShowing) {
val listener = object : Listener, KeyboardStateListener {
override fun onInputHidden() {
onHidden()
removeInputListener(this)
removeKeyboardStateListener(this)
}
override fun onKeyboardHidden() {
onHidden()
removeInputListener(this)
removeKeyboardStateListener(this)
}
override fun onInputShown(key: MediaKeyboardKey) = Unit
override fun onKeyboardShown() = Unit
}
addInputListener(listener)
addKeyboardStateListener(listener)
hideAll(imeTarget)
} else {
onHidden()
}
}
/**
* Like [runAfterAllHidden], but suspends until the keyboards have finished animating out rather
* than returning as soon as the hide has been asked for. For callers that measure themselves
* against the content area, which stays shrunk for the length of that animation.
*
* Gives up after [SETTLE_TIMEOUT]. A keyboard that never reports its exit should leave a caller
* measuring against a stale content area, not stranded.
*
* @param contentView The area the keyboards resize. The settle itself lands in the middle of an
* inset dispatch, a frame before the space is handed back, so this is waited on as well.
* Runs [onHidden] once whatever is up has reported itself away, or right now if nothing is.
* Only one action is queued at a time; a second call replaces the first.
*/
suspend fun hideAllAndAwaitSettled(imeTarget: EditText, contentView: View) {
if (controller.isSettled) {
fun runAfterAllHidden(imeTarget: EditText, onHidden: () -> Unit) {
if (!isInputShowing && !isKeyboardShowing) {
onHidden()
return
}
pendingHiddenAction = onHidden
hideAll(imeTarget)
withTimeoutOrNull(SETTLE_TIMEOUT) {
controller.awaitSettled()
contentView.awaitAfterNextLayout()
}
}
private fun runPendingHiddenAction() {
val action = pendingHiddenAction ?: return
pendingHiddenAction = null
action()
}
/**
@@ -9,15 +9,19 @@ import android.view.View
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -50,6 +54,7 @@ private const val BUBBLE_HEIGHT_FRACTION = 0.55f
* @param isBubble Whether we're displaying content in a bubble
* @param backgroundView The chat wallpaper
* @param contentView The area that actually moves up when the keyboards appear
* @param overlayView The long press overlay, which a keyboard neither covers nor resizes
*/
@Composable
fun ChatScreen(
@@ -59,6 +64,7 @@ fun ChatScreen(
isBubble: Boolean,
backgroundView: View,
contentView: View,
overlayView: View,
modifier: Modifier = Modifier
) {
val minimumHeight = dimensionResource(R.dimen.default_custom_keyboard_size)
@@ -137,5 +143,14 @@ fun ChatScreen(
modifier = Modifier.fillMaxSize()
)
}
// Above the scaffold so a closing keyboard neither covers nor resizes it. The bottom inset is
// left on; the overlay subtracts the navigation bar itself.
AndroidView(
factory = { overlayView },
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars.add(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)))
)
}
}
@@ -19,6 +19,7 @@ import android.content.IntentFilter
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.PointF
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
@@ -99,7 +100,6 @@ import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -267,6 +267,7 @@ import org.thoughtcrime.securesms.database.model.Quote
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.databinding.V2ConversationBackgroundBinding
import org.thoughtcrime.securesms.databinding.V2ConversationFragmentBinding
import org.thoughtcrime.securesms.databinding.V2ConversationOverlayBinding
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.events.GroupCallPeekEvent
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration
@@ -371,6 +372,7 @@ import org.thoughtcrime.securesms.util.MessageConstraintsUtil.getEditMessageThre
import org.thoughtcrime.securesms.util.MessageConstraintsUtil.isValidEditMessageSend
import org.thoughtcrime.securesms.util.MessageUtil
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.Projection
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.thoughtcrime.securesms.util.TextSecurePreferences
@@ -473,6 +475,7 @@ class ConversationFragment :
private val disposables = LifecycleDisposable()
private val backgroundBinding by ViewBinderDelegate(bindingFactory = { V2ConversationBackgroundBinding.bind(conversationBackground) })
private val overlayBinding by ViewBinderDelegate(bindingFactory = { V2ConversationOverlayBinding.bind(conversationOverlay) })
private val binding by ViewBinderDelegate(bindingFactory = { V2ConversationFragmentBinding.bind(conversationContent) }, onBindingWillBeDestroyed = { _binding ->
_binding.conversationInputPanel.embeddedTextEditor.apply {
setOnEditorActionListener(null)
@@ -630,6 +633,9 @@ class ConversationFragment :
/** The wallpaper, drawn behind [conversationContent]. */
private lateinit var conversationBackground: View
/** The long press overlay, drawn above [conversationContent]. */
private lateinit var conversationOverlay: View
private val chatScreenViewModel: ChatScreenViewModel by viewModels()
/** Stable across recomposition, so view code can ask for a keyboard from a click listener. */
@@ -672,25 +678,8 @@ class ConversationFragment :
private val scheduledMessagesStub: Stub<View> by lazy { Stub(binding.scheduledMessagesStub) }
/**
* The long press waiting on the keyboards to clear before the overlay can measure itself. The list
* has to stop taking taps for that whole wait, or a tap lands on a message that is about to be
* covered by the overlay.
*/
private var pendingReactionOverlayJob: Job? = null
private val isReactionOverlayPending: Boolean
get() = pendingReactionOverlayJob?.isActive == true
/** Swallows list touches for the length of [pendingReactionOverlayJob]. */
private val pendingReactionOverlayTouchGuard = object : RecyclerView.OnItemTouchListener {
override fun onInterceptTouchEvent(recyclerView: RecyclerView, event: MotionEvent): Boolean = isReactionOverlayPending
override fun onTouchEvent(recyclerView: RecyclerView, event: MotionEvent) = Unit
override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) = Unit
}
private val reactionDelegate: ConversationReactionDelegate by lazy(LazyThreadSafetyMode.NONE) {
val conversationReactionStub = Stub<ConversationReactionOverlay>(binding.conversationReactionScrubberStub)
val conversationReactionStub = Stub<ConversationReactionOverlay>(overlayBinding.conversationReactionScrubberStub)
val delegate = ConversationReactionDelegate(conversationReactionStub)
delegate.setOnReactionSelectedListener(OnReactionsSelectedListener())
@@ -718,6 +707,7 @@ class ConversationFragment :
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
conversationBackground = inflater.inflate(R.layout.v2_conversation_background, container, false)
conversationContent = inflater.inflate(R.layout.v2_conversation_fragment, container, false)
conversationOverlay = inflater.inflate(R.layout.v2_conversation_overlay, container, false)
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
@@ -729,13 +719,29 @@ class ConversationFragment :
scrims = chatScrims,
isBubble = args.conversationScreenType == ConversationScreenType.BUBBLE,
backgroundView = conversationBackground,
contentView = conversationContent
contentView = conversationContent,
overlayView = conversationOverlay
)
}
}
}
}
/**
* Where [target]'s row sits in the overlay's coordinate space. [Projection] walks the layout
* positions; translations are not part of that walk, so they are added here.
*/
private fun overlayOriginOf(target: InteractiveConversationElement, recycler: RecyclerView): PointF {
val projection = Projection.relativeToViewWithCommonRoot(target.root, conversationOverlay, null)
val origin = PointF(
projection.x + target.root.translationX,
projection.y + target.root.translationY + recycler.translationY
)
projection.release()
return origin
}
private fun onMediaKeyboardEvent(event: MediaKeyboardEvents) {
when (event) {
is MediaKeyboardEvents.SystemKeyboardVisibilityChanged -> container.onKeyboardVisibilityChanged(event.visible)
@@ -919,10 +925,6 @@ class ConversationFragment :
override fun onPause() {
super.onPause()
// Abandoned rather than resumed on the way back in, where the long press is no longer the last
// thing the user did.
pendingReactionOverlayJob?.cancel()
ConversationUtil.refreshRecipientShortcuts()
if (!args.conversationScreenType.isInBubble) {
@@ -2342,7 +2344,6 @@ class ConversationFragment :
binding.conversationItemRecycler.layoutManager = layoutManager
scrollListener = ScrollListener()
binding.conversationItemRecycler.addOnScrollListener(scrollListener!!)
binding.conversationItemRecycler.addOnItemTouchListener(pendingReactionOverlayTouchGuard)
adapter = ConversationAdapterV2(
lifecycleOwner = viewLifecycleOwner,
@@ -4165,13 +4166,8 @@ class ConversationFragment :
// Read before anything is asked to hide, or the keyboard cannot be brought back on dismiss.
val focusedView = if (container.isInputShowing || !container.isKeyboardShowing) null else itemView.rootView.findFocus()
// The overlay sizes itself to the content area, so every keyboard has to be all the way
// out before it measures. Mid-animation it has half a screen to fit the menu into.
pendingReactionOverlayJob?.cancel()
pendingReactionOverlayJob = viewLifecycleOwner.lifecycleScope.launch {
container.hideAllAndAwaitSettled(composeText, conversationContent)
showReactionOverlay(itemView, item, target, focusedView)
}
container.hideAll(composeText)
showReactionOverlay(item, target, focusedView)
}
} else if (item.conversationMessage.isActiveCollapsedHead) {
viewModel.onExpandEvents(item.conversationMessage.messageRecord.id)
@@ -4182,12 +4178,8 @@ class ConversationFragment :
}
}
/**
* Snapshots [target] and hands it to the reaction overlay. Split out of [onItemLongClick] because
* it runs once the keyboards are out of the way, which is not until a few frames later.
*/
/** Snapshots [target] and hands it to the reaction overlay. */
private fun showReactionOverlay(
itemView: View,
item: MultiselectPart,
target: InteractiveConversationElement,
focusedView: View?
@@ -4198,22 +4190,21 @@ class ConversationFragment :
val messageRecord = item.getMessageRecord()
// The wait gave the list room to move on, so the row may be gone or bound to another message.
if (isActionModeStarted() || adapter.selectedItems.isNotEmpty() || target.conversationMessage.messageRecord.id != messageRecord.id) {
return
}
// Held, not re-read: teardown has to work from a screen that is already going.
val recycler = binding.conversationItemRecycler
val shade = overlayBinding.reactionsShade
multiselectItemDecoration.setFocusedItem(MultiselectPart.Message(item.conversationMessage))
binding.conversationItemRecycler.invalidateItemDecorations()
binding.reactionsShade.visibility = View.VISIBLE
binding.conversationItemRecycler.suppressLayout(true)
recycler.invalidateItemDecorations()
shade.visibility = View.VISIBLE
recycler.suppressLayout(true)
val audioUri = messageRecord.getAudioUriForLongClick()
if (audioUri != null) {
getVoiceNoteMediaController().pausePlayback(audioUri)
}
val childAdapterPosition = target.getAdapterPosition(binding.conversationItemRecycler)
val childAdapterPosition = target.getAdapterPosition(recycler)
var mp4Holder: GiphyMp4ProjectionPlayerHolder? = null
var videoBitmap: Bitmap? = null
if (childAdapterPosition != RecyclerView.NO_POSITION) {
@@ -4225,22 +4216,32 @@ class ConversationFragment :
}
}
val snapshot = ConversationItemSelection.snapshotView(target, binding.conversationItemRecycler, messageRecord, videoBitmap)
val snapshot = ConversationItemSelection.snapshotView(target, recycler, messageRecord, videoBitmap)
val bodyBubble = target.bubbleView
val snapshotMetrics = target.getSnapshotStrategy()?.snapshotMetrics ?: InteractiveConversationElement.SnapshotMetrics(
snapshotOffset = bodyBubble.x,
contextMenuPadding = bodyBubble.x
)
val origin = overlayOriginOf(target, recycler)
val selectedConversationModel = SelectedConversationModel(
bitmap = snapshot,
itemX = itemView.x,
itemY = itemView.y + binding.conversationItemRecycler.translationY,
bubbleY = bodyBubble.y,
bubbleX = origin.x + snapshotMetrics.snapshotOffset,
bubbleY = origin.y + bodyBubble.y,
bubbleWidth = bodyBubble.width,
contextMenuX = origin.x + snapshotMetrics.contextMenuPadding,
audioUri = audioUri,
isOutgoing = messageRecord.isOutgoing,
focusedView = focusedView,
snapshotMetrics = target.getSnapshotStrategy()?.snapshotMetrics ?: InteractiveConversationElement.SnapshotMetrics(
snapshotOffset = bodyBubble.x,
contextMenuPadding = bodyBubble.x
)
returnPosition = SelectedConversationModel.ReturnPosition {
if (view == null || target.root.parent == null || target.conversationMessage.messageRecord.id != messageRecord.id) {
null
} else {
val current = overlayOriginOf(target, recycler)
PointF(current.x + snapshotMetrics.snapshotOffset, current.y + bodyBubble.y)
}
}
)
bodyBubble.visibility = View.INVISIBLE
@@ -4259,13 +4260,14 @@ class ConversationFragment :
selectedConversationModel,
object : OnHideListener {
override fun startHide(focusedView: View?) {
// Ahead of the started check: a dismiss while stopped would leave the chat dimmed.
multiselectItemDecoration.hideShade(recycler)
ViewUtil.fadeOut(shade, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE)
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) || activity == null || activity?.isFinishing == true) {
return
}
multiselectItemDecoration.hideShade(binding.conversationItemRecycler)
ViewUtil.fadeOut(binding.reactionsShade, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE)
val searchField = expandedSearchField()
if (searchField != null && focusedView == searchField) {
// The input panel is gone while search is open, so composeText cannot take the keyboard back.
@@ -4278,30 +4280,30 @@ class ConversationFragment :
override fun onHide() {
viewModel.setIsReactionDelegateShowing(false)
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) || activity == null || activity?.isFinishing == true) {
return
}
binding.conversationItemRecycler.suppressLayout(false)
if (selectedConversationModel.audioUri != null) {
getVoiceNoteMediaController().resumePlayback(selectedConversationModel.audioUri, messageRecord.id)
}
clearFocusedItem()
if (mp4Holder != null) {
mp4Holder.show()
mp4Holder.resume()
}
// Likewise: otherwise the list stays frozen and the message invisible.
recycler.suppressLayout(false)
multiselectItemDecoration.setFocusedItem(null)
recycler.invalidateItemDecorations()
bodyBubble.visibility = View.VISIBLE
target.reactionsView.visibility = View.VISIBLE
viewModel.setHideScrollButtonsForReactionOverlay(false)
if (quotedIndicatorVisible && target.quotedIndicatorView != null) {
ViewUtil.fadeIn(target.quotedIndicatorView!!, 150)
}
viewModel.setHideScrollButtonsForReactionOverlay(false)
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) || activity == null || activity?.isFinishing == true) {
return
}
if (selectedConversationModel.audioUri != null) {
getVoiceNoteMediaController().resumePlayback(selectedConversationModel.audioUri, messageRecord.id)
}
if (mp4Holder != null) {
mp4Holder.show()
mp4Holder.resume()
}
}
}
)
@@ -11,8 +11,6 @@ import androidx.core.view.doOnNextLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.findFragment
import androidx.lifecycle.Lifecycle
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
var View.visible: Boolean
get() {
@@ -50,22 +48,6 @@ inline fun View.doAfterNextLayout(crossinline action: () -> Unit) {
}
}
/**
* The suspending form of [doAfterNextLayout]. Resumes once the traversal that laid this view out has
* finished, so a caller both reads the size it was just handed and is free to touch the hierarchy.
*/
suspend fun View.awaitAfterNextLayout(): Unit = suspendCancellableCoroutine { continuation ->
val listener = object : View.OnLayoutChangeListener {
override fun onLayoutChange(view: View, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {
view.removeOnLayoutChangeListener(this)
view.post { continuation.resume(Unit) }
}
}
addOnLayoutChangeListener(listener)
continuation.invokeOnCancellation { removeOnLayoutChangeListener(listener) }
}
fun TextView.setRelativeDrawables(
@DrawableRes start: Int = 0,
@DrawableRes top: Int = 0,
@@ -4,14 +4,10 @@
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/conversation_reaction_scrubber"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:elevation="1000dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@+id/parent_start_guideline"
app:layout_constraintTop_toTopOf="@+id/status_bar_guideline"
tools:visibility="visible">
<Space
@@ -336,14 +336,6 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<FrameLayout
android:id="@+id/reactions_shade"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/reactions_screen_light_shade_color"
android:foreground="@color/reactions_screen_dark_shade_color"
android:visibility="gone" />
<org.thoughtcrime.securesms.components.menu.SignalBottomActionBar
android:id="@+id/conversation_bottom_action_bar"
android:layout_width="0dp"
@@ -355,15 +347,4 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ViewStub
android:id="@+id/conversation_reaction_scrubber_stub"
android:layout_width="0dp"
android:layout_height="0dp"
android:inflatedId="@+id/conversation_reaction_scrubber"
android:layout="@layout/conversation_reaction_scrubber"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?><!--
The long press overlay and its shade, drawn above the scaffold so a closing keyboard never resizes them.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/reactions_shade"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/reactions_screen_light_shade_color"
android:foreground="@color/reactions_screen_dark_shade_color"
android:visibility="gone" />
<ViewStub
android:id="@+id/conversation_reaction_scrubber_stub"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:inflatedId="@+id/conversation_reaction_scrubber"
android:layout="@layout/conversation_reaction_scrubber" />
</FrameLayout>