From 109c3f06fd6e862982d8ac8fcc482dea84e51b44 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Wed, 12 Jun 2019 10:49:40 +0200 Subject: [PATCH] Allow checklist items to be reordered --- Habitica/build.gradle | 2 +- .../drawable-hdpi/ab_solid_shadow_holo.9.png | Bin 0 -> 192 bytes Habitica/res/drawable-hdpi/drag_grip.png | Bin 0 -> 461 bytes .../drawable-mdpi/ab_solid_shadow_holo.9.png | Bin 0 -> 168 bytes Habitica/res/drawable-mdpi/drag_grip.png | Bin 0 -> 190 bytes .../drawable-xhdpi/ab_solid_shadow_holo.9.png | Bin 0 -> 290 bytes Habitica/res/drawable-xhdpi/drag_grip.png | Bin 0 -> 463 bytes .../ab_solid_shadow_holo.9.png | Bin 0 -> 1126 bytes Habitica/res/drawable-xxhdpi/drag_grip.png | Bin 0 -> 692 bytes Habitica/res/drawable-xxxhdpi/drag_grip.png | Bin 0 -> 996 bytes .../drawable/ab_solid_shadow_holo_flipped.xml | 9 + .../res/layout/task_form_checklist_item.xml | 10 +- Habitica/res/values/dimens.xml | 1 + .../habitica/ui/views/DragLinearLayout.kt | 706 ++++++++++++++++++ .../ui/views/tasks/form/ChecklistContainer.kt | 8 +- .../views/tasks/form/ChecklistItemFormView.kt | 37 +- 16 files changed, 757 insertions(+), 16 deletions(-) create mode 100644 Habitica/res/drawable-hdpi/ab_solid_shadow_holo.9.png create mode 100644 Habitica/res/drawable-hdpi/drag_grip.png create mode 100644 Habitica/res/drawable-mdpi/ab_solid_shadow_holo.9.png create mode 100644 Habitica/res/drawable-mdpi/drag_grip.png create mode 100644 Habitica/res/drawable-xhdpi/ab_solid_shadow_holo.9.png create mode 100644 Habitica/res/drawable-xhdpi/drag_grip.png create mode 100644 Habitica/res/drawable-xxhdpi/ab_solid_shadow_holo.9.png create mode 100644 Habitica/res/drawable-xxhdpi/drag_grip.png create mode 100644 Habitica/res/drawable-xxxhdpi/drag_grip.png create mode 100644 Habitica/res/drawable/ab_solid_shadow_holo_flipped.xml create mode 100644 Habitica/src/main/java/com/habitrpg/android/habitica/ui/views/DragLinearLayout.kt 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 0000000000000000000000000000000000000000..2d59f354eed3ce3f0564fac30fc8ac278ed4a60f GIT binary patch literal 192 zcmeAS@N?(olHy`uVBq!ia0vp^QXtI11|(N{`J4k%?Vc`-^V;Vpy{TG>rs2jRbtPx$heLX^+%I-N;@4^7Z-#O{ zIhwrurA7I?m&?$@-Q#{k%`H(JcfznNs5m)1K6v7d^oa7cw$PJxxC!4kVkWWTxU1O; zl&^Pj8KOvgTC~DejY-y;Rb##yeTD)(D@_Nj8k4LwtHyjax(tm%c|jDGqHB97j?QJ? zM<`#bT}_lu!{Yiil1k5GPJoo6|LVF|cy>U0G9oIv^fH(Z9$M?1=AGih|)L+GO& z<->;ZBVR{mVf4|C^7WanrcMI%t3mnNRK_`hAqo5d6=>a-P%{av00000NkvXXu0mjf DWlha2 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ddfc8e3d5c4131f2460254f183938477fc5a0679 GIT binary patch literal 168 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+1|-AI^@Rhed`}n05R21qr&#kfCPg`@kT=WR_nY-)l@u)2m z9YjR(TNrC5J8zlf@MOx-MX#^q?zd~tP;1Ok`WbQLQ#YGy{=Yfu7pEGW;JmVaai5Cr S@!LSF89ZJ6T-G@yGywoUXh2*5 literal 0 HcmV?d00001 diff --git a/Habitica/res/drawable-mdpi/drag_grip.png b/Habitica/res/drawable-mdpi/drag_grip.png new file mode 100644 index 0000000000000000000000000000000000000000..2770668478cbc186bde079d1f362f31730e15db6 GIT binary patch literal 190 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjjKx9jP7LeL$-D$|Dm+~rLp*qs z6C_w!oEikD|Cz{q-1fh*!8xXr#zueqHy3WW)3;{Y1REb+;hsi6XP*E1hYx&+XG;^@ zB<1wUV5^00gXV%M$1)i;A{rV$v$QDYgskQ`xp2`H_#0Xp00i_>zopr04qR3jsO4v literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d0df29d8b3fef9f71cda9b7a0975c68dcfb05685 GIT binary patch literal 290 zcmV+-0p0$IP)(^RAa&-b-qmfdL= zKY^DZ_=R_1v^+>Y{!mW=MF!v|2#Q>Q>z_&4i6T94j-Y4q`P>I~g?cE`S~^QhBCU5$ z-8a`dJ38hrt)qwm8XJx0+%VAuW=z7Jg@`1pBAUrf#Ef=wZl|UqH7RO)u1OwK(@xK~ zuV&5*5lN0GQV~igR!o15*n1TfDTP6ilQ72>QBJOyK^2jQYHlAzlrUMuFCtAA$s$=K oi)4{qME3aCu~lSF#Q4a(0o}otDK%H_Q2+n{07*qoM6N<$f*T@rc>n+a literal 0 HcmV?d00001 diff --git a/Habitica/res/drawable-xhdpi/drag_grip.png b/Habitica/res/drawable-xhdpi/drag_grip.png new file mode 100644 index 0000000000000000000000000000000000000000..0f676f410f994b9dcafb2592feb5571837dce1fe GIT binary patch literal 463 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC#^NA%Cx&(BWL^TZY<<*;#__gacPG2ouJ!jd=9n0r+z5l*|^T)Gz`O}G! zZ@z^uOv;~r{b^ly3sC&aM^?X_qw8$U-b>7net*i1QMCVQCe!lcY}%D~V_wb>Y@Q2IGu851hnsL68@3~>v$B2Hl2g`fr zn|*DpUl+T7-G8fl#Y{h+X~}V>%mF%a<$(y+4CCi-SLFpA{XXd;ONjZ(N57xd`qZBQ zu~L~9KECrgam-?gwVg%0{Nb}2Ua5uJd^x}W$g0^P zA3YQvhbV}8upZNLm~ELKUOHK8-}D}}1-m>&@0-6*{^h*}$kG?~Fz*uTGW^RC2{I_# z1mYVL#;d-oDqhU5?+gXnJkjCUN(W#d_Jlr=zG2A_bySo2H86%4JYD@<);T3K0RYs; B%MAbk literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8071886c0091c72feb665ff8213d9eeb4ee33f35 GIT binary patch literal 1126 zcmbVLTWHfz7>-v&m^kL0p@+<;VUu&xrfK8q);5i++Bv(TMG?%Bvn_17n4H;eAl{}- z7`iDaC?d$-1VO}?$xw#id=mB|3gUzMB3=*?!3XhVYo`xm4+fI+|0n8id(75B z4|fc<74*SgJ#MhgyFgP}rUVuy8Yo+Rwl9|x>@2TL<=~oU!K?}CO|Wx86?5$%?RXf7 z91Q6&3PBWcC>)U_X$KG>f;<#>B!rM8hXolzP{>yrNcoQ7yst5aP-_Un-Tj zQkZkRZXU(s@qj}RLewJU57)`%=U8C2zOuQ1|Aq2SMPOVCXVjD!QP>LB_@A2b zG#EY@j(>)wdPOHN5a&mqD)Y;OZMrcYU5&)vb9d=lUCS!TLizQl8wb|3z_lxWif!+H zA39$5_F(qcv)eOAA8ah@YGd+?J-K`4!o~5JTU*8-P zC&nV?5}|SWLhZ=0_uJH|bL;jB^~2-mYiFibf$_{?DvdCy!>30#sPJrU%dYy#Z?`6Q z4!s{2>!;s*UB79`^3fsiV9N7!aSW+o zd^^L@@2~@ptFb8ex&x9o!k8LgFctEY73kb3p4m_oy;g?m^{;u4c z@a3jO>6`a6&7F-3FMsG~@n}GxDvl*`_v6;hDXyxm+x_?C%Vj5jR;W(9bX?`>@6Fwp zcTWkrx7=Y%U3Jd72C>gaFNXiCi|~82Db}M-|DlcSvgX$>bAH_{uRkHa_I`N%rROq_ zCr|y$-Iu|&qm*Zl!Np(K=GRrI*(8zrSQ&()&bVBXj9r240b0>$m@9(2aj;?d*^gDss{>DfIq^ zyoM)plCQkjF)_l|t!P5T)$KZec5Q8WV*1CZzFwwk^2x{-HDyJuauSQbuC3hpR$=A_ z879_-P@i{elOJ3z*|N_v?e(#U{cB2m=SwA98X$+of+D88`$?x(ZkxPCd%=rkRa^Gy z6=mMF3rl^zAv1naZmhfc)x}?xAa@)~ku}tUHiAt6pYhFcP;}w|wP3+OBzjf9e znNyTE?qcxZ6I%VNt(3XNfQASKcc3*U=4k$|i`dcpL2e z+|`>&#Q}k$St=&oJ9P2yLoSV7E9KVSKYH0DPkwdIt?v(YYd~iFP11^fF2@89W6K8-0*9139q1BHeVYTMACD_P(#-iH@=UigSxuwk+ jC8WRzLyfCA|1jNV%RgQHEG{0H3>iFK{an^LB{Ts5T68;9 literal 0 HcmV?d00001 diff --git a/Habitica/res/drawable-xxxhdpi/drag_grip.png b/Habitica/res/drawable-xxxhdpi/drag_grip.png new file mode 100644 index 0000000000000000000000000000000000000000..c3137d973df3edd5926adf235eb46c414dca577d GIT binary patch literal 996 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-HD>V4mmc;uuoF z_;$|TzF;P2`ifJlpwYD!tLAnx4(RUcX!UE*(T@p zyZ)K{J<(a?89saV>(}AGPt3XS>-AP%VIDjm+k%=eEZoIyDfFv?-ki}y8q8PxIa&|=*IlsAG3Tben+S+_{x8M+rPNK-ZA-4 z{_58MJbp-A_h#A6=slNI<<{owD%J2u^zFPK-99(0=xIU8*2I=R`zrOtMhpmI( zj^5;bYJu6_K7DI@ZCCKGz3*vur=e);WzR*XZ#u79+7?$Nh3>!JCjH@m(B|o$xiR{| z*~!PhRX+Q0L~dsM9!7VyqLs6cN>9k&heXtBs6W^fzDpm7E#e%i__14Lz8{@gZ@}j*0QM)3(PmX9Zt>#&66J zJG=8*eDn4D&O8p+(-wUdEn6%5$%zdly1w!Hz7Muw1>qOgn$_`x6+}Oo{;z=9=IhjX z5wY9T`TWf zJ464*N#`+SJLhsA;su+S`$rS#Yt9AP#utB0ou^XHBj;?-{#3K z**An&7gqt3z^c0SyygvC7j9#H$Od+|*+0SWjF!wGv+o-z*6<4yLYNBRc-cBL + + + \ 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 {