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()
}
Related
I'm building my first game in Android Studio. Right now, dots fall from the top of the screen down to the bottom. For some reason, in Layout Inspector the view of each dot is the entire screen even though the dots are comparatively small. This negatively affects the game since when a user presses anywhere on the screen, it deletes the most recently created dot rather than the one pressed. I want to get the dot's view to match the size of the actual dots without effecting other functionality.
Dot.kt
class Dot(context: Context, attrs: AttributeSet?, private var dotColor: Int, private var xPos: Int, private var yPos: Int) : View(context, attrs) {
private var isMatching: Boolean = false
private var dotIsPressed: Boolean = false
private var isDestroyed: Boolean = false
private lateinit var mHandler: Handler
private lateinit var runnable: Runnable
init {
this.isPressed = false
this.isDestroyed = false
mHandler = Handler()
runnable = object : Runnable {
override fun run() {
moveDown()
invalidate()
mHandler.postDelayed(this, 20)
}
}
val random = Random()
xPos = random.nextInt(context.resources.displayMetrics.widthPixels)
startFalling()
startDrawing()
}
// other methods
fun getDotColor() = dotColor
fun getXPos() = xPos
fun getYPos() = yPos
fun isMatching() = isMatching
fun setMatching(matching: Boolean) {
this.isMatching = matching
}
fun dotIsPressed() = dotIsPressed
override fun setPressed(pressed: Boolean) {
this.dotIsPressed = pressed
}
fun isDestroyed() = isDestroyed
fun setDestroyed(destroyed: Boolean) {
this.isDestroyed = destroyed
}
fun moveDown() {
// code to move the dot down the screen
yPos += 10
}
fun checkCollision(line: Line) {
// check if dot is colliding with line
// if yes, check if dot is matching or not
// update the dot state accordingly
}
fun startFalling() {
mHandler.post(runnable)
}
fun startDrawing() {
mHandler.postDelayed(object : Runnable {
override fun run() {
invalidate()
mHandler.postDelayed(this, 500)
}
}, 500)
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
if (!isDestroyed) {
val paint = Paint().apply {
color = dotColor
}
canvas?.drawCircle(xPos.toFloat(), yPos.toFloat(), 30f, paint)
}
}
}
MainActivity.kt
class MainActivity : AppCompatActivity() {
private var score = 0
private lateinit var scoreCounter: TextView
private val dots = mutableListOf<Dot>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
createLine(Color.RED, 5000)
scoreCounter = TextView(this)
scoreCounter.text = score.toString()
scoreCounter.setTextColor(Color.WHITE)
val layout = findViewById<ConstraintLayout>(R.id.layout)
layout.setBackgroundColor(Color.BLACK)
val params = ConstraintLayout.LayoutParams(
ConstraintLayout.LayoutParams.WRAP_CONTENT,
ConstraintLayout.LayoutParams.WRAP_CONTENT
)
params.topToTop = ConstraintLayout.LayoutParams.PARENT_ID
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
scoreCounter.layoutParams = params
layout.addView(scoreCounter)
val dotColors = intArrayOf(Color.RED, Color.BLUE, Color.GREEN, Color.YELLOW)
val random = Random()
val handler = Handler()
val runnable = object : Runnable {
override fun run() {
val dotColor = dotColors[random.nextInt(dotColors.size)]
createAndAddDot(0, 0, dotColor)
handler.postDelayed(this, 500)
}
}
handler.post(runnable)
}
fun updateScore(increment: Int) {
score += increment
scoreCounter.text = score.toString()
}
fun createAndAddDot(x: Int, y: Int, color: Int) {
Log.d("Dot", "createAndAddDot called")
val dot = Dot(this, null, color, x, y)
val layout = findViewById<ConstraintLayout>(R.id.layout)
layout.addView(dot)
dots.add(dot)
dot.setOnTouchListener { view, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
val dotToRemove = dots.find { it == view }
dotToRemove?.let {
layout.removeView(it)
dots.remove(it)
updateScore(1)
view.performClick()
}
}
true
}
}
fun createLine(color: Int, interval: Int) {
Log.d("Line", "createLine called")
val line = Line(color, interval)
val lineView = Line.LineView(this, null, line)
val layout = findViewById<ConstraintLayout>(R.id.layout)
if (layout == null) {
throw IllegalStateException("Layout not found")
}
layout.addView(lineView)
val params = ConstraintLayout.LayoutParams(2000, 350)
lineView.layoutParams = params
params.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
params.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
params.bottomMargin = (0.1 * layout.height).toInt()
}
}
activity_main.xml
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="#+id/layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- Your view here -->
<View
android:id="#+id/view"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<!-- Guideline set to 10% from the bottom -->
<androidx.constraintlayout.widget.Guideline
android:id="#+id/bottom_guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.1" />
</androidx.constraintlayout.widget.ConstraintLayout>
I tried changing the view size with
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) val diameter = 40 // or any other desired diameter for the dots setMeasuredDimension(diameter, diameter) }
That made the view size a square stuck in the top left corner. As I played around with it, I could only get dots to show in that small window in the top corner rather than moving down the screen from different starting x-positions
Your custom view isn't a dot, it's a large display area that draws a dot somewhere inside it and animates its position. In onDraw you're drawing a circle at xPos (a random point on the screen width via displayMetrics.widthPixels) and yPos (an increasing value which moves the dot down the view).
There are two typical approaches to things like this:
use simple views like ImageViews. Let the containing Activity or Fragment add them to a container and control their position, maybe using the View Animation system. Handle player interaction by giving them click listeners and let the view system work out what's been clicked.
create a custom view that acts as the game area. Let that custom view control the game state (what dots exist, where they currently are) and draw that state in onDraw. Handle touch events on the view, and work out if those touches coincide with a dot (by comparing to the current game state).
What you're doing is sort of a combination of the two with none of the advantages that either approach gives on its own. You have multiple equally-sized "game field" views stacked on top of each other, so any clicks will be consumed by the top one, because you're clicking the entire view itself. And because your custom view fills the whole area, you can't move it around with basic view properties to control where the dot is - you have to write the logic to draw the view and animate its contents.
You could implement some code that handles the clicks and decides whether the view consumes it (because it intersects a dot) or passes it on to the next view in the stack, but that's a lot of work and you still have all your logic split between the Activity/Fragment and the custom view itself.
I think it would be way easier to just pick one approach - either use ImageViews sized to the dot you want and let the view system handle the interaction, or make a view that runs the game internally. Personally I'd go with the latter (you'll find it a lot easier to handle dots going out of bounds, get better performance, more control over the look and interaction etc, no need to cancel Runnables) but it's up to you!
I have a listener for zooming in and out mapview:
class ZoomMapListener(
mapView: MapView,
private val zoom: Zoom,
) : View.OnClickListener {
private val localMapView = WeakReference(mapView)
private var clickCount = 0
override fun onClick(view: View?) {
clickCount++
}
fun moveCamera() {
val mapView = localMapView.get()
mapView?.let {
var cameraPosition = it.map.cameraPosition
val zoom = if (zoom == IN) {
cameraPosition.zoom + (1.0f * clickCount)
} else {
cameraPosition.zoom - (1.0f * clickCount)
}
cameraPosition = CameraPosition(
cameraPosition.target,
zoom,
cameraPosition.azimuth,
cameraPosition.tilt,
)
clickCount = 0
it.map.move(cameraPosition, Animation(Animation.Type.SMOOTH, 0.5f), null)
}
}
}
enum class Zoom {
IN,
OUT
}
In order if user clicks on button several times I've decided to use debounce operator from another answer(https://stackoverflow.com/a/60234167/13236614), so if there are five clicks, for example, camera makes fivefold increase in one operation.
The extension function:
#FlowPreview
#ExperimentalCoroutinesApi
fun View.setDebouncedListener(
listener: ZoomMapListener,
lifecycleCoroutineScope: LifecycleCoroutineScope,
) {
callbackFlow {
setOnClickListener {
listener.onClick(this#setDebouncedListener)
offer(Unit)
}
awaitClose {
setOnClickListener(null)
}
}
.debounce(500L)
.onEach { listener.moveCamera() }
.launchIn(lifecycleCoroutineScope)
}
And how I use it in my fragment:
zoomInMapButton.setDebouncedListener(ZoomMapListener(mapView, Zoom.IN), lifecycleScope)
I think it all looks kinda bad and I'm doubting because of #FlowPreview annotation, so is there a way to make it right in the custom listener class at least?
Using something with #FlowPreview or #ExperimentalCoroutinesApi is sort of like using a deprecated function, because it's possible it will stop working as expected or be removed in a future version of the library. They are relatively stable, but you'll need to check them each time you update your core Kotlin libraries.
My coroutine-free answer on that other question is more like throttleFirst than debounce, because it doesn't delay the first click.
I think you can directly handle debounce in your ZoomListener class by changing only one line of code! Replace clickCount++ with if (++clickCount == 1) v.postDelayed(::moveCamera, interval).
Disclaimer: I didn't test this.
The strategy here is on the first click to immediately post a delayed call to moveCamera(). If any clicks come in during that delay time, they do not post new delayed calls, because their contribution is accounted for in the clickCount that moveCamera() will use when the delay is over.
I also did some cleanup in moveCamera(), but it's functionally the same. In my opinion, ?.let should not be used for local variables because you can take advantage of smart casting (or early returns) for local variables, so you can keep your code more readable and less nested.
class ZoomMapListener(
mapView: MapView,
private val zoom: Zoom,
private val interval: Long
) : View.OnClickListener {
private val localMapView = WeakReference(mapView)
private var clickCount = 0
override fun onClick(v: View) {
if (++clickCount == 1) v.postDelayed(::moveCamera, interval)
}
fun moveCamera() {
val map = localMapView.get()?.map ?: return
val multiplier = if (zoom == IN) 1f else -1f
val newCameraPosition = CameraPosition.builder(map.cameraPosition)
.zoom(map.cameraPosition.zoom + multiplier * clickCount)
.build()
clickCount = 0
map.move(newCameraPosition, Animation(Animation.Type.SMOOTH, 0.5f), null)
}
}
...so is there a way to make it right in the custom listener class at least?
If I'm not mistaken, you want to make the debounce in the method of onClick, right?
override fun onClick(view: View?) {
// debounce
clickCount++
}
If that, why not use this reference link [it's in the link you provided]
https://stackoverflow.com/a/60193549/11835023
I am currently working on chat application. I need swipe to reply a particular message like WhatsApp in android programmatically. kindly help me to achieve this. thanks in advance.
link I am referring
https://github.com/shainsingh89/SwipeToReply.
Well, here I will list the main challenges with the proposed solutions, you can find the entire project on the github over here:
Challenge 1: New layouts with the quoted text
Adding two new chat message layouts for the sender & receiver send_message_quoted_row & received_message_quoted_row ->> The layout can just be better than that but it's not a big deal for now.
Modifying MessageAdapter to accept them as a new type, and update the quote text in onBindViewHolder:
private fun getItemViewType(message: Message): Int {
return if (message.type == MessageType.SEND)
if (message.quotePos == -1) R.layout.send_message_row
else R.layout.send_message_quoted_row
else
if (message.quotePos == -1) R.layout.received_message_row
else R.layout.received_message_quoted_row
}
override fun onBindViewHolder(holder: MessageViewHolder, position: Int) {
val message = messageList[position]
holder.txtSendMsg.text = message.body
holder.txtQuotedMsg?.text = message.quote
}
class MessageViewHolder(view: View) : RecyclerView.ViewHolder(view) {
var txtSendMsg = view.txtBody!!
var txtQuotedMsg: TextView? = view.textQuote
}
Adding new constructor to the Message data class to accept the quote and the position of the original message (which is quoted in the current message
data class Message(var body: String, var time: Long, var type: Int) {
var quote: String = ""
var quotePos: Int = -1
constructor(
body: String,
time: Long,
type: Int,
quote: String,
quotePos: Int
) : this(body, time, type) {
this.quote = quote
this.quotePos = quotePos
}
}
object MessageType {
const val SEND = 1
const val RECEIVED = 2
}
Adding sample quoted messages for testing in SampleMessages
Challenge 2: Embed the quote layout into the reply layout animated like WhatsApp
In WhatsApp: the quote layout appears as a part of the reply layout, and it gradually comes from bottom to top behind the original layout. Also when the cancel button is pressed, it reverses the animation to the bottom.
Solved by using a custom Animation class by changing the height of the quoted TextView, and then using View.Gone/Visible to show the layout.
class ResizeAnim(var view: View, private val startHeight: Int, private val targetHeight: Int) :
Animation() {
override fun applyTransformation(interpolatedTime: Float, t: Transformation) {
if (startHeight == 0 || targetHeight == 0) {
view.layoutParams.height =
(startHeight + (targetHeight - startHeight) * interpolatedTime).toInt()
} else {
view.layoutParams.height = (startHeight + targetHeight * interpolatedTime).toInt()
}
view.requestLayout()
}
override fun willChangeBounds(): Boolean {
return true
}
}
And handle this animation within the activity showQuotedMessage() & hideReplyLayout()
private fun hideReplyLayout() {
val resizeAnim = ResizeAnim(reply_layout, mainActivityViewModel.currentMessageHeight, 0)
resizeAnim.duration = ANIMATION_DURATION
Handler().postDelayed({
reply_layout.layout(0, -reply_layout.height, reply_layout.width, 0)
reply_layout.requestLayout()
reply_layout.forceLayout()
reply_layout.visibility = View.GONE
}, ANIMATION_DURATION - 50)
reply_layout.startAnimation(resizeAnim)
mainActivityViewModel.currentMessageHeight = 0
resizeAnim.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation?) {
}
override fun onAnimationEnd(animation: Animation?) {
val params = reply_layout.layoutParams
params.height = 0
reply_layout.layoutParams = params
}
override fun onAnimationRepeat(animation: Animation?) {
}
})
}
private fun showQuotedMessage(message: Message) {
edit_message.requestFocus()
val inputMethodManager =
getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.showSoftInput(edit_message, InputMethodManager.SHOW_IMPLICIT)
textQuotedMessage.text = message.body
val height = textQuotedMessage.getActualHeight()
val startHeight = mainActivityViewModel.currentMessageHeight
if (height != startHeight) {
if (reply_layout.visibility == View.GONE)
Handler().postDelayed({
reply_layout.visibility = View.VISIBLE
}, 50)
val targetHeight = height - startHeight
val resizeAnim =
ResizeAnim(
reply_layout,
startHeight,
targetHeight
)
resizeAnim.duration = ANIMATION_DURATION
reply_layout.startAnimation(resizeAnim)
mainActivityViewModel.currentMessageHeight = height
}
}
private fun TextView.getActualHeight(): Int {
textQuotedMessage.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED)
return this.measuredHeight
}
Challenge 3: Calculating the real value of the new quoted text height
Especially when the quoted text height need to be expanded/shrink when the user swipes another message of a different height while there is currently a quoted message.
Handled by using getHeight() function to programmatically inflate the quoted TextView and set its text to the new text, compare its height to the height of the old text, and manipulate the animation accordingly.
This already covered in the top methods, and I tracked the old height in the ViewModel using currentMessageHeight integer.
Challenge 4: Adding OnClickListener to the quoted message
So to go to the original position of the quoted message, we registered this in the Message class as a field, when it's -1, then it's not a quoted message; otherwise it is a quoted message.
The click listener is handled with a custom QuoteClickListener interface in the MessageAdapter
Preview:
Adding a new Message:
You should read this for swipe to reply to chat messages hope it'll help you
https://medium.com/mindorks/swipe-to-reply-android-recycler-view-ui-c11365f8999f
I want to dynamically enable and disable scrolling programmatically in a LazyColumn.
There don't seem to be any relevant functions on LazyListState or relevant parameters on LazyColumn itself. How can I achieve this in Compose?
There's not (currently) a built-in way to do this, which is a reasonable feature request.
However, the scroll API is flexible enough that we can add it ourselves. Basically, we create a never-ending fake scroll at MutatePriority.PreventUserInput to prevent scrolling, and then use a do-nothing scroll at the same priority to cancel the first "scroll" and re-enable scrolling.
Here are two utility functions on LazyListState to disable/re-enable scrolling, and a demo of them both in action (some imports will be required, but Android Studio should suggest them for you).
Note that because we're taking control of scrolling to do this, calling reenableScrolling will also cancel any ongoing scrolls or flings (that is, you should only call it when scrolling is disabled and you want to re-enable it, not just to confirm that it's enabled).
fun LazyListState.disableScrolling(scope: CoroutineScope) {
scope.launch {
scroll(scrollPriority = MutatePriority.PreventUserInput) {
// Await indefinitely, blocking scrolls
awaitCancellation()
}
}
}
fun LazyListState.reenableScrolling(scope: CoroutineScope) {
scope.launch {
scroll(scrollPriority = MutatePriority.PreventUserInput) {
// Do nothing, just cancel the previous indefinite "scroll"
}
}
}
#Composable
fun StopScrollDemo() {
val scope = rememberCoroutineScope()
val state = rememberLazyListState()
Column {
Row {
Button(onClick = { state.disableScrolling(scope) }) { Text("Disable") }
Button(onClick = { state.reenableScrolling(scope) }) { Text("Re-enable") }
}
LazyColumn(Modifier.fillMaxWidth(), state = state) {
items((1..100).toList()) {
Text("$it", fontSize = 24.sp)
}
}
}
}
Since 1.2.0-alpha01 userScrollEnabled was added to LazyColumn, LazyRow, and LazyVerticalGrid
Answer for 1.1.0 and earlier versions:
#Ryan's solution will also disable programmatically-called scrolling.
Here's a solution proposed by a maintainer in this feature request. It'll disable scrolling, allow programmatic scrolling as well as children view touches.
private val VerticalScrollConsumer = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource) = available.copy(x = 0f)
override suspend fun onPreFling(available: Velocity) = available.copy(x = 0f)
}
private val HorizontalScrollConsumer = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource) = available.copy(y = 0f)
override suspend fun onPreFling(available: Velocity) = available.copy(y = 0f)
}
fun Modifier.disabledVerticalPointerInputScroll(disabled: Boolean = true) =
if (disabled) this.nestedScroll(VerticalScrollConsumer) else this
fun Modifier.disabledHorizontalPointerInputScroll(disabled: Boolean = true) =
if (disabled) this.nestedScroll(HorizontalScrollConsumer) else this
Usage:
LazyColumn(
modifier = Modifier.disabledVerticalPointerInputScroll()
) {
// ...
}
NestedScrollConnection allows you to consume any scroll applied to a lazy column or row. When true, all of the available scroll is consumed. If false, none is consumed and scrolling happens normally. With this information, you can see how this can be extended for slow/fast scrolls by returning the offset multiple by some factor.
fun Modifier.scrollEnabled(
enabled: Boolean,
) = nestedScroll(
connection = object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset = if(enabled) Offset.Zero else available
}
)
it can be used like this:
LazyColumn(
modifier = Modifier.scrollEnabled(
enabled = enabled, //provide a mutable state boolean here
)
){
...
However, this does block programmatic scrolls.
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.