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
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 don't know if there is an exact solution to my problem on SO, but I have been stuck in this for a long time and need to unblock myself.
I have a string "0000 111 222", and I need to set a link to it. My code to set it is like this.
spanManager.SpannableBuilder(text, link)
.setColorSpan(ContextCompat.getColor(containerView.context, R.color.blue))
.setFontSpan(Typeface.BOLD)
.setClickableSpan(object : ClickableSpan() {
override fun onClick(textView: View) {
onClicked.value = action
}
override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
ds.isUnderlineText = false
}
})
.build()
where link and text both are "0000 111 222".
Now the
setClickableSpan(object: ClickableSpan)
is defined as
fun setClickableSpan(clickableSpan: ClickableSpan): SpannableBuilder {
for (span in spans) {
spannable.setSpan(clickableSpan, span.start, span.end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
return this
}
The variable set span is set by SpannableBuilder(text, link) constructor which is like this
fun getSpans(fullText: String, subText: String): Set<Span> {
val spans = HashSet<Span>()
if (fullText.isEmpty() || subText.isEmpty()) {
return spans
}
val ft = fullText.toLowerCase()
val st = subText.trim().toLowerCase()
val fullSplit = ft.split(" ")
val subSplit = st.split(" ")
var startPosition = 0
for (full in fullSplit) {
for (sub in subSplit) {
val subTrim = sub.trim()
if (!full.contains(subTrim)) {
continue
}
val index = startPosition + full.indexOf(subTrim)
val span = Span.create(index, index + subTrim.length)
spans.add(span)
}
startPosition += full.length + 1
}
return spans
}
It basically splices the link variable across space delimiters. So the function
setClickableSpan(object: ClickableSpan)
has size of spans as 3 for "0000 111 222". setSpan is a function defined SpannableStringInternal library.
Now the problem is that the link is being set only for "0000" and not the whole string. Whereas color and font span is able to get set for the whole string. I can not find the cause for this behaviour. If anybody can point out what is wrong here and what could be the probably fix, I'd be grateful.
I'm building a calculator app and in it there's a ScrollView, used to show and switch the buttons for operators and units whenever the user switches between modes.
The problem is that I didn't want to create a XML layout for each mode, so I thought of adding those buttons programmatically (which now, for me, seems pretty hard to accomplish). Here's the code that's supposed to add them:
// For each text in texts (which represents the modes), a Button is created and, if its id (represented by the index of its respective text in the list) is greater than 0, it is positioned below the previous one.
fun ScrollView.add(context: Context, input: EditText, texts: List<String>) {
removeAllViews()
val container = ConstraintLayout(context).apply {
layoutParams = ConstraintLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
topToTop = this#add.top
startToStart = this#add.left
endToEnd = this#add.right
}
}
val buttons: MutableList<Button> = mutableListOf()
texts.forEach { text ->
val button = Button(context)
val originalWidth = 60
val width = originalWidth.toDimension(context, COMPLEX_UNIT_DIP)
val originalHeight = 60
val height = originalHeight.toDimension(context, COMPLEX_UNIT_DIP)
with(button) {
layoutParams = ConstraintLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
id = texts.indexOf(text)
val previous = try { buttons[id - 1] } catch (exception: Exception) { this }
setWidth(width)
setHeight(height)
with(layoutParams as ConstraintLayout.LayoutParams) {
if (id == 0)
topToTop = this#add.top
else if (id > 0) {
layoutParams = RelativeLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
(layoutParams as RelativeLayout.LayoutParams).addRule(BELOW, previous.id)
}
}
left = this#add.left
right = this#add.right
button.text = text
isAllCaps = false
textSize = 25f
while (text().contains(System.getProperty("line.separator").toString())) textSize -= 5
setOnClickListener { input.input((it as Button).text()) }
setBackgroundResource(0)
container.addView(this)
buttons.add(this)
val buttonAdded = "[Dynamic List] Button $id added as \"$text\""
println(if (id == 0) "$buttonAdded." else "$buttonAdded. The button was positioned below \"${previous.text}\" (${previous.id}).")
}
}
addView(container)
}
And here's the code I implemented using the method above:
// Each calculator mode is represented by an Int within the list.
val modes = listOf("calculator" to 1, "length" to 2, "temperature" to 3, "time" to 4)
fun mode(context: Context, button: Button, input: EditText, view: ScrollView) {
var counter = 0
val operators = listOf("+", "-", times.toString(), division.toString())
val length = with(context) { listOf(getString(R.string.light_year), context.getString(R.string.kilometer), context.getString(R.string.hectometer), context.getString(R.string.decameter), context.getString(R.string.mile), context.getString(R.string.meter), context.getString(R.string.centimeter), context.getString(R.string.millimeter), context.getString(R.string.micrometer)) }
val temperature = with(context) { listOf(getString(R.string.celsius), context.getString(R.string.fahrenheit), context.getString(R.string.kevin), context.getString(R.string.rankine), context.getString(R.string.reaumur)) }
val time = with(context) { listOf(getString(R.string.year), context.getString(R.string.month), context.getString(R.string.day), context.getString(R.string.hour), context.getString(R.string.minute), context.getString(R.string.second), context.getString(R.string.millisecond)) }
with(button) {
setOnClickListener {
if (counter < modes.size - 1) counter++ else counter = 0
with(view) {
val mode: Pair<String, Int>? = modes[counter]
when (mode?.first) {
"calculator" -> add(context, input, operators)
"length" -> add(context, input, length)
"temperature" -> add(context, input, temperature)
"time" -> add(context, input, time)
}
text = with(context) {
with(resources) { getString(identify(context, mode?.first, "string")) }
}
}
}
}
}
Well, the problem is when I run it, the UI ends up looking like this, with all the buttons positioned at the same place:
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 am writing test cases for recyclerview. I am stuck with drag and drop test in recyclerview. I tried in some way. But not able to write drag and drop test for recyclerview.
In recyclerview I am having drag button. Using of that button I am drag and drop the item on position to other position.
My code to swipe bottom to top
onView(withId(R.id.recyclerView)).perform(
RecyclerViewActions.actionOnItemAtPosition(0, new GeneralSwipeAction(
Swipe.SLOW, GeneralLocation.BOTTOM_CENTER, GeneralLocation.TOP_CENTER,
Press.FINGER)));
This code is swipe the recyclerview.
So I am trying like this:
onView(withId(R.id.recyclerView)).perform(
RecyclerViewActions.actionOnItemAtPosition(0,
MyViewAction.clickChildViewWithId(R.id.img_drag)),new GeneralSwipeAction(
Swipe.SLOW, GeneralLocation.TOP_RIGHT, GeneralLocation.CENTER_RIGHT,
Press.FINGER));
But this is not workout to me. Please let me any idea to test the drag and drop functionality.
The code you wrote first clicks the drag handle and then swipes from top right of the first item to its center right. If you want to drag and drop an item, you will need several custom CoordinatesProviders.
RecyclerViewCoordinatesProvider will find coordinates of an item within your RecyclerView:
class RecyclerViewCoordinatesProvider(private val position: Int, private val childItemCoordinatesProvider: CoordinatesProvider) : CoordinatesProvider {
override fun calculateCoordinates(view: View): FloatArray {
return childItemCoordinatesProvider.calculateCoordinates((view as RecyclerView).layoutManager!!.findViewByPosition(position)!!)
}
}
ChildViewCoordinatesProvider will find coordinates of a child with a given id:
class ChildViewCoordinatesProvider(private val childViewId: Int, private val insideChildViewCoordinatesProvider: CoordinatesProvider) : CoordinatesProvider {
override fun calculateCoordinates(view: View): FloatArray {
return insideChildViewCoordinatesProvider.calculateCoordinates(view.findViewById<View>(childViewId))
}
}
Lastly we will create new custom general locations, which will provide coordinates above and under a View (I assume we are reordering inside a LinearLayoutManager):
enum class CustomGeneralLocation : CoordinatesProvider {
UNDER_RIGHT {
override fun calculateCoordinates(view: View): FloatArray {
val screenLocation = IntArray(2)
view.getLocationOnScreen(screenLocation)
return floatArrayOf(screenLocation[0] + view.width - 1f, screenLocation[1] + view.height * 1.5f)
}
},
ABOVE_RIGHT {
override fun calculateCoordinates(view: View): FloatArray {
val screenLocation = IntArray(2)
view.getLocationOnScreen(screenLocation)
return floatArrayOf(screenLocation[0] + view.width - 1f, screenLocation[1] - view.height * 0.5f)
}
}
}
Now if you want to drag an item from the original position to the target position where RecyclerView's ID is server_list and the drag handle's ID is drag_handle, use the following method:
private fun reorder(from: Int, to: Int) {
onView(withId(R.id.server_list)).perform(GeneralSwipeAction(
Swipe.SLOW,
RecyclerViewCoordinatesProvider(from, ChildViewCoordinatesProvider(R.id.drag_handle, GeneralLocation.CENTER)),
RecyclerViewCoordinatesProvider(to, if (from < to) CustomGeneralLocation.UNDER_RIGHT else CustomGeneralLocation.ABOVE_RIGHT),
Press.FINGER))
}
I have a working example, although I do not know if it works with scrolling, and it throws an Exception if it fails.
The idea is to create a custom ViewAction that acts on a RecyclerView. MotionEvents are created and injected into the uicontroller. GeneralLocation, despite its name, is used to get accurate coordinates for the view holders.
I tried to use MotionEvents.sendMovement previously, but it did not work, so it appears that the interpolation is necessary in order for the phone to process the event.
class MoveViewHolder(val fromPosition: Int, val toPosition: Int) : ViewAction {
override fun getConstraints(): Matcher<View?>? {
return allOf(isAssignableFrom(RecyclerView::class.java), isDisplayed())
}
override fun getDescription(): String {
return "Long click ViewHolder #$fromPosition and swipe to position $toPosition"
}
override fun perform(uiController: UiController, view: View?) {
// For long clicking the ViewHolder
val rv = view as RecyclerView
rv.scrollToPosition(fromPosition)
val viewHolder = rv.findViewHolderForAdapterPosition(fromPosition)!!
val location = GeneralLocation.CENTER.calculateCoordinates(viewHolder.itemView)
val downEvent = MotionEvents.obtainDownEvent(
location,
floatArrayOf(0f, 0f)
)
// For moving the ViewHolder
val viewHolder2 = rv.findViewHolderForAdapterPosition(toPosition)!!
val location2 = if (toPosition > fromPosition) {
GeneralLocation.BOTTOM_CENTER.calculateCoordinates(viewHolder2.itemView)
} else {
GeneralLocation.TOP_CENTER.calculateCoordinates(viewHolder2.itemView)
}
// Interpolating the move
val longPressTimeout = (ViewConfiguration.getLongPressTimeout() * 1.5f).toLong()
var eventTime = downEvent.downTime + longPressTimeout
val durationMS = 1500
val steps = interpolate(location, location2, 10)
val intervalMS: Long = (durationMS / steps.size).toLong()
val events = mutableListOf<MotionEvent>()
for (step in steps) {
eventTime += intervalMS
events.add(MotionEvents.obtainMovement(downEvent.downTime, eventTime, step))
}
eventTime += intervalMS
events.add(
MotionEvent.obtain(
downEvent.downTime,
eventTime,
MotionEvent.ACTION_UP,
location2[0],
location2[1],
0
)
)
// For releasing the ViewHolder to its new position
val upEvent = MotionEvents.obtainUpEvent(downEvent, location2)
events.add(upEvent)
try {
uiController.injectMotionEvent(downEvent)
rv.scrollToPosition(toPosition)
uiController.loopMainThreadForAtLeast(longPressTimeout)
uiController.injectMotionEventSequence(events)
} finally {
downEvent.recycle()
for (event in events) {
event.recycle()
}
}
}
}