diff --git a/Habitica/build.gradle b/Habitica/build.gradle
index 2e0aa5ca5..6d4c15acf 100644
--- a/Habitica/build.gradle
+++ b/Habitica/build.gradle
@@ -148,7 +148,7 @@ android {
buildConfigField "String", "TESTING_LEVEL", "\"production\""
multiDexEnabled true
- versionCode 2151
+ versionCode 2153
versionName "1.10"
}
diff --git a/Habitica/res/drawable-hdpi/ab_solid_shadow_holo.9.png b/Habitica/res/drawable-hdpi/ab_solid_shadow_holo.9.png
new file mode 100644
index 000000000..2d59f354e
Binary files /dev/null and b/Habitica/res/drawable-hdpi/ab_solid_shadow_holo.9.png differ
diff --git a/Habitica/res/drawable-hdpi/drag_grip.png b/Habitica/res/drawable-hdpi/drag_grip.png
new file mode 100644
index 000000000..11d0b490b
Binary files /dev/null and b/Habitica/res/drawable-hdpi/drag_grip.png differ
diff --git a/Habitica/res/drawable-mdpi/ab_solid_shadow_holo.9.png b/Habitica/res/drawable-mdpi/ab_solid_shadow_holo.9.png
new file mode 100644
index 000000000..ddfc8e3d5
Binary files /dev/null and b/Habitica/res/drawable-mdpi/ab_solid_shadow_holo.9.png differ
diff --git a/Habitica/res/drawable-mdpi/drag_grip.png b/Habitica/res/drawable-mdpi/drag_grip.png
new file mode 100644
index 000000000..277066847
Binary files /dev/null and b/Habitica/res/drawable-mdpi/drag_grip.png differ
diff --git a/Habitica/res/drawable-xhdpi/ab_solid_shadow_holo.9.png b/Habitica/res/drawable-xhdpi/ab_solid_shadow_holo.9.png
new file mode 100644
index 000000000..d0df29d8b
Binary files /dev/null and b/Habitica/res/drawable-xhdpi/ab_solid_shadow_holo.9.png differ
diff --git a/Habitica/res/drawable-xhdpi/drag_grip.png b/Habitica/res/drawable-xhdpi/drag_grip.png
new file mode 100644
index 000000000..0f676f410
Binary files /dev/null and b/Habitica/res/drawable-xhdpi/drag_grip.png differ
diff --git a/Habitica/res/drawable-xxhdpi/ab_solid_shadow_holo.9.png b/Habitica/res/drawable-xxhdpi/ab_solid_shadow_holo.9.png
new file mode 100644
index 000000000..8071886c0
Binary files /dev/null and b/Habitica/res/drawable-xxhdpi/ab_solid_shadow_holo.9.png differ
diff --git a/Habitica/res/drawable-xxhdpi/drag_grip.png b/Habitica/res/drawable-xxhdpi/drag_grip.png
new file mode 100644
index 000000000..22538fa68
Binary files /dev/null and b/Habitica/res/drawable-xxhdpi/drag_grip.png differ
diff --git a/Habitica/res/drawable-xxxhdpi/drag_grip.png b/Habitica/res/drawable-xxxhdpi/drag_grip.png
new file mode 100644
index 000000000..c3137d973
Binary files /dev/null and b/Habitica/res/drawable-xxxhdpi/drag_grip.png differ
diff --git a/Habitica/res/drawable/ab_solid_shadow_holo_flipped.xml b/Habitica/res/drawable/ab_solid_shadow_holo_flipped.xml
new file mode 100644
index 000000000..87be4a32f
--- /dev/null
+++ b/Habitica/res/drawable/ab_solid_shadow_holo_flipped.xml
@@ -0,0 +1,9 @@
+
+
+
+
\ No newline at end of file
diff --git a/Habitica/res/layout/task_form_checklist_item.xml b/Habitica/res/layout/task_form_checklist_item.xml
index 3543dcc6e..0fb0075d6 100644
--- a/Habitica/res/layout/task_form_checklist_item.xml
+++ b/Habitica/res/layout/task_form_checklist_item.xml
@@ -24,9 +24,17 @@
android:layout_marginEnd="@dimen/spacing_large"/>
+
\ No newline at end of file
diff --git a/Habitica/res/values/dimens.xml b/Habitica/res/values/dimens.xml
index 2220aa86e..23538df1b 100644
--- a/Habitica/res/values/dimens.xml
+++ b/Habitica/res/values/dimens.xml
@@ -141,4 +141,5 @@
38dp
16sp
26dp
+ 16dp
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/DragLinearLayout.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/DragLinearLayout.kt
new file mode 100644
index 000000000..db56d3199
--- /dev/null
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/DragLinearLayout.kt
@@ -0,0 +1,706 @@
+package com.habitrpg.android.habitica.ui.views
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.animation.ObjectAnimator
+import android.animation.ValueAnimator
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Rect
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import android.util.Log
+import android.util.SparseArray
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewConfiguration
+import android.view.ViewTreeObserver.OnPreDrawListener
+import android.widget.LinearLayout
+import android.widget.ScrollView
+import androidx.core.content.ContextCompat
+import androidx.core.view.MotionEventCompat
+import com.habitrpg.android.habitica.R
+
+// Adapted from https://github.com/justasm/DragLinearLayout
+
+/**
+ * A LinearLayout that supports children Views that can be dragged and swapped around.
+ * See [.addDragView],
+ * [.addDragView],
+ * [.setViewDraggable], and
+ * [.removeDragView].
+ *
+ *
+ * Currently, no error-checking is done on standard [.addView] and
+ * [.removeView] calls, so avoid using these with children previously
+ * declared as draggable to prevent memory leaks and/or subtle bugs. Pull requests welcome!
+ */
+open class DragLinearLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : LinearLayout(context, attrs) {
+ private val nominalDistanceScaled: Float
+
+ private var swapListener: OnViewSwapListener? = null
+ private val draggableChildren: SparseArray
+ private val draggedItem: DragItem
+ private val slop: Int
+ private var downY = -1
+ private var activePointerId = INVALID_POINTER_ID
+ private val dragTopShadowDrawable: Drawable?
+ private val dragBottomShadowDrawable: Drawable?
+ private val dragShadowHeight: Int
+ private var containerScrollView: ScrollView? = null
+ /**
+ * Sets the height from upper / lower edge at which a container [android.widget.ScrollView],
+ * if one is registered via [.setContainerScrollView],
+ * is scrolled.
+ */
+ var scrollSensitiveHeight: Int = 0
+
+ private var dragUpdater: Runnable? = null
+
+ /**
+ * Use with [com.habitrpg.android.habitica.ui.views.DragLinearLayout.setOnViewSwapListener]
+ * to listen for draggable view swaps.
+ */
+ interface OnViewSwapListener {
+ /**
+ * Invoked right before the two items are swapped due to a drag event.
+ * After the swap, the firstView will be in the secondPosition, and vice versa.
+ *
+ *
+ * No guarantee is made as to which of the two has a lesser/greater position.
+ */
+ fun onSwap(firstView: View?, firstPosition: Int, secondView: View, secondPosition: Int)
+ }
+
+ private inner class DraggableChild {
+ /**
+ * If non-null, a reference to an on-going position animation.
+ */
+ internal var swapAnimation: ValueAnimator? = null
+
+ fun endExistingAnimation() {
+ swapAnimation?.end()
+ }
+
+ fun cancelExistingAnimation() {
+ swapAnimation?.cancel()
+ }
+ }
+
+ /**
+ * Holds state information about the currently dragged item.
+ *
+ *
+ * Rough lifecycle:
+ * * #startDetectingOnPossibleDrag - #detecting == true
+ * * if drag is recognised, #onDragStart - #dragging == true
+ * * if drag ends, #onDragStop - #dragging == false, #settling == true
+ * * if gesture ends without drag, or settling finishes, #stopDetecting - #detecting == false
+ */
+ private inner class DragItem {
+ internal var view: View? = null
+ private var startVisibility: Int = 0
+ internal var viewDrawable: BitmapDrawable? = null
+ internal var position: Int = 0
+ internal var startTop: Int = 0
+ internal var height: Int = 0
+ internal var totalDragOffset: Int = 0
+ internal var targetTopOffset: Int = 0
+ internal var settleAnimation: ValueAnimator? = null
+
+ internal var detecting: Boolean = false
+ internal var dragging: Boolean = false
+
+ init {
+ stopDetecting()
+ }
+
+ fun startDetectingOnPossibleDrag(view: View, position: Int) {
+ this.view = view
+ this.startVisibility = view.visibility
+ this.viewDrawable = getDragDrawable(view)
+ this.position = position
+ this.startTop = view.top
+ this.height = view.height
+ this.totalDragOffset = 0
+ this.targetTopOffset = 0
+ this.settleAnimation = null
+
+ this.detecting = true
+ }
+
+ fun onDragStart() {
+ view?.visibility = View.INVISIBLE
+ this.dragging = true
+ }
+
+ fun setTotalOffset(offset: Int) {
+ totalDragOffset = offset
+ updateTargetTop()
+ }
+
+ fun updateTargetTop() {
+ targetTopOffset = startTop - (view?.top ?: 0) + totalDragOffset
+ }
+
+ fun onDragStop() {
+ this.dragging = false
+ }
+
+ fun settling(): Boolean {
+ return null != settleAnimation
+ }
+
+ fun stopDetecting() {
+ this.detecting = false
+ if (null != view) view?.visibility = startVisibility
+ view = null
+ startVisibility = -1
+ viewDrawable = null
+ position = -1
+ startTop = -1
+ height = -1
+ totalDragOffset = 0
+ targetTopOffset = 0
+ if (null != settleAnimation) settleAnimation?.end()
+ settleAnimation = null
+ }
+ }
+
+ init {
+
+ orientation = VERTICAL
+
+ draggableChildren = SparseArray()
+
+ draggedItem = DragItem()
+ val vc = ViewConfiguration.get(context)
+ slop = vc.scaledTouchSlop
+
+ val resources = resources
+ dragTopShadowDrawable = ContextCompat.getDrawable(context, R.drawable.ab_solid_shadow_holo_flipped)
+ dragBottomShadowDrawable = ContextCompat.getDrawable(context, R.drawable.ab_solid_shadow_holo)
+ dragShadowHeight = resources.getDimensionPixelSize(R.dimen.downwards_drop_shadow_height)
+
+ scrollSensitiveHeight = (DEFAULT_SCROLL_SENSITIVE_AREA_HEIGHT_DP * resources.displayMetrics.density + 0.5f).toInt()
+
+ nominalDistanceScaled = (NOMINAL_DISTANCE * resources.displayMetrics.density + 0.5f).toInt().toFloat()
+ }
+
+ override fun setOrientation(orientation: Int) {
+ // enforce VERTICAL orientation; remove if HORIZONTAL support is ever added
+ if (HORIZONTAL == orientation) {
+ throw IllegalArgumentException("DragLinearLayout must be VERTICAL.")
+ }
+ super.setOrientation(orientation)
+ }
+
+ /**
+ * Calls [.addView] followed by [.setViewDraggable].
+ */
+ fun addDragView(child: View, dragHandle: View) {
+ addView(child)
+ setViewDraggable(child, dragHandle)
+ }
+
+ /**
+ * Calls [.addView] followed by
+ * [.setViewDraggable] and correctly updates the
+ * drag-ability state of all existing views.
+ */
+ fun addDragView(child: View, dragHandle: View, index: Int) {
+ addView(child, index)
+
+ // update drag-able children mappings
+ val numMappings = draggableChildren.size()
+ for (i in numMappings - 1 downTo 0) {
+ val key = draggableChildren.keyAt(i)
+ if (key >= index) {
+ draggableChildren.put(key + 1, draggableChildren.get(key))
+ }
+ }
+
+ setViewDraggable(child, dragHandle)
+ }
+
+ /**
+ * Makes the child a candidate for dragging. Must be an existing child of this layout.
+ */
+ fun setViewDraggable(child: View, dragHandle: View) {
+ if (this === child.parent) {
+ dragHandle.setOnTouchListener(DragHandleOnTouchListener(child))
+ draggableChildren.put(indexOfChild(child), DraggableChild())
+ } else {
+ Log.e(LOG_TAG, "$child is not a child, cannot make draggable.")
+ }
+ }
+
+ /**
+ * Makes the child a candidate for dragging. Must be an existing child of this layout.
+ */
+ fun removeViewDraggable(child: View) {
+ if (this === child.parent) {
+ draggableChildren.remove(indexOfChild(child))
+ draggableChildren.put(indexOfChild(child), DraggableChild())
+ }
+ }
+
+ /**
+ * Calls [.removeView] and correctly updates the drag-ability state of
+ * all remaining views.
+ */
+ fun removeDragView(child: View) {
+ if (this === child.parent) {
+ val index = indexOfChild(child)
+ removeView(child)
+
+ // update drag-able children mappings
+ val mappings = draggableChildren.size()
+ for (i in 0 until mappings) {
+ val key = draggableChildren.keyAt(i)
+ if (key >= index) {
+ val next = draggableChildren.get(key + 1)
+ if (null == next) {
+ draggableChildren.delete(key)
+ } else {
+ draggableChildren.put(key, next)
+ }
+ }
+ }
+ }
+ }
+
+ override fun removeAllViews() {
+ super.removeAllViews()
+ draggableChildren.clear()
+ }
+
+ /**
+ * If this layout is within a [android.widget.ScrollView], register it here so that it
+ * can be scrolled during item drags.
+ */
+ fun setContainerScrollView(scrollView: ScrollView) {
+ this.containerScrollView = scrollView
+ }
+
+ /**
+ * See [com.habitrpg.android.habitica.ui.views.DragLinearLayout.OnViewSwapListener].
+ */
+ fun setOnViewSwapListener(swapListener: OnViewSwapListener) {
+ this.swapListener = swapListener
+ }
+
+ /**
+ * A linear relationship b/w distance and duration, bounded.
+ */
+ private fun getTranslateAnimationDuration(distance: Float): Long {
+ return Math.min(MAX_SWITCH_DURATION, Math.max(MIN_SWITCH_DURATION,
+ (NOMINAL_SWITCH_DURATION * Math.abs(distance) / nominalDistanceScaled).toLong()))
+ }
+
+ /**
+ * Initiates a new [.draggedItem] unless the current one is still
+ * [com.habitrpg.android.habitica.ui.views.DragLinearLayout.DragItem.detecting].
+ */
+ private fun startDetectingDrag(child: View) {
+ if (draggedItem.detecting)
+ return // existing drag in process, only one at a time is allowed
+
+ val position = indexOfChild(child)
+
+ // complete any existing animations, both for the newly selected child and the previous dragged one
+ draggableChildren.get(position).endExistingAnimation()
+
+ draggedItem.startDetectingOnPossibleDrag(child, position)
+ containerScrollView?.requestDisallowInterceptTouchEvent(true)
+ }
+
+ private fun startDrag() {
+ // remove layout transition, it conflicts with drag animation
+ // we will restore it after drag animation end, see onDragStop()
+ layoutTransition = layoutTransition
+ if (layoutTransition != null) {
+ layoutTransition = null
+ }
+
+ draggedItem.onDragStart()
+ requestDisallowInterceptTouchEvent(true)
+ }
+
+ /**
+ * Animates the dragged item to its final resting position.
+ */
+ private fun onDragStop() {
+ draggedItem.settleAnimation = ValueAnimator.ofFloat(draggedItem.totalDragOffset.toFloat(),
+ (draggedItem.totalDragOffset - draggedItem.targetTopOffset).toFloat())
+ .setDuration(getTranslateAnimationDuration(draggedItem.targetTopOffset.toFloat()))
+ draggedItem.settleAnimation?.addUpdateListener(ValueAnimator.AnimatorUpdateListener { animation ->
+ if (!draggedItem.detecting) return@AnimatorUpdateListener // already stopped
+
+ draggedItem.setTotalOffset((animation.animatedValue as? Float)?.toInt() ?: 0)
+
+ val shadowAlpha = ((1 - animation.animatedFraction) * 255).toInt()
+ if (null != dragTopShadowDrawable) dragTopShadowDrawable.alpha = shadowAlpha
+ dragBottomShadowDrawable?.alpha = shadowAlpha
+ invalidate()
+ })
+ draggedItem.settleAnimation?.addListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationStart(animation: Animator) {
+ draggedItem.onDragStop()
+ }
+
+ override fun onAnimationEnd(animation: Animator) {
+ if (!draggedItem.detecting) {
+ return // already stopped
+ }
+
+ draggedItem.settleAnimation = null
+ draggedItem.stopDetecting()
+
+ if (null != dragTopShadowDrawable) dragTopShadowDrawable.alpha = 255
+ dragBottomShadowDrawable?.alpha = 255
+
+ // restore layout transition
+ if (layoutTransition != null && layoutTransition == null) {
+ layoutTransition = layoutTransition
+ }
+ }
+ })
+ draggedItem.settleAnimation?.start()
+ }
+
+ /**
+ * Updates the dragged item with the given total offset from its starting position.
+ * Evaluates and executes draggable view swaps.
+ */
+ private fun onDrag(offset: Int) {
+ draggedItem.setTotalOffset(offset)
+ invalidate()
+
+ val currentTop = draggedItem.startTop + draggedItem.totalDragOffset
+
+ handleContainerScroll(currentTop)
+
+ val belowPosition = nextDraggablePosition(draggedItem.position)
+ val abovePosition = previousDraggablePosition(draggedItem.position)
+
+ val belowView = getChildAt(belowPosition)
+ val aboveView = getChildAt(abovePosition)
+
+ val isBelow = belowView != null && currentTop + draggedItem.height > belowView.top + belowView.height / 2
+ val isAbove = aboveView != null && currentTop < aboveView.top + aboveView.height / 2
+
+ if (isBelow || isAbove) {
+ val switchView = if (isBelow) belowView else aboveView
+
+ // swap elements
+ val originalPosition = draggedItem.position
+ val switchPosition = if (isBelow) belowPosition else abovePosition
+
+ draggableChildren.get(switchPosition).cancelExistingAnimation()
+ val switchViewStartY = switchView.y
+
+ if (null != swapListener) {
+ swapListener?.onSwap(draggedItem.view, draggedItem.position, switchView, switchPosition)
+ }
+
+ if (isBelow) {
+ removeViewAt(originalPosition)
+ removeViewAt(switchPosition - 1)
+
+ addView(belowView, originalPosition)
+ addView(draggedItem.view, switchPosition)
+ } else {
+ removeViewAt(switchPosition)
+ removeViewAt(originalPosition - 1)
+
+ addView(draggedItem.view, switchPosition)
+ addView(aboveView, originalPosition)
+ }
+ draggedItem.position = switchPosition
+
+ val switchViewObserver = switchView.viewTreeObserver
+ switchViewObserver.addOnPreDrawListener(object : OnPreDrawListener {
+ override fun onPreDraw(): Boolean {
+ switchViewObserver.removeOnPreDrawListener(this)
+
+ val switchAnimator = ObjectAnimator.ofFloat(switchView, "y", switchViewStartY, switchView.top.toFloat())
+ .setDuration(getTranslateAnimationDuration(switchView.top - switchViewStartY))
+ switchAnimator.addListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationStart(animation: Animator) {
+ draggableChildren.get(originalPosition).swapAnimation = switchAnimator
+ }
+
+ override fun onAnimationEnd(animation: Animator) {
+ draggableChildren.get(originalPosition).swapAnimation = null
+ }
+ })
+ switchAnimator.start()
+
+ return true
+ }
+ })
+
+ val observer = draggedItem.view!!.viewTreeObserver
+ observer.addOnPreDrawListener(object : OnPreDrawListener {
+ override fun onPreDraw(): Boolean {
+ observer.removeOnPreDrawListener(this)
+ draggedItem.updateTargetTop()
+
+ // TODO test if still necessary..
+ // because draggedItem#view#getTop() is only up-to-date NOW
+ // (and not right after the #addView() swaps above)
+ // we may need to update an ongoing settle animation
+ if (draggedItem.settling()) {
+ Log.d(LOG_TAG, "Updating settle animation")
+ draggedItem.settleAnimation!!.removeAllListeners()
+ draggedItem.settleAnimation!!.cancel()
+ onDragStop()
+ }
+ return true
+ }
+ })
+ }
+ }
+
+ private fun previousDraggablePosition(position: Int): Int {
+ val startIndex = draggableChildren.indexOfKey(position)
+ return if (startIndex < 1 || startIndex > draggableChildren.size()) -1 else draggableChildren.keyAt(startIndex - 1)
+ }
+
+ private fun nextDraggablePosition(position: Int): Int {
+ val startIndex = draggableChildren.indexOfKey(position)
+ return if (startIndex < -1 || startIndex > draggableChildren.size() - 2) -1 else draggableChildren.keyAt(startIndex + 1)
+ }
+
+ private fun handleContainerScroll(currentTop: Int) {
+ if (null != containerScrollView) {
+ val startScrollY = containerScrollView!!.scrollY
+ val absTop = top - startScrollY + currentTop
+ val height = containerScrollView!!.height
+
+ val delta: Int
+
+ if (absTop < scrollSensitiveHeight) {
+ delta = (-MAX_DRAG_SCROLL_SPEED * smootherStep(scrollSensitiveHeight.toFloat(), 0f, absTop.toFloat())).toInt()
+ } else if (absTop > height - scrollSensitiveHeight) {
+ delta = (MAX_DRAG_SCROLL_SPEED * smootherStep((height - scrollSensitiveHeight).toFloat(), height.toFloat(), absTop.toFloat())).toInt()
+ } else {
+ delta = 0
+ }
+
+ containerScrollView?.removeCallbacks(dragUpdater)
+ containerScrollView?.smoothScrollBy(0, delta)
+ dragUpdater = Runnable {
+ if (draggedItem.dragging && startScrollY != containerScrollView!!.scrollY) {
+ onDrag(draggedItem.totalDragOffset + delta)
+ }
+ }
+ containerScrollView?.post(dragUpdater)
+ }
+ }
+
+ override fun dispatchDraw(canvas: Canvas) {
+ super.dispatchDraw(canvas)
+
+ if (draggedItem.detecting && (draggedItem.dragging || draggedItem.settling())) {
+ canvas.save()
+ canvas.translate(0f, draggedItem.totalDragOffset.toFloat())
+ draggedItem.viewDrawable?.draw(canvas)
+
+ val left = draggedItem.viewDrawable?.bounds?.left ?: 0
+ val right = draggedItem.viewDrawable?.bounds?.right ?: 0
+ val top = draggedItem.viewDrawable?.bounds?.top ?: 0
+ val bottom = draggedItem.viewDrawable?.bounds?.bottom ?: 0
+
+ dragBottomShadowDrawable?.setBounds(left, bottom, right, bottom + dragShadowHeight)
+ dragBottomShadowDrawable?.draw(canvas)
+
+ if (null != dragTopShadowDrawable) {
+ dragTopShadowDrawable.setBounds(left, top - dragShadowHeight, right, top)
+ dragTopShadowDrawable.draw(canvas)
+ }
+
+ canvas.restore()
+ }
+ }
+
+ /*
+ * Note regarding touch handling:
+ * In general, we have three cases -
+ * 1) User taps outside any children.
+ * #onInterceptTouchEvent receives DOWN
+ * #onTouchEvent receives DOWN
+ * draggedItem.detecting == false, we return false and no further events are received
+ * 2) User taps on non-interactive drag handle / child, e.g. TextView or ImageView.
+ * #onInterceptTouchEvent receives DOWN
+ * DragHandleOnTouchListener (attached to each draggable child) #onTouch receives DOWN
+ * #startDetectingDrag is called, draggedItem is now detecting
+ * view does not handle touch, so our #onTouchEvent receives DOWN
+ * draggedItem.detecting == true, we #startDrag() and proceed to handle the drag
+ * 3) User taps on interactive drag handle / child, e.g. Button.
+ * #onInterceptTouchEvent receives DOWN
+ * DragHandleOnTouchListener (attached to each draggable child) #onTouch receives DOWN
+ * #startDetectingDrag is called, draggedItem is now detecting
+ * view handles touch, so our #onTouchEvent is not called yet
+ * #onInterceptTouchEvent receives ACTION_MOVE
+ * if dy > touch slop, we assume user wants to drag and intercept the event
+ * #onTouchEvent receives further ACTION_MOVE events, proceed to handle the drag
+ *
+ * For cases 2) and 3), lifting the active pointer at any point in the sequence of events
+ * triggers #onTouchEnd and the draggedItem, if detecting, is #stopDetecting.
+ */
+
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ if (draggedItem.detecting) return false // an existing item is (likely) settling
+ downY = event.y.toInt()
+ activePointerId = event.getPointerId(0)
+ }
+ MotionEvent.ACTION_MOVE -> {
+ if (!draggedItem.detecting) return false
+ if (INVALID_POINTER_ID == activePointerId) return false
+ val y = event.y
+ val dy = y - downY
+ if (Math.abs(dy) > slop) {
+ startDrag()
+ return true
+ }
+ return false
+ }
+ MotionEvent.ACTION_POINTER_UP -> {
+ run {
+ val pointerIndex = event.actionIndex
+ val pointerId = event.getPointerId(pointerIndex)
+
+ if (pointerId != activePointerId)
+ return false // if active pointer, fall through and cancel!
+ }
+ run {
+ onTouchEnd()
+
+ if (draggedItem.detecting) draggedItem.stopDetecting()
+ return false
+ }
+ }
+ MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
+ onTouchEnd()
+ if (draggedItem.detecting) draggedItem.stopDetecting()
+ }
+ }
+
+ return false
+ }
+
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ when (MotionEventCompat.getActionMasked(event)) {
+ MotionEvent.ACTION_DOWN -> {
+ if (!draggedItem.detecting || draggedItem.settling()) return false
+ startDrag()
+ return true
+ }
+ MotionEvent.ACTION_MOVE -> {
+ if (!draggedItem.dragging) return false
+ if (INVALID_POINTER_ID == activePointerId) return false
+
+ val pointerIndex = event.findPointerIndex(activePointerId)
+ val lastEventY = MotionEventCompat.getY(event, pointerIndex).toInt()
+ val deltaY = lastEventY - downY
+
+ onDrag(deltaY)
+ return true
+ }
+ MotionEvent.ACTION_POINTER_UP -> {
+ run {
+ val pointerIndex = MotionEventCompat.getActionIndex(event)
+ val pointerId = MotionEventCompat.getPointerId(event, pointerIndex)
+
+ if (pointerId != activePointerId)
+ return false // if active pointer, fall through and cancel!
+ }
+ run {
+ onTouchEnd()
+
+ if (draggedItem.dragging) {
+ onDragStop()
+ } else if (draggedItem.detecting) {
+ draggedItem.stopDetecting()
+ }
+ return true
+ }
+ }
+ MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
+ onTouchEnd()
+ if (draggedItem.dragging) {
+ onDragStop()
+ } else if (draggedItem.detecting) {
+ draggedItem.stopDetecting()
+ }
+ return true
+ }
+ }
+ return false
+ }
+
+ private fun onTouchEnd() {
+ downY = -1
+ activePointerId = INVALID_POINTER_ID
+ }
+
+ private inner class DragHandleOnTouchListener(private val view: View) : OnTouchListener {
+
+ override fun onTouch(v: View, event: MotionEvent): Boolean {
+ if (MotionEvent.ACTION_DOWN == MotionEventCompat.getActionMasked(event)) {
+ startDetectingDrag(view)
+ }
+ return false
+ }
+ }
+
+ private fun getDragDrawable(view: View): BitmapDrawable {
+ val top = view.top
+ val left = view.left
+
+ val bitmap = getBitmapFromView(view)
+
+ val drawable = BitmapDrawable(resources, bitmap)
+
+ drawable.bounds = Rect(left, top, left + view.width, top + view.height)
+
+ return drawable
+ }
+
+ companion object {
+ private val LOG_TAG = DragLinearLayout::class.java.simpleName
+ private val NOMINAL_SWITCH_DURATION: Long = 150
+ private val MIN_SWITCH_DURATION = NOMINAL_SWITCH_DURATION
+ private val MAX_SWITCH_DURATION = NOMINAL_SWITCH_DURATION * 2
+ private val NOMINAL_DISTANCE = 20f
+
+ private val INVALID_POINTER_ID = -1
+ private val DEFAULT_SCROLL_SENSITIVE_AREA_HEIGHT_DP = 48
+ private val MAX_DRAG_SCROLL_SPEED = 16
+
+ /**
+ * By Ken Perlin. See [Smoothstep - Wikipedia](http://en.wikipedia.org/wiki/Smoothstep).
+ */
+ private fun smootherStep(edge1: Float, edge2: Float, `val`: Float): Float {
+ var `val` = `val`
+ `val` = Math.max(0f, Math.min((`val` - edge1) / (edge2 - edge1), 1f))
+ return `val` * `val` * `val` * (`val` * (`val` * 6 - 15) + 10)
+ }
+
+ /**
+ * @return a bitmap showing a screenshot of the view passed in.
+ */
+ private fun getBitmapFromView(view: View): Bitmap {
+ val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+ view.draw(canvas)
+ return bitmap
+ }
+ }
+}
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/tasks/form/ChecklistContainer.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/tasks/form/ChecklistContainer.kt
index df841dcc2..a25d66a84 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/tasks/form/ChecklistContainer.kt
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/tasks/form/ChecklistContainer.kt
@@ -4,16 +4,15 @@ import android.animation.LayoutTransition
import android.content.Context
import android.util.AttributeSet
import android.view.View
-import android.widget.LinearLayout
import androidx.core.view.children
import androidx.core.view.updateMargins
import com.habitrpg.android.habitica.extensions.dpToPx
import com.habitrpg.android.habitica.models.tasks.ChecklistItem
+import com.habitrpg.android.habitica.ui.views.DragLinearLayout
import io.realm.RealmList
class ChecklistContainer @JvmOverloads constructor(
- context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
-) : LinearLayout(context, attrs, defStyleAttr) {
+ context: Context, attrs: AttributeSet? = null) : DragLinearLayout(context, attrs) {
var checklistItems: RealmList
get() {
val list = RealmList()
@@ -60,10 +59,12 @@ class ChecklistContainer @JvmOverloads constructor(
addChecklistViewAt(-1)
view.animDuration = 300
view.isAddButton = false
+ setViewDraggable(view, view.dragGrip)
} else if (shouldBecomeNewAddButton(view)) {
removeViewAt(childCount-1)
view.animDuration = 300
view.isAddButton = true
+ removeViewDraggable(view)
}
}
val indexToUse = if (index < 0) {
@@ -76,6 +77,7 @@ class ChecklistContainer @JvmOverloads constructor(
view.isAddButton = true
} else {
addView(view, indexToUse)
+ setViewDraggable(view, view.dragGrip)
}
val layoutParams = view.layoutParams as? LayoutParams
layoutParams?.updateMargins(bottom = 8.dpToPx(context))
diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/tasks/form/ChecklistItemFormView.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/tasks/form/ChecklistItemFormView.kt
index e100cbc88..069a81c85 100644
--- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/tasks/form/ChecklistItemFormView.kt
+++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/tasks/form/ChecklistItemFormView.kt
@@ -3,6 +3,7 @@ package com.habitrpg.android.habitica.ui.views.tasks.form
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
+import android.view.View
import android.view.ViewGroup
import android.view.animation.Animation
import android.view.animation.LinearInterpolator
@@ -26,6 +27,7 @@ class ChecklistItemFormView @JvmOverloads constructor(
private val button: ImageButton by bindView(R.id.button)
private val editText: AppCompatEditText by bindView(R.id.edit_text)
+ internal val dragGrip: View by bindView(R.id.drag_grip)
var item: ChecklistItem = ChecklistItem()
set(value) {
@@ -38,21 +40,34 @@ class ChecklistItemFormView @JvmOverloads constructor(
var animDuration = 0L
var isAddButton: Boolean = true
set(value) {
+ if (field == value) {
+ return
+ }
field = value
editText.hint = context.getString(if (value) R.string.new_checklist_item else R.string.checklist_text)
- if (value) {
- val rotate = RotateAnimation(135f, 0f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f)
- rotate.duration = animDuration
- rotate.interpolator = LinearInterpolator()
- rotate.fillAfter = true
- button.startAnimation(rotate)
+ val rotate = if (value) {
+ dragGrip.visibility = View.GONE
+ RotateAnimation(135f, 0f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f)
} else {
- val rotate = RotateAnimation(0f, 135f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f)
- rotate.duration = animDuration
- rotate.interpolator = LinearInterpolator()
- rotate.fillAfter = true
- button.startAnimation(rotate)
+ dragGrip.visibility = View.VISIBLE
+ RotateAnimation(0f, 135f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f)
}
+ rotate.duration = animDuration
+ rotate.interpolator = LinearInterpolator()
+ rotate.setAnimationListener(object : Animation.AnimationListener {
+ override fun onAnimationRepeat(animation: Animation?) {}
+
+ override fun onAnimationEnd(animation: Animation?) {
+ button.rotation = if (value) {
+ 0f
+ } else {
+ 135f
+ }
+ }
+
+ override fun onAnimationStart(animation: Animation?) {}
+ })
+ button.startAnimation(rotate)
}
init {