mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 09:20:19 +01:00
Add ScrollToPositionDelegate and install in calls log fragment.
This commit is contained in:
committed by
Cody Henthorne
parent
6db71f4a39
commit
9081230286
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user