I am looking for a way to programmatically slowly scroll a RecyclerView so as to bring a certain element targetPosition exactly in the middle of the screen. Note that all my items in the RecylerView have the same height by design. The RecyclerView is vertical. I am programming in Kotlin and AndroidX
I have tried:
smoothScrollToPosition(targetPosition)
It does the scrolling slowly (I can also control the speed by extending the LinearLayoutManager and overriding calculateSpeedPerPixel() - see How can i control the scrolling speed of recyclerView.smoothScrollToPosition(position)), however I can't control the exact location of the list item on the screen after the scrolling. All I know is it will be fully visible on the screen.
I have tried:
scrollToX(0, targetPosition * itemHeight - screenHeight / 2)
It gives me the error message: "W/RecyclerView: RecyclerView does not support scrolling to an absolute position. Use scrollToPosition instead"
I have also tried replacing the RecyclerView by a LinearLayoutManager which contains all the items as children, and translate the LinearLayoutManager in the view, but I don't get the children initially outside of the screen to draw.
Is there a solution to this issue?
You can calculate the smoothScrollToPosition's targetPosition based on your actual target position and the number of visible items.
A quick POC on how to do that:
val scrollToPosition = 50
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val firstPosition = layoutManager.findFirstVisibleItemPosition()
val lastPosition = layoutManager.findLastVisibleItemPosition()
val visibleItems = lastPosition - firstPosition + 1
if (firstPosition < scrollToPosition) {
recyclerView.smoothScrollToPosition(scrollToPosition + (visibleItems / 2))
} else {
recyclerView.smoothScrollToPosition(scrollToPosition - (visibleItems / 2))
}
If you want more precise results that the item should be exactly at the middle of the screen, you can use the heights of the item (since its fixed) and the height of the RecyclerView, then calculate the offset to scroll. And call:
recyclerView.scrollBy(dx, dy)
Or:
recyclerView.smoothScrollBy(dx, dy)
Thank you #Bob. I missed scrollBy(). Following your advice, here is the code that worked for me.
class RecyclerViewFixedItemSize : RecyclerView {
var itemFixedSize : Int = 0
constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) {
}
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
}
constructor(context: Context) : super(context) {
}
fun smoothScrollToPositionCentered(position: Int) {
// Find the fixed item size - once for all
if (itemFixedSize == 0) {
val ll = layoutManager as LinearLayoutManager
val fp = ll.findFirstCompletelyVisibleItemPosition()
val fv = ll.getChildAt(fp)
itemFixedSize = fv!!.height
}
// find the recycler view position and screen coordinates of the first item (partially) visible on screen
val ll = layoutManager as LinearLayoutManagerFixedItemSize
val fp = ll.findFirstVisibleItemPosition()
val fv = ll.getChildAt(0)
// find the middle of the recycler view
val dyrv = (top - bottom ) / 2
// compute the number of pixels to scroll to get the view position in the center
val dy = (position - fp) * itemFixedSize + dyrv + fv!!.top + itemFixedSize / 2
smoothScrollBy(0, dy)
}
}
Related
I want to display items in a horizontal list using RecyclerView. At a time, only 3 items will be displayed. 1 in the middle and the other 2 on the side, below is an image of what I'm trying to achieve:
I'm using LinearSnapHelper which centers an item all of the time. When an item is moved away from the center I would like the opacity to progessively change from 1f to 0.5f.
Here is the below code which I've written to help:
class CustomRecyclerView(context: Context, attrs: AttributeSet) : RecyclerView(context, attrs) {
private var itemBoundsRect: Rect? = null
init {
itemBoundsRect = Rect()
addOnScrollListener(object : OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
calculateVisibility()
}
})
}
private fun calculateVisibility() {
val linearLayoutManger: LinearLayoutManager = layoutManager as LinearLayoutManager
val firstVisibleItem = linearLayoutManger.findFirstVisibleItemPosition()
val lastVisibleItem = linearLayoutManger.findLastVisibleItemPosition()
var indexes: MutableList<Int> = mutableListOf()
for (i in firstVisibleItem..lastVisibleItem) {
indexes.add(i)
val item: View = layoutManager?.findViewByPosition(i) ?: continue
item.getGlobalVisibleRect(itemBoundsRect)
var itemSize = layoutManager!!.findViewByPosition(i)!!.width
var visibleSize = 0
if (indexes.size == 1) {
visibleSize = itemBoundsRect!!.right
} else {
visibleSize = itemBoundsRect!!.right - itemBoundsRect!!.left
}
var visibilty = visibleSize * 100 / itemSize
if (visibilty > 0) {
visibilty = 100 - visibilty
}
val viewHolder = findViewHolderForLayoutPosition(i)
viewHolder!!.itemView.alpha = (100 - visibilty).toFloat() / 100f
}
}
}
It doesn't work as expected as the opacity changes at the wrong time. The image below demonstrates this better. I expect the opacity to progressively begin to change when the item edges come out of the red box. However, it only starts when the item reaches the yellow edges.
Is there a way to achieve this effect?
Thank you :)
Your code for calculateVisibility() is looking at global position when looking at the relative position within the RecyclerView is sufficient. Maybe there is more to the code than you posted, but try the following. This code looks at the x position of each visible view and calculates the alpha value as a function of displacement from the center of the RecyclerView. Comments are in the code.
private fun calculateVisibility(recycler: RecyclerView) {
val midRecycler = recycler.width / 2
val linearLayoutManger: LinearLayoutManager = recycler.layoutManager as LinearLayoutManager
val firstVisibleItem = linearLayoutManger.findFirstVisibleItemPosition()
val lastVisibleItem = linearLayoutManger.findLastVisibleItemPosition()
for (i in firstVisibleItem..lastVisibleItem) {
val viewHolder = recycler.findViewHolderForLayoutPosition(i)
viewHolder?.itemView?.apply {
// This is the end of the view in the parent's coordinates
val viewEnd = x + width
// This is the maximum pixels the view can slide left or right until it disappears.
val maxSlide = (midRecycler + width / 2).toFloat()
// Alpha is determined by the percentage of the maximum slide the view has moved.
// This assumes a linear fade but can be adjusted to fade in alternate ways.
alpha = 1f - abs(maxSlide - viewEnd) / maxSlide
Log.d("Applog", String.format("pos=%d alpha=%f", i, alpha))
}
}
}
The foregoing assumes that sizes remain constant.
if you need the center View, you can call
View view = snapHelper.findSnapView(layoutManagaer);
once you have the View, you should be able to get the position on the dataset for that View. For instance using
int pos = adapter.getChildAdapterPosition(view)
And then you can update the center View opacity and invoke
adapter.notifyItemChanged(pos);
I have a custom view that extends LinearLayout and implements onMeasure. I'd like the children to be either as wide as they need to be or filling the available space.
XML files:
Parent:
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.example.myapplication.AtMostLinearLayout
android:id="#+id/at_most_linear_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</FrameLayout>
Button example:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:src="#android:drawable/ic_delete" />
</FrameLayout>
Views are added programmatically, for example:
findViewById<AtMostLinearLayout>(R.id.at_most_linear_layout).apply {
repeat(4) {
LayoutInflater.from(context).inflate(R.layout.button, this)
}
}
Finally the Custom view class:
class AtMostLinearLayout #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : LinearLayout(context, attrs, defStyle) {
private val maxTotalWidth = context.resources.getDimensionPixelOffset(R.dimen.max_buttons_width)
init {
orientation = HORIZONTAL
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
if (childCount < 1) return
val newWidth = min(measuredWidth, maxTotalWidth)
var availableWidth = newWidth
var numberOfLargeChildren = 0
repeat(childCount) {
getChildAt(it).let { child ->
if (child.measuredWidth > availableWidth / childCount) {
availableWidth -= child.measuredWidth
numberOfLargeChildren++
}
}
}
val minChildWidth = availableWidth / max(childCount - numberOfLargeChildren, 1)
repeat(childCount) {
getChildAt(it).apply {
measure(
MeasureSpec.makeMeasureSpec(max(measuredWidth, minChildWidth), EXACTLY),
UNSPECIFIED
)
}
}
setMeasuredDimension(
makeMeasureSpec(newWidth, EXACTLY), makeMeasureSpec(measuredHeight, EXACTLY))
}
}
It works fine in LTR:
In RTL however the views are off set for some reason and are drawn outside the ViewGroup:
Where could this offset coming from? It looks like the children's measure calls are being added to the part, or at least half of it...
You could use the Layout Inspector (or "show layout boundaries" on device), in order to determine why it behaves as it does. The calculation of the horizontal offset may have to be flipped; by substracting instead of adding ...in order to account for the change in layout direction, where the absolute offset in pixels may always be understood as LTR.
If the canvas is rtl in the onDraw method, have you tried inverting it?
You could try using View.getLayoutDirection(). Return the layout direction.
onDraw method override and
val isRtl = layoutDirection == View.LAYOUT_DIRECTION_RTL
if(isRtrl){
canvas.scale(-1f, 1f, width / 2, height / 2)
}
After reading through the LinearLayout & View measure and layout code some more I figured out why this it's happening.
Whenever LinearLayout#measure is called mTotalLength is calculated, which represents the calculated width of the entire view. As I'm manually remeasuring the children with a different MeasureSpec LinearLayout cannot cache these values. Later in the layout pass the views use the cached mTotalLength to set the child's left i.e. the offset. The left is based on the gravity of the child and thus being affected by the cached value.
See: LinearLayout#onlayout
final int layoutDirection = getLayoutDirection();
switch (Gravity.getAbsoluteGravity(majorGravity, layoutDirection)) {
case Gravity.RIGHT:
// mTotalLength contains the padding already
childLeft = mPaddingLeft + right - left - mTotalLength;
break;
case Gravity.CENTER_HORIZONTAL:
// mTotalLength contains the padding already
childLeft = mPaddingLeft + (right - left - mTotalLength) / 2;
break;
case Gravity.LEFT:
default:
childLeft = mPaddingLeft;
break;
}
I've change the impl to ensure it always sets the gravity to Gravity.LEFT. I should probably manually implement onLayout instead!
class AtMostLinearLayout #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : LinearLayout(context, attrs, defStyle) {
private val maxTotalWidth = context.resources.getDimensionPixelOffset(R.dimen.max_buttons_width)
init {
orientation = HORIZONTAL
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
if (childCount < 1) return
val newWidth = min(measuredWidth, maxTotalWidth)
var availableWidth = newWidth
var numberOfLargeChildren = 0
repeat(childCount) {
getChildAt(it).let { child ->
if (child.measuredWidth > availableWidth / childCount) {
availableWidth -= child.measuredWidth
numberOfLargeChildren++
}
}
}
val minChildWidth = availableWidth / max(childCount - numberOfLargeChildren, 1)
repeat(childCount) {
getChildAt(it).let { child ->
child.measure(
makeMeasureSpec(max(child.measuredWidth, minChildWidth), EXACTLY),
UNSPECIFIED
)
}
}
// Effectively always set it to Gravity.LEFT to prevent LinearLayout using its
// internally-cached mTotalLength to set the Child's left.
gravity = if (layoutDirection == View.LAYOUT_DIRECTION_RTL) Gravity.END else Gravity.START
setMeasuredDimension(
makeMeasureSpec(newWidth, EXACTLY), makeMeasureSpec(measuredHeight, EXACTLY))
}
}
I don't understand why you need a custom ViewGroup for this work. How about set layout_weight when you add child view to LinearLayout.
Just simple by:
val layout = findViewById<LinearLayout>(R.id.linear_layout)
repeat(4) {
val view = LayoutInflater.from(this).inflate(R.layout.button, layout, false)
view.apply {
updateLayoutParams<LinearLayout.LayoutParams> {
width = 0
weight = 1f
}
}
layout.addView(view)
}
I am adding a space below the last item in the RecyclerView using this popular and efficient solution:
class ListMarginDecorator(
private val left: Int = 0,
private val top: Int = 0,
private val right: Int = 0,
private val bottom: Int = 0,
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
super.getItemOffsets(outRect, view, parent, state)
val position = parent.getChildAdapterPosition(view)
outRect.left = left
outRect.top = if (position == 0) top else 0
outRect.right = right
outRect.bottom = if (position == state.itemCount - 1) bottom else 0
}
}
I also add item dividers using DividerItemDecoration, and enable drag-to-reorder by implementing ItemTouchHelper.
Here is how I use these ItemDecorators in the fragment class:
binding.recyclerView.addItemDecoration(
DividerItemDecoration(
binding.rvCurrencies.context,
DividerItemDecoration.VERTICAL
)
)
binding.recyclerView.addItemDecoration(
ListMarginDecorator(
bottom = resources.getDimensionPixelSize(R.dimen.list_last_item_bottom_margin) // = 88dp
)
)
I see two issues with this approach to space under the last item though.
The first is that ListMarginDecorator seems to be applying padding, not margin, so the bottom divider line for the last item in the list is drawn below the spacing that is applied to that last item.
The second issue is that I can no longer drag an item in the list to the bottom-most position.
When I comment out the line adding the ListMarginDecorator though, both of these work as expected:
Is there any other way to efficiently add a space under the last item, without running into these issues?
If you want space after the last item, why don't just use
rvCurrencies.setPadding(0, 0, 0, 100)
rvCurrencies.clipToPadding = false
or in XML
android:paddingBottom="100dp"
android:clipToPadding="false"
in this way, your all two problems will be sorted out.
When scrolling in a RecyclerView, I want to achieve something like this:
Where the center item is scaled bigger than the other items. I've found a way to scale all the items, or what is called a Carousel Effect. But that is not what I want. I want to scale only the center item, and keep the other items their default size.
this is what I've found on other answers:
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
val snapHelper = LinearSnapHelper()
snapHelper.attachToRecyclerView(recyclerView)
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val layoutManager = recyclerView.layoutManager!!
val midpoint = layoutManager.width / 2f
val distance = shrinkDistance * midpoint
for (i in 0 until layoutManager.childCount) {
val child = layoutManager.getChildAt(i)!!
val childMidpoint = (layoutManager.getDecoratedRight(child) + layoutManager.getDecoratedLeft(child)) / 2f
val d = Math.min(distance, Math.abs(midpoint - childMidpoint))
val scale = 1 + -shrinkAmount * d / distance
child.scaleX = scale
child.scaleY = scale
}
}
})
}
As I said, this scales all the visible items, where I only want to zoom the center item.
You could give the middle child different scaling.
Calculate the middle child from childCount and use a different scaling for it than the others.
I'm using data binding to setup a RecyclerView. Here is the binding adapter:
fun setRecyclerDevices(recyclerView: RecyclerView, items: List<Device>, itemBinder: MultipleTypeItemBinder,
listener: BindableListAdapter.OnClickListener<Device>?) {
var adapter = recyclerView.adapter as? DevicesBindableAdapter
if (adapter == null) {
val spannedGridLayoutManager = SpannedGridLayoutManager(orientation = SpannedGridLayoutManager.Orientation.VERTICAL,
spans = getSpanSizeFromScreenWidth(recyclerView.context, recyclerView))
recyclerView.layoutManager = spannedGridLayoutManager
recyclerView.addItemDecoration(SpaceItemDecorator(left = 15, top = 15, right = 15, bottom = 15))
adapter = DevicesBindableAdapter(items, itemBinder)
adapter.setOnClickListener(listener)
recyclerView.adapter = adapter
} else {
adapter.setOnClickListener(listener)
adapter.setItemBinder(itemBinder)
adapter.setItems(items)
}
}
getSpanSizeFromScreenWidth needs the recycler's width to do some calculation. But it always returns 0.
I tried to apply a ViewTreeObserver like this:
recyclerView.viewTreeObserver.addOnGlobalLayoutListener(object: ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
recyclerView.viewTreeObserver.removeOnGlobalLayoutListener(this)
val spannedGridLayoutManager = SpannedGridLayoutManager(orientation = SpannedGridLayoutManager.Orientation.VERTICAL,
spans = getSpanSizeFromScreenWidth(recyclerView.context, recyclerView))
recyclerView.layoutManager = spannedGridLayoutManager
}
})
Or use post like this:
recyclerView.post({
val spannedGridLayoutManager = SpannedGridLayoutManager(orientation = SpannedGridLayoutManager.Orientation.VERTICAL,
spans = getSpanSizeFromScreenWidth(recyclerView.context, recyclerView))
recyclerView.layoutManager = spannedGridLayoutManager
})
Code of getSpanSizeFormScreenWidth:
private fun getSpanSizeFromScreenWidth(context: Context, recyclerView: RecyclerView): Int {
val availableWidth = recyclerView.width.toFloat()
val px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 300f, context.resources.displayMetrics)
val margin = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, 15f, context.resources.displayMetrics)
return Math.max(1, Math.floor((availableWidth / (px + margin)).toDouble()).toInt()) * DevicesBindableAdapter.WIDTH_UNIT_VALUE
}
But it still returns 0 despite my RecyclerView being displayed on the screen (not 0).
Any ideas?
In inspecting the code, it appears that your RecyclerView may actually be fine, but your logic in getSpanSizeFromScreenWidth may not be.
It looks like this: Math.floor((availableWidth / (px + margin)).toDouble()).toInt() will always be 0 when availableWidth is less than (px + margin). This will then cause getSpanSizeFromScreenWidth to return 0.
Breaking it down:
Math.floor - rounds a double down to a whole number
availableWidth / (px + margin) - will be a low number (a fraction of availableWidth)
Therefore, you're going to get 0 at times especially on smaller screens and/or smaller density screens.
Does that make sense? May not be this issue, but I'd start there. It's hard to tell you exactly the issue without knowing the whole context, but that's likely your issue.
If that is not your issue, could you say what your value is for availableWidth, px, and margin during execution?