Add ScrollToPositionDelegate and install in calls log fragment.

This commit is contained in:
Alex Hart
2023-04-18 10:45:24 -03:00
committed by Cody Henthorne
parent 6db71f4a39
commit 9081230286
5 changed files with 184 additions and 35 deletions

View File

@@ -65,7 +65,8 @@ class CallLogAdapter(
fun submitCallRows(
rows: List<CallLogRow?>,
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
}

View File

@@ -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)

View File

@@ -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<Unit>()
private val scrollPositionRequested = BehaviorSubject.createDefault(EMPTY)
private val scrollPositionRequests: Observable<ScrollToPositionRequest> = 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
)
}

View File

@@ -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,

View File

@@ -45,6 +45,35 @@ public class PagingMappingAdapter<Key> 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);