diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt index 069bdebfc7..ba462ff621 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt @@ -65,7 +65,8 @@ class CallLogAdapter( fun submitCallRows( rows: List, selectionState: CallLogSelectionState, - stagedDeletion: CallLogStagedDeletion? + stagedDeletion: CallLogStagedDeletion?, + onCommit: () -> Unit ): Int { val filteredRows = rows .filterNotNull() @@ -78,7 +79,7 @@ class CallLogAdapter( } } - submitList(filteredRows) + submitList(filteredRows, onCommit) return filteredRows.size } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt index 457fea91c0..7c3cb7d54b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt @@ -15,7 +15,6 @@ import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.appbar.AppBarLayout import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar @@ -27,6 +26,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.calls.new.NewCallActivity import org.thoughtcrime.securesms.components.Material3SearchToolbar +import org.thoughtcrime.securesms.components.ScrollToPositionDelegate import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.components.menu.ActionItem import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity @@ -47,9 +47,8 @@ import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.stories.tabs.ConversationListTab import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel import org.thoughtcrime.securesms.util.CommunicationActions -import org.thoughtcrime.securesms.util.SnapToTopDataObserver -import org.thoughtcrime.securesms.util.SnapToTopDataObserver.ScrollRequestValidator import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.doAfterNextLayout import org.thoughtcrime.securesms.util.fragments.requireListener import org.thoughtcrime.securesms.util.visible import java.util.Objects @@ -60,10 +59,6 @@ import java.util.Objects @SuppressLint("DiscouragedApi") class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Callbacks, CallLogContextMenu.Callbacks { - companion object { - private const val LIST_SMOOTH_SCROLL_TO_TOP_THRESHOLD = 25 - } - private val viewModel: CallLogViewModel by viewModels() private val binding: CallLogFragmentBinding by ViewBinderDelegate(CallLogFragmentBinding::bind) private val disposables = LifecycleDisposable() @@ -104,30 +99,21 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal disposables.bindTo(viewLifecycleOwner) adapter.setPagingController(viewModel.controller) - val snapToTopDataObserver = SnapToTopDataObserver( - binding.recycler, - object : ScrollRequestValidator { - override fun isPositionStillValid(position: Int): Boolean { - return position < adapter.itemCount && position >= 0 - } - - override fun isItemAtPositionLoaded(position: Int): Boolean { - return adapter.getItem(position) != null - } - } - ) { - val layoutManager = binding.recycler.layoutManager as? LinearLayoutManager ?: return@SnapToTopDataObserver - if (layoutManager.findFirstVisibleItemPosition() <= LIST_SMOOTH_SCROLL_TO_TOP_THRESHOLD) { - binding.recycler.smoothScrollToPosition(0) - } else { - binding.recycler.scrollToPosition(0) - } - } + val scrollToPositionDelegate = ScrollToPositionDelegate( + recyclerView = binding.recycler, + canJumpToPosition = { adapter.isAvailableAround(it) } + ) + disposables += scrollToPositionDelegate disposables += Flowables.combineLatest(viewModel.data, viewModel.selectedAndStagedDeletion) .observeOn(AndroidSchedulers.mainThread()) .subscribe { (data, selected) -> - val filteredCount = adapter.submitCallRows(data, selected.first, selected.second) + val filteredCount = adapter.submitCallRows( + data, + selected.first, + selected.second, + scrollToPositionDelegate::notifyListCommitted + ) binding.emptyState.visible = filteredCount == 0 } @@ -167,8 +153,8 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal ) ) - initializePullToFilter() - initializeTapToScrollToTop(snapToTopDataObserver) + initializePullToFilter(scrollToPositionDelegate) + initializeTapToScrollToTop(scrollToPositionDelegate) requireActivity().onBackPressedDispatcher.addCallback( viewLifecycleOwner, @@ -195,11 +181,11 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal viewModel.markAllCallEventsRead() } - private fun initializeTapToScrollToTop(snapToTopDataObserver: SnapToTopDataObserver) { + private fun initializeTapToScrollToTop(scrollToPositionDelegate: ScrollToPositionDelegate) { disposables += tabsViewModel.tabClickEvents .filter { it == ConversationListTab.CALLS } .subscribeBy(onNext = { - snapToTopDataObserver.requestScrollPosition(0) + scrollToPositionDelegate.resetScrollPosition() }) } @@ -245,13 +231,18 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal } } - private fun initializePullToFilter() { + private fun initializePullToFilter(scrollToPositionDelegate: ScrollToPositionDelegate) { val collapsingToolbarLayout = binding.collapsingToolbar val openHeight = DimensionUnit.DP.toPixels(FilterLerp.FILTER_OPEN_HEIGHT).toInt() binding.pullView.onFilterStateChanged = OnFilterStateChanged { state: FilterPullState?, source: ConversationFilterSource -> when (state) { - FilterPullState.CLOSING -> viewModel.setFilter(CallLogFilter.ALL) + FilterPullState.CLOSING -> { + viewModel.setFilter(CallLogFilter.ALL) + binding.recycler.doAfterNextLayout { + scrollToPositionDelegate.resetScrollPosition() + } + } FilterPullState.OPENING -> { ViewUtil.setMinimumHeight(collapsingToolbarLayout, openHeight) viewModel.setFilter(CallLogFilter.MISSED) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ScrollToPositionDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ScrollToPositionDelegate.kt new file mode 100644 index 0000000000..c1886d5def --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ScrollToPositionDelegate.kt @@ -0,0 +1,116 @@ +package org.thoughtcrime.securesms.components + +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.kotlin.subscribeBy +import io.reactivex.rxjava3.subjects.BehaviorSubject +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.util.doAfterNextLayout +import kotlin.math.max + +/** + * Delegate object to help manage scroll position requests. + * + * @param recyclerView The recycler view that will be scrolled + * @param canJumpToPosition Allows additional checks to see if we can scroll. For example, PagingMappingAdapter#isAvailableAround + * @param mapToTruePosition Allows additional offsets to be applied to the position. + */ +class ScrollToPositionDelegate private constructor( + private val recyclerView: RecyclerView, + canJumpToPosition: (Int) -> Boolean, + mapToTruePosition: (Int) -> Int, + disposables: CompositeDisposable +) : Disposable by disposables { + companion object { + private val TAG = Log.tag(ScrollToPositionDelegate::class.java) + const val NO_POSITION = -1 + private val EMPTY = ScrollToPositionRequest(NO_POSITION, true) + private const val SMOOTH_SCROLL_THRESHOLD = 25 + } + + private val listCommitted = BehaviorSubject.create() + private val scrollPositionRequested = BehaviorSubject.createDefault(EMPTY) + private val scrollPositionRequests: Observable = Observable.combineLatest(listCommitted, scrollPositionRequested) { _, b -> b } + + constructor( + recyclerView: RecyclerView, + canJumpToPosition: (Int) -> Boolean = { true }, + mapToTruePosition: (Int) -> Int = { it } + ) : this(recyclerView, canJumpToPosition, mapToTruePosition, CompositeDisposable()) + + init { + disposables += scrollPositionRequests + .observeOn(AndroidSchedulers.mainThread()) + .filter { it.position >= 0 && canJumpToPosition(it.position) } + .map { it.copy(position = mapToTruePosition(it.position)) } + .subscribeBy(onNext = { position -> + recyclerView.doAfterNextLayout { + handleScrollPositionRequest(position, recyclerView) + } + }) + } + + private fun handleScrollPositionRequest( + request: ScrollToPositionRequest, + recyclerView: RecyclerView + ) { + requestScrollPosition(NO_POSITION, false) + + val layoutManager = recyclerView.layoutManager as? LinearLayoutManager + if (layoutManager == null) { + Log.w(TAG, "Layout manager is not set or of an invalid type.") + return + } + + if (recyclerView.scrollState == RecyclerView.SCROLL_STATE_DRAGGING) { + return + } + + val position = max(0, request.position - 1) + val offset = when { + position == 0 -> 0 + layoutManager.reverseLayout -> recyclerView.height + else -> 0 + } + + Log.d(TAG, "Scrolling to position $position with offset $offset.") + + if (request.smooth && position == 0 && layoutManager.findFirstVisibleItemPosition() < SMOOTH_SCROLL_THRESHOLD) { + recyclerView.smoothScrollToPosition(position) + } else { + layoutManager.scrollToPositionWithOffset(position, offset) + } + } + + /** + * Entry point for requesting a specific scroll position. + */ + fun requestScrollPosition(position: Int, smooth: Boolean = true) { + scrollPositionRequested.onNext(ScrollToPositionRequest(position, smooth)) + } + + /** + * Reset the scroll position to 0 + */ + fun resetScrollPosition() { + requestScrollPosition(0, true) + recyclerView.requestLayout() + } + + /** + * This should be called every time a list is submitted to the RecyclerView's adapter. + */ + fun notifyListCommitted() { + listCommitted.onNext(Unit) + } + + private data class ScrollToPositionRequest( + val position: Int, + val smooth: Boolean + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt index d8bb92cea2..1c03c60523 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewExtensions.kt @@ -5,6 +5,7 @@ import android.widget.TextView import androidx.annotation.DrawableRes import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.view.doOnNextLayout var View.visible: Boolean get() { @@ -31,6 +32,17 @@ inline fun View.doOnEachLayout(crossinline action: (view: View) -> Unit): View.O return listener } +/** + * OnLayout gets called prior to the view *actually* being laid out. This + * method will wait until the next layout and then post the action to happen + * afterwards. + */ +inline fun View.doAfterNextLayout(crossinline action: () -> Unit) { + doOnNextLayout { + post { action() } + } +} + fun TextView.setRelativeDrawables( @DrawableRes start: Int = 0, @DrawableRes top: Int = 0, diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/PagingMappingAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/PagingMappingAdapter.java index 8c006968ca..38f6c9984d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/PagingMappingAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/PagingMappingAdapter.java @@ -45,6 +45,35 @@ public class PagingMappingAdapter extends MappingAdapter { } } + public boolean isAvailableAround(int position) { + int start = position - 10; + int end = position + 10; + + if (isRangeAvailable(start, end)) { + return true; + } else { + getItem(position); + return false; + } + } + + protected final boolean isRangeAvailable(int start, int end) { + if (end <= start || start >= getItemCount() || end <= 0) { + return false; + } + + int clampedStart = Math.max(0, start); + int clampedEnd = Math.min(getItemCount(), end); + + for (int i = clampedStart; i < clampedEnd; i++) { + if (super.getItem(i) == null) { + return false; + } + } + + return true; + } + @Override public int getItemViewType(int position) { MappingModel item = getItem(position);