mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Improve long press menu performance.
This commit is contained in:
+1
-23
@@ -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
|
||||
|
||||
+9
-3
@@ -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) }
|
||||
|
||||
+31
-10
@@ -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);
|
||||
|
||||
+18
-5
@@ -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?
|
||||
}
|
||||
}
|
||||
|
||||
+22
-48
@@ -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)))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+75
-73
@@ -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>
|
||||
Reference in New Issue
Block a user