I'm trying to implement a timeline similar to Google Nest Camera timeline, I'm using a recyclerview which I want all rows to be the same height, therefore, for a row with an image, I'm using offset, however, when scrolling, since it is a negative scroll, the image appears/disappear of all of the sudden, here is a gif
https://media.giphy.com/media/3DZApAZd284aVPWVGu/giphy.gif
Is there a way to make the row load before appearing on the screen?
recyclerview initialize
timelineRecyclerView.apply {
addItemDecoration(OverlapDecoration(-15.toDp()))
adapter = timelineAdapter
val lm = layoutManager as LinearLayoutManager
lm.reverseLayout = true
lm.stackFromEnd = true
layoutManager = lm
}
ItemDecoration for offset:
class OverlapDecoration(val offset: Int) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, itemPosition: Int, parent: RecyclerView) {
Log.d("MainActivity", "${parent.adapter?.getItemViewType(itemPosition) }")
when( parent.adapter?.getItemViewType(itemPosition) ) {
1 -> {}
2 -> { outRect.set(0, offset, 0, offset) }
}
}
Since you want all rows to have the same height, you can use
recyclerView.setHasFixedSize(true)
for optimization.
To pre-load items and keep them in cache, you can use
recyclerView.setItemViewCacheSize(3) // example: 3 items will be held in cache off-screen.
If this does not work, you can implement a custom LayoutManager to pre-cache items. This example is a bit older, but it should still work. Not tested though.
Related
I am working on an idea, which is make a RecyclerView auto scrolling but allow user to click item without stop scrolling.
First, I create a custom LayoutManager to disable manual scroll, also change the speed of scroll to a certain position
class CustomLayoutManager(context: Context, countOfColumns: Int) :
GridLayoutManager(context, countOfColumns) {
// Custom smooth scroller
private val smoothScroller = object : LinearSmoothScroller(context) {
override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics): Float =
500f / displayMetrics.densityDpi
}
// Disable manual scroll
override fun canScrollVertically(): Boolean = false
// Using custom smooth scroller to control the duration of smooth scroll to a certain position
override fun smoothScrollToPosition(
recyclerView: RecyclerView,
state: RecyclerView.State?,
position: Int
) {
smoothScroller.targetPosition = position
startSmoothScroll(smoothScroller)
}
}
Then I do the initial work for the RecyclerView and start smooth scroll after 1 sec
viewBinding.list.apply {
// initial recycler view
setHasFixedSize(true)
customLayoutManager = CustomLayoutManager(context = context, countOfColumns = 2)
layoutManager = customLayoutManager
// data list
val dataList = mutableListOf<TestModel>()
repeat(times = 100) { dataList.add(TestModel(position = it, clicked = false)) }
// adapter
testAdapter =
TestAdapter(clickListener = { testAdapter.changeVhColorByPosition(position = it) })
adapter = testAdapter
testAdapter.submitList(dataList)
// automatically scroll after 1 sec
postDelayed({ smoothScrollToPosition(dataList.lastIndex) }, 1000)
}
Everything goes as my expected until I found that the auto scrolling stopped when I clicked on any item on the RecycelerView, the function when clickListener triggered just change background color of the view holder in TestAdapter
fun changeVhColor(position: Int) {
position
.takeIf { it in 0..itemCount }
?.also { getItem(it).clicked = true }
?.also { notifyItemChanged(it) }
}
here is the screen recording screen recording
issues I encounter
auto scrolling stopped when I tap any item on the ReycelerView
first tap make scrolling stopped, second tap trigger clickListener, but I expect to trigger clickListener by one tap
Can anybody to tell me how to resolve this? Thanks in advance.
There is a lot going on here. You should suspect the touch handling of the RecyclerView and, maybe, the call to notifyItemChanged(it), but I believe that the RecyclerView is behaving correctly. You can look into overriding the touch code in the RecyclerView to make it do what you want - assuming you can get to it and override it.
An alternative would be to overlay the RecyclerView with another view that is transparent and capture all touches on the transparent view. You can then write code for the transparent view that interacts with the RecyclerView in the way that meets your objectives. This will also be tricky and you will have to make changes to the RecyclerView as it is constantly layout out views as scrolling occurs. Since you have your own layout manager, this might be easier if you queue changes to occur pre-layout as scrolling occurs.
After tried several ways, found that the key of keep recycler view scrolling automatically is override onInterceptTouchEvent
Example
class MyRecyclerView #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : RecyclerView(context, attrs, defStyle) {
override fun onInterceptTouchEvent(e: MotionEvent?): Boolean = false
}
that will make the custom RecyclerView ignore all touch event
I have a ViewPager2 inside a BottomSheetDialog in which I load a Fragment that contains a ComposeView. Inside this view I populate a LazyList with items as soon as they're loaded.
Now this works all fine, except that the ViewPager2 makes no height adaptions when it's inner contents change, so naturally I adapted the peekHeight at first and then added a GlobalLayoutListener to give the pager the height of the inner, currently displayed fragment view, like so:
val myPager = ...
myPager.registerOnPageChangeCallback(AdaptChildHeightOnPageChange(myPager))
...
internal class AdaptChildHeightOnPageChange(private val viewPager: ViewPager2) : ViewPager2.OnPageChangeCallback() {
private val otherViews = mutableSetOf<View>()
private fun getViewAtPosition(position: Int): View =
(viewPager.getChildAt(0) as RecyclerView).layoutManager?.findViewByPosition(position)
?: error("No layout manager set or no view found at position $position")
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
val itemView = getViewAtPosition(position)
val layoutListener = ViewTreeObserver.OnGlobalLayoutListener {
itemView.updatePagerHeightForChild()
}
// remove the global layout listener from other views
otherViews.forEach { it.viewTreeObserver.removeOnGlobalLayoutListener(it.tag as ViewTreeObserver.OnGlobalLayoutListener) }
itemView.viewTreeObserver.addOnGlobalLayoutListener(layoutListener)
itemView.tag = layoutListener
otherViews.add(itemView)
}
private fun View.updatePagerHeightForChild() {
post {
val wMeasureSpec = View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY)
val hMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
measure(wMeasureSpec, hMeasureSpec)
if (viewPager.layoutParams.height != measuredHeight) {
viewPager.layoutParams = (viewPager.layoutParams as ViewGroup.LayoutParams)
.also { lp -> lp.height = measuredHeight }
}
}
}
}
(taken and adapted from https://stackoverflow.com/a/58632613/305532)
Now while this works fantastically with regular compose content, as soon as I switch my compose view to the LazyList implementation (or anything that uses Modifier.verticalScroll(...)), I receive the following exception:
Nesting scrollable in the same direction layouts like LazyColumn and \
Column(Modifier.verticalScroll()) is not allowed (Scroll.kt:370)
But I don't get this really, because I haven't nested any vertical-scolling compose elements that could trigger this exception. My only guess is that because of the height constraint I give to the ViewPager2 this internally triggers the enablement of vertical scrolling, making the inner LazyList unable to take over.
How can I solve this issue?
Ok, the crash seem to have stem from an issue with the GlobalLayoutListener. This constantly fired updates and kicked of relayouts, even though I tried to remove the listener explicitely before setting a new height to the surrounding pager.
I am attempting to write a recyclerview which has some of the Viewholders inside it as stacked ontop of one another. The idea is that you can drag the topmost view above the stacked list and have drop it above where it becomes separate.
I managed to get this working using a Recyclerview with a custom RecyclerView.ItemDecoration. However, after I drop the item, i have the adapter call notifyDataSetChange to update the background code. This causes the the next item in the stack to appear to be the wrong one (though this does change sometimes if you touch the item and start scrolling, then it displays the correct one).
The custom RecyclerView.ItemDecoration class:
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
)
{
val itemPosition = parent.getChildAdapterPosition(view)
val adapter = parent.adapter
if (adapter is BaseRecVAdapter)
{
val item = adapter.getDataModel(itemPosition)
if (item is DragDropModel && item.mStackedPos != PMConsts.negNum)
{
if (item.mStackedPos != 0)
{
val context = view.context
val top = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 148f, context.resources.displayMetrics).toInt()
outRect.set(0, -top, 0, 0)
return
}
}
}
super.getItemOffsets(outRect, view, parent, state)
}
The drag interface I made for the Adapter and the ItemTouchHelper.Callback can be found below:
interface ItemTouchHelperListener
{
fun onItemMove(fromPosition: Int, toPosition: Int): Boolean
fun onClearView(recyclerView: RecyclerView?, viewHolder: RecyclerView.ViewHolder?)
}
The onItem move code is as follows:
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean
{
var newToPosition = toPosition
if (toPosition <= mDragUpLimit)
{//Prevent items from being dragged above maximum movement.
newToPosition = mDragUpLimit + 1
}
else if (toPosition >= mDragDownLimit)
{//Cannot drag below stacked List...
newToPosition = mDragDownLimit - 1
}
if (fromPosition < newToPosition)
{
for (i in fromPosition until newToPosition)
{
swap(mDataList, i, i + 1)
}
}
else
{
for (i in fromPosition downTo newToPosition + 1)
{
swap(mDataList, i, i - 1)
}
}
notifyItemMoved(fromPosition, newToPosition)
return true
}
I have a simple viewholder which is an invisible bar which i mark as the position you need to drag above in order to make a valid change to the list order.
I have the code call notifyDataSetChanged after the onClearView() method is called as I need to update the background features so that the next item in the stack is draggable and the background data feeding into the adapter is also updated. It seems the simplest way to keep the data updating smoothly, but I wonder if it is causing my problems
If someone would be able to give me a hand with this, I would be most grateful. I am tearing my hair out somewhat. I thought I had a good system setup but it was not quite working. I hope that this is enough information to get some help with this issue.
Thank you in advance
I have a RecyclerView that contains TextViews. The number of TextViews can vary and the size of them vary as well and can be dynamically changed.
When the user scrolls to a certain position within the list and exits app, I want to be able to return to that exact position in the next session.
To do this, I need to know how many pixels have scrolled past from where the current TextView in view started and where the current position of the scroll is. For example, if the user has the 3rd TextView in view and scrolls 100 pixels down from where that TextView started, I will be able to return to this spot with scrollToPositionWithOffset(2, 100). If the TextView changes size (due to font changes), I can also return to the same spot by calculating the percentage of offset using the TextView's height.
Problem is, I cannot get the offset value in any accurate manor.
I know I can keep a running calculation on the Y value scrolled using get scroll Y of RecyclerView or Webview, but this does not give me where the TextView actually started. I can listen to when the user scrolled past the start of any TextView and record the Y position there but this will be inaccurate on fast scrolling.
Is there a better way?
Don't use position in pixels, use the index of the view. Using layout manager's findFirstVisibleItemPosition or findFirstCompletelyVisibleItemPosition.
That's a very popular question, although it may be intuitive to think and search for pixels not index.
Get visible items in RecyclerView
Find if the first visible item in the recycler view is the first item of the list or not
how to get current visible item in Recycler View
A good reason to not trust pixels is that it's not useful on some situations where index is, like rotating the screen, resizeing/splitting the app size to fit other apps side by side, foldable phones, and changing text / screen resolution.
I solved this by converting to a ListView:
lateinit var adapterRead: AdapterRead // Custom Adapter
lateinit var itemListView: ListView
/*=======================================================================================================*/
// OnViewCreated
itemListView = view.findViewById(R.id.read_listview)
setListView(itemListView)
// Upon entering this Fragment, will automatically scroll to saved position:
itemListView.afterMeasured {
scrollToPosition(itemListView, getPosition(), getOffset())
}
itemListView.setOnScrollListener(object : AbsListView.OnScrollListener {
private var currentFirstVisibleItem = 0
var offset = 0
override fun onScrollStateChanged(view: AbsListView, scrollState: Int) {
// When scrolling stops, will save the current position and offset:
if(scrollState == AbsListView.OnScrollListener.SCROLL_STATE_IDLE) {
offset = if(itemListView.getChildAt(0) == null) 0 else itemListView.getChildAt(0).top - itemListView.paddingTop
saveReadPosition(getReadPosition(itemListView), offset)
}
}
override fun onScroll(view: AbsListView, firstVisibleItem: Int, visibleItemCount: Int, totalItemCount: Int) {
currentFirstVisibleItem = firstVisibleItem
}
})
/*=======================================================================================================*/
// Thanks to https://antonioleiva.com/kotlin-ongloballayoutlistener/ for this:
inline fun <T : View> T.afterMeasured(crossinline f: T.() -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
if(measuredWidth > 0 && measuredHeight > 0) {
viewTreeObserver.removeOnGlobalLayoutListener(this)
f()
}
}
})
}
/*=======================================================================================================*/
fun setListView(lv: ListView) {
adapterRead = AdapterRead(list, context!!)
lv.apply {this.adapter = adapterRead}
}
/*=======================================================================================================*/
fun scrollToPosition(lv: ListView, position: Int, offset: Int) {
lv.post { lv.setSelectionFromTop(position, offset) }
}
/*=======================================================================================================*/
fun saveReadPosition(position: Int, offset: Int) {
// Persist your data to database here
}
/*=======================================================================================================*/
fun getPosition() {
// Get your saved position here
}
/*=======================================================================================================*/
fun getOffse() {
// Get your saved offset here
}
First: I created a sample project showing this problem. By now I begin to think that this is a bug in either RecyclerView or MotionLayout.
https://github.com/muetzenflo/SampleRecyclerView
This project is set up a little bit different than what is described below: It uses data binding to toggle between the MotionLayout states. But the outcome is the same. Just play around with toggling the state and swiping between the items. Sooner than later you'll come upon a ViewHolder with the wrong MotionLayout state.
So the main problem is:
ViewHolders outside of the screen are not updated correctly when transition from one MotionLayout state to another.
So here is the problem / What I've found so far:
I am using a RecyclerView.
It has only 1 item type which is a MotionLayout (so every item of the RV is a MotionLayout).
This MotionLayout has 2 states, let's call them State big and State small
All items should always have the same State. So whenever the state is switched for example from big => small then ALL items should be in small from then on.
But what happens is that the state changes to small and most(!) of the items are also updated correctly. But one or two items are always left with the old State. I am pretty sure it has to do with recycled ViewHolders. These steps produce the issue reliably when using the adapter code below (not in the sample project):
swipe from item 1 to the right to item 2
change from big to small
change back from small to big
swipe from item 2 to the left to item 1
=> item 1 is now in the small state, but should be in the big state
Additional findings:
After step 4 if I continue swiping to the left, there comes 1 more item in the small state (probably the recycled ViewHolder from step 4). After that no other item is wrong.
Starting from step 4, I continue swiping for a few items (let's say 10) and then swipe all the way back, no item is in the wrong small state anymore. The faulty recycled ViewHolder seems to be corrected then.
What did I try?
I tried to call notifyDataSetChanged() whenever the transition has completed
I tried keeping a local Set of created ViewHolders to call the transition on them directly
I tried to use data-binding to set the motionProgress to the MotionLayout
I tried to set viewHolder.isRecycable(true|false) to block recycling during the transition
I searched this great in-depth article about RVs for hint what to try next
Anyone had this problem and found a good solution?
Just to avoid confusion: big and small does not indicate that I want to collapse or expand each item! It is just a name for different arrangement of the motionlayouts' children.
class MatchCardAdapter() : DataBindingAdapter<Match>(DiffCallback, clickListener) {
private val viewHolders = ArrayList<RecyclerView.ViewHolder>()
private var direction = Direction.UNDEFINED
fun setMotionProgress(direction: MatchCardViewModel.Direction) {
if (this.direction == direction) return
this.direction = direction
viewHolders.forEach {
updateItemView(it)
}
}
private fun updateItemView(viewHolder: RecyclerView.ViewHolder) {
if (viewHolder.adapterPosition >= 0) {
val motionLayout = viewHolder.itemView as MotionLayout
when (direction) {
Direction.TO_END -> motionLayout.transitionToEnd()
Direction.TO_START -> motionLayout.transitionToStart()
Direction.UNDEFINED -> motionLayout.transitionToStart()
}
}
}
override fun onBindViewHolder(holder: DataBindingViewHolder<Match>, position: Int) {
val item = getItem(position)
holder.bind(item, clickListener)
val itemView = holder.itemView
if (itemView is MotionLayout) {
if (!viewHolders.contains(holder)) {
viewHolders.add(holder)
}
updateItemView(holder)
}
}
override fun onViewRecycled(holder: DataBindingViewHolder<Match>) {
if (holder.adapterPosition >= 0 && viewHolders.contains(holder)) {
viewHolders.remove(holder)
}
super.onViewRecycled(holder)
}
}
I made some progress but this is not a final solution, it has a few quirks to polish. Like the animation from end to start doesn't work properly, it just jumps to the final position.
https://github.com/fmatosqg/SampleRecyclerView/commit/907ec696a96bb4a817df20c78ebd5cb2156c8424
Some things that I changed but are not relevant to the solution, but help with finding the problem:
made duration 1sec
more items in recycler view
recyclerView.setItemViewCacheSize(0) to try to keep as few unseen items as possible, although if you track it closely you know they tend to stick around
eliminated data binding for handling transitions. Because I don't trust it in view holders in general, I could never make them work without a bad side-effect
upgraded constraint library with implementation "androidx.constraintlayout:constraintlayout:2.0.0-rc1"
Going into details about what made it work better:
all calls to motion layout are done in a post manner
// https://stackoverflow.com/questions/51929153/when-manually-set-progress-to-motionlayout-it-clear-all-constraints
fun safeRunBlock(block: () -> Unit) {
if (ViewCompat.isLaidOut(motionLayout)) {
block()
} else {
motionLayout.post(block)
}
}
Compared actual vs desired properties
val goalProgress =
if (currentState) 1f
else 0f
val desiredState =
if (currentState) motionLayout.startState
else motionLayout.endState
safeRunBlock {
startTransition(currentState)
}
if (motionLayout.progress != goalProgress) {
if (motionLayout.currentState != desiredState) {
safeRunBlock {
startTransition(currentState)
}
}
}
This would be the full class of the partial solution
class DataBindingViewHolder<T>(private val binding: ViewDataBinding) :
RecyclerView.ViewHolder(binding.root) {
val motionLayout: MotionLayout =
binding.root.findViewById<MotionLayout>(R.id.root_item_recycler_view)
.also {
it.setTransitionDuration(1_000)
it.setDebugMode(DEBUG_SHOW_PROGRESS or DEBUG_SHOW_PATH)
}
var lastPosition: Int = -1
fun bind(item: T, position: Int, layoutState: Boolean) {
if (position != lastPosition)
Log.i(
"OnBind",
"Position=$position lastPosition=$lastPosition - $layoutState "
)
lastPosition = position
setMotionLayoutState(layoutState)
binding.setVariable(BR.item, item)
binding.executePendingBindings()
}
// https://stackoverflow.com/questions/51929153/when-manually-set-progress-to-motionlayout-it-clear-all-constraints
fun safeRunBlock(block: () -> Unit) {
if (ViewCompat.isLaidOut(motionLayout)) {
block()
} else {
motionLayout.post(block)
}
}
fun setMotionLayoutState(currentState: Boolean) {
val goalProgress =
if (currentState) 1f
else 0f
safeRunBlock {
startTransition(currentState)
}
if (motionLayout.progress != goalProgress) {
val desiredState =
if (currentState) motionLayout.startState
else motionLayout.endState
if (motionLayout.currentState != desiredState) {
Log.i("Pprogress", "Desired doesn't match at position $lastPosition")
safeRunBlock {
startTransition(currentState)
}
}
}
}
fun startTransition(currentState: Boolean) {
if (currentState) {
motionLayout.transitionToStart()
} else {
motionLayout.transitionToEnd()
}
}
}
Edit: added constraint layout version