Fix CFv2 initial scrolling bugs.
This commit is contained in:
parent
9c308588b5
commit
05fc30e6e8
3 changed files with 146 additions and 18 deletions
|
@ -0,0 +1,76 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2023 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package androidx.recyclerview.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.signal.core.util.logging.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variation of a vertical, reversed [LinearLayoutManager] that makes specific assumptions in how it will
|
||||||
|
* be used by Conversation view to support easier scrolling to the initial start position.
|
||||||
|
*
|
||||||
|
* Primarily, it assumes that an initial scroll to position call will always happen and that the implementation
|
||||||
|
* of [LinearLayoutManager] remains unchanged with respect to how it assigns [mPendingScrollPosition] and
|
||||||
|
* [mPendingScrollPositionOffset] in [LinearLayoutManager.scrollToPositionWithOffset] and how it always clears
|
||||||
|
* the pending state variables in every call to [LinearLayoutManager.onLayoutCompleted].
|
||||||
|
*
|
||||||
|
* The assumptions are necessary to force the requested scroll position/layout to occur even if the request
|
||||||
|
* happens prior to the data source populating the recycler view/adapter.
|
||||||
|
*/
|
||||||
|
class ConversationLayoutManager(context: Context) : LinearLayoutManager(context, RecyclerView.VERTICAL, true) {
|
||||||
|
|
||||||
|
private var afterScroll: (() -> Unit)? = null
|
||||||
|
|
||||||
|
override fun supportsPredictiveItemAnimations(): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll to the desired position and be notified when the layout manager has completed the request
|
||||||
|
* via [afterScroll] callback.
|
||||||
|
*/
|
||||||
|
fun scrollToPositionWithOffset(position: Int, offset: Int, afterScroll: () -> Unit) {
|
||||||
|
this.afterScroll = afterScroll
|
||||||
|
super.scrollToPositionWithOffset(position, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If a scroll to position request is made and a layout pass occurs prior to the list being populated with via the data source,
|
||||||
|
* the base implementation clears the request as if it was never made.
|
||||||
|
*
|
||||||
|
* This override will capture the pending scroll position and offset, determine if the scroll request was satisfied, and
|
||||||
|
* re-request the scroll to position to force another attempt if not satisfied.
|
||||||
|
*
|
||||||
|
* A pending scroll request will be re-requested if the pending scroll position is outside the bounds of the current known size of
|
||||||
|
* items in the list.
|
||||||
|
*/
|
||||||
|
override fun onLayoutCompleted(state: RecyclerView.State?) {
|
||||||
|
val pendingScrollPosition = mPendingScrollPosition
|
||||||
|
val pendingScrollOffset = mPendingScrollPositionOffset
|
||||||
|
|
||||||
|
val reRequestPendingPosition = pendingScrollPosition >= (state?.mItemCount ?: 0)
|
||||||
|
|
||||||
|
// Base implementation always clears mPendingScrollPosition+mPendingScrollPositionOffset
|
||||||
|
super.onLayoutCompleted(state)
|
||||||
|
|
||||||
|
// Re-request scroll to position request if necessary thus forcing mPendingScrollPosition+mPendingScrollPositionOffset to be re-assigned
|
||||||
|
if (reRequestPendingPosition) {
|
||||||
|
Log.d(TAG, "Re-requesting pending scroll position: $pendingScrollPosition offset: $pendingScrollOffset")
|
||||||
|
if (pendingScrollOffset != INVALID_OFFSET) {
|
||||||
|
scrollToPositionWithOffset(pendingScrollPosition, pendingScrollOffset)
|
||||||
|
} else {
|
||||||
|
scrollToPosition(pendingScrollPosition)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
afterScroll?.invoke()
|
||||||
|
afterScroll = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = Log.tag(ConversationLayoutManager::class.java)
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.conversation;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
import androidx.lifecycle.Lifecycle;
|
import androidx.lifecycle.Lifecycle;
|
||||||
import androidx.lifecycle.LifecycleOwner;
|
import androidx.lifecycle.LifecycleOwner;
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
@ -39,9 +40,10 @@ public class MarkReadHelper {
|
||||||
|
|
||||||
private final ConversationId conversationId;
|
private final ConversationId conversationId;
|
||||||
private final Context context;
|
private final Context context;
|
||||||
private final LifecycleOwner lifecycleOwner;
|
private final LifecycleOwner lifecycleOwner;
|
||||||
private final Debouncer debouncer = new Debouncer(DEBOUNCE_TIMEOUT);
|
private final Debouncer debouncer = new Debouncer(DEBOUNCE_TIMEOUT);
|
||||||
private long latestTimestamp;
|
private long latestTimestamp;
|
||||||
|
private boolean ignoreViewReveals = false;
|
||||||
|
|
||||||
public MarkReadHelper(@NonNull ConversationId conversationId, @NonNull Context context, @NonNull LifecycleOwner lifecycleOwner) {
|
public MarkReadHelper(@NonNull ConversationId conversationId, @NonNull Context context, @NonNull LifecycleOwner lifecycleOwner) {
|
||||||
this.conversationId = conversationId;
|
this.conversationId = conversationId;
|
||||||
|
@ -50,7 +52,7 @@ public class MarkReadHelper {
|
||||||
}
|
}
|
||||||
|
|
||||||
public void onViewsRevealed(long timestamp) {
|
public void onViewsRevealed(long timestamp) {
|
||||||
if (timestamp <= latestTimestamp || lifecycleOwner.getLifecycle().getCurrentState() != Lifecycle.State.RESUMED) {
|
if (timestamp <= latestTimestamp || lifecycleOwner.getLifecycle().getCurrentState() != Lifecycle.State.RESUMED || ignoreViewReveals) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,11 +71,33 @@ public class MarkReadHelper {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent calls to {@link #onViewsRevealed(long)} from causing messages to be marked read.
|
||||||
|
* <p>
|
||||||
|
* This is particularly useful when the conversation could move around after views are
|
||||||
|
* displayed (e.g., initial scrolling to start position).
|
||||||
|
*/
|
||||||
|
public void ignoreViewReveals() {
|
||||||
|
ignoreViewReveals = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop preventing calls to {@link #onViewsRevealed(long)} from not marking messages as read.
|
||||||
|
*
|
||||||
|
* @param timestamp Timestamp of most recent reveal messages, same as usually provided to {@code onViewsRevealed}
|
||||||
|
*/
|
||||||
|
public void stopIgnoringViewReveals(@Nullable Long timestamp) {
|
||||||
|
this.ignoreViewReveals = false;
|
||||||
|
if (timestamp != null) {
|
||||||
|
onViewsRevealed(timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given the adapter and manager, figure out the timestamp to mark read up to.
|
* Given the adapter and manager, figure out the timestamp to mark read up to.
|
||||||
*
|
*
|
||||||
* @param conversationAdapter The conversation thread's adapter
|
* @param conversationAdapter The conversation thread's adapter
|
||||||
* @param layoutManager The conversation thread's layout manager
|
* @param layoutManager The conversation thread's layout manager
|
||||||
* @return A Present(Long) if there's a timestamp to proceed with, or Empty if this request should be ignored.
|
* @return A Present(Long) if there's a timestamp to proceed with, or Empty if this request should be ignored.
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("resource")
|
@SuppressWarnings("resource")
|
||||||
|
|
|
@ -60,6 +60,7 @@ import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.Observer
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.recyclerview.widget.ConversationLayoutManager
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
@ -118,7 +119,6 @@ import org.thoughtcrime.securesms.components.location.SignalPlace
|
||||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
|
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
|
||||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||||
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar
|
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar
|
||||||
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
|
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment
|
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment
|
||||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
||||||
|
@ -442,7 +442,7 @@ class ConversationFragment :
|
||||||
private val textDraftSaveDebouncer = Debouncer(500)
|
private val textDraftSaveDebouncer = Debouncer(500)
|
||||||
private val recentEmojis: RecentEmojiPageModel by lazy { RecentEmojiPageModel(ApplicationDependencies.getApplication(), TextSecurePreferences.RECENT_STORAGE_KEY) }
|
private val recentEmojis: RecentEmojiPageModel by lazy { RecentEmojiPageModel(ApplicationDependencies.getApplication(), TextSecurePreferences.RECENT_STORAGE_KEY) }
|
||||||
|
|
||||||
private lateinit var layoutManager: LinearLayoutManager
|
private lateinit var layoutManager: ConversationLayoutManager
|
||||||
private lateinit var markReadHelper: MarkReadHelper
|
private lateinit var markReadHelper: MarkReadHelper
|
||||||
private lateinit var giphyMp4ProjectionRecycler: GiphyMp4ProjectionRecycler
|
private lateinit var giphyMp4ProjectionRecycler: GiphyMp4ProjectionRecycler
|
||||||
private lateinit var addToContactsLauncher: ActivityResultLauncher<Intent>
|
private lateinit var addToContactsLauncher: ActivityResultLauncher<Intent>
|
||||||
|
@ -519,6 +519,7 @@ class ConversationFragment :
|
||||||
FullscreenHelper(requireActivity()).showSystemUI()
|
FullscreenHelper(requireActivity()).showSystemUI()
|
||||||
|
|
||||||
markReadHelper = MarkReadHelper(ConversationId.forConversation(args.threadId), requireContext(), viewLifecycleOwner)
|
markReadHelper = MarkReadHelper(ConversationId.forConversation(args.threadId), requireContext(), viewLifecycleOwner)
|
||||||
|
markReadHelper.ignoreViewReveals()
|
||||||
|
|
||||||
initializeConversationThreadUi()
|
initializeConversationThreadUi()
|
||||||
|
|
||||||
|
@ -778,7 +779,6 @@ class ConversationFragment :
|
||||||
binding.conversationItemRecycler.doAfterNextLayout {
|
binding.conversationItemRecycler.doAfterNextLayout {
|
||||||
SignalLocalMetrics.ConversationOpen.onRenderFinished()
|
SignalLocalMetrics.ConversationOpen.onRenderFinished()
|
||||||
doAfterFirstRender()
|
doAfterFirstRender()
|
||||||
animationsAllowed = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1395,7 +1395,7 @@ class ConversationFragment :
|
||||||
private fun getVoiceNoteMediaController() = requireListener<VoiceNoteMediaControllerOwner>().voiceNoteMediaController
|
private fun getVoiceNoteMediaController() = requireListener<VoiceNoteMediaControllerOwner>().voiceNoteMediaController
|
||||||
|
|
||||||
private fun initializeConversationThreadUi() {
|
private fun initializeConversationThreadUi() {
|
||||||
layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true)
|
layoutManager = ConversationLayoutManager(requireContext())
|
||||||
binding.conversationItemRecycler.setHasFixedSize(false)
|
binding.conversationItemRecycler.setHasFixedSize(false)
|
||||||
binding.conversationItemRecycler.layoutManager = layoutManager
|
binding.conversationItemRecycler.layoutManager = layoutManager
|
||||||
binding.conversationItemRecycler.addOnScrollListener(ScrollListener())
|
binding.conversationItemRecycler.addOnScrollListener(ScrollListener())
|
||||||
|
@ -2105,15 +2105,43 @@ class ConversationFragment :
|
||||||
//region Scroll Handling
|
//region Scroll Handling
|
||||||
|
|
||||||
private fun moveToStartPosition(meta: ConversationData) {
|
private fun moveToStartPosition(meta: ConversationData) {
|
||||||
scrollToPositionDelegate.requestScrollPosition(
|
if (meta.getStartPosition() == 0) {
|
||||||
position = meta.getStartPosition(),
|
layoutManager.scrollToPositionWithOffset(0, 0) {
|
||||||
smooth = true,
|
animationsAllowed = true
|
||||||
scrollStrategy = if (meta.shouldJumpToMessage()) {
|
markReadHelper.stopIgnoringViewReveals(MarkReadHelper.getLatestTimestamp(adapter, layoutManager).orNull())
|
||||||
jumpAndPulseScrollStrategy
|
|
||||||
} else {
|
|
||||||
ScrollToPositionDelegate.DefaultScrollStrategy
|
|
||||||
}
|
}
|
||||||
)
|
} else {
|
||||||
|
binding.toolbar.viewTreeObserver.addOnGlobalLayoutListener(StartPositionScroller(meta))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper to scroll the conversation to the correct position and offset based on toolbar height and the type of position */
|
||||||
|
private inner class StartPositionScroller(private val meta: ConversationData) : ViewTreeObserver.OnGlobalLayoutListener {
|
||||||
|
|
||||||
|
override fun onGlobalLayout() {
|
||||||
|
val rect = Rect()
|
||||||
|
binding.toolbar.getGlobalVisibleRect(rect)
|
||||||
|
val toolbarOffset = rect.bottom
|
||||||
|
binding.toolbar.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||||
|
|
||||||
|
val offset = when {
|
||||||
|
meta.getStartPosition() == 0 -> 0
|
||||||
|
meta.shouldJumpToMessage() -> (binding.conversationItemRecycler.height - toolbarOffset) / 4
|
||||||
|
meta.shouldScrollToLastSeen() -> binding.conversationItemRecycler.height - toolbarOffset
|
||||||
|
else -> binding.conversationItemRecycler.height
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Scrolling to start position ${meta.getStartPosition()}")
|
||||||
|
layoutManager.scrollToPositionWithOffset(meta.getStartPosition(), offset) {
|
||||||
|
animationsAllowed = true
|
||||||
|
markReadHelper.stopIgnoringViewReveals(MarkReadHelper.getLatestTimestamp(adapter, layoutManager).orNull())
|
||||||
|
if (meta.shouldJumpToMessage()) {
|
||||||
|
binding.conversationItemRecycler.post {
|
||||||
|
adapter.pulseAtPosition(meta.getStartPosition())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Add table
Reference in a new issue