I am attempting to create a custom, vertical SeekBar that will be used to scroll text within a ScrollView. Here is that SeekBar:
class CustomSeekBar : SeekBar {
constructor(context: Context) : super(context) {}
constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(context, attrs, defStyle) {}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(h, w, oldh, oldw)
}
#Synchronized
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(heightMeasureSpec, widthMeasureSpec)
setMeasuredDimension(measuredHeight, measuredWidth)
}
override fun onDraw(c: Canvas) {
c.rotate(90f)
c.translate(0f, -width.toFloat())
super.onDraw(c)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
super.onTouchEvent(event)
if (!isEnabled) {
return false
}
when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_UP -> {
val i = (max - (max * event.y / height)).toInt()
progress = 100 - i
onSizeChanged(width, height, 0, 0)
}
MotionEvent.ACTION_CANCEL -> {
}
}
return true
}
}
Here is my code that attempts to attach the ScrollView to the SeekBar in the onStart of my Fragment:
override fun onStart() {
super.onStart()
val helpScrollView = view!!.findViewById<ScrollView>(R.id.helpScrollView)
val helpSeekBar = view!!.findViewById<CustomSeekBar>(R.id.helpSeekBar)
helpScrollView.viewTreeObserver.addOnGlobalLayoutListener {
val scroll: Int = getScrollRange(helpScrollView)
helpSeekBar.max = scroll
}
val seekBarListener = object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
val min = 0
if(progress < min) {
seekBar?.progress = min;}
helpScrollView.scrollTo(0, progress)
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
}
override fun onStopTrackingTouch(seekBar: SeekBar?) {
}
}
helpSeekBar.setOnSeekBarChangeListener(seekBarListener)
}
private fun getScrollRange(scrollView: ScrollView): Int {
var scrollRange = 0
if (scrollView.childCount > 0) {
val child = scrollView.getChildAt(0)
scrollRange =
Math.max(0, child.height - (scrollView.height - scrollView.paddingBottom - scrollView.paddingTop))
}
return scrollRange
}
The SeekBar on seems to only react to touches on its lower half, and the thumbs shadow still scrolls horizontally off the screen. The ScrollView moves slightly, but only in one direction at the beginning of the scroll. What am I doing wrong?
Check updated answer at the bottom for a solution
Who are we to judge what you need to do, client is almost always right!
Anyway, if you still wanted to do this, (it may or may not work) here's what I think the architecture should try to look like:
The SeekBar is stupid, shouldn't know ANYTHING about what it is doing. You give it a min (0?) and a MAX (n). You subscribe to its listener and receive the "progress" updates. (ha, that's the easy part)
ScrollView containing the text, is also very unintelligent beyond its basics, it can be told to scroll to a position (this will involve some work I guess, but I'm sure it's not difficult).
Calculating the MAX value is going to be what determines how hard step 2 will be. If you use each "step" in your scrolling content as a "Line of text", then this is fine, but you'd need to account for layout changes and re-measures that may change the size of the text. Shouldn't be "crazy" but, keep it in mind or you will receive your first bug report as soon as a user rotates the phone :)
If the text is dynamic (can change real-time) your function for step 3 should be as efficient as possible, you want those numbers "asap".
Responding to progress changes from the seekbar and having your scrollable content scroll is going to be either super easy (you found a way to do it very easily) or complicated if you cannot make the ScrollView behave as you want.
Alternative Approach
If your text is really long, I bet you're going to have better performance if you split your text in lines and use a recyclerview to display it, which would then make scrolling "slightly easier" as you could move your recyclerview to a position (line!).
UPDATE
Ok, so out of curiosity, I tried this. I launched AS, created a new project with an "Empty Activity" and added a ScrollView with a TextView inside, and a SeekBar at the bottom (horizontal).
Here's the Gist with all the relevant bits:
https://gist.github.com/Gryzor/5a68e4d247f4db1e0d1d77c40576af33
At its core the solution works out of the box:
scrollView.scrollTo(0, progress) where progress is returned to you by the framework callback in the seekBar's onProgressChanged().
Now the key is to set the correct "Max" to the seekbar.
This is obtained by dodging a private method in the ScrollView with this:
(credit where is due, I got this idea from here)
private fun getScrollRange(scrollView: ScrollView): Int {
var scrollRange = 0
if (scrollView.childCount > 0) {
val child = scrollView.getChildAt(0)
scrollRange =
Math.max(0, child.height - (scrollView.height - scrollView.paddingBottom - scrollView.paddingTop))
}
return scrollRange
}
This works fine, assuming you wait for the seekBar to measure/layout (hence why I had to do this in the treeObserver).
Related
I have a listView filled with multi-line TextViews. Each TextView has a different amount of text. After pressing a button, the user is taken to another Activity where they can change the font and the font size. Upon reEntry into the Fragment, if these settings have changed, the listView is reset and the measurements of the TextViews are changed.
I need to know the measured height of the first TextView in view after these settings have changed. For some reason, the measured height is different at first after it is measured. Once I manually scroll the list, the real height measurement is recorded.
Log output:
After measured: tv height = 2036
After measured: tv height = 2036
After scroll: tv height = 7950
Minimal Code:
class FragmentRead : Fragment() {
private var firstVisiblePos = 0
lateinit var adapterRead: AdapterRead
lateinit var lvTextList: ListView
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
lvTextList = view.findViewById(R.id.read_listview)
setListView(lvTextList)
lvTextList.setOnScrollListener(object : AbsListView.OnScrollListener {
var offset = 0
override fun onScrollStateChanged(view: AbsListView, scrollState: Int) {
if(scrollState == AbsListView.OnScrollListener.SCROLL_STATE_IDLE) {
offset = if(lvTextList.getChildAt(0) == null) 0 else lvTextList.getChildAt(0).top - lvTextList.paddingTop
println("After scroll: tv height = ${lvTextList[0].height}")
}
}
override fun onScroll(view: AbsListView, firstVisibleItem: Int, visibleItemCount: Int, totalItemCount: Int) {
firstVisiblePos = firstVisibleItem
}
})
}
/*=======================================================================================================*/
fun setListView(lv: ListView) {
adapterRead = AdapterRead(Data.getTextList(), context!!)
lv.apply {this.adapter = adapterRead}
}
/*=======================================================================================================*/
inline fun <T : View> T.afterMeasured(crossinline f: T.() -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
if(measuredWidth > 0 && measuredHeight > 0) {
println("After measured: tv height = ${lvTextList[0].height}")
viewTreeObserver.removeOnGlobalLayoutListener(this)
f()
}
}
})
}
/*=======================================================================================================*/
override fun onStart() {
if(Settings.settingsChanged) {
setListView(lvTextList)
lvTextList.afterMeasured {
println("After measured: tv height = ${lvTextList[0].height}")
}
}
}
}
What I have tried:
I have tried setting a TextView with the text and layoutParams and reading the height as explained here (Getting height of text view before rendering to layout) but the results are the same. The measured height is much less than after I scroll the list.
I have also tried to programatically scroll the list using lvTextList.scrollBy(0,1) in order to trigger the scroll listener or whatever else is triggered when the correct height is read.
EDIT: I put a delay in after coming back to the Fragment:
Handler().postDelayed({
println("tv height after delay = ${lvScriptureList[0].height}")}, 1000)
And this reports the correct height. So my guess is that the OnGlobalLayoutListener is being called to early. Any way to fix this?
Here is my solution. The reason I need to know the height of the TextView is because after the user changes settings (e.g. font, font size, line spacing) the size of the TextView changes. In order to return to the same spot the TextView was in previously, I need to know the height of the newly measured TextView. Then I can go to the same spot (or very close) based on the position previously and recalculating it based on the new height.
So after the settings are changed and the Fragment is loaded back up:
override fun onStart(){
if(Settings.settingsChanged) {
setListView(lvTextList)
lvTextList.afterMeasured {
lvTextList.post { lvTextList.setSelectionFromTop(readPos, 0) }
Handler().postDelayed({
val newOffset = getNewOffset() // Recalculates the new offset based on the last offset and the new TextView height
lvTextList.post { lvTextList.setSelectionFromTop(readPos, newOffset) }
}, 500)
}
}
}
For some reason I had to scroll to a position first before scheduling the delay so I simply just scrolled to the beginning of the TextView.
The 500ms is goofy and is just an estimate but it works. It actually works with a value of 100ms on my phone but I want to ensure a better chance of success across devices.
I know there is way to change animation duration of ViewPager programmatical slide (here).
But its not working on ViewPager2
I tried this:
try {
final Field scrollerField = ViewPager2.class.getDeclaredField("mScroller");
scrollerField.setAccessible(true);
final ResizeViewPagerScroller scroller = new ResizeViewPagerScroller(getContext());
scrollerField.set(mViewPager, scroller);
} catch (Exception e) {
e.printStackTrace();
}
IDE gives me warning on "mScroller":
Cannot resolve field 'mScroller'
If we Run This code thats not going to work and give us Error below:
No field mScroller in class Landroidx/viewpager2/widget/ViewPager2; (declaration of 'androidx.viewpager2.widget.ViewPager2' appears in /data/app/{packagename}-RWJhF9Gydojax8zFyFwFXg==/base.apk)
So how we can acheive this functionality?
Based on this issue ticket Android team is not planning to support such behavior for ViewPager2, advise from the ticket is to use ViewPager2.fakeDragBy(). Disadvantage of this method is that you have to supply page width in pixels, although if your page width is the same as ViewPager's width then you can use that value instead.
Here's sample implementation
fun ViewPager2.setCurrentItem(
item: Int,
duration: Long,
interpolator: TimeInterpolator = AccelerateDecelerateInterpolator(),
pagePxWidth: Int = width // Default value taken from getWidth() from ViewPager2 view
) {
val pxToDrag: Int = pagePxWidth * (item - currentItem)
val animator = ValueAnimator.ofInt(0, pxToDrag)
var previousValue = 0
animator.addUpdateListener { valueAnimator ->
val currentValue = valueAnimator.animatedValue as Int
val currentPxToDrag = (currentValue - previousValue).toFloat()
fakeDragBy(-currentPxToDrag)
previousValue = currentValue
}
animator.addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator?) { beginFakeDrag() }
override fun onAnimationEnd(animation: Animator?) { endFakeDrag() }
override fun onAnimationCancel(animation: Animator?) { /* Ignored */ }
override fun onAnimationRepeat(animation: Animator?) { /* Ignored */ }
})
animator.interpolator = interpolator
animator.duration = duration
animator.start()
}
To support RTL you have to flip the value supplied to ViewPager2.fakeDragBy(), so from above example instead of fakeDragBy(-currentPxToDrag) use fakeDragBy(currentPxToDrag) when using RTL.
Few things to keep in mind when using this, based on official docs:
negative values scroll forward, positive backward (flipped with RTL)
before calling fakeDragBy() use beginFakeDrag() and after you're finished endFakeDrag()
this API can be easily used with onPageScrollStateChanged from ViewPager2.OnPageChangeCallback, where you can distinguish between programmatical drag and user drag thanks to isFakeDragging() method
sample implementation from above doesn't have security checks if the given item is correct. Also consider adding cancellation capabilities for UI's lifecycle, it can be easily achieved with RxJava.
ViewPager2 team made it REALLY hard to change the scrolling speed. If you look at the method setCurrentItemInternal, they instantiate their own private ScrollToPosition(..) object. along with state management code, so this would be the method that you would have to somehow override.
As a solution from here: https://issuetracker.google.com/issues/122656759, they say use (ViewPager2).fakeDragBy() which is super ugly.
Not the best, just have to wait form them to give us an API to set duration or copy their ViewPager2 code and directly modify their LinearLayoutImpl class.
When you want your ViewPager2 to scroll with your speed, and in your direction, and by your number of pages, on some button click, call this function with your parameters. Direction could be leftToRight = true if you want it to be that, or false if you want from right to left, duration is in miliseconds, numberOfPages should be 1, except when you want to go all the way back, when it should be your number of pages for that viewPager:
fun fakeDrag(viewPager: ViewPager2, leftToRight: Boolean, duration: Long, numberOfPages: Int) {
val pxToDrag: Int = viewPager.width
val animator = ValueAnimator.ofInt(0, pxToDrag)
var previousValue = 0
animator.addUpdateListener { valueAnimator ->
val currentValue = valueAnimator.animatedValue as Int
var currentPxToDrag: Float = (currentValue - previousValue).toFloat() * numberOfPages
when {
leftToRight -> {
currentPxToDrag *= -1
}
}
viewPager.fakeDragBy(currentPxToDrag)
previousValue = currentValue
}
animator.addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator?) { viewPager.beginFakeDrag() }
override fun onAnimationEnd(animation: Animator?) { viewPager.endFakeDrag() }
override fun onAnimationCancel(animation: Animator?) { /* Ignored */ }
override fun onAnimationRepeat(animation: Animator?) { /* Ignored */ }
})
animator.interpolator = AccelerateDecelerateInterpolator()
animator.duration = duration
animator.start()
}
I need to optimize my Android app to look good on phones with a notch, like the Essential Phone.
This phones have different status bar heights than the standard 25dp value, so you can't hardcode that value.
The Android P developer preview released yesterday includes support for the notch and some APIs to query its location and bounds, but for my app I only need to be able to get the status bar height from XML, and not use a fixed value.
Unfortunately, I couldn't find any way to get that value from XML.
Is there any way?
Thank you.
I have already found the answer:
The recommended way is to use the WindowInsets API, like the following:
view.setOnApplyWindowInsetsListener { v, insets ->
view.height = insets.systemWindowInsetTop // status bar height
insets
}
Accessing the status_bar_height, as it's a private resource and thus subject to change in future versions of Android, is highly discouraged.
It is not a solution, but it can explain you how it should be don in correct way and how things are going under the hood:
https://www.youtube.com/watch?v=_mGDMVRO3iE
OLD SOLUTION:
Here is small trick:
#*android:dimen/status_bar_height
android studio will show you an error, but it will work like a charm :)
Method provides the height of status bar
public int getStatusBarHeight() {
int result = 0;
int resourceId = getResources().getIdentifier("status_bar_height", "dimen", "android");
if (resourceId > 0) {
result = getResources().getDimensionPixelSize(resourceId);
}
return result;
}
class StatusBarSpacer #JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
View(context, attrs) {
private var statusHeight: Int = 60
init {
if (context is Activity && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
context.window.decorView.setOnApplyWindowInsetsListener { _, insets ->
statusHeight = insets.systemWindowInsetTop
requestLayout()
insets
}
context.window.decorView.requestApplyInsets()
} else statusHeight = resources.getDimensionPixelSize(
resources.getIdentifier("status_bar_height", "dimen", "android")
)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) =
setMeasuredDimension(0, statusHeight)
}
I have found a solution to work with status bar size that should be fit to any device. It is a way to transform the size from code to XML layouts.
The solution is to create a view that is auto-sized with the status bar size. It is like a guide from constraint layout.
I am sure it should be a better method but I didn't find it. And if you consider something to improve this code please let me now:
KOTLIN
class StatusBarSizeView: View {
companion object {
// status bar saved size
var heightSize: Int = 0
}
constructor(context: Context):
super(context) {
this.init()
}
constructor(context: Context, attrs: AttributeSet?):
super(context, attrs) {
this.init()
}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int):
super(context, attrs, defStyleAttr) {
this.init()
}
private fun init() {
// do nothing if we already have the size
if (heightSize != 0) {
return
}
// listen to get the height
(context as? Activity)?.window?.decorView?.setOnApplyWindowInsetsListener { _, windowInsets ->
// get the size
heightSize = windowInsets.systemWindowInsetTop
// return insets
windowInsets
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// if height is not zero height is ok
if (h != 0 || heightSize == 0) {
return
}
// apply the size
postDelayed(Runnable {
applyHeight(heightSize)
}, 0)
}
private fun applyHeight(height: Int) {
// apply the status bar height to the height of the view
val lp = this.layoutParams
lp.height = height
this.layoutParams = lp
}
}
Then you can use it in XML as a guide:
<com.foo.StatusBarSizeView
android:id="#+id/fooBarSizeView"
android:layout_width="match_parent"
android:layout_height="0dp" />
The "heightSize" variable is public to can use it in the code if some view need it.
Maybe it helps to someone else.
Background
Sometimes, all items of the recyclerView are already visible to the user.
In this case, it wouldn't matter to the user to see overscroll effect, because it's impossible to actually scroll and see more items.
The problem
I know that in order to disable overscroll effect on RecyclerView, I can just use:
recyclerView.setOverScrollMode(View.OVER_SCROLL_NEVER);
but I can't find out when to trigger this, when the scrolling isn't possible anyway.
The question
How can I Identify that all items are fully visible and that the user can't really scroll ?
If it helps to assume anything, I always use LinearLayoutManager (vertical and horizontal) for the RecyclerView.
<android.support.v7.widget.RecyclerView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:overScrollMode="never"/>
Just add android:overScrollMode="never" in XML
you could give OVER_SCROLL_IF_CONTENT_SCROLLS a try. Accordingly to the documentation
Allow a user to over-scroll this view only if the content is large
enough to meaningfully scroll, provided it is a view that can scroll.
Or you could check if you have enough items to trigger the scroll and enable/disable the over scroll mode, depending on it. Eg
boolean notAllVisible = layoutManager.findLastCompletelyVisibleItemPosition() < adapter.getItemCount() - 1;
if (notAllVisible) {
recyclerView.setOverScrollMode(allVisible ? View.OVER_SCROLL_NEVER);
}
Since android:overScrollMode="ifContentScrolls" is not working for RecyclerView(see https://issuetracker.google.com/issues/37076456) I found some kind of a workaround which want to share with you:
class MyRecyclerView #JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
super.onLayout(changed, l, t, r, b)
val canScrollVertical = computeVerticalScrollRange() > height
overScrollMode = if (canScrollVertical) OVER_SCROLL_ALWAYS else OVER_SCROLL_NEVER
}
}
You could try something like this:
totalItemCount = linearLayoutManager.getItemCount();
firstVisibleItem = linearLayoutManager.findFirstCompletelyVisibleItemPosition()
lastVisibleItem = linearLayoutManager.findLastCompletelyVisibleItemPosition();
if(firstVisibleItem == 0 && lastVisibleItem -1 == totalItemCount){
// trigger the overscroll effect
}
Which you could add in the onScrolled() of an OnScrollListener that you add on your RecyclerView.
Longer workaround, based on here (to solve this issue), handles more cases, but still a workaround:
/**a temporary workaround to make RecyclerView handle android:overScrollMode="ifContentScrolls" */
class NoOverScrollWhenNotNeededRecyclerView : RecyclerView {
private var enableOverflowModeOverriding: Boolean? = null
private var isOverFlowModeChangingAccordingToSize = false
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
override fun setOverScrollMode(overScrollMode: Int) {
if (!isOverFlowModeChangingAccordingToSize)
enableOverflowModeOverriding = overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS
else isOverFlowModeChangingAccordingToSize = false
super.setOverScrollMode(overScrollMode)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
super.onLayout(changed, l, t, r, b)
if (enableOverflowModeOverriding == null)
enableOverflowModeOverriding = overScrollMode == View.OVER_SCROLL_IF_CONTENT_SCROLLS
if (enableOverflowModeOverriding == true) {
val canScrollVertical = computeVerticalScrollRange() > height
val canScrollHorizontally = computeHorizontalScrollRange() > width
isOverFlowModeChangingAccordingToSize = true
overScrollMode = if (canScrollVertical || canScrollHorizontally) OVER_SCROLL_ALWAYS else OVER_SCROLL_NEVER
}
}
}
Write custom RecyclerView.OnScrollListener() for working with overScrollMode
/**
* Workaround because [View.OVER_SCROLL_IF_CONTENT_SCROLLS] not working properly.
*
* [showHeader]/[showFooter] - for customization, if need show only specific scroll edge.
*/
class RecyclerOverScrollListener(
private val showHeader: Boolean = true,
private val showFooter: Boolean = true
) : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val lm = recyclerView.layoutManager as? LinearLayoutManager ?: return
val isFirstVisible = lm.findFirstCompletelyVisibleItemPosition() == 0
val isLastVisible = lm.findLastCompletelyVisibleItemPosition() == lm.itemCount - 1
val allVisible = isFirstVisible && isLastVisible
recyclerView.overScrollMode = if (allVisible) {
View.OVER_SCROLL_NEVER
} else {
val showHeaderEdge = showHeader && isFirstVisible && !isLastVisible
val showFooterEdge = showFooter && !isFirstVisible && isLastVisible
if (showHeader && showFooter || showHeaderEdge || showFooterEdge) {
View.OVER_SCROLL_ALWAYS
} else {
View.OVER_SCROLL_NEVER
}
}
}
}
How implement for RecyclerView:
recyclerView.addOnScrollListener(RecyclerOverScrollListener(showFooter = false))
Also don't forget about specify edge color inside styles:
<item name="android:colorEdgeEffect">#color/yourRippleColor</item>
I have a RecyclerView managed by a LinearlayoutManager, if I swap item 1 with 0 and then call mAdapter.notifyItemMoved(0,1), the moving animation causes the screen to scroll. How can I prevent it?
Sadly the workaround presented by yigit scrolls the RecyclerView to the top. This is the best workaround I found till now:
// figure out the position of the first visible item
int firstPos = manager.findFirstCompletelyVisibleItemPosition();
int offsetTop = 0;
if(firstPos >= 0) {
View firstView = manager.findViewByPosition(firstPos);
offsetTop = manager.getDecoratedTop(firstView) - manager.getTopDecorationHeight(firstView);
}
// apply changes
adapter.notify...
// reapply the saved position
if(firstPos >= 0) {
manager.scrollToPositionWithOffset(firstPos, offsetTop);
}
Call scrollToPosition(0) after moving items. Unfortunately, i assume, LinearLayoutManager tries to keep first item stable, which moves so it moves the list with it.
Translate #Andreas Wenger's answer to kotlin:
val firstPos = manager.findFirstCompletelyVisibleItemPosition()
var offsetTop = 0
if (firstPos >= 0) {
val firstView = manager.findViewByPosition(firstPos)!!
offsetTop = manager.getDecoratedTop(firstView) - manager.getTopDecorationHeight(firstView)
}
// apply changes
adapter.notify...
if (firstPos >= 0) {
manager.scrollToPositionWithOffset(firstPos, offsetTop)
}
In my case, the view can have a top margin, which also needs to be counted in the offset, otherwise the recyclerview will not scroll to the intended position. To do so, just write:
val topMargin = (firstView.layoutParams as? MarginLayoutParams)?.topMargin ?: 0
offsetTop = manager.getDecoratedTop(firstView) - manager.getTopDecorationHeight(firstView) - topMargin
Even easier if you have ktx dependency in your project:
offsetTop = manager.getDecoratedTop(firstView) - manager.getTopDecorationHeight(firstView) - firstView.marginTop
I've faced the same problem. Nothing of the suggested helped. Each solution fix and breakes different cases.
But this workaround worked for me:
adapter.registerAdapterDataObserver(object: RecyclerView.AdapterDataObserver() {
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
if (fromPosition == 0 || toPosition == 0)
binding.recycler.scrollToPosition(0)
}
})
It helps to prevent scrolling while moving the first item for cases: direct notifyItemMoved and via ItemTouchHelper (drag and drop)
I have faced the same problem. In my case, the scroll happens on the first visible item (not only on the first item in the dataset). And I would like to thanks everybody because their answers help me to solve this problem.
I inspire my solution based on Andreas Wenger' answer and from resoluti0n' answer
And, here is my solution (in Kotlin):
RecyclerViewOnDragFistItemScrollSuppressor.kt
class RecyclerViewOnDragFistItemScrollSuppressor private constructor(
lifecycleOwner: LifecycleOwner,
private val recyclerView: RecyclerView
) : LifecycleObserver {
private val adapterDataObserver = object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
suppressScrollIfNeeded(fromPosition, toPosition)
}
}
init {
lifecycleOwner.lifecycle.addObserver(this)
}
#OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
fun registerAdapterDataObserver() {
recyclerView.adapter?.registerAdapterDataObserver(adapterDataObserver) ?: return
}
#OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun unregisterAdapterDataObserver() {
recyclerView.adapter?.unregisterAdapterDataObserver(adapterDataObserver) ?: return
}
private fun suppressScrollIfNeeded(fromPosition: Int, toPosition: Int) {
(recyclerView.layoutManager as LinearLayoutManager).apply {
var scrollPosition = -1
if (isFirstVisibleItem(fromPosition)) {
scrollPosition = fromPosition
} else if (isFirstVisibleItem(toPosition)) {
scrollPosition = toPosition
}
if (scrollPosition == -1) return
scrollToPositionWithCalculatedOffset(scrollPosition)
}
}
companion object {
fun observe(
lifecycleOwner: LifecycleOwner,
recyclerView: RecyclerView
): RecyclerViewOnDragFistItemScrollSuppressor {
return RecyclerViewOnDragFistItemScrollSuppressor(lifecycleOwner, recyclerView)
}
}
}
private fun LinearLayoutManager.isFirstVisibleItem(position: Int): Boolean {
apply {
return position == findFirstVisibleItemPosition()
}
}
private fun LinearLayoutManager.scrollToPositionWithCalculatedOffset(position: Int) {
apply {
val offset = findViewByPosition(position)?.let {
getDecoratedTop(it) - getTopDecorationHeight(it)
} ?: 0
scrollToPositionWithOffset(position, offset)
}
}
and then, you may use it as (e.g. fragment):
RecyclerViewOnDragFistItemScrollSuppressor.observe(
viewLifecycleOwner,
binding.recyclerView
)
LinearLayoutManager has done this for you in LinearLayoutManager.prepareForDrop.
All you need to provide is the moving (old) View and the target (new) View.
layoutManager.prepareForDrop(oldView, targetView, -1, -1)
// the numbers, x and y don't matter to LinearLayoutManager's implementation of prepareForDrop
It's an "unofficial" API because it states in the source
// This method is only intended to be called (and should only ever be called) by
// ItemTouchHelper.
public void prepareForDrop(#NonNull View view, #NonNull View target, int x, int y) {
...
}
But it still works and does exactly what the other answers say, doing all the offset calculations accounting for layout direction for you.
This is actually the same method that is called by LinearLayoutManager when used by an ItemTouchHelper to account for this dreadful bug.